Skip to content

Commit 64383b5

Browse files
committed
Better flag handler and *opts* support
Recognize `-` and `\\--foo`
1 parent 47e4b91 commit 64383b5

5 files changed

Lines changed: 176 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
- Bind the options map to `cli/*opts*`, for easy access.
66
- Show the default for a flag in the help text.
7+
- Add a docstring to the main entry point (`dispatch`)
8+
- Bind `*opts*` during flag handler execution
9+
10+
## Fixed
11+
12+
- Recognize `-` and `\\--foo` as positional args
713

814
## Changed
915

@@ -13,6 +19,8 @@
1319
default value through the parse function, rather than using it directly. Using
1420
the unparsed string form for the default is preferable over for instance using
1521
a keyword, since it leads to better help text rendering.
22+
- Improve and document the processing logic, especially when it comes to
23+
subcommand flags with handler functions.
1624

1725
# 0.3.19-alpha (2024-02-11 / d79ac0c)
1826

README.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ home-grown or general purpose tools.
3737

3838
It scales from extremely low ceremony basic scripts, to fairly complex setups.
3939

40+
This library helps you write [Well-behaved Command Line
41+
Tools](https://lambdaisland.com/blog/2020-07-28-well-behaved-command-line-tools)
42+
4043
## Usage
4144

4245
The main entrypoint is `lambdaisland.cli/dispatch`, usually you're fine with the
@@ -178,6 +181,13 @@ At this point a few things are worth calling out.
178181
then the default will be run through parse as well. It's generally best to set
179182
the default to a string or a number, this will look better in the help text,
180183
where we show the default.
184+
- A single dash (`-`) is considered a positional argument, conventially
185+
indicating stdin/stdout
186+
- To pass a positional argument that starts with a dash, prefix it with a
187+
backslash. lambdaisland/cli will remove the backslash, and treat the remainder
188+
as a positional argument rather than a flag. Note that the shell does its own
189+
backspace (character escape) handling, so in practice this means prefixing
190+
with two backslashes, e.g. `\\--foo`.
181191

182192
You can also explicitly set which key to use with `:key`, as well as setting a
183193
specific `:value`, for instance:
@@ -360,6 +370,47 @@ This is my cool CLI tool. Use it well.
360370
-l, --long Use long format
361371
```
362372

373+
### Processing Order
374+
375+
Most of what lambdaisland/cli does is combine the command and flag descriptions
376+
with the incoming command line arguments to build up a map, which then gets
377+
passed to the command handler. This map gets built up in multiple steps.
378+
379+
The `:init` configuration flag, if present, provides the starting point. It can
380+
be a map, or a zero-arity function returning a map.
381+
382+
Then we add `:default` values for top-level `:flags`. Normally these are simply
383+
assoc'ed into the map provided by `:init`. If the flag also has a `:handler`,
384+
then the opts map we have so far is passed to the handler, which can manipulate
385+
it and return an updated version.
386+
387+
Then we actually start processing command line arguments, splitting them into
388+
flags (start with a dash), or positional argument (does not start with a dash,
389+
or a single dash). Flag arguments are processed as we encounter them,
390+
potentially calling their handler, with the opts map we have so far, from
391+
`:init`, defaults, and earlier flags and flag handlers. The positional arguments
392+
are then used to determine which (sub-)command to invoke.
393+
394+
During any handler execution `cli/*opts*` is bound to the intermediate opts map
395+
that we have so far, so any utility functions that are called can assume this is
396+
available.
397+
398+
Sub-commands can add additional flag specifications, if we encounter those then
399+
their defaults are added to the opts map, either directly or through their
400+
defined handler. This does mean that these kind of flags can only be used
401+
*after* the command. This may change in the future, since this is an unfortunate
402+
asymmetry.
403+
404+
Finally the opts map gets bound to `cli/*opts*`, middleware gets invoked (so
405+
`*opts*` can be accessed during middleware execution).
406+
407+
Pay attention to the fact that the opts map is built up in multiple steps, and
408+
so flag handlers will only see a partial `opts`/`*opts*`. Because of this we
409+
recommend mainly using handlers to manipulate the opts map, and not much else.
410+
Any behavior should be reserved for the main command handler, and for
411+
middleware, which are guaranteed to see all possible flags reflected in the opts
412+
map they receive.
413+
363414
<!-- opencollective -->
364415
## Lambda Island Open Source
365416

bin/cli-test

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@
4949
:default 8080}
5050
"-l, --long"
5151
{:doc "Use long format"
52-
}]}]})
52+
}]}]
53+
:flags
54+
["-s, --silent" "Shut up"]})
5355
#_
5456
(cli/dispatch
5557
{:name "cli-test"

bin/proj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
{:license :mpl
88
:inception-year 2024
99
:description "Command line parser with good subcommand and help handling"
10-
:version-qualifier "alpha"
1110
:group-id "com.lambdaisland"})
1211

1312
;; Local Variables:

src/lambdaisland/cli.clj

Lines changed: 114 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,13 @@
9292
([flags flagspec]
9393
(add-middleware
9494
(if-let [handler (:handler flagspec)]
95-
(handler flags)
95+
(binding [*opts* flags] (handler flags))
9696
(assoc flags (:key flagspec) (:value flagspec)))
9797
flagspec))
9898
([flags flagspec & args]
9999
(add-middleware
100100
(if-let [handler (:handler flagspec)]
101-
(apply handler flags args)
101+
(binding [*opts* flags] (apply handler flags args))
102102
(assoc flags (:key flagspec)
103103
(if (= 1 (count args))
104104
(first args)
@@ -108,7 +108,7 @@
108108
(defn update-flag [flags flagspec f & args]
109109
(add-middleware
110110
(if-let [handler (:handler flagspec)]
111-
(apply handler flags args)
111+
(binding [*opts* flags] (apply handler flags args))
112112
(apply update flags (:key flagspec) f args))
113113
flagspec))
114114

@@ -156,7 +156,15 @@
156156
(mapv (fn [[_ u l]] (keyword (str/lower-case (or u l)))) (re-seq args-re str))])
157157

158158
(defn to-cmdspec [?var]
159-
(if (var? ?var) (assoc (meta ?var) :command ?var) ?var))
159+
(cond
160+
(var? ?var)
161+
(assoc (meta ?var) :command ?var)
162+
163+
(var? (:command ?var))
164+
(merge (meta (:command ?var)) ?var)
165+
166+
:else
167+
?var))
160168

161169
(defn prepare-cmdpairs [commands]
162170
(let [m (if (vector? commands) (apply hash-map commands) commands)]
@@ -222,50 +230,120 @@
222230
(map (juxt :flag identity)))
223231
flagpairs))
224232

225-
(defn split-flags [cmdspec cli-args]
233+
(defn add-defaults [init flagpairs]
234+
(reduce (fn [opts flagspec]
235+
(if-let [d (:default flagspec)]
236+
(if-let [h (:handler flagspec)]
237+
(binding [*opts* opts]
238+
(h opts (if (and (string? d) (:parse flagspec))
239+
((:parse flagspec default-parse) d)
240+
d)))
241+
(assoc opts (:key flagspec) d))
242+
opts))
243+
init
244+
(map second flagpairs)))
245+
246+
(defn add-extra-flags
247+
"We process flag information for easier use, this results in
248+
`:flagpairs` (ordered sequence of pairs, mainly used in printing help
249+
information), and `:flagmap` (for easy lookup), added to the `cmdspec`. As we
250+
process arguments we may need to add additional flags, based on the current
251+
subcommand. This function is used both for the top-level as for subcommand
252+
handling of flags."
253+
[cmdspec extra-flags]
254+
(let [flagpairs (prepare-flagpairs extra-flags)
255+
flagmap (parse-flagstrs flagpairs)]
256+
(-> cmdspec
257+
(update :flagpairs (fn [fp]
258+
(into (vec fp)
259+
;; This prevents duplicates. Yes, this is not pretty. I'm very sorry.
260+
(remove #((into #{} (map first) fp) (first %)))
261+
flagpairs)))
262+
(update :flagmap merge flagmap))))
263+
264+
(defn split-flags
265+
"Main processing loop, go over raw arguments, split into positional and flags,
266+
building up an argument vector, and flag/options map."
267+
[cmdspec cli-args init]
226268
(loop [cmdspec cmdspec
227269
[arg & cli-args] cli-args
228270
args []
229-
flags {}]
271+
flags init]
230272
;; Handle additional flags by nested commands
231273
(let [extra-flags (cmd->flags cmdspec args)
232-
flagpairs (prepare-flagpairs extra-flags)
233-
flagmap (parse-flagstrs flagpairs)
234-
cmdspec (-> cmdspec
235-
(update :flagpairs (fn [fp] (into (vec fp) (remove #((into #{} (map first) fp) (first %))) flagpairs))) ; This prevents duplicates. Yes, this is not pretty. I'm very sorry.
236-
(update :flagmap merge flagmap))]
237-
274+
flags (add-defaults flags (prepare-flagpairs extra-flags))
275+
cmdspec (add-extra-flags cmdspec extra-flags)]
238276
(cond
239-
(nil? arg) [cmdspec args flags]
240-
(= "--" arg) [cmdspec (into args cli-args) flags]
241-
(= \- (first arg)) (let [[cli-args args flags] (handle-flag cmdspec arg cli-args args flags)]
242-
(recur cmdspec cli-args args flags))
243-
:else (recur cmdspec cli-args (conj args arg) flags)))))
277+
(nil? arg)
278+
[cmdspec args flags]
244279

245-
(defn default-flags [flagpairs]
246-
(reduce (fn [opts flagspec]
247-
(if-let [d (:default flagspec)]
248-
(if-let [h (:handler flagspec)]
249-
(h opts (if (and (string? d) (:parse flagspec))
250-
((:parse flagspec default-parse) d)
251-
d))
252-
(assoc opts (:key flagspec) d))
253-
opts))
254-
{}
255-
(map second flagpairs)))
280+
(= "--" arg)
281+
[cmdspec (into args cli-args) flags]
282+
283+
(and (= \- (first arg))
284+
(not= 1 (count arg))) ; single dash is considered a positional argument
285+
(let [[cli-args args flags] (handle-flag cmdspec arg cli-args args flags)]
286+
(recur (dissoc cmdspec :flags) cli-args args flags))
287+
288+
:else
289+
(recur (dissoc cmdspec :flags) cli-args (conj args (str/replace arg #"^\\(.)" (fn [[_ o]] o))) flags)))))
256290

257291
(defn dispatch
292+
"Main entry point for com.lambdaisland/cli.
293+
294+
Takes either a single var, or a map describing the commands and flags that
295+
your CLI tool accepts. At a minimum it should contain either a `:command` or
296+
`:commands`, optionally followed by a vector of positional command line
297+
arguments (this second argument can generally be omitted, since we can access
298+
these through [[*command-line-args*]]).
299+
300+
- `:name` Name of the script/command as used in the shell, used in the help text
301+
- `:command` Function that implements your command logic, receives a map of
302+
parsed CLI args. Can be a var, in which case additional configuration can be
303+
done through var metadata.
304+
- `:commands` Map or flat vector of command-string command-map pairs
305+
- `:doc` Docstring, taken from `:command` if it is a var.
306+
- `:flags` Map or flat vector of flag-string flag-map
307+
- `:argnames` Vector of positional argument names, only needed on the top
308+
level, for subcommands use the command-string to specify these.
309+
- `:init` map or zero-arity function that provides the base options map, that
310+
parsed flags and arguments are added onto
311+
312+
These flags can also be used in (sub)command maps, with the exception of
313+
`:name`, `:argnames`, and `:init`.
314+
315+
A command-string consists of the name of the command, optionally followed by
316+
any named positional argument, either in all-caps, or delineated by angle
317+
brackets, e.g. `create <id> <name>` or `delete ID`.
318+
319+
A flag-string consists of command separated short (single-dash) or
320+
long (double-dash) flags, optionally followed by an argument name, either in
321+
all-caps, or delineated by angle brackets. The flag and argument are separated
322+
by either a space or an equals sign. e.g. `--input=<filename>`, `-o, --output
323+
FILENAME`.
324+
325+
Flag-maps can contain
326+
- `:doc` Docstring, used in the help text
327+
- `:parse` Function that parses/coerces the flag argument from string.
328+
- `:default` Default value, gets passed through `:parse` if it's a string.
329+
- `:handler` Function that transforms the options map when this flag is
330+
present. Zero-arity for boolean (no-argument) flag, one-arity for flags that
331+
take an argument.
332+
- `:middleware` Function or sequence of functions that will wrap the command
333+
function if this flag is present.
334+
335+
This docstring is just a summary, see the `com.lambdaisland/cli` README for
336+
details.
337+
"
258338
([cmdspec]
259339
(dispatch (to-cmdspec cmdspec) *command-line-args*))
260-
([{:keys [flags] :as cmdspec} cli-args]
261-
(let [flagpairs (prepare-flagpairs flags)
262-
flagmap (parse-flagstrs flagpairs)
263-
cmdspec (assoc cmdspec :flagpairs flagpairs :flagmap flagmap)
264-
[cmdspec pos-args flags] (split-flags cmdspec cli-args)
340+
([{:keys [flags init] :as cmdspec} cli-args]
341+
(let [init (if (or (fn? init) (var? init)) (init) init)
342+
[cmdspec pos-args flags] (split-flags cmdspec cli-args init)
265343
flagpairs (get cmdspec :flagpairs)]
266-
(dispatch (merge (meta (:command cmdspec)) cmdspec)
267-
pos-args
268-
(merge (default-flags flagpairs) flags))))
344+
(dispatch cmdspec pos-args flags)))
345+
;; Note: this three-arg version of dispatch is considered private, it's used
346+
;; for internal recursion on subcommands.
269347
([{:keys [commands doc argnames command flags flagpairs flagmap]
270348
:as cmdspec
271349
program-name :name

0 commit comments

Comments
 (0)