changelog shortlog graph tags branches changeset files revisions annotate raw help

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

changeset 357: 7c1383c08493
parent: 8f1c1d79a96c
child: a5a2d756ee2f
author: Richard Westhaver <ellis@rwest.io>
date: Tue, 21 May 2024 22:20:29 -0400
permissions: -rw-r--r--
description: port xsubseq, proc-parse. work on http and clap
1 ;;; lib/cli/api.lisp --- Command Line Argument Parser
2 
3 ;;
4 
5 ;;; Code:
6 (in-package :cli/clap)
7 (declaim (optimize (speed 3)))
8 (defun cli-arg0 () (car sb-ext:*posix-argv*))
9 (defun cli-args () (cdr sb-ext:*posix-argv*))
10 
11 (declaim (simple-string *cli-group-separator*))
12 (defparameter *cli-group-separator*
13  "--"
14  "A marker specifying the end of a unique group of CLI args.")
15 
16 ;; uiop:command-line-arguments
17 
18 ;;; Macros
19 (defmacro argp (arg &optional (args (cli-args)))
20  "Test for presence of ARG in ARGS. Return the tail of
21 ARGS starting from the position of ARG."
22  `(member ,arg ,args :test 'equal))
23 
24 (defmacro make-shorty (name)
25  "Return the first char of symbol or string NAME."
26  `(character (aref (if (stringp ,name) ,name (symbol-name ,name)) 0)))
27 
28 ;; (defun treat-as-argument (condition)
29 ;; "A handler which can be used to invoke the `treat-as-argument' restart"
30 ;; (invoke-restart (find-restart 'treat-as-argument condition)))
31 
32 ;; (defun discard-argument (condition)
33 ;; "A handler which can be used to invoke the `discard-argument' restart"
34 ;; (invoke-restart (find-restart 'discard-argument condition)))
35 (deferror clap-error (std-error) () (:auto t))
36 
37 (defvar *no-exit* nil
38  "Indicate whether the WITH-CLI-HANDLERS form should exit on completion.")
39 
40 (defmacro with-cli-handlers (form)
41  "A wrapper which handles common cli errors that may occur during
42 evaluation of FORM."
43  `(progn
44  (if *no-exit*
45  (sb-ext:enable-debugger)
46  (sb-ext:disable-debugger))
47  (handler-case ,form
48  (sb-sys:interactive-interrupt ()
49  (println "(:SIGINT)")
50  (sb-ext:exit :code 130))
51  ;; ,@(when *no-exit* '()))
52  )))
53 
54 (defmacro with-cli (slots cli &body body)
55  "Like with-slots with some extra bindings."
56  `(progn
57  (setf (cli-cd ,cli) (sb-posix:getcwd))
58  (with-slots ,slots (parse-args ,cli (cli-args) :compile t)
59  ,@body)))
60 
61 (defvar *default-cli-def* 'defparameter)
62 
63 (defvar *default-cli-class* 'cli
64  "The name of the class of the top-level CLI object which will be
65 generated by the DEFINE-CLI macro.")
66 
67 (defmacro defcmd (name &body body)
68  `(defun ,name ($args $opts)
69  (declare (ignorable $args $opts))
70  (let (($argc (length $args))
71  ($optc (length $opts)))
72  (declare (ignorable $argc $optc))
73  ,@body)))
74 
75 (defmacro defopt (name &body body)
76  `(defun ,name (&optional $val)
77  (declare (ignorable $val))
78  ,@body))
79 
80 (declaim (inline walk-cli-slots))
81 (defun walk-cli-slots (cli)
82  "Walk the plist CLI, performing actions as necessary based on the slot
83 keys."
84  (loop for kv in (group cli 2)
85  when (eql :thunk (car kv))
86  return (let ((th (cdr kv)))
87  (if (or (functionp th) (symbolp th)) (funcall th) (compile nil (lambda () th)))))
88  cli)
89 
90 (defmacro define-cli (name &body body)
91  "Define a symbol NAME bound to a top-level CLI object."
92  (with-gensyms (%name %class)
93  (if (atom name)
94  (setq %name name
95  %class nil)
96  (setq %name (car name)
97  %class (cdr name)))
98  `(,*default-cli-def* ,%name (apply #'make-cli ,%class (walk-cli-slots ',body)))))
99 
100 (defmacro defmain ((&key return (exit t)) &body body)
101  "Define a CLI main function in the current package."
102  (with-gensyms (retval)
103  (let ((main (symbolicate 'main)))
104  (when return (setf retval return))
105  `(prog1
106  (defun ,main ()
107  "Run the top-level function and print to *STDOUT*."
108  (let ((*no-exit* ,(not exit)))
109  (with-cli-handlers
110  (progn ,@body ,@(unless (not (boundp 'retval)) (list retval))))))
111  (export '(,main))))))
112 
113 ;;; Utils
114 (defun make-cli (kind &rest slots)
115  "Creates a new CLI object of the given kind."
116  (declare (type (member :opt :cmd :cli t) kind))
117  (cond
118  ((eql kind :cli) (apply #'make-instance 'cli slots))
119  ((eql kind :opt) (apply #'make-cli-opt slots))
120  ((eql kind :cmd) (apply #'make-instance 'cli-cmd slots))
121  (t (apply #'make-instance kind slots))))
122 
123 ;; RESEARCH 2023-09-12: closed over hash-table with short/long flags
124 ;; to avoid conflicts. if not, need something like a flag-function
125 ;; slot at class allocation.
126 (defmacro make-opts (&body opts)
127  "Make a vector of CLI-OPTs based on OPTS."
128  `(map 'vector
129  (lambda (x)
130  (etypecase x
131  (string (make-cli-opt :name x))
132  (list (apply #'make-cli :opt x))
133  (t (make-cli :opt :name (format nil "~(~A~)" x) :global t))))
134  (walk-cli-slots ',opts)))
135 
136 (defmacro make-cmds (&body cmds)
137  "Make a vector of CLI-CMDs based on CMDS."
138  `(map 'vector
139  (lambda (x)
140  (etypecase x
141  (string (make-cli :cmd :name x))
142  (list (apply #'make-cli :cmd x))
143  (t (make-cli :cmd :name (format nil "~(~A~)" x)))))
144  (walk-cli-slots ',cmds)))
145 
146 (defun long-opt-p (str)
147  (declare (simple-string str))
148  (and (char= (aref str 0) (aref str 1) #\-)
149  (> (length str) 2)))
150 
151 (defun short-opt-p (str)
152  (declare (simple-string str))
153  (and (char= (aref str 0) #\-)
154  (not (char= (aref str 1) #\-))
155  (> (length str) 1)))
156 
157 (defun opt-group-p (str)
158  (declare (simple-string str))
159  (equalp str *cli-group-separator*))
160 
161 (defun opt-string-prefix-eq (ch str)
162  (declare (simple-string str) (character ch))
163  (char= ch (aref str 0)))
164 
165 ;; currently not in use
166 (defun gen-thunk-ll (origin args)
167  (let ((a0 (list (symbolicate '$a 0) origin)))
168  (group
169  (nconc (loop for i from 1 for a in args nconc (list (symbolicate '$a (the fixnum i)) a)) a0)
170  2)))
171 
172 ;; TODO 2023-10-06:
173 ;; (defmacro gen-cli-thunk (pvars &rest thunk)
174 ;; "Generate and return a function based on THUNK suitable for the :thunk
175 ;; slot of cli objects with pandoric bindings PVARS.")
176 
177 ;;; Protocol
178 (defgeneric push-cmd (cmd place))
179 
180 (defgeneric push-opt (opt place))
181 
182 (defgeneric pop-cmd (place))
183 
184 (defgeneric pop-opt (place))
185 
186 (defgeneric find-cmd (self name &optional active))
187 
188 (defgeneric find-opts (self name &key active recurse))
189 
190 (defgeneric active-cmds (self))
191 
192 (defgeneric active-opts (self &optional global))
193 
194 (defgeneric find-short-opts (self ch &key))
195 
196 (defgeneric call-opt (self arg))
197 
198 (defgeneric do-opt (self))
199 
200 (defgeneric call-cmd (self args opts))
201 
202 (defgeneric parse-args (self args &key &allow-other-keys)
203  (:documentation "Parse list of strings ARGS using SELF.
204 
205 A list of the same length as ARGS is returned containing 'cli-ast'
206 objects: (OPT . (or char string)) (CMD . string) NIL"))
207 
208 (defgeneric do-cmd (self)
209  (:documentation "Run the command SELF with args parsed at runtime."))
210 
211 (defgeneric print-help (self &optional stream)
212  (:documentation "Format cli SELF as a helpful string."))
213 
214 (defgeneric print-version (self &optional stream)
215  (:documentation "Print the version of SELF."))
216 
217 (defgeneric print-usage (self &optional stream)
218  (:documentation "Format cli SELF as a useful string."))
219 
220 (defgeneric handle-unknown-argument (self arg)
221  (:documentation "Handle an unknown argument."))
222 
223 (defgeneric handle-missing-argument (self arg)
224  (:documentation "Handle a missing argument."))
225 
226 (defgeneric handle-invalid-argument (self arg)
227  (:documentation "Handle an invalid argument."))
228 
229 (defgeneric cli-equal (a b))
230 
231 (defun default-thunk (args opts)
232  (declare (ignore args opts)))
233 
234 (declaim ((vector symbol) *cli-opt-kinds*))
235 (defvar *cli-opt-kinds*
236  (let ((kinds '(bool str form list sym key num file dir)))
237  (make-array (length kinds) :element-type 'symbol :initial-contents kinds)))
238 
239 (defun cli-opt-kind-p (s)
240  (declare (type symbol s))
241  (find s *cli-opt-kinds*))
242 
243 ;; TODO 2024-03-16: this should map directly to Lisp types (fixnum, boolean, etc)
244 (eval-always
245  (defmacro make-opt-parser (kind-spec &body body)
246  "Return a KIND-opt-parser function based on KIND-SPEC which is either a
247 symbol from *cli-opt-kinds* or a list, and optional BODY which
248 is a list of handlers for the opt-val."
249  (let* ((kind (if (consp kind-spec) (car kind-spec) kind-spec))
250  (super (when (consp kind-spec) (cadr kind-spec)))
251  (fn-name (symbolicate 'parse- kind '-opt)))
252  ;; thread em
253  (let ((fn1 (when (not (eql 'nil super)) (symbolicate 'parse- super '-opt))))
254  `(progn
255  (defun ,fn-name ($val)
256  "Parse the cli-opt-val $VAL."
257  ;; do stuff
258  (when (not (eql ',fn1 'nil)) (setq $val (funcall ',fn1 $val)))
259  ,@body)))))
260 
261  (make-opt-parser bool $val)
262 
263  (make-opt-parser str (when (stringp $val) $val))
264 
265  (make-opt-parser (form str) (read-from-string $val))
266 
267  (make-opt-parser (list form) (when (listp $val) $val))
268 
269  (make-opt-parser (sym form) (when (symbolp $val) $val))
270 
271  (make-opt-parser (key form) (when (keywordp $val) $val))
272 
273  (make-opt-parser (num form) (when (numberp $val) $val))
274 
275  (make-opt-parser (file str)
276  (when $val (pathname (the simple-string (parse-native-namestring $val nil *default-pathname-defaults* :as-directory nil)))))
277 
278  (make-opt-parser (dir str)
279  (when $val (sb-ext:parse-native-namestring $val nil *default-pathname-defaults* :as-directory t))))
280 
281 ;;; Objects
282 (defstruct cli-opt
283  ;; note that cli-opts can have a nil or unbound name slot
284  (name "" :type string)
285  (kind 'bool :type symbol)
286  (thunk nil :type (or null function symbol))
287  (val nil)
288  (global nil :type boolean)
289  (description nil :type (or null string))
290  (lock nil :type boolean))
291 
292 (defmethod handle-unknown-argument ((self cli-opt) arg))
293 (defmethod handle-missing-argument ((self cli-opt) arg))
294 (defmethod handle-invalid-argument ((self cli-opt) arg))
295 
296 (defmethod initialize-instance :after ((self cli-opt) &key)
297  (with-slots (name thunk) self
298  (unless (stringp name) (setf name (format nil "~(~A~)" name)))
299  (when (symbolp thunk) (setf thunk (funcall (compile nil `(lambda () ,(symbol-function thunk))))))
300  self))
301 
302 (defmethod install-thunk ((self cli-opt) (lambda function) &optional compile)
303  "Install THUNK into the corresponding slot in cli-cmd SELF."
304  (let ((%thunk (if compile (compile nil lambda) lambda)))
305  (setf (cli-thunk self) %thunk)
306  self))
307 
308 (defmethod print-object ((self cli-opt) stream)
309  (print-unreadable-object (self stream :type t)
310  (format stream "~A :global ~A :val ~A"
311  (cli-opt-name self)
312  (cli-opt-global self)
313  (cli-opt-val self))))
314 
315 (defmethod print-usage ((self cli-opt) &optional stream)
316  (format stream "-~(~{~A~^/--~}~)~A~A"
317  (let ((n (cli-opt-name self)))
318  (declare (simple-string n))
319  (list (make-shorty n) n))
320  (if (cli-opt-global self) "* " " ")
321  (if-let ((d (and (slot-boundp self 'description) (cli-opt-description self))))
322  (format stream ": ~A" (the simple-string d))
323  "")))
324 
325 (defmethod cli-equal ((a cli-opt) (b cli-opt))
326  (with-slots (name global kind) a
327  (with-slots ((bn name) (bg global) (bk kind)) b
328  (and (equal name bn)
329  (eq global bg)
330  (equal kind bk)))))
331 
332 (defmethod call-opt ((self cli-opt) arg)
333  (when-let ((thunk (cli-opt-thunk self)))
334  (setf (cli-opt-val self) (funcall thunk arg))))
335 
336 (defmethod do-opt ((self cli-opt))
337  (call-opt self (cli-opt-val self)))
338 
339 (defclass cli-cmd ()
340  ;; name slot is required and must be a string
341  ((name :initarg :name :initform (required-argument :name) :accessor cli-name :type string)
342  (opts :initarg :opts :initform (make-array 0 :element-type 'cli-opt :adjustable t)
343  :accessor cli-opts :type (vector cli-opt))
344  (cmds :initarg :cmds :initform (make-array 0 :element-type 'cli-cmd :adjustable t)
345  :accessor cli-cmds :type (vector cli-cmd))
346  (thunk :initform #'default-thunk :initarg :thunk :accessor cli-thunk :type function-lambda-expression)
347  (lock :initform nil :initarg :lock :accessor cli-lock-p :type boolean)
348  (description :initarg :description :accessor cli-description :type string)
349  (args :initform nil :initarg :args :accessor cli-cmd-args))
350  (:documentation "CLI command class inherited by both the 'main' command which is executed when
351 a CLI is called without arguments, and all subcommands."))
352 
353 (defmethod initialize-instance :after ((self cli-cmd) &key)
354  (with-slots (name cmds opts thunk) self
355  (unless (stringp name) (setf name (format nil "~(~A~)" name)))
356  (unless (vectorp cmds) (setf cmds (funcall (compile nil `(lambda () ,cmds)))))
357  (unless (vectorp opts) (setf opts (funcall (compile nil `(lambda () ,opts)))))
358  (when (symbolp thunk) (setf thunk (symbol-function thunk)))
359  self))
360 
361 (defmethod print-object ((self cli-cmd) stream)
362  (print-unreadable-object (self stream :type t)
363  (format stream "~A :opts ~A :cmds ~A :args ~A"
364  (cli-name self)
365  (length (cli-opts self))
366  (length (cli-cmds self))
367  (length (cli-cmd-args self)))))
368 
369 (defmethod print-usage ((self cli-cmd) &optional stream)
370  (with-slots (opts cmds) self
371  (format stream "~(~A~) ~A~A~A"
372  (cli-name self)
373  (if-let ((d (and (slot-boundp self 'description) (cli-description self))))
374  (format nil ": ~A" d)
375  "")
376  (if (null opts)
377  ""
378  (format nil "~{~% ~A~^~}" (loop for o across opts collect (print-usage o nil))))
379  (if (null cmds)
380  ""
381  (format nil "~{!~A~}" (loop for c across cmds collect (print-usage c nil)))))))
382 
383 (defmethod push-cmd ((self cli-cmd) (place cli-cmd))
384  (vector-push self (cli-cmds place)))
385 
386 (defmethod push-opt ((self cli-opt) (place cli-cmd))
387  (vector-push self (cli-opts place)))
388 
389 (defmethod pop-cmd ((self cli-cmd))
390  (vector-pop (cli-cmds self)))
391 
392 (defmethod pop-opt ((self cli-opt))
393  (vector-pop (cli-opts self)))
394 
395 (defmethod cli-equal ((a cli-cmd) (b cli-cmd))
396  (with-slots (name opts cmds) a
397  (with-slots ((bn name) (bo opts) (bc cmds)) b
398  (and (string= name bn)
399  (if (and (null opts) (null bo))
400  t
401  (unless (member nil (loop for oa across opts
402  for ob across bo
403  collect (cli-equal oa ob)))
404  t))
405  (if (and (null cmds) (null bc))
406  t
407  (unless (member nil (loop for ca across cmds
408  for cb across bc
409  collect (cli-equal ca cb)))
410  t))))))
411 
412 ;; typically when starting from a top-level CLI, the global
413 ;; CLI-OPTS will be parsed first, followed by the first command
414 ;; found. If a command is found, the tail of the list is passed as
415 ;; arguments to this function, which can pass additonal arguments to
416 ;; nested commands.
417 
418 ;; TODO 2023-09-12: Parsing restarts at the `*cli-group-separator*'
419 ;; if present, or stops at EOI.
420 
421 (defstruct (cli-node (:constructor make-cli-node (kind form))) kind form)
422 
423 (defstruct (cli-ast (:constructor make-cli-ast (ast))) ast)
424 
425 (defmethod find-cmd ((self cli-cmd) name &optional active)
426  (when-let ((c (find name (cli-cmds self) :key #'cli-name :test #'string=)))
427  (if active
428  ;; maybe issue warning here? report to user
429  (if (cli-lock-p c)
430  c
431  (clap-error c))
432  c)))
433 
434 (defmethod active-cmds ((self cli-cmd))
435  (remove-if-not #'cli-lock-p (cli-cmds self)))
436 
437 (defmethod find-opts ((self cli-cmd) name &key active recurse)
438  (let ((ret))
439  (flet ((%find (o obj)
440  (when-let ((found (find o (cli-opts obj) :key #'cli-opt-name :test 'equal)))
441  (push found ret))))
442  (when (and recurse (cli-cmds self))
443  (loop for c across (cli-cmds self)
444  do (%find name c)))
445  (%find name self)
446  (when active
447  (setf ret (remove-if-not #'cli-lock-p ret)))
448  ret)))
449 
450 (defun active-global-opt-p (opt)
451  "Return non-nil if OPT is active at runtime and global."
452  (and (cli-opt-lock opt) (cli-opt-global opt)))
453 
454 (defmethod active-opts ((self cli-cmd) &optional global)
455  (remove-if-not
456  (if global
457  #'active-global-opt-p
458  #'cli-opt-lock)
459  (cli-opts self)))
460 
461 (defmethod find-short-opts ((self cli-cmd) ch &key recurse)
462  (let ((ret))
463  (flet ((%find (ch obj)
464  (when-let ((found (find ch (cli-opts obj) :key #'cli-opt-name :test #'opt-string-prefix-eq)))
465  (push found ret))))
466  (when (and recurse (cli-cmds self))
467  (loop for c across (cli-cmds self)
468  do (%find ch c)))
469  (%find ch self)
470  ret)))
471 
472 (defun %compose-short-opt (o arg)
473  (declare (ignorable arg))
474  (setf (cli-opt-val o) t)
475  (make-cli-node 'opt o))
476 
477 (defun %compose-long-opt (o args)
478  (declare (ignorable args))
479  (setf (cli-opt-val o) (or (pop args) t))
480  (make-cli-node 'opt o))
481 
482 (defmethod proc-args ((self cli-cmd) args)
483  "Process ARGS into an ast. Each element of the ast is a node with a
484 :kind slot, indicating the type of node and a :form slot which stores
485 a value.
486 
487 For now we parse group separators '--' and insert a nil into the tree,
488 this will likely change to generating a new branch in the ast as it
489 should be."
490  (make-cli-ast
491  (let ((holes)) ;; list of arg indexes which can be skipped since they're
492  ;; consumed by an opt
493  (loop
494  for i below (length args)
495  for (a . args) on args
496  if (member i holes)
497  do (continue) ;; skip args which have been consumed already
498  else if (= (length a) 1)
499  collect (make-cli-node 'arg a) ; always treat single-char as arg
500  else if (short-opt-p a) ;; SHORT OPT
501  collect (if-let ((o (find-short-opts self (aref a 1) :recurse t)))
502  (%compose-short-opt (car o) a)
503  (make-cli-node 'arg a))
504  else if (long-opt-p a) ;; LONG OPT
505  collect (if-let ((o (find-opts self (string-left-trim "-" a) :recurse t)))
506  (prog1 (%compose-long-opt (car o) args)
507  (push (1+ i) holes))
508  (make-cli-node 'arg a))
509  ;; OPT GROUP
510  else if (opt-group-p a)
511  collect nil
512  ;; CMD
513  else
514  collect (let ((cmd (find-cmd self a)))
515  (if cmd
516  ;; TBD
517  (make-cli-node 'cmd (find-cmd self a))
518  ;; ARG
519  (make-cli-node 'arg a)))))))
520 
521 (declaim (inline solop))
522 (defun solop (self)
523  (and (= 0 (length (active-cmds self)) (length (active-opts self)))))
524 
525 (defmethod install-ast ((self cli-cmd) (ast cli-ast))
526  "Install the given AST, recursively filling in value slots."
527  (with-slots (cmds opts) self
528  ;; we assume all nodes in the ast have been validated and the ast
529  ;; itself is consumed. validation is performed in proc-args.
530 
531  ;; before doing anything else we lock SELF, which should remain
532  ;; locked for the full runtime duration or until GC.
533  (setf (cli-lock-p self) t)
534  (loop named install
535  for (node . tail) on (debug! (cli-ast-ast ast))
536  until (null node)
537  do
538  (with-slots (kind form) node
539  (case kind
540  ;; opts
541  (opt
542  (let ((name (cli-opt-name form)))
543  (when-let ((o (car (find-opts self name))))
544  (setf o form)
545  (setf (cli-opt-lock o) t))))
546  ;; when we encounter a command we recurse over the tail
547  (cmd
548  (when-let ((c (find-cmd self (cli-name form))))
549  ;; handle the rest of the AST
550  (setf c (install-ast c (make-cli-ast tail)))
551  (return-from install)))
552  (arg (push-arg form self)))))
553  (setf (cli-cmd-args self) (nreverse (cli-cmd-args self)))
554  self))
555 
556 (defmethod install-thunk ((self cli-cmd) (lambda function) &optional compile)
557  "Install THUNK into the corresponding slot in cli-cmd SELF."
558  (let ((%thunk (if compile (compile nil lambda) lambda)))
559  (setf (cli-thunk self) %thunk)
560  self))
561 
562 (defmethod push-arg (arg (self cli-cmd))
563  (push arg (cli-cmd-args self)))
564 
565 (defmethod parse-args ((self cli-cmd) args &key (compile t))
566  "Parse ARGS and return the updated object SELF.
567 
568 ARGS is assumed to be a valid cli-ast (list of cli-nodes), unless
569 COMPILE is t, in which case a list of strings is assumed."
570  (with-slots (opts cmds) self
571  (let ((args (if compile (proc-args self args) args)))
572  (install-ast self args))))
573 
574 ;; warning: make sure to fill in the opt and cmd slots with values
575 ;; from the top-level args before calling a command.
576 (defmethod call-cmd ((self cli-cmd) args opts)
577  (trace! args opts)
578  (funcall (cli-thunk self) args opts))
579 
580 (defmethod do-cmd ((self cli-cmd))
581  (if (solop self)
582  (call-cmd self (cli-cmd-args self) (active-opts self))
583  (progn
584  (loop for o across (active-opts self)
585  do (do-opt o))
586  (loop for c across (active-cmds self)
587  do (do-cmd c)))))
588 
589 (defclass cli (cli-cmd)
590  ;; name slot defaults to *package*, must be string
591  ((name :initarg :name :initform (string-downcase (package-name *package*)) :accessor cli-name :type string)
592  (version :initarg :version :initform "0.1.0" :accessor cli-version :type string)
593  ;; TODO 2023-10-11: look into pushd popd - cd-stack?
594  (cd :initarg :cd :initform (sb-posix:getcwd) :type string :accessor cli-cd
595  :documentation "working directory of the top-level CLI."))
596  (:documentation "CLI"))
597 
598 (defmethod print-usage ((self cli) &optional stream)
599  (iprintln (format nil "usage: ~A [global] <command> [<arg>]~%" (cli-name self)) 2 stream))
600 
601 (defmethod print-version ((self cli) &optional stream)
602  (println (cli-version self) stream))
603 
604 (defmethod print-help ((self cli) &optional stream)
605  (println (format nil "~A v~A --- ~A~%" (cli-name self) (cli-version self) (cli-description self)) stream)
606  (print-usage self stream)
607  ;; (terpri stream)
608  (println "options:" stream)
609  (with-slots (opts cmds) self
610  (unless (null opts)
611  (loop for o across opts
612  do (iprintln (print-usage o) 2 stream)))
613  (terpri stream)
614  (println "commands:" stream)
615  (unless (null cmds)
616  (loop for c across cmds
617  do (iprintln (print-usage c) 2 stream)))))
618 
619 (defmethod cli-equal :before ((a cli) (b cli))
620  "Return T if A is the same cli object as B.
621 
622 Currently this function is intended only for instances of the CLI
623 class and is used as a specialized EQL for DEFINE-CONSTANT."
624  (with-slots (version) a
625  (with-slots ((bv version)) b
626  (string= version bv))))
627 
628 (declaim (inline debug-opts))
629 (defun debug-opts (cli)
630  (let ((o (active-opts cli))
631  (a (cli-cmd-args cli))
632  (c (active-cmds cli)))
633  (log:debug! (cli-cd cli) o a c)))
634 
635 ;;; SIMPLE-CLI
636 
637 ;; TODO this is intended to be a simplified functional argument parser
638 ;; which is completely compatible with the toplevel SBCL options.
639 
640 ;; Instead of consuming the args into an AST, we loop over command
641 ;; line options in a lexical context, binding individual symbols.
642 
643 (defun namestring-to-opt (str) (sb-int:symbolicate (string-upcase (trim str :char-bag '(#\-)))))
644 
645 (defvar *default-opt-handlers*
646  (map 'list
647  (lambda (o) (cons (namestring-to-opt o) #'set))
648  sb-impl::+runtime-options+))
649 
650 ;; TODO 2024-03-19: need a way to terminate the loop early. (throw/catch)
651 
652 ;; do handlers need to be able to set multiple symbols?
653 
654 ;; should we define opts as special symbols in their own package? (defpackage :OPTS)
655 (defvar *opt-handlers* *default-opt-handlers*)
656 
657 (defun find-opt-handler (str)
658  (find (namestring-to-opt str) *opt-handlers* :key #'car))
659 
660 (defmacro with-opts-handled (&body body)
661  (let* ((syms (mapcar #'car *opt-handlers*)))
662  `(let ((opts (cdr *posix-argv*))
663  ,@(mapcar #'list syms))
664  (declare (type list opts))
665  (flet (($pop ()
666  (if opts
667  (pop opts)
668  (sb-impl::startup-error "unexpected end of cli opts"))))
669  (loop while opts do
670  (if-let ((opt (find-opt-handler (car opts))))
671  (apply (cdr opt) (car opt) ($pop))))
672  (when *posix-argv*
673  (setf (cdr *posix-argv*) opts))
674  ,@body))))
675 
676 ;;; TOPLEVEL
677 
678 ;; These macros help with defining a toplevel initialization
679 ;; function. Initialization functions are responsible for parsing runtime
680 ;; options and starting a REPL if needed.
681 ;; (defmacro define-toplevel-init (name (props opts) &body body))
682 ;; (defmacro define-toplevel-repl (name (props opts) &body body))
683 
684 (defun default-toplevel-init ()
685  (let ((opts (cdr *posix-argv*))
686  (sysinit))
687  (declare (type list opts))
688  (flet (($pop ()
689  (if opts
690  (pop opts)
691  (sb-impl::startup-error "unexpected end of cli opts"))))
692  (loop while opts do
693  (let ((opt (car opts)))
694  (cond
695  ((string= opt "--sysinit")
696  ($pop)
697  (if sysinit
698  (sb-impl::startup-error "multiple --sysinit opts")
699  (setf sysinit ($pop))))
700  (t
701  (if (find "--end-toplevel-options" opts
702  :test #'string=)
703  (sb-impl::startup-error "bad toplevel opt: ~S"
704  (car opts))
705  (return))))))
706  (when *posix-argv*
707  (setf (cdr *posix-argv*) opts)))))