;;; 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->fdes port i))
                (#f #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
       (dynamic-wind
         install-current-ports!
         (lambda ()
           (catch 'system-error
             (lambda ()
               (apply execle path utility-env name args))
             (lambda args
               ;; Since PATH is already the absolute file name of an
               ;; executable file, the kind of error we can get here is
               ;; ENOENT if the script or ELF interpreter is not found.
               (format (current-error-port)
                       "~a: ~a: ~a~%"
                       (car (program-arguments)) path
                       (strerror (system-error-errno args)))
               (primitive-exit 126))))
         (lambda ()
           ;; Always terminate the child process.
           (primitive-exit 127))))
      (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."

  (define* (make-processed-redir fd target #:optional (open-flags 0))
    (let ((port (match target
                  ((? port?) 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 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?)
                      (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 ()
           (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))))
                     (parameterize ((current-input-port in)
                                    (current-output-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)))
