;;; Gash -- Guile As SHell ;;; Copyright © 2018, 2019 Timothy Sample ;;; ;;; This file is part of Gash. ;;; ;;; Gash is free software: you can redistribute it and/or modify ;;; it under the terms of the GNU General Public License as published by ;;; the Free Software Foundation, either version 3 of the License, or ;;; (at your option) any later version. ;;; ;;; Gash is distributed in the hope that it will be useful, ;;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;;; GNU General Public License for more details. ;;; ;;; You should have received a copy of the GNU General Public License ;;; along with Gash. If not, see . (define-module (gash word) #:use-module (gash environment) #:use-module (gash pattern) #:use-module (ice-9 ftw) #:use-module (ice-9 match) #:use-module (srfi srfi-1) #:use-module (srfi srfi-26) #:export (parameter-ref expand-qword)) ;;; Commentary: ;;; ;;; This module contains functions for manipulating shell words. This ;;; includes tilde expansion, parameter expansions, field splitting, ;;; globbing, etc. ;;; ;;; In the code below, the term "qword" is used to refer to words that ;;; only contain quotations (i.e., no substitutions). ;;; ;;; Code: (define (normalize-word word) "Normalize @var{word} (which may be a word or a qword) so that it is guaranteed to be a list." (match word ((? string?) (list word)) (((? symbol?) _) (list word)) (_ word))) (define (infix x xs) "Place @var{x} between each element of the list @var{xs}." (if (null? xs) xs (let loop ((xs (cdr xs)) (acc (list (car xs)))) (if (null? xs) (reverse acc) (loop (cdr xs) (cons* (car xs) x acc)))))) (define (list-split xs sym) "Split the list @var{xs} into sublists delimited by the symbol @var{sym}." (let loop ((xs xs) (small-acc '()) (big-acc '())) (cond ((null? xs) (reverse (cons (reverse small-acc) big-acc))) ((eq? (car xs) sym) (loop (cdr xs) '() (cons (reverse small-acc) big-acc))) (else (loop (cdr xs) (cons (car xs) small-acc) big-acc))))) (define (string-tokenize* s token-set) "Split the string @var{s} into a list of substrings, where each substring is a maximal non-empty contiguous sequence of characters from the character set @var{token-set} or its compliment." (define token-set-complement (let ((token-set* (char-set-complement token-set))) (lambda (cs) (if (eq? cs token-set) token-set* token-set)))) (let loop ((index 0) (start 0) (cs token-set) (acc '())) (cond ((>= index (string-length s)) (reverse! (if (> index start) (cons (substring s start index) acc) acc))) ((char-set-contains? cs (string-ref s index)) (loop (1+ index) start cs acc)) (else (loop index index (token-set-complement cs) (if (> index start) (cons (substring s start index) acc) acc)))))) (define (split-fields qword ifs) "Split @var{qword} into a list of qwords delimited by any character in the string @var{ifs}." (define char-set:ifs (string->char-set ifs)) (define char-set:ifs/nw (string->char-set (string-delete char-set:whitespace ifs))) (define (wedge-apart-quote qword) (let loop ((qword (normalize-word qword)) (acc '())) (match qword (() (reverse! acc)) (((' qword*) . t) (loop t (append-reverse (wedge-apart-quote qword*) acc))) (((' vals) . t) (loop t (append-reverse (infix 'wedge (map (cut list ' <>) vals)) acc))) (((? string? h) . t) (loop t (cons `( ,h) acc))) (((qwords ...) . t) (loop t (append-reverse (wedge-apart-quote qwords) acc)))))) (define (wedge-apart qword) (match qword ((' quote) (wedge-apart-quote quote)) ((' vals) (apply append (infix '(wedge) (map wedge-apart vals)))) ((? string? str) (let ((tokens (string-tokenize* str char-set:ifs))) (append-map (lambda (token) (if (string-any char-set:ifs token) ;; Every occurrence of a non-whitespace ;; separator must delimit a field. This ;; means that we have to add a blank field ;; for every non-whitespace separator in ;; 'token' beyond the first. (let ((count (string-count token char-set:ifs/nw))) (cons 'wedge (append-map (const '("" wedge)) (iota (max 0 (- count 1)))))) (list token))) ;; When a word starts with a non-whitespace ;; separator, it still delimits two fields, the ;; one on the left being empty. (match tokens (((? (cut string-any char-set:ifs/nw <>)) . rest) (cons "" tokens)) (_ tokens))))) (_ (append-map wedge-apart qword)))) (let ((wedged (wedge-apart qword))) (filter pair? (list-split wedged 'wedge)))) (define (argument-separator ifs) "Find the argument separator string by taking the first character of the string @var{ifs}. If @var{ifs} is @code{#f} the separator will be a space (@code{\" \"}), and if @var{ifs} is null (@code{\"\"}) the separator will be null as well." (let ((ifs (or ifs " "))) (if (string-null? ifs) "" (string (string-ref ifs 0))))) (define (remove-quotes qword ifs) "Remove quote forms from @var{qword} and concatenate the result into a single field (string). When converting an argument list to a string, the separator is derived from @var{ifs} using @code{argument-separator}." (let loop ((qword (normalize-word qword)) (acc '())) (match qword (() (string-concatenate-reverse acc)) (((' qword*) . t) (loop t (cons (remove-quotes qword* ifs) acc))) (((' vals) . t) (let ((sep (argument-separator ifs))) (loop t (cons (string-join vals sep) acc)))) (((? string? h) . t) (loop t (cons h acc))) (((qwords ...) . t) (loop t (cons (remove-quotes qwords ifs) acc)))))) (define (qword->pattern-string qword ifs) (let loop ((qword (normalize-word qword)) (acc '())) (match qword (() (string-concatenate-reverse acc)) (((' qword*) . t) (loop t (cons (pattern-quote (remove-quotes qword* ifs)) acc))) (((? string? h) . t) (loop t (cons h acc)))))) (define (qword->pattern qword ifs) (parse-pattern (qword->pattern-string qword ifs))) (define (find-files base patterns) "Find all the files starting from @var{base} where each node of the file's relative path matchs the corresponding pattern in @var{patterns}." (define (make-select pattern) (cut pattern-match? pattern <> #:explicit-initial-period? #t)) (define (ensure-directory path) (and (scandir (string-append base "/" path)) (string-append path "/"))) (define* (list-directory path pattern) (map (cond ((string-null? path) values) ((string-every #\/ path) (cut string-append path <>)) (else (cut string-append path "/" <>))) (or (scandir (string-append base "/" path) (make-select pattern)) '()))) (let loop ((paths (list "")) (patterns patterns)) (match patterns (() paths) (((? pattern-null?) . rest) (loop (filter-map ensure-directory paths) rest)) ((pattern . rest) (loop (append-map (cut list-directory <> pattern) paths) rest))))) (define (expand-pathnames qword pwd ifs) "Interpret @var{qword} as a pattern and find all files matching that pattern. If no files are found, return a singleton list containing a string version of @var{qword}. If the pattern is relative, @var{pwd} will be used as the current directory. If @var{qword} contains preserved fields (e.g., @code{\"$@\"}), @var{ifs} will be used to faltten them." (define absolute? (match-lambda (((? pattern-null?) . _) #t) (_ #f))) (if (getopt 'noglob) `(,(remove-quotes qword ifs)) (let* ((pattern-string (qword->pattern-string qword ifs)) (patterns (map parse-pattern (string-split pattern-string #\/))) (base (if (absolute? patterns) "/" pwd))) (if (every pattern-plain? patterns) `(,(remove-quotes qword ifs)) (match (find-files base patterns) (() `(,(remove-quotes qword ifs))) (matches matches)))))) (define* (parameter-ref name #:optional dflt) "Get the value of the variable or special parameter @var{name} from the environment. If @var{name} is unset, return @var{dflt} if provided or @code{#f} if not." (match name ("@" `( ,(cdr (program-arguments)))) ("*" (let* ((ifs (or (getvar "IFS") (string #\space #\tab #\newline))) (sep (argument-separator ifs))) (string-join (cdr (program-arguments)) sep))) ("0" (car (program-arguments))) ("#" (number->string (length (cdr (program-arguments))))) ("?" (number->string (get-status))) ("$" (number->string (get-root-pid))) ("!" (cond ((get-last-job) => number->string) (else dflt))) (("LINENO" . line) (number->string line)) (x (let ((n (string->number x))) (if (and n (integer? n) (> n 0) (<= n (length (cdr (program-arguments))))) (list-ref (program-arguments) n) (getvar name dflt)))))) (define* (expand-qword qword #:key (output 'fields) (rhs-tildes? #f)) "Expand @var{qword} into a list of fields." (let ((ifs (getvar "IFS" (string #\space #\tab #\newline))) (pwd (getvar "PWD"))) (match output ('fields (if pwd (append-map (cut expand-pathnames <> pwd ifs) (split-fields qword ifs)) (map (cut remove-quotes <> ifs) (split-fields qword ifs)))) ('string (remove-quotes qword ifs)) ('pattern (qword->pattern qword ifs)))))