Skip to content

Commit 0c28f8b

Browse files
committed
Improve completion of subcommand flags
1 parent 93679e7 commit 0c28f8b

5 files changed

Lines changed: 103 additions & 98 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
# Unreleased
22

3-
## Added
4-
53
## Fixed
64

7-
## Changed
5+
- Improve shell completion of flags tied to subcommands
86

97
# 1.29.127 (2026-04-10 / c8d5d75)
108

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -437,11 +437,13 @@ must always be passed:
437437

438438
- `:doc` docstring
439439
- `:default` default value
440+
- `:key` key to assoc into options map (defaults to flag name)
440441
- `:value` value to be associated with the key (for flags that don't take arguments)
441442
- `:handler` handler function, `(fn [opts & flag-args] ,,,)`
442443
- `:middleware` function(s) which wrap the final command handler (function or sequence of functions)
443444
- `:coll?` flag can be specified multiple times, will result in a vector
444445
- `:parse` function used to coerce the flag argument
446+
- `:required` if true, flag must be provided
445447

446448
### How to Build a Multi-Command CLI Tool
447449

@@ -723,4 +725,4 @@ line with the project's goals.
723725
Copyright © 2024-2025 Arne Brasseur and Contributors
724726

725727
Licensed under the term of the Mozilla Public License 2.0, see LICENSE.
726-
<!-- /license -->
728+
<!-- /license -->

src/lambdaisland/cli.clj

Lines changed: 3 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,6 @@
3939
(doseq [row rows]
4040
(println (apply format (str (format (str "%" pad "s") "") fstr) row)))))))
4141

42-
(defn coerce-to-pairs [o]
43-
(if (vector? o)
44-
(partition 2 o)
45-
o))
46-
4742
(defn short? [f]
4843
(re-find #"^-[^-]$" f))
4944

@@ -221,70 +216,6 @@
221216
(map #(str "-" %) (next flag))
222217
[flag])))
223218

224-
(def flag-re #"^(--?)(\[no-\])?(.+)$")
225-
226-
(defn cmd->flags [cmdspec args]
227-
(if (seq args)
228-
(when-let [cmds (:commands cmdspec)]
229-
(cmd->flags (get (into {} (cmdspec/prepare-cmdpairs (:commands cmdspec)))
230-
(first args))
231-
(rest args)))
232-
(:flags cmdspec)))
233-
234-
(defn parse-flagstr [flagstr flagopts]
235-
(let [;; support "--foo=<hello>" and "--foo HELLO"
236-
[flags argdoc argnames] (cmdspec/parse-arg-names flagstr)
237-
argcnt (count argnames)
238-
;; e.g. "-i,--input, --[no-]foo ARG"
239-
flagstrs (map #(re-find flag-re %) flags)
240-
flag-key (keyword (or (some (fn [[_ dash no flag]]
241-
(when (= "--" dash) flag))
242-
flagstrs)
243-
(last (first flagstrs))))]
244-
(merge {:flagstr flagstr
245-
:argdoc argdoc
246-
:flags flags
247-
:args argnames
248-
:key flag-key
249-
:argcnt argcnt}
250-
flagopts)))
251-
252-
(defn prepare-flagpairs [flagstrs]
253-
(when (seq flagstrs)
254-
(map (fn [[flagstr flagopts]]
255-
(let [flagopts (if (var? flagopts)
256-
{:doc (:doc (meta flagopts))
257-
:handler flagopts}
258-
flagopts)]
259-
[flagstr (parse-flagstr flagstr (if (string? flagopts) {:doc flagopts} flagopts))]))
260-
(coerce-to-pairs flagstrs))))
261-
262-
(defn build-flagmap-entries [[flagstr flagopts]]
263-
(let [{:keys [args key argcnt flags] :as parsed-flagstr} flagopts
264-
flags (map #(re-find flag-re %) flags)]
265-
(for [[_ dash no flag] flags
266-
negative? (if no [true false] [false])]
267-
(cond-> {:flag (str dash (if negative? "no-") flag)
268-
:key key
269-
:argcnt argcnt}
270-
(= dash "-")
271-
(assoc :short? true)
272-
(seq args)
273-
(assoc :args args)
274-
no
275-
(assoc :value (not negative?)
276-
:boolean? true)
277-
:->
278-
(merge flagopts)))))
279-
280-
(defn parse-flagstrs [flagpairs]
281-
(into {"-h" {:key :help :value true}
282-
"--help" {:key :help :value true}}
283-
(comp
284-
(mapcat build-flagmap-entries)
285-
(map (juxt :flag identity)))
286-
flagpairs))
287-
288219
(defn add-defaults [init-opts flagpairs]
289220
(reduce (fn [opts flagspec]
290221
(if (some? (get opts (:key flagspec)))
@@ -302,24 +233,6 @@
302233
init-opts
303234
(map second flagpairs)))
304235

305-
(defn add-processed-flags
306-
"We process flag information for easier use, this results in
307-
`:flagpairs` (ordered sequence of pairs, mainly used in printing help
308-
information), and `:flagmap` (for easy lookup), added to the `cmdspec`. As we
309-
process arguments we may need to add additional flags, based on the current
310-
subcommand. This function is used both for the top-level as for subcommand
311-
handling of flags."
312-
[cmdspec extra-flags]
313-
(let [flagpairs (prepare-flagpairs extra-flags)
314-
flagmap (parse-flagstrs flagpairs)]
315-
(-> cmdspec
316-
(update :flagpairs (fn [fp]
317-
(into (vec fp)
318-
;; This prevents duplicates. Yes, this is not pretty. I'm very sorry.
319-
(remove #((into #{} (map first) fp) (first %)))
320-
flagpairs)))
321-
(update :flagmap merge flagmap))))
322-
323236
(defn split-flags
324237
"Main processing loop, go over raw arguments, split into positional and flags,
325238
building up an argument vector, and flag/options map."
@@ -331,9 +244,9 @@
331244
opts opts]
332245
;; Handle additional flags by nested commands
333246
(let [extra-flags (when-not (seen-prefixes args)
334-
(cmd->flags cmdspec args))
335-
opts (add-defaults opts (prepare-flagpairs extra-flags))
336-
cmdspec (add-processed-flags cmdspec extra-flags)]
247+
(cmdspec/cmd->flags cmdspec args))
248+
opts (add-defaults opts (cmdspec/prepare-flagpairs extra-flags))
249+
cmdspec (cmdspec/add-processed-flags cmdspec extra-flags)]
337250
(cond
338251
(nil? arg)
339252
[cmdspec args opts]

src/lambdaisland/cli/cmdspec.clj

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
(:require
55
[clojure.string :as str]))
66

7-
(def args-re #" ([A-Z][A-Z_-]*)|[= ]<([^>]+)>")
7+
(def args-re #" ([A-Z][A-Z_-]*)|[= ]<([^>]+)>(\.\.\.)?")
88

99
(defn parse-arg-names [str]
1010
[(str/split (str/replace str args-re "") #",\s*")
1111
(str/join (map first (re-seq args-re str)))
12-
(mapv (fn [[_ u l]] (keyword (str/lower-case (or u l)))) (re-seq args-re str))])
12+
(mapv (fn [[_ u l more]] (let [arg-kw (keyword (str/lower-case (or u l)))]
13+
(if more
14+
[:& arg-kw]
15+
arg-kw))) (re-seq args-re str))])
1316

1417
(defn to-cmdspec [?var]
1518
(cond
@@ -34,3 +37,90 @@
3437
[[cmd] doc argnames] (parse-arg-names k)]
3538
[cmd (assoc v :argdoc doc :argnames argnames)]))
3639
m)))
40+
41+
(defn cmd->flags [cmdspec args]
42+
(if (seq args)
43+
(when-let [cmds (:commands cmdspec)]
44+
(recur (get (into {} (prepare-cmdpairs (:commands cmdspec)))
45+
(first args))
46+
(rest args)))
47+
(:flags cmdspec)))
48+
49+
(def flag-re #"^(--?)(\[no-\])?(.+)$")
50+
51+
(defn parse-flagstr [flagstr flagopts]
52+
(let [;; support "--foo=<hello>" and "--foo HELLO"
53+
[flags argdoc argnames] (parse-arg-names flagstr)
54+
argcnt (count argnames)
55+
;; e.g. "-i,--input, --[no-]foo ARG"
56+
flagstrs (map #(re-find flag-re %) flags)
57+
flag-key (keyword (or (some (fn [[_ dash no flag]]
58+
(when (= "--" dash) flag))
59+
flagstrs)
60+
(last (first flagstrs))))]
61+
(merge {:flagstr flagstr
62+
:argdoc argdoc
63+
:flags flags
64+
:args argnames
65+
:key flag-key
66+
:argcnt argcnt}
67+
flagopts)))
68+
69+
(defn coerce-to-pairs [o]
70+
(if (vector? o)
71+
(partition 2 o)
72+
o))
73+
74+
(defn prepare-flagpairs [flagstrs]
75+
(when (seq flagstrs)
76+
(map (fn [[flagstr flagopts]]
77+
(let [flagopts (if (var? flagopts)
78+
{:doc (:doc (meta flagopts))
79+
:handler flagopts}
80+
flagopts)]
81+
[flagstr (parse-flagstr flagstr (if (string? flagopts) {:doc flagopts} flagopts))]))
82+
(coerce-to-pairs flagstrs))))
83+
84+
(defn build-flagmap-entries [[flagstr flagopts]]
85+
(let [{:keys [args key argcnt flags] :as parsed-flagstr} flagopts
86+
flags (map #(re-find flag-re %) flags)]
87+
(for [[_ dash no flag] flags
88+
negative? (if no [true false] [false])]
89+
(cond-> {:flag (str dash (if negative? "no-") flag)
90+
:key key
91+
:argcnt argcnt}
92+
(= dash "-")
93+
(assoc :short? true)
94+
(seq args)
95+
(assoc :args args)
96+
no
97+
(assoc :value (not negative?)
98+
:boolean? true)
99+
:->
100+
(merge flagopts)))))
101+
102+
(defn parse-flagstrs [flagpairs]
103+
(into {"-h" {:doc "Show help text" :key :help :value true}
104+
"--help" {:doc "Show help text" :key :help :value true}}
105+
(comp
106+
(mapcat build-flagmap-entries)
107+
(map (juxt :flag identity)))
108+
flagpairs))
109+
110+
(defn add-processed-flags
111+
"We process flag information for easier use, this results in
112+
`:flagpairs` (ordered sequence of pairs, mainly used in printing help
113+
information), and `:flagmap` (for easy lookup), added to the `cmdspec`. As we
114+
process arguments we may need to add additional flags, based on the current
115+
subcommand. This function is used both for the top-level as for subcommand
116+
handling of flags."
117+
[cmdspec extra-flags]
118+
(let [flagpairs (prepare-flagpairs extra-flags)
119+
flagmap (parse-flagstrs flagpairs)]
120+
(-> cmdspec
121+
(update :flagpairs (fn [fp]
122+
(into (vec fp)
123+
;; This prevents duplicates. Yes, this is not pretty. I'm very sorry.
124+
(remove #((into #{} (map first) fp) (first %)))
125+
flagpairs)))
126+
(update :flagmap merge flagmap))))

src/lambdaisland/cli/completions.clj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
(declare with-completions)
88

99
(defn shell-completions [cmdspec {:lambdaisland.cli/keys [argv] :as opts}]
10-
(let [cmdspec (reduce
10+
(let [args (butlast (next argv))
11+
cmdspec (cmdspec/add-processed-flags cmdspec (cmdspec/cmd->flags cmdspec args))
12+
cmdspec (reduce
1113
(fn [cmdspec arg]
1214
(let [commands (cmdspec/prepare-cmdpairs (:commands (with-completions cmdspec)))]
1315
(if-let [command-match (get (into {} commands) arg)]
@@ -18,7 +20,7 @@
1820
(:flagmap (cmdspec/to-cmdspec command-match)))))
1921
cmdspec)))
2022
cmdspec
21-
(butlast (next argv)))]
23+
args)]
2224
(doseq [[cmd {:keys [doc]}] (cmdspec/prepare-cmdpairs (:commands cmdspec))]
2325
(println (str cmd (when doc (str ":" (first (str/split doc #"\R")))))))
2426
(doseq [[flag {:keys [doc]}] (:flagmap cmdspec)]

0 commit comments

Comments
 (0)