467 lines
16 KiB
Scheme
467 lines
16 KiB
Scheme
;;; Gash -- Guile As SHell
|
||
;;; Copyright © 2018, 2019, 2020 Timothy Sample <samplet@ngyro.com>
|
||
;;;
|
||
;;; 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 <http://www.gnu.org/licenses/>.
|
||
|
||
(define-module (gash shell)
|
||
#:use-module (gash built-ins)
|
||
#:use-module (gash compat)
|
||
#:use-module (gash compat textual-ports)
|
||
#:use-module (gash environment)
|
||
#:use-module (gash pattern)
|
||
#:use-module (ice-9 match)
|
||
#:use-module (srfi srfi-1)
|
||
#:use-module (srfi srfi-26)
|
||
#:export (sh:and
|
||
sh:async
|
||
sh:case
|
||
sh:cond
|
||
sh:exec-let
|
||
sh:exec
|
||
sh:for
|
||
sh:not
|
||
sh:or
|
||
sh:pipeline
|
||
sh:set-redirects
|
||
sh:subshell
|
||
sh:substitute-command
|
||
sh:while
|
||
sh:with-redirects
|
||
sh:until))
|
||
|
||
;;; Commentary:
|
||
;;;
|
||
;;; This module provides functions for executing Shell language
|
||
;;; constructs.
|
||
;;;
|
||
;;; Code:
|
||
|
||
(define ignore-errexit? (make-parameter #f))
|
||
|
||
(define (without-errexit thunk)
|
||
(parameterize ((ignore-errexit? #t)) (thunk)))
|
||
|
||
(define (errexit)
|
||
(unless (or (zero? (get-status)) (ignore-errexit?))
|
||
(when (getopt 'errexit)
|
||
(sh:exit))))
|
||
|
||
(define (install-current-ports!)
|
||
"Install all current ports into their usual file descriptors. For
|
||
example, if @code{current-input-port} is a @code{file-port?}, make the
|
||
process file descriptor 0 refer to the file open for
|
||
@code{current-input-port}. If any current port is a @code{port?} but
|
||
not a @code{file-port?}, its corresponding file descriptor will refer
|
||
to @file{/dev/null}."
|
||
;; XXX: Input/output ports? Closing other FDs?
|
||
(for-each (lambda (i)
|
||
(match ((fd->current-port i))
|
||
((? file-port? port)
|
||
(dup port i))
|
||
((? input-port? port)
|
||
(dup (open-file "/dev/null" "r") i))
|
||
((? output-port? port)
|
||
(dup (open-file "/dev/null" "w") i))
|
||
(_ #t)))
|
||
(iota *fd-count*)))
|
||
|
||
(define (exec-utility bindings path name args)
|
||
"Execute @var{path} as a subprocess with extra environment variables
|
||
@var{bindings}. The first argument given to the new process will be
|
||
@var{name}, and the rest of the arguments will be @var{args}."
|
||
(let ((utility-env (get-environ bindings)))
|
||
;; We need to flush all ports here to ensure the proper sequence
|
||
;; of output. Without flushing, output that we have written could
|
||
;; stay in a buffer while the utility (which does not know about
|
||
;; the buffer) produces its output.
|
||
(flush-all-ports)
|
||
(match (primitive-fork)
|
||
(0 (install-current-ports!)
|
||
(apply execle path utility-env name args))
|
||
(pid (match-let (((pid . status) (waitpid pid)))
|
||
(set-status! (status:exit-val status)))))))
|
||
|
||
(define (slashless? s)
|
||
"Test if the string @var{s} does not contain any slashes ('/')."
|
||
(not (string-index s #\/)))
|
||
|
||
(define (split-search-path s)
|
||
"Split the search path string @var{s}."
|
||
(if (string-null? s) '() (string-split s #\:)))
|
||
|
||
(define (find-utility name)
|
||
"Search for the path of the utility @var{name} using the current
|
||
search path as specified by the environment variable @code{$PATH}. If
|
||
it cannot be found, return @code{#f}."
|
||
(let loop ((prefixes (split-search-path (getvar "PATH" ""))))
|
||
(and (pair? prefixes)
|
||
(let* ((prefix (car prefixes))
|
||
(path (if (string-suffix? "/" prefix)
|
||
(string-append prefix name)
|
||
(string-append prefix "/" name))))
|
||
(if (access? path X_OK)
|
||
path
|
||
(loop (cdr prefixes)))))))
|
||
|
||
(define (sh:exec-let bindings name . args)
|
||
"Find and execute @var{name} with arguments @var{args} and extra
|
||
environment variable bindings @var{bindings}."
|
||
(when (getopt 'xtrace)
|
||
(format (current-error-port) "+~a\n"
|
||
(string-join (cons name
|
||
(map (lambda (x)
|
||
(if (string-null? x) "''"
|
||
x)) args)))))
|
||
(if (slashless? name)
|
||
(or (and=> (search-special-built-ins name)
|
||
(lambda (proc)
|
||
(for-each (match-lambda
|
||
((name . value)
|
||
(setvar! name value)))
|
||
bindings)
|
||
(set-status! (apply proc args))))
|
||
(and=> (getfun name)
|
||
(lambda (proc)
|
||
(save-variables-excursion (map car bindings)
|
||
(lambda ()
|
||
(for-each (match-lambda
|
||
((name . value)
|
||
(setvar! name value)))
|
||
bindings)
|
||
(with-arguments (cons (car (program-arguments)) args)
|
||
(lambda ()
|
||
(call-with-return
|
||
(lambda ()
|
||
(apply proc args)))))))))
|
||
(and=> (search-built-ins name)
|
||
(lambda (proc)
|
||
(save-variables-excursion (map car bindings)
|
||
(lambda ()
|
||
(for-each (match-lambda
|
||
((name . value)
|
||
(setvar! name value)))
|
||
bindings)
|
||
(let ((exit-val (apply proc args)))
|
||
(set-status! exit-val))))))
|
||
(and=> (find-utility name)
|
||
(lambda (path)
|
||
(exec-utility bindings path name args)))
|
||
(begin (format (current-error-port)
|
||
"~a: ~a: Command not found.~%"
|
||
(car (program-arguments)) name)
|
||
(set-status! 127)))
|
||
(exec-utility bindings name name args))
|
||
(errexit))
|
||
|
||
(define (sh:exec name . args)
|
||
"Find and execute @var{name} with arguments @var{args}."
|
||
(apply sh:exec-let '() name args))
|
||
|
||
|
||
;;; Redirects.
|
||
|
||
(define (process-redir redir)
|
||
"Convert @var{redir} into a list consisting of the current-port
|
||
parameter to be updated, the port that should be its new value (or
|
||
@code{#f} if it should be considered closed), and a boolean indicating
|
||
if it is our responsibility to close the port."
|
||
|
||
;; On Mes, 'port?' does not really work. Hence, if TARGET is a
|
||
;; port, it will be wrapped in a list. This means that the 'pair?'
|
||
;; case below is actaully checking for a port.
|
||
(define* (make-processed-redir fd target #:optional (open-flags 0))
|
||
(let ((port (match target
|
||
((? pair?) (car target))
|
||
((? string?) (open target open-flags))
|
||
;; TODO: Verify open-flags.
|
||
((? integer?) ((fd->current-port target)))
|
||
(#f #f))))
|
||
`(,(fd->current-port fd) ,port ,(string? target))))
|
||
|
||
(match redir
|
||
(('< (? integer? fd) (? string? filename))
|
||
(make-processed-redir fd filename O_RDONLY))
|
||
(('> (? integer? fd) (? string? filename))
|
||
(let* ((clobber-flags (logior O_WRONLY O_CREAT O_TRUNC))
|
||
(flags (if (getopt 'noclobber)
|
||
(logior clobber-flags O_EXCL)
|
||
clobber-flags)))
|
||
(make-processed-redir fd filename flags)))
|
||
(('>! (? integer? fd) (? string? filename))
|
||
(make-processed-redir fd filename (logior O_WRONLY O_CREAT O_TRUNC)))
|
||
(('>> fd filename)
|
||
(make-processed-redir fd filename (logior O_WRONLY O_CREAT O_APPEND)))
|
||
(('<> fd filename)
|
||
(make-processed-redir fd filename (logior O_RDWR O_CREAT)))
|
||
(('<& (? integer? fd1) (? integer? fd2))
|
||
(make-processed-redir fd1 fd2))
|
||
(('<& (? integer? fd) '-)
|
||
(make-processed-redir fd #f))
|
||
(('>& (? integer? fd1) (? integer? fd2))
|
||
(make-processed-redir fd1 fd2))
|
||
(('>& (? integer? fd) '-)
|
||
(make-processed-redir fd #f))
|
||
(('<< (? integer? fd) text)
|
||
(let ((port (tmpfile)))
|
||
(display text port)
|
||
(seek port 0 SEEK_SET)
|
||
(make-processed-redir fd (list port))))))
|
||
|
||
(define (sh:set-redirects redirs)
|
||
"Put the redirects @var{redirs} into effect."
|
||
(let loop ((redirs redirs))
|
||
(match redirs
|
||
(() #t)
|
||
((redir . rest)
|
||
(match (false-if-exception (process-redir redir))
|
||
(#f (set-status! 1)
|
||
(errexit))
|
||
((parameter port close?)
|
||
(parameter port)
|
||
(loop rest)))))))
|
||
|
||
(define (sh:with-redirects redirs thunk)
|
||
"Call @var{thunk} with the redirects @var{redirs} in effect."
|
||
;; This may be too clever! We need to parameterize a variable
|
||
;; number of things in a particular order, and this seems to be the
|
||
;; only way.
|
||
((fold-right (lambda (redir thunk)
|
||
(lambda ()
|
||
(match (false-if-exception (process-redir redir))
|
||
(#f (set-status! 1)
|
||
(errexit))
|
||
((parameter port close?)
|
||
(cond
|
||
((eq? parameter current-0-port)
|
||
(with-input-from-port port thunk))
|
||
((eq? parameter current-1-port)
|
||
(with-output-to-port port thunk))
|
||
((eq? parameter current-2-port)
|
||
(with-error-to-port port thunk))
|
||
(else
|
||
(parameterize ((parameter port)) (thunk))))
|
||
(cond
|
||
(close? (close-port port))
|
||
((output-port? port) (force-output port)))))))
|
||
thunk
|
||
redirs)))
|
||
|
||
|
||
;;; Subshells and command substitution.
|
||
|
||
(define* (%subshell thunk)
|
||
"Run @var{thunk} in a new process and return the ID of the new
|
||
process."
|
||
;; We need to flush all ports before forking to avoid copying the
|
||
;; port buffers into the child process, which could lead to
|
||
;; duplicate output.
|
||
(flush-all-ports)
|
||
(match (primitive-fork)
|
||
(0 (dynamic-wind
|
||
(lambda () #t)
|
||
(lambda ()
|
||
(cond-expand
|
||
(mes #f)
|
||
(else (restore-signals)))
|
||
(set-atexit! #f)
|
||
;; We need to preserve the status given to 'exit', so we
|
||
;; catch the 'quit' key here.
|
||
(catch 'quit
|
||
thunk
|
||
(lambda (_ status)
|
||
(primitive-exit status)))
|
||
(primitive-exit (get-status)))
|
||
(lambda ()
|
||
(primitive-exit 1))))
|
||
(pid pid)))
|
||
|
||
(define (sh:subshell thunk)
|
||
"Run @var{thunk} in a subshell environment."
|
||
(match-let* ((pid (%subshell thunk))
|
||
((pid . status) (waitpid pid)))
|
||
(set-status! (status:exit-val status))
|
||
(errexit)))
|
||
|
||
(define (sh:substitute-command thunk)
|
||
"Run @var{thunk} in a subshell environment and return its output as
|
||
a string."
|
||
(match-let* (((sink . source) (pipe))
|
||
(thunk* (lambda ()
|
||
(close-port sink)
|
||
(with-output-to-port source thunk)))
|
||
(pid (%subshell thunk*)))
|
||
(close-port source)
|
||
(match-let ((result (string-trim-right (get-string-all sink) #\newline))
|
||
((pid . status) (waitpid pid)))
|
||
(set-status! (status:exit-val status))
|
||
(errexit)
|
||
result)))
|
||
|
||
|
||
;;; Pipelines.
|
||
|
||
(define (plumb in out close thunk)
|
||
"Run @var{thunk} in a new process with @code{current-input-port} set
|
||
to @var{in} and @code{current-output-port} set to @var{out}. If
|
||
@var{in} or @var{out} is @code{#f}, the corresponding ``current'' port
|
||
is left unchanged. Unless it is @code{#f}, the port @var{close} will
|
||
be closed in the new process."
|
||
(let* ((thunk* (lambda ()
|
||
(when close (close-port close))
|
||
(let ((in (or in (current-input-port)))
|
||
(out (or out (current-output-port))))
|
||
(with-input-from-port in
|
||
(lambda ()
|
||
(with-output-to-port out
|
||
thunk))))))
|
||
(pid (%subshell thunk*)))
|
||
(when in (close-port in))
|
||
(when out (close-port out))
|
||
pid))
|
||
|
||
(define (waitpid/any pids)
|
||
"Wait for any process with an ID in the list @var{pids} to terminate
|
||
and return its status information."
|
||
(let loop ((interval 10))
|
||
(or (any (lambda (pid)
|
||
(match (waitpid pid WNOHANG)
|
||
((0 . _) #f)
|
||
(x x)))
|
||
pids)
|
||
(begin
|
||
(usleep interval)
|
||
(loop (if (< interval 160)
|
||
(* 2 interval)
|
||
interval))))))
|
||
|
||
(define (sh:pipeline . thunks)
|
||
"Run each thunk in @var{thunks} in its own process with the output
|
||
of each thunk sent to the input of the next thunk."
|
||
(define (plumb-thunks)
|
||
(let loop ((thunks thunks) (in #f) (pids '()))
|
||
(match thunks
|
||
(() pids)
|
||
((thunk) (reverse! (cons (plumb in #f #f thunk) pids)))
|
||
((thunk . rest)
|
||
(match-let (((next-in . out) (pipe)))
|
||
(loop rest next-in (cons (plumb in out next-in thunk) pids)))))))
|
||
|
||
(let ((pids (plumb-thunks)))
|
||
(unless (null? pids)
|
||
(let ((last-pid (last pids)))
|
||
;; We have to wait for all the processes to finish so we can
|
||
;; reap them. We do this by polling them with 'waitpid'.
|
||
;; This is something of a naive approach, but it works for now
|
||
;; and will be easier to improve later when we have job
|
||
;; control.
|
||
(let loop ((pids pids))
|
||
(unless (null? pids)
|
||
(match (waitpid/any pids)
|
||
((pid . status)
|
||
(when (= pid last-pid)
|
||
(set-status! (status:exit-val status)))
|
||
(loop (remove (cut = pid <>) pids)))))))
|
||
(errexit))))
|
||
|
||
|
||
;;; Boolean expressions.
|
||
|
||
(define (sh:and thunk1 thunk2)
|
||
"Run @var{thunk1} and if it exits with status zero, run
|
||
@var{thunk2}."
|
||
(without-errexit thunk1)
|
||
(when (= (get-status) 0)
|
||
(thunk2)
|
||
(errexit)))
|
||
|
||
(define (sh:or thunk1 thunk2)
|
||
"Run @var{thunk1} and if it exits with a nonzero status, run
|
||
@var{thunk2}."
|
||
(without-errexit thunk1)
|
||
(unless (= (get-status) 0)
|
||
(thunk2)
|
||
(errexit)))
|
||
|
||
(define (sh:not thunk)
|
||
"Run @var{thunk}, inverting its exit status."
|
||
(without-errexit thunk)
|
||
(let ((inverted-status (if (= (get-status) 0) 1 0)))
|
||
(set-status! inverted-status)))
|
||
|
||
|
||
;;; Loops.
|
||
|
||
(define (sh:for bindings thunk)
|
||
"Run @var{thunk} for each binding in @var{bindings}. The value of
|
||
@var{bindings} have the form @code{(@var{name} (@var{value} ...))}."
|
||
(set-status! 0)
|
||
(match-let (((name (values ...)) bindings))
|
||
(call-with-break
|
||
(lambda ()
|
||
(for-each (lambda (value)
|
||
(setvar! name value)
|
||
(call-with-continue thunk))
|
||
values)))))
|
||
|
||
(define (sh:while test-thunk thunk)
|
||
(call-with-break
|
||
(lambda ()
|
||
(let loop ((last-status 0))
|
||
(without-errexit test-thunk)
|
||
(cond
|
||
((= (get-status) 0)
|
||
(call-with-continue thunk)
|
||
(loop (get-status)))
|
||
(else
|
||
(set-status! last-status)))))))
|
||
|
||
(define (sh:until test-thunk thunk)
|
||
(sh:while (lambda () (sh:not test-thunk)) thunk))
|
||
|
||
|
||
;;; Conditionals.
|
||
|
||
(define (sh:case value . cases)
|
||
(set-status! 0)
|
||
(let loop ((cases cases))
|
||
(match cases
|
||
(() #t)
|
||
(((patterns thunk) . tail)
|
||
(if (any (cut pattern-match? <> value) patterns)
|
||
(thunk)
|
||
(loop tail))))))
|
||
|
||
(define (sh:cond . cases)
|
||
(let loop ((cases cases))
|
||
(match cases
|
||
(() (set-status! 0))
|
||
(((#t thunk))
|
||
(thunk))
|
||
(((test-thunk thunk) . tail)
|
||
(without-errexit test-thunk)
|
||
(if (= (get-status) 0)
|
||
(thunk)
|
||
(loop tail))))))
|
||
|
||
|
||
;;; Asynchronous commands.
|
||
|
||
(define (sh:async thunk)
|
||
"Run @var{thunk} asynchronously."
|
||
(let ((pid (%subshell thunk)))
|
||
(set-last-job! pid)
|
||
(set-status! 0)))
|