src/base.lisp @ 0329a2650981

Implement batch mode, CLI args, minor cleanups
author Steve Losh <steve@stevelosh.com>
date Mon, 20 Jun 2022 23:19:54 -0400
parents 024e32992c58
children (none)
(in-package :cacl)

;;;; Config -------------------------------------------------------------------
(defparameter *undo-limit* 30)


;;;; State --------------------------------------------------------------------
(defvar *running* nil)
(defvar *stack* nil)
(defvar *previous* nil)
(defvar *commands* nil)


;;;; Stack --------------------------------------------------------------------
(defun push! (&rest objects)
  (dolist (o objects)
    (push (if (floatp o)
            (coerce o 'double-float)
            o)
          *stack*)))

(defun pop! ()
  (assert *stack* () "Cannot pop empty stack")
  (pop *stack*))

(defun pop-all! ()
  (prog1 *stack* (setf *stack* nil)))


(defmacro with-args (symbols &body body)
  `(let (,@(iterate (for symbol :in (reverse symbols))
                    (collect `(,symbol (pop!)))))
     ,@body))

(defmacro with-read-only-args (symbols &body body)
  `(let (,@(iterate (for i :from 0)
                    (for symbol :in (reverse symbols))
                    (collect `(,symbol (nth ,i *stack*)))))
     ,@body))


;;;; Undo ---------------------------------------------------------------------
(defun save-stack ()
  (unless (eql *stack* (car *previous*))
    (push *stack* *previous*))
  (setf *previous* (subseq *previous* 0 (min (1+ *undo-limit*)
                                             (length *previous*)))))

(defun save-thunk (thunk)
  (push thunk *previous*))

(defun undo ()
  (assert (cdr *previous*) () "Cannot undo any further")
  ;; The first element in *previous* is the current stack, so remove it.
  (pop *previous*)
  (let ((top (car *previous*)))
    (etypecase top
      (list nil)
      (function (funcall top)
                (pop *previous*)))
    (setf *stack* (car *previous*))))


;;;; Help ---------------------------------------------------------------------
(defun first-letter (command)
  (let ((ch (aref (symbol-name command) 0)))
    (if (alphanumericp ch)
      ch
      #\!)))

(defun partition-commands (commands)
  (mapcar (lambda (letter-and-commands)
            (sort (second letter-and-commands) #'string<))
          (sort (hash-table-contents (group-by #'first-letter commands))
                #'char< :key #'first)))

(defun print-version ()
  (format t "CACL v0.0.0 (~A)~%"
          #+sbcl 'sbcl
          #+ccl 'ccl
          #+ecl 'ecl
          #+abcl 'abcl))

(defun print-help ()
  (terpri)
  (format t "CACL is an RPN calculator written in Common Lisp.~@
          ~@
          The current stack is displayed above the prompt (the top is at the right).~@
          ~@
          Forms are read from standard input with the standard Common Lisp READ function.~@
          This means you can put multiple things on one line if you want, like this:~@
          ~%    1 2 +~@
          ~@
          What happens when a form is read depends on the form:~@
          ~@
          * Numbers and characters are pushed onto the stack.~@
          * Symbols run commands.~@
          * Quoted forms are pushed onto the stack.~@
          ~@
          Type `commands` for a list of available commands.~@
          ~@
          To get help for a particular command, push its symbol onto the stack~@
          and run the `doc` command:~@
          ~%    'float doc~@
          "))

(defun print-commands ()
  (terpri)
  (format t "AVAILABLE COMMANDS:~@
             ~(~{~{~A~^ ~}~%~}~)~%"
          (partition-commands *commands*)))


;;;; Commands -----------------------------------------------------------------
(defgeneric command (symbol))

(defmethod command ((symbol symbol))
  (error "Unknown command ~S" symbol))


(defgeneric command-documentation (symbol))

(defmethod command-documentation (object)
  (flet ((friendly-type (object)
           (let ((type (type-of object)))
             (if (consp type) (first type) type))))
    (error "Cannot retrieve documentation for ~S ~S"
           (friendly-type object) object)))

(defmethod command-documentation ((symbol symbol))
  (error "Unknown command ~S" symbol))


(defmacro define-command% (symbol args read-only &body body)
  (multiple-value-bind (forms declarations documentation)
      (alexandria:parse-body body :documentation t)
    `(progn
       (defmethod command ((symbol (eql ',symbol)))
         (,(if read-only 'with-read-only-args 'with-args) ,args
          ,@declarations
          ,@forms))
       (defmethod command-documentation ((symbol (eql ',symbol)))
         (format nil "~A ~:S~A" ',symbol ',args
                 ,(if documentation
                    (format nil "~2%~A" (string-right-trim
                                          #(#\newline #\space)
                                          documentation))
                    "")))
       (pushnew ',symbol *commands*))))

(defmacro define-command (symbol-or-symbols args &body body)
  (let ((read-only (member '&read-only args))
        (args (remove '&read-only args)))
    `(progn ,@(iterate
                (for symbol :in (alexandria:ensure-list symbol-or-symbols))
                (collect `(define-command% ,symbol ,args ,read-only ,@body))))))

(defmacro define-simple-command (symbols args &optional (lisp-function (first symbols)))
  `(define-command ,symbols ,args
     (push! (,lisp-function ,@args))))

(defmacro define-constant-command (symbol value)
  `(define-command ,symbol ()
     ,(format nil "Push ~A (~A)." symbol (eval value))
     (push! ,value)))


;;;; Commands/IO --------------------------------------------------------------
(define-command pbc (&read-only x)
  "Copy `x` to the system clipboard.  It will remain on the stack."
  (pbcopy x))

(define-command pbp ()
  "Push the contents of the system clipboard as a string."
  (push! (pbpaste)))


;;;; Commands/Stack -----------------------------------------------------------
(define-command (clear cl) ()
  "Clear the entire stack."
  (pop-all!))

(define-command (print p) (&read-only item)
  "Print `item`.  It will remain on the stack."
  (princ (structural-string item))
  (terpri)
  (force-output))

(define-command (pprint pp) (&read-only item)
  "Pretty print `item`.  It will remain on the stack."
  (pprint item)
  (terpri)
  (force-output))

(define-command (dup d) (x)
  "Duplicate `x`."
  (push! x x))

(define-command pop ()
  "Pop the top element of the stack."
  (pop!))

(define-command (length len) (item)
  "Pop `item` and push its length."
  (push! (length item)))

(define-command (swap x) (x y)
  "Exchange the top two elements of the stack."
  (push! y x))

(define-command (reverse rev) ()
  "Reverse the stack."
  (setf *stack* (reverse *stack*)))

(define-command (hist history) ()
  (let ((*read-default-float-format* 'double-float))
    (flet ((print-entry (e)
             (typecase e
               (list (print-stack :stack e))
               (t (prin1 e) (terpri)))))
      (mapc #'print-entry (reverse *previous*))))
  (terpri))

(define-command count ()
  "Push the length of the stack."
  (push! (length *stack*)))

(define-command (undo un) ()
  (undo)
  (throw :do-not-add-undo-state nil))


;;;; Commands/System ----------------------------------------------------------
(define-command doc (symbol)
  "Print the documentation for the symbol at the top of the stack."
  (write-line (command-documentation symbol)))

(define-command help ()
  "Print some basic help information."
  (print-help))

(define-command commands ()
  "Print a list of available commands."
  (print-commands))

(define-command reload ()
  "Reload the entire CACL system from Quicklisp."
  (funcall (read-from-string "ql:quickload") :cacl))

(define-command (quit q) ()
  "Quit CACL."
  (setf *running* nil))

(define-command version ()
  "Print the version and host Lisp."
  (print-version))

(define-command nop ()
  "Do nothing.")


;;;; Commands/Misc ------------------------------------------------------------
(define-command char-code (char)
  (push! (char-code char)))

(define-command code-char (code)
  (push! (code-char code)))


;;;; Special Forms ------------------------------------------------------------
(defgeneric special-form (symbol &rest body))

(defmacro define-special-form (symbol arguments &rest body)
  (let ((args (gensym "ARGUMENTS")))
    `(defmethod special-form ((symbol (eql ',symbol)) &rest ,args)
       (destructuring-bind ,arguments ,args
         ,@body))))

(define-special-form quote (value)
  (push! value))


;;;; REPL ---------------------------------------------------------------------
(defmacro with-errors-handled (&body body)
  (alexandria:with-gensyms (old-stack)
    `(let ((,old-stack *stack*))
       (handler-case (progn ,@body)
         (error (e)
           (format t "~A: ~A~%" (type-of e) e)
           (setf *stack* ,old-stack))))))


(defun read-input ()
  (let ((*read-default-float-format* 'double-float)
        (line (read-line *standard-input* nil :eof nil)))
    (if (eq :eof line)
      (setf *running* nil)
      (read-all-from-string line))))

(defun handle-input (input)
  (with-errors-handled
    (catch :do-not-add-undo-state
      (etypecase input
        ((or number string character) (push! input))
        (symbol (command input))
        (cons (apply 'special-form input)))
      (save-stack))))

(defun handle-all-input ()
  (mapc #'handle-input (read-input)))


(defun print-stack (&key (stack *stack*) (decorated t))
  (when decorated
    (write-char #\())
  (let ((*read-default-float-format* 'double-float))
    (format t "~{~S~^ ~}" (reverse stack)))
  (when decorated
    (write-char #\))
    (terpri))
  (force-output))

(defun print-prompt ()
  (princ "? ")
  (force-output))


(defun run (&key (batch nil))
  (setf *running* t
        *stack* nil
        *previous* (list nil))
  (let ((*package* (find-package :cacl)))
    (iterate (while *running*)
             (progn
               (unless batch
                 (terpri)
                 (print-stack)
                 (print-prompt))
               (handle-all-input)))
    (when batch
      (print-stack :decorated nil)))
  (values))


;;;; Command Line -------------------------------------------------------------
(adopt:define-string *documentation*
  "CACL is a TUI RPN calculator.~@
  ~@
  Documentation about the command-line options to the CACL binary follows.  ~
  For information about how to use CACL itself type help when running in interactive mode.")

(defparameter *o-help*
  (adopt:make-option 'help
    :help "display help and exit"
    :long "help"
    :short #\h
    :reduce (constantly t)))

(defparameter *o-rcfile*
  (adopt:make-option 'rcfile
    :help "path to the custom initialization file (default ~/.caclrc)"
    :long "rcfile"
    :parameter "PATH"
    :initial-value "~/.caclrc"
    :reduce #'adopt:last))

(defparameter *o-no-rcfile*
  (adopt:make-option 'no-rcfile
    :result-key 'rcfile
    :help "disable loading of any rcfile"
    :long "no-rcfile"
    :reduce (constantly nil)))

(defparameter *o-interactive*
  (adopt:make-option 'interactive
    :result-key 'batch
    :help "run in interactive mode (the default)"
    :long "interactive"
    :short #\i
    :initial-value nil
    :reduce (constantly nil)))

(defparameter *o-batch*
  (adopt:make-option 'batch
    :result-key 'batch
    :help "run in batch processing mode"
    :long "batch"
    :short #\b
    :reduce (constantly t)))

(defparameter *o-inform*
  (adopt:make-option 'inform
    :help "print informational message at startup (the default)"
    :long "inform"
    :initial-value t
    :reduce (constantly t)))

(defparameter *o-no-inform*
  (adopt:make-option 'no-inform
    :result-key 'inform
    :help "suppress printing of informational message at startup"
    :long "no-inform"
    :reduce (constantly nil)))


(defparameter *ui*
   (adopt:make-interface
     :name "cacl"
     :usage "[OPTIONS]"
     :summary "A TUI RPN calculator written and customizable in Common Lisp."
     :help *documentation*
     :contents
     (list *o-help*
           *o-rcfile*
           *o-no-rcfile*
           *o-interactive*
           *o-batch*
           *o-inform*
           *o-no-inform*)))


(defun input-source (arguments)
  (if (null arguments)
    *standard-input*
    (make-string-input-stream (str:join #\space arguments))))

(defun toplevel ()
  ;; ccl clobbers the pprint dispatch table when dumping an image, no idea why
  (set-pprint-dispatch 'hash-table 'losh:pretty-print-hash-table)
  (adopt::quit-on-ctrl-c ()
    (multiple-value-bind (arguments options) (adopt:parse-options *ui*)
      (when (gethash 'help options)
        (adopt:print-help-and-exit *ui*))
      (when (gethash 'inform options)
        (print-version))
      (when-let ((rc (gethash 'rcfile options)))
        (load rc :if-does-not-exist nil))
      (let ((*standard-input* (input-source arguments)))
        (run :batch (gethash 'batch options))))))