changelog shortlog graph tags branches changeset files revisions annotate raw help

Mercurial > core / lisp/lib/cli/pkg.lisp

changeset 116: 797d729e14cc
parent: cae8da4b1415
child: d0b235557fab
author: ellis <ellis@rwest.io>
date: Thu, 21 Dec 2023 15:10:14 -0500
permissions: -rw-r--r--
description: added cli/ansi and did some work on ffi/xkb
1 ;;; cli.lisp --- cli programming api and utils
2 
3 ;; This package contains a simple api and macros for building lisp CLI
4 ;; programs.
5 
6 ;;; Commentary:
7 
8 ;; - inspired by: clingon, uiop
9 
10 ;; Basic assumptions at runtime:
11 ;; - running in a POSIX-compliant shell
12 ;; - output stream supports UTF-8
13 
14 ;; TODO 2023-10-14: install-ast, install-thunk, proc-args, etc should
15 ;; return IR types - CLI-IR THUNK and CLI-IR respectively.
16 
17 ;; TODO 2023-10-14: rename cli-ast to cli-ir, install-ast to
18 ;; install-ir, etc.
19 
20 ;;; Code:
21 (uiop:define-package :cli
22  (:use :cl :std :log :sb-ext)
23  (:import-from :uiop :println)
24  (:import-from :sb-ext :parse-native-namestring)
25  (:shadowing-import-from :sb-ext :exit)
26  (:export
27  :*argv*
28  :init-args
29  :cli-arg0
30  :cli-args
31  :command-line-args
32  :*cli-group-separator*
33  :*cli-opt-kinds*
34  :global-opt-p
35  :exec-path-list
36  :program-list
37  :find-exe
38  :ld-library-path-list
39  :argp
40  :$val
41  :$args
42  :$argc
43  :$opts
44  :$optc
45  :make-shorty
46  :with-cli-handlers
47  :completing-read
48  :defprompt
49  :defmain
50  :main
51  :with-cli
52  :make-cli
53  ;; opt-parsers
54  :make-opt-parser
55  :parse-bool-opt
56  :parse-str-opt
57  :parse-form-opt
58  :parse-list-opt
59  :parse-sym-opt
60  :parse-key-opt
61  :parse-num-opt
62  :parse-file-opt
63  :parse-dir-opt
64  :make-opts
65  :make-cmds
66  :active-opts
67  :active-cmds
68  :proc-args
69  :make-cli-node
70  :make-cli-ast
71  :proc-args
72  :parse-args
73  :debug-opts
74  :do-cmd
75  :do-opt
76  :call-opt
77  :call-cmd
78  :apply-cmd
79  :print-help
80  :print-version
81  :print-usage
82  :handle-unknown-argument
83  :handle-missing-argument
84  :handle-invalid-argument
85  :cli-opt
86  :cli-val
87  :cli-cmd-args
88  :cli-cmd
89  :cli-cwd
90  :find-cmd
91  :find-opt
92  :find-short-opt
93  :install-ast
94  ;; :gen-cli-thunk
95  :install-thunk
96  :cli
97  :cli-equal
98  :defopt
99  :defcmd
100  :define-cli
101  ;; ast types
102  :opt
103  :cmd
104  ;; :arg
105  :cli-name
106  :cli-opts
107  :cli-cmds
108  :cli-thunk
109  :cli-description
110  :cli-version
111  :cli-usage))
112 
113 (defpackage :cli/ansi
114  (:use :cl :std)
115  (:shadow :ed)
116  (:nicknames :ansi)
117  (:export
118  ;; ESC sequences
119  :ris :reset-to-initial-state
120  ;; CSI sequences
121  ;; Cursor control
122  :cuu :cursor-up
123  :cud :cursor-down
124  :cuf :cursor-forward
125  :cub :cursor-backward
126  :cnl :cursor-next-line
127  :cpl :cursor-preceding-line
128  :cha :cursor-horizontal-absolute
129  :cup :cursor-position
130  :vpa :vertical-position-absolute
131  :vpr :vertical-position-relative
132  :vpb :vertical-position-backward
133  :scosc :save-cursor-position
134  :scorc :restore-cursor-position
135  :ed :erase-in-display :erase-below :erase-above :erase :erase-saved-lines
136  :el :erase-in-line :erase-right :erase-left :erase-line
137  :sgr :select-graphic-rendition
138  :dsr :device-status-report
139  ;; DEC private mode set and reset
140  :decset :dec-private-mode-set
141  :decrst :dec-private-mode-reset
142  :show-cursor :hide-cursor
143  :use-alternate-screen-buffer :use-normal-screen-buffer
144  ;; common
145  :clear
146  :home
147  ;; stty
148  :set-tty-mode))
149 
150 (defpackage :cli/progress
151  (:use :cl :std)
152  (:export
153  :update-progress
154  :with-progress-bar
155  :*progress-bar*
156  :*progress-bar-enabled*
157  :start-progress-display
158  :finish-progress-display
159  :progress-mutex
160  :uncertain-size-progress-bar
161  :progress-bar))
162 
163 (defpackage :cli/spark
164  (:use :cl :std)
165  (:export
166  :spark :*ticks*
167  :vspark :*vticks*))
168 
169 (defpackage :cli/repl
170  (:use :cl :std :cli :cli/progress :cli/spark)
171  (:export))
172 
173 (in-package :cli)
174 
175 (defvar *argv*)
176 
177 (defun cli-arg0 () (car sb-ext:*posix-argv*))
178 (defun cli-args () (cdr sb-ext:*posix-argv*))
179 
180 (declaim (inline exec-path-list))
181 (defun exec-path-list ()
182  "Return a list of all members of PATH"
183  (let ((var (sb-posix:getenv "PATH")))
184  (let ((lst (loop for i = 0 then (1+ j)
185  as j = (position #\: var :start i)
186  when (uiop:directory-exists-p (probe-file (subseq var i j)))
187  collect (probe-file (subseq var i j))
188  while j)))
189  (unless (null (car lst))
190  (mapcar (lambda (x) (car (directory x)))
191  lst)))))
192 
193 (defun program-list ()
194  "Return a fresh list of all files in PATH directories."
195  (loop for p in (exec-path-list)
196  append (uiop:directory-files p)))
197 
198 (defun find-exe (name &optional programs)
199  "Find NAME in list of PROGRAMS, defaulting to the result of #'program-list."
200  (find name (or programs (program-list))
201  :test #'equalp
202  :key #'pathname-name))
203 
204 (defun open-with-editor (path &key editor args)
205  (unless editor (setq editor (or (sb-posix:getenv "EDITOR") (find-exe "emacs"))))
206  (run-program editor (nconc args (list path))))
207 
208 (declaim (inline ld-library-path-list))
209 (defun ld-library-path-list ()
210  (let ((var (sb-posix:getenv "LD_LIBRARY_PATH")))
211  (let ((lst (loop for i = 0 then (1+ j)
212  as j = (position #\: var :start i)
213  collect (subseq var i j)
214  while j)))
215  (unless (null (car lst))
216  (mapcar (lambda (x) (car (directory x))) lst)))))
217 
218 (defparameter *cli-group-separator*
219  "--"
220  "A marker specifying the end of a unique group of CLI args.")
221 
222 ;; uiop:command-line-arguments
223 
224 ;;; Macros
225 (defmacro argp (arg &optional (args (cli-args)))
226  "Test for presence of ARG in ARGS. Return the tail of
227 ARGS starting from the position of ARG."
228  `(member ,arg ,args :test #'string=))
229 
230 (defmacro make-shorty (name)
231  "Return the first char of symbol or string NAME."
232  `(character (aref (if (stringp ,name) ,name (symbol-name ,name)) 0)))
233 
234 ;; (defun treat-as-argument (condition)
235 ;; "A handler which can be used to invoke the `treat-as-argument' restart"
236 ;; (invoke-restart (find-restart 'treat-as-argument condition)))
237 
238 ;; (defun discard-argument (condition)
239 ;; "A handler which can be used to invoke the `discard-argument' restart"
240 ;; (invoke-restart (find-restart 'discard-argument condition)))
241 
242 (defmacro with-cli-handlers (form)
243  "A wrapper which handles common cli errors that may occur during
244 evaluation of FORM."
245  `(handler-case ,form
246  (sb-sys:interactive-interrupt ()
247  (format *error-output* "~&(:SIGINT)~&")
248  (exit :code 130))
249  (error (c)
250  (format *error-output* "~&~A~&" c)
251  (exit :code 1))))
252 
253 (defun init-args () (setq *argv* (cons (cli-arg0) (cli-args))))
254 
255 (defmacro with-cli (slots cli &body body)
256  "Like with-slots with some extra bindings."
257  ;; (with-gensyms (cli-body)
258  ;; (let ((cli-body (mapcar (lambda (x) ()) cli-body)
259  `(progn
260  (init-args)
261  (setf (cli-cwd ,cli) (sb-posix:getcwd))
262  (with-slots ,slots (parse-args ,cli *argv* :compile t)
263  ,@body)))
264 
265 ;;; Prompts
266 (declaim (inline completing-read))
267 (defun completing-read (prompt collection
268  &key (history nil) (default nil)
269  (key nil) (test nil)
270  (input *standard-input*)
271  (output *standard-output*))
272 
273  "A simplified COMPLETING-READ for common-lisp.
274 
275 The Emacs completion framework includes a function called
276 `completing-read' which prompts the user for input from the
277 mini-buffer. It is a very flexible interface which can be used to read
278 user input programatically. This is incredibly useful for building
279 data entry interfaces -- for example see the `defprompt' macro.
280 
281 Obviously writing a completion framework is out-of-scope, but we can
282 simulate one by embedding a DSL in our prompters if we choose. For
283 example, perhaps we treat a single '?' character as a request from the
284 user to list valid options while continue waiting for input."
285  (princ prompt output)
286  ;; ensure we empty internal buffer
287  (finish-output output)
288  (let* ((coll collection)
289  (res (let ((%i (read-line input)))
290  (if (> (length %i) 0)
291  %i default)))
292  (r (if coll
293  (find res coll :key key :test test)
294  res)))
295  (prog1
296  r
297  (setf history (push r history)))))
298 
299 (defmacro defprompt (var &optional prompt)
300  "Generate a 'prompter' from list or variable VAR and optional
301 PROMPT string.
302 
303 This isn't an ideal solution as it does in fact expose a dynamic
304 variable (VAR-prompt-history). We should generate accessors and
305 keep the variables within lexical scope of the generated
306 closure."
307  (with-gensyms (s p h)
308  `(let ((,s (if (boundp ',var) (symbol-value ',var)
309  (progn
310  (defvar ,(symbolicate var) nil)
311  ',(symbolicate var))))
312  (,p (when (stringp ,prompt) ,prompt)) ;; prompt string
313  (,h ',(symbolicate var '-prompt-history))) ;; history symbol
314  (defvar ,(symbolicate var '-prompt-history) nil)
315  (defun ,(symbolicate var '-prompt) ()
316  ,(format nil "Prompt for a value from `~A', use DEFAULT if non-nil
317 and no value is provided by user, otherwise fallback to the `car'
318 of `~A-PROMPT-HISTORY'." var var)
319  (completing-read
320  (format nil "~A [~A]: "
321  (or ,p ">")
322  (car (symbol-value ,h)))
323  ,s :history ,h :default nil)))))
324 
325 (defmacro define-cli-constant (name cli &optional doc)
326  `(define-constant ,name ,cli ,@doc :test #'cli-equal))
327 
328 (defvar *default-cli-def* 'defparameter)
329 
330 (defmacro defcmd (name &body body)
331  `(defun ,name ($args $opts)
332  (declare (ignorable $args $opts))
333  (let (($argc (length $args))
334  ($optc (length $opts)))
335  (declare (ignorable $argc $optc))
336  ,@body)))
337 
338 (defmacro defopt (name &body body)
339  `(defun ,name ($val)
340  (declare (ignorable $val))
341  ,@body))
342 
343 (declaim (inline walk-cli-slots))
344 (defun walk-cli-slots (cli)
345  "Walk the plist CLI, performing actions as necessary based on the slot
346 keys."
347  (loop for kv in (group cli 2)
348  when (eql :thunk (car kv))
349  return (let ((th (cdr kv)))
350  (if (or (functionp th) (symbolp th)) (funcall th) (compile nil (lambda () th)))))
351  cli)
352 
353 (defmacro define-cli (name &body body)
354  "Define a symbol NAME bound to a top-level CLI object."
355  (declare (type symbol name))
356  `(,*default-cli-def* ,name (apply #'make-cli t (walk-cli-slots ',body))))
357 
358 (defmacro defmain (ret &body body)
359  "Define a CLI main function in the current package which returns RET.
360 
361 Note that this macro does not export the defined function and requires
362 `cli:main' to be an external symbol."
363  `(progn
364  (declaim (type stream output))
365  (defun main (&key (output *standard-output*))
366  "Run the top-level function and print to OUTPUT."
367  (let ((*standard-output* output))
368  (with-cli-handlers
369  (progn ,@body ,ret))))))
370 
371 ;;; Utils
372 (defun make-cli (kind &rest slots)
373  "Creates a new CLI object of the given kind."
374  (declare (type (member :opt :cmd :cli t) kind))
375  (apply #'make-instance
376  (cond
377  ((eql kind :cli) 'cli)
378  ((eql kind :opt) 'cli-opt)
379  ((eql kind :cmd) 'cli-cmd)
380  (t 'cli))
381  slots))
382 
383 ;; RESEARCH 2023-09-12: closed over hash-table with short/long flags
384 ;; to avoid conflicts. if not, need something like a flag-function
385 ;; slot at class allocation.
386 (defmacro make-opts (&body opts)
387  `(map 'vector
388  (lambda (x)
389  (etypecase x
390  (string (make-cli :opt :name x))
391  (list (apply #'make-cli :opt x))
392  (t (make-cli :opt :name (format nil "~(~A~)" x) :global t))))
393  (walk-cli-slots ',opts)))
394 
395 (defmacro make-cmds (&body opts)
396  `(map 'vector
397  (lambda (x)
398  (etypecase x
399  (string (make-cli :cmd :name x))
400  (list (apply #'make-cli :cmd x))
401  (t (make-cli :cmd :name (format nil "~(~A~)" x)))))
402  (walk-cli-slots ',opts)))
403 
404 (defun long-opt-p (str)
405  (and (char= (aref str 0) (aref str 1) #\-)
406  (> (length str) 2)))
407 
408 (defun short-opt-p (str)
409  (and (char= (aref str 0) #\-)
410  (not (char= (aref str 1) #\-))
411  (> (length str) 1)))
412 
413 (defun opt-group-p (str)
414  (string= str *cli-group-separator*))
415 
416 (defun opt-prefix-eq (ch str)
417  (char= (aref str 0) ch))
418 
419 (defun gen-thunk-ll (origin args)
420  (let ((a0 (list (symb '$a 0) origin)))
421  (group
422  (nconc (loop for i from 1 for a in args nconc (list (symb '$a i) a)) a0 )
423  2)))
424 
425 ;; TODO 2023-10-06:
426 ;; (defmacro gen-cli-thunk (pvars &rest thunk)
427 ;; "Generate and return a function based on THUNK suitable for the :thunk
428 ;; slot of cli objects with pandoric bindings PVARS.")
429 
430 ;;; Protocol
431 (defgeneric push-cmd (cmd place))
432 
433 (defgeneric push-opt (opt place))
434 
435 (defgeneric pop-cmd (place))
436 
437 (defgeneric pop-opt (place))
438 
439 (defgeneric find-cmd (self name &optional active))
440 
441 (defgeneric find-opt (self name &optional active))
442 
443 (defgeneric active-cmds (self))
444 
445 (defgeneric active-opts (self &optional global))
446 
447 (defgeneric find-short-opt (self ch))
448 
449 (defgeneric call-opt (self arg))
450 
451 (defgeneric do-opt (self))
452 
453 (defgeneric call-cmd (self args opts))
454 
455 (defgeneric parse-args (self args &key &allow-other-keys)
456  (:documentation "Parse list of strings ARGS using SELF.
457 
458 A list of the same length as ARGS is returned containing 'cli-ast'
459 objects: (OPT . (or char string)) (CMD . string) NIL"))
460 
461 (defgeneric do-cmd (self)
462  (:documentation "Run the command SELF with args parsed at runtime."))
463 
464 (defgeneric print-help (self &optional stream)
465  (:documentation "Format cli SELF as a helpful string."))
466 
467 (defgeneric print-version (self &optional stream)
468  (:documentation "Print the version of SELF."))
469 
470 (defgeneric print-usage (self &optional stream)
471  (:documentation "Format cli SELF as a useful string."))
472 
473 (defgeneric handle-unknown-argument (self arg)
474  (:documentation "Handle an unknown argument."))
475 
476 (defgeneric handle-missing-argument (self arg)
477  (:documentation "Handle a missing argument."))
478 
479 (defgeneric handle-invalid-argument (self arg)
480  (:documentation "Handle an invalid argument."))
481 
482 (defgeneric cli-equal (a b))
483 
484 (defun default-thunk (cli) (lambda (x) (declare (ignore x)) (print-help cli)))
485 
486 (defvar *cli-opt-kinds* '(bool str form list sym key num file dir))
487 
488 (defun cli-opt-kind-p (s)
489  (declare (type symbol s))
490  (find s *cli-opt-kinds*))
491 
492 
493 (eval-always
494  (defmacro make-opt-parser (kind-spec &body body)
495  "Return a KIND-opt-parser function based on KIND-SPEC which is either a
496 symbol from *cli-opt-kinds* or a list, and optional BODY which
497 is a list of handlers for the opt-val."
498  (let* ((kind (if (consp kind-spec) (car kind-spec) kind-spec))
499  (super (when (consp kind-spec) (cadr kind-spec)))
500  (fn-name (symbolicate 'parse- kind '-opt)))
501  ;; thread em
502  (let ((fn1 (when (not (eql 'nil super)) (symbolicate 'parse- super '-opt))))
503  `(progn
504  (defun ,fn-name ($val)
505  "Parse the cli-opt-val $VAL."
506  ;; do stuff
507  (when (not (eql ',fn1 'nil)) (setq $val (funcall ',fn1 $val)))
508  ,@body)))))
509 
510  (make-opt-parser bool $val)
511 
512  (make-opt-parser (str bool) (when (stringp $val) $val))
513 
514  (make-opt-parser (form str) (read-from-string $val))
515 
516  (make-opt-parser (list form) (when (listp $val) $val))
517 
518  (make-opt-parser (sym form) (when (symbolp $val) $val))
519 
520  (make-opt-parser (key form) (when (keywordp $val) $val))
521 
522  (make-opt-parser (num form) (when (numberp $val) $val))
523 
524  (make-opt-parser (file str)
525  (when $val (parse-native-namestring $val nil *default-pathname-defaults* :as-directory nil)))
526 
527  (make-opt-parser (dir str)
528  (when $val (sb-ext:parse-native-namestring $val nil *default-pathname-defaults* :as-directory t))))
529 
530 ;;; Objects
531 (defclass cli-opt ()
532  ;; note that cli-opts can have a nil or unbound name slot
533  ((name :initarg :name :initform (required-argument :name) :accessor cli-name :type string)
534  (kind :initarg :kind :initform 'boolean :accessor cli-opt-kind :type cli-opt-kind-p)
535  (thunk :initform #'default-thunk :initarg :thunk :type function-lambda-expression :accessor cli-thunk)
536  (val :initarg :val :initform nil :accessor cli-val :type form)
537  (global :initarg :global :initform nil :accessor global-opt-p :type boolean)
538  (description :initarg :description :accessor cli-description :type string)
539  (lock :initform nil :initarg :lock :accessor cli-lock-p :type boolean))
540  (:documentation "CLI option"))
541 
542 (defmethod handle-unknown-argument ((self cli-opt) arg))
543 (defmethod handle-missing-argument ((self cli-opt) arg))
544 (defmethod handle-invalid-argument ((self cli-opt) arg))
545 
546 (defmethod initialize-instance :after ((self cli-opt) &key)
547  (with-slots (name thunk) self
548  (unless (stringp name) (setf name (format nil "~(~A~)" name)))
549  (when (symbolp thunk) (setf thunk (funcall (compile nil `(lambda () ,(symbol-function thunk))))))
550  self))
551 
552 (defmethod install-thunk ((self cli-opt) (lambda function) &optional compile)
553  "Install THUNK into the corresponding slot in cli-cmd SELF."
554  (let ((%thunk (if compile (compile nil lambda) lambda)))
555  (setf (cli-thunk self) %thunk)
556  self))
557 
558 (defmethod print-object ((self cli-opt) stream)
559  (print-unreadable-object (self stream :type t)
560  (format stream "~A :global ~A :val ~A"
561  (cli-name self)
562  (global-opt-p self)
563  (cli-val self))))
564 
565 (defmethod print-usage ((self cli-opt) &optional stream)
566  (format stream " -~(~{~A~^/--~}~)~A~A"
567  (if-let ((n (cli-name self)))
568  (list (make-shorty n) n)
569  'dyn)
570  (if (global-opt-p self) "* " " ")
571  (if-let ((d (and (slot-boundp self 'description) (cli-description self))))
572  (format stream ": ~A" d)
573  "")))
574 
575 (defmethod cli-equal ((a cli-opt) (b cli-opt))
576  (with-slots (name global kind) a
577  (with-slots ((bn name) (bg global) (bk kind)) b
578  (and (string= name bn)
579  (eql global bg)
580  (eql kind bk)))))
581 
582 (defmethod call-opt ((self cli-opt) arg)
583  (funcall (compile nil (cli-thunk self)) arg))
584 
585 (defmethod do-opt ((self cli-opt))
586  (call-opt self (cli-val self)))
587 
588 (defclass cli-cmd ()
589  ;; name slot is required and must be a string
590  ((name :initarg :name :initform (required-argument :name) :accessor cli-name :type string)
591  (opts :initarg :opts :initform (make-array 0 :element-type 'cli-opt)
592  :accessor cli-opts :type (vector cli-opt))
593  (cmds :initarg :cmds :initform (make-array 0 :element-type 'cli-cmd)
594  :accessor cli-cmds :type (vector cli-cmd))
595  (thunk :initform #'default-thunk :initarg :thunk :accessor cli-thunk :type function-lambda-expression)
596  (lock :initform nil :initarg :lock :accessor cli-lock-p :type boolean)
597  (description :initarg :description :accessor cli-description :type string)
598  (args :initform nil :initarg :args :accessor cli-cmd-args))
599  (:documentation "CLI command"))
600 
601 (defmethod initialize-instance :after ((self cli-cmd) &key)
602  (with-slots (name cmds opts thunk) self
603  (unless (stringp name) (setf name (format nil "~(~A~)" name)))
604  (unless (vectorp cmds) (setf cmds (funcall (compile nil `(lambda () ,cmds)))))
605  (unless (vectorp opts) (setf opts (funcall (compile nil `(lambda () ,opts)))))
606  (when (symbolp thunk) (setf thunk (symbol-function thunk)))
607  self))
608 
609 (defmethod print-object ((self cli-cmd) stream)
610  (print-unreadable-object (self stream :type t)
611  (format stream "~A :opts ~A :cmds ~A :args ~A"
612  (cli-name self)
613  (length (cli-opts self))
614  (length (cli-cmds self))
615  (length (cli-cmd-args self)))))
616 
617 (defmethod print-usage ((self cli-cmd) &optional stream)
618  (with-slots (opts cmds) self
619  (format stream "~(~A~) ~A~A~A"
620  (cli-name self)
621  (if-let ((d (and (slot-boundp self 'description) (cli-description self))))
622  (format nil ": ~A" d)
623  "")
624  (if (null opts)
625  ""
626  (format nil "~{~% ~A~^~}" (loop for o across opts collect (print-usage o nil))))
627  (if (null cmds)
628  ""
629  (format nil "~% ~{! ~A~}" (loop for c across cmds collect (print-usage c nil)))))))
630 
631 (defmethod push-cmd ((self cli-cmd) (place cli-cmd))
632  (vector-push self (cli-cmds place)))
633 
634 (defmethod push-opt ((self cli-opt) (place cli-cmd))
635  (vector-push self (cli-opts place)))
636 
637 (defmethod pop-cmd ((self cli-cmd))
638  (vector-pop (cli-cmds self)))
639 
640 (defmethod pop-opt ((self cli-opt))
641  (vector-pop (cli-opts self)))
642 
643 (defmethod cli-equal ((a cli-cmd) (b cli-cmd))
644  (with-slots (name opts cmds) a
645  (with-slots ((bn name) (bo opts) (bc cmds)) b
646  (and (string= name bn)
647  (if (and (null opts) (null bo))
648  t
649  (unless (member nil (loop for oa across opts
650  for ob across bo
651  collect (cli-equal oa ob)))
652  t))
653  (if (and (null cmds) (null bc))
654  t
655  (unless (member nil (loop for ca across cmds
656  for cb across bc
657  collect (cli-equal ca cb)))
658  t))))))
659 
660 ;; typically when starting from a top-level `cli', the global
661 ;; `cli-opts' will be parsed first, followed by the first command
662 ;; found. If a command is found, the tail of the list is passed as
663 ;; arguments to this function, which can pass additonal arguments to
664 ;; nested commands.
665 
666 ;; TODO 2023-09-12: Parsing restarts at the `*cli-group-separator*'
667 ;; if present, or stops at EOI.
668 
669 (declaim (inline %make-cli-node))
670 (defstruct (cli-node (:constructor %make-cli-node)) kind form)
671 
672 (defun make-cli-node (kind form)
673  (%make-cli-node :kind kind :form form))
674 
675 (declaim (inline %make-cli-ast))
676 (defstruct (cli-ast (:constructor %make-cli-ast)) ast)
677 
678 (defun make-cli-ast (nodes)
679  (%make-cli-ast :ast nodes))
680 
681 (defmethod find-cmd ((self cli-cmd) name &optional active)
682  (when-let ((c (find name (cli-cmds self) :key #'cli-name :test #'string=)))
683  (if active
684  ;; maybe issue warning here? report to user
685  (when (cli-lock-p c) c)
686  c)))
687 
688 (defmethod active-cmds ((self cli-cmd))
689  (remove-if-not #'cli-lock-p (cli-cmds self)))
690 
691 
692 (defmethod find-opt ((self cli-cmd) name &optional active)
693  (when-let ((o (find name (cli-opts self) :key #'cli-name :test #'string=)))
694  (if active
695  (when (cli-lock-p o) o)
696  o)))
697 
698 (defun active-global-opt-p (opt)
699  "Return non-nil if OPT is active at runtime and global."
700  (when (and (cli-lock-p opt) (global-opt-p opt)) t))
701 
702 (defmethod active-opts ((self cli-cmd) &optional global)
703  (remove-if-not
704  (if global
705  #'active-global-opt-p
706  #'cli-lock-p)
707  (cli-opts self)))
708 
709 (defmethod find-short-opt ((self cli-cmd) ch)
710  (find ch (cli-opts self) :key #'cli-name :test #'opt-prefix-eq))
711 
712 (defmethod proc-args ((self cli-cmd) args)
713  "process ARGS into an ast. Each element of the ast is a node with a
714 :kind slot, indicating the type of node and a :form slot which stores
715 a value.
716 
717 For now we parse group separators '--' and insert a nil into the tree,
718 this will likely change to generating a new branch in the ast as it
719 should be."
720  (make-cli-ast
721  (loop
722  for a in args
723  if (= (length a) 1) collect (make-cli-node 'arg a)
724  ;; SHORT OPT
725  else if (short-opt-p a)
726  collect (if-let ((o (find-short-opt self (aref a 1))))
727  (progn
728  (setf (cli-val o) t)
729  (make-cli-node 'opt o))
730  (make-cli-node 'arg a))
731 
732  ;; LONG OPT
733  else if (long-opt-p a)
734  ;; what we actually want to do is consume the next sequence of args - TBD
735  collect (if-let ((o (find-opt self (string-trim "-" a))))
736  (progn
737  (setf (cli-val o) (string-trim "-" a))
738  (make-cli-node 'opt o))
739  (make-cli-node 'arg a))
740  ;; OPT GROUP
741  else if (opt-group-p a)
742  collect nil
743  ;; CMD
744  else if (find-cmd self a)
745  ;; TBD
746  collect (make-cli-node 'cmd (find-cmd self a))
747  ;; ARG
748  else collect (make-cli-node 'arg a))))
749 
750 (defmethod install-ast ((self cli-cmd) (ast cli-ast))
751  "Install the given AST, recursively filling in value slots."
752  (with-slots (cmds opts) self
753  ;; we assume all nodes in the ast have been validated and the ast
754  ;; itself is consumed. validation is performed in proc-args.
755 
756  ;; before doing anything else we lock SELF, which should remain
757  ;; locked for the full runtime duration.
758  (setf (cli-lock-p self) t)
759  (loop named install
760  for (node . tail) on (cli-ast-ast ast)
761  unless (null node)
762  do
763  (with-slots (kind form) node
764  (case kind
765  ;; opts
766  (opt
767  (let ((name (cli-name form))
768  (val (cli-val form)))
769  (when-let ((o (find-opt self name)))
770  (setf (cli-val o) val
771  (cli-lock-p o) t))))
772  ;; when we encounter a command we recurse over the tail
773  (cmd
774  (when-let ((c (find-cmd self (cli-name form))))
775  (setf (cli-lock-p c) t)
776  ;; handle the rest of the AST
777  (install-ast c (make-cli-ast tail))
778  (return-from install)))
779  (arg (push-arg form self)))))
780  (setf (cli-cmd-args self) (nreverse (cli-cmd-args self)))
781  self))
782 
783 (defmethod install-thunk ((self cli-cmd) (lambda function) &optional compile)
784  "Install THUNK into the corresponding slot in cli-cmd SELF."
785  (let ((%thunk (if compile (compile nil lambda) lambda)))
786  (setf (cli-thunk self) %thunk)
787  self))
788 
789 (defmethod push-arg (arg (self cli-cmd))
790  (push arg (cli-cmd-args self)))
791 
792 (defmethod parse-args ((self cli-cmd) args &key (compile nil))
793  "Parse ARGS and return the updated object SELF.
794 
795 ARGS is assumed to be a valid cli-ast (list of cli-nodes), unless
796 COMPILE is t, in which case a list of strings is assumed."
797  (with-slots (opts cmds) self
798  (let ((args (if compile (proc-args self args) args)))
799  (print (install-ast self args)))))
800 
801 ;; warning: make sure to fill in the opt and cmd slots with values
802 ;; from the top-level args before doing a command.
803 (defmethod call-cmd ((self cli-cmd) args opts)
804  ;; TODO 2023-09-12: handle args/env
805  (funcall (cli-thunk self) args opts))
806 
807 (defmethod do-cmd ((self cli-cmd))
808  (call-cmd self (cli-cmd-args self) (cli-opts self)))
809 
810 (defclass cli (cli-cmd)
811  ;; name slot defaults to *package*, must be string
812  ((name :initarg :name :initform (string-downcase (package-name *package*)) :accessor cli-name :type string)
813  (version :initarg :version :initform "0.1.0" :accessor cli-version :type string)
814  ;; TODO 2023-10-11: look into pushd popd - wd-stack?
815  (cwd :initarg :cwd :initform (sb-posix:getcwd) :type string :accessor cli-cwd
816  :documentation "working directory of the top-level CLI."))
817  (:documentation "CLI"))
818 
819 (defmethod print-usage ((self cli) &optional stream)
820  (iprintln (format nil "usage: ~A [global] <command> [<arg>]~%" (cli-name self)) 2 stream))
821 
822 (defmethod print-version ((self cli) &optional stream)
823  (println (cli-version self) stream))
824 
825 (defmethod print-help ((self cli) &optional stream)
826  (println (format nil "~A v~A" (cli-name self) (cli-version self)) stream)
827  (print-usage self stream)
828  (iprintln (cli-description self) 2 stream)
829  ;; (terpri stream)
830  (iprintln "options:" 2 stream)
831  (with-slots (opts cmds) self
832  (unless (null opts)
833  (loop for o across opts
834  do (iprintln (print-usage o) 4 stream)))
835  ;; (terpri stream)
836  (iprintln "commands:" 2 stream)
837  (unless (null cmds)
838  (loop for c across cmds
839  do (iprintln (print-usage c) 4 stream)))))
840 
841 (defmethod cli-equal :before ((a cli) (b cli))
842  "Return T if A is the same cli object as B.
843 
844 Currently this function is intended only for instances of the CLI
845 class and is used as a specialized EQL for DEFINE-CONSTANT."
846  (with-slots (version) a
847  (with-slots ((bv version)) b
848  (string= version bv))))
849 
850 ;; same as cli-cmd method, default is to compile though
851 (defmethod parse-args ((self cli) (args list) &key (compile t))
852  "Parse list of string arguments ARGS and return the updated object SELF."
853  (with-slots (opts cmds) self
854  (let ((args (if compile (proc-args self args) args)))
855  (install-ast self args))))
856 
857 (declaim (inline debug-opts))
858 (defun debug-opts (cli)
859  (let ((o (active-opts cli))
860  (a (cli-cmd-args cli))
861  (c (active-cmds cli)))
862  (debug! (cli-cwd cli) o a c)))
863 
864 (declaim (inline solop))
865 (defun solop (self)
866  (and (= 0 (length (active-opts self t)) (length (active-cmds self)))))
867 
868 (defmethod do-cmd ((self cli))
869  (if (solop self)
870  (call-cmd self (cli-cmd-args self) (cli-opts self))
871  (progn
872  (loop for o across (active-opts self t)
873  do (do-opt o))
874  (loop for c across (active-cmds self)
875  do (do-cmd c)))))
876 
877 (provide :cli)