Skip to content

Commit 99afa1e

Browse files
committed
TCLI-107 add proper subcommand support
Signed-off-by: Sean Corfield <sean@corfield.org>
1 parent a717e00 commit 99afa1e

File tree

5 files changed

+132
-57
lines changed

5 files changed

+132
-57
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Change Log
22

3+
* Release 1.4.next in progress
4+
* Enhance support for subcommand option parsing: add `:subcommand :explicit` and `:subcommand :implicit` options. The former replaces `:in-order true` (which is deprecated) and the latter expands that parsing to treat an unknown option as starting a new subcommand.
5+
36
* Release 1.3.250 2025-12-30
47
* Update parent pom and Clojure dependency version
58

doc/new-in-0-4.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ options. To aid in designing such programs, `clojure.tools.cli/parse-opts`
3333
accepts an `:in-order` option that directs it to stop processing arguments at
3434
the first unrecognized token.
3535

36+
_As of 1.4.next, the `:in-order` option is deprecated and replaced by the `:subcommand` option with values `:explicit` and `:implicit`._
37+
3638
For instance, the `git` program has a set of top-level options that are
3739
unrecognized by subcommands and vice-versa:
3840

doc/parse-opts.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,6 @@ this into complete documentation for the library, with examples, over time):
188188
A few function options may be specified to influence the behavior of
189189
parse-opts:
190190
191-
:in-order Stop option processing at the first unknown argument. Useful
192-
for building programs with subcommands that have their own
193-
option specs.
194-
195191
:no-defaults Only include option values specified in arguments and do not
196192
include any default values in the resulting options map.
197193
Useful for parsing options from multiple sources; i.e. from a
@@ -201,6 +197,12 @@ this into complete documentation for the library, with examples, over time):
201197
matches any other option, it is considered to be missing (and
202198
you have a parse error).
203199
200+
:subcommand Stop option processing at the first unknown argument. Useful
201+
for building programs with subcommands that have their own
202+
option specs. Can be set to :explicit or :implicit. :explicit
203+
requires a non-option (explicit) subcommand argument to
204+
trigger collection of subcommand arguments. :implicit treats an unknown option as starting a new subcommand.
205+
204206
:summary-fn A function that receives the sequence of compiled option specs
205207
(documented at #'clojure.tools.cli/compile-option-specs), and
206208
returns a custom option summary string.

src/main/clojure/clojure/tools/cli.cljc

Lines changed: 73 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@
3535
Long options with `=` are always parsed as option + optarg, even if nothing
3636
follows the `=` sign.
3737
38-
If the :in-order flag is true, the first non-option, non-optarg argument
38+
If the :subcommand option is present, the first non-option, non-optarg argument
3939
stops options processing. This is useful for handling subcommand options."
4040
[required-set args & options]
41-
(let [{:keys [in-order]} (apply hash-map options)]
41+
(let [{:keys [subcommand]} (apply hash-map options)]
4242
(loop [opts [] argv [] [car & cdr] args]
4343
(if car
4444
(condp re-seq car
@@ -66,7 +66,7 @@
6666
(recur (conj os [:short-opt o]) cs)
6767
[(conj os [:short-opt o]) cdr]))))]
6868
(recur (into opts os) argv cdr))
69-
(if in-order
69+
(if subcommand
7070
(recur opts (into argv (cons car cdr)) [])
7171
(recur opts (conj argv car) cdr)))
7272
[opts argv]))))
@@ -282,59 +282,67 @@
282282
If the :no-defaults flag is true, only options specified in the tokens are
283283
included in the option-map.
284284
285-
Unknown options, missing options, missing required arguments, option
286-
argument parsing exceptions, and validation failures are collected into
287-
a vector of error message strings.
285+
By default, unknown options, missing options, missing required arguments,
286+
option argument parsing exceptions, and validation failures are collected
287+
into a vector of error message strings.
288+
289+
If :subcommand :implicit is provided, unknown options trigger in-order
290+
processing, collecting the rest of the tokens as positional arguments.
288291
289292
If the :strict flag is true, required arguments that match other options
290293
are treated as missing, instead of a literal value beginning with - or --.
291294
292295
Returns [option-map error-messages-vector]."
293296
[specs tokens & options]
294-
(let [{:keys [no-defaults strict]} (apply hash-map options)
297+
(let [{:keys [no-defaults strict subcommand]} (apply hash-map options)
295298
defaults (default-option-map specs :default)
296299
default-fns (default-option-map specs :default-fn)
297-
requireds (missing-errors specs)]
300+
requireds (missing-errors specs)
301+
collecting (atom false)]
298302
(-> (reduce
299-
(fn [[m ids errors] [opt-type opt optarg]]
300-
(if-let [spec (find-spec specs opt-type opt)]
301-
(let [[value error] (parse-optarg spec opt optarg)
302-
id (:id spec)]
303-
(if-not (= value ::error)
304-
(if (and strict
305-
(or (find-spec specs :short-opt optarg)
306-
(find-spec specs :long-opt optarg)))
307-
[m ids (conj errors (missing-required-error opt (:required spec)))]
308-
(let [m' (if-let [update-fn (:update-fn spec)]
309-
(if (:multi spec)
310-
(update m id update-fn value)
311-
(update m id update-fn))
312-
((:assoc-fn spec assoc) m id value))]
313-
(if (:post-validation spec)
314-
(let [[value error] (validate (get m' id) spec opt optarg)]
315-
(if (= value ::error)
316-
[m ids (conj errors error)]
317-
[m' (conj ids id) errors]))
318-
[m' (conj ids id) errors])))
319-
[m ids (conj errors error)]))
320-
[m ids (conj errors (str "Unknown option: " (pr-str opt)))]))
321-
[defaults [] []] tokens)
303+
(fn [[m ids errors args] [opt-type opt optarg]]
304+
(if-let [spec (when-not @collecting (find-spec specs opt-type opt))]
305+
(let [[value error] (parse-optarg spec opt optarg)
306+
id (:id spec)]
307+
(if-not (= value ::error)
308+
(if (and strict
309+
(or (find-spec specs :short-opt optarg)
310+
(find-spec specs :long-opt optarg)))
311+
[m ids (conj errors (missing-required-error opt (:required spec))) args]
312+
(let [m' (if-let [update-fn (:update-fn spec)]
313+
(if (:multi spec)
314+
(update m id update-fn value)
315+
(update m id update-fn))
316+
((:assoc-fn spec assoc) m id value))]
317+
(if (:post-validation spec)
318+
(let [[value error] (validate (get m' id) spec opt optarg)]
319+
(if (= value ::error)
320+
[m ids (conj errors error)]
321+
[m' (conj ids id) errors]))
322+
[m' (conj ids id) errors args])))
323+
[m ids (conj errors error) args]))
324+
(if (or @collecting (= :implicit subcommand))
325+
(do
326+
(reset! collecting true)
327+
[m ids errors (conj args opt)])
328+
[m ids (conj errors (str "Unknown option: " (pr-str opt))) args])))
329+
[defaults [] [] []] tokens)
322330
(#(reduce
323-
(fn [[m ids errors] [id error]]
331+
(fn [[m ids errors args] [id error]]
324332
(if (contains? m id)
325-
[m ids errors]
326-
[m ids (conj errors error)]))
333+
[m ids errors args]
334+
[m ids (conj errors error) args]))
327335
% requireds))
328336
(#(reduce
329-
(fn [[m ids errors] [id f]]
337+
(fn [[m ids errors args] [id f]]
330338
(if (contains? (set ids) id)
331-
[m ids errors]
332-
[(assoc m id (f (first %))) ids errors]))
339+
[m ids errors args]
340+
[(assoc m id (f (first %))) ids errors args]))
333341
% default-fns))
334-
(#(let [[m ids errors] %]
342+
(#(let [[m ids errors args] %]
335343
(if no-defaults
336-
[(select-keys m ids) errors]
337-
[m errors]))))))
344+
[(select-keys m ids) errors args]
345+
[m errors args]))))))
338346

339347
(defn make-summary-part
340348
"Given a single compiled option spec, turn it into a formatted string,
@@ -588,10 +596,6 @@
588596
A few function options may be specified to influence the behavior of
589597
parse-opts:
590598
591-
:in-order Stop option processing at the first unknown argument. Useful
592-
for building programs with subcommands that have their own
593-
option specs.
594-
595599
:no-defaults Only include option values specified in arguments and do not
596600
include any default values in the resulting options map.
597601
Useful for parsing options from multiple sources; i.e. from a
@@ -601,19 +605,39 @@
601605
matches any other option, it is considered to be missing (and
602606
you have a parse error).
603607
608+
:subcommand Stop option processing at the first unknown argument. Useful
609+
for building programs with subcommands that have their own
610+
option specs. Can be set to :explicit or :implicit. :explicit
611+
requires a non-option (explicit) subcommand argument to
612+
trigger collection of subcommand arguments. :implicit treats an
613+
unknown option as starting a new subcommand.
614+
604615
:summary-fn A function that receives the sequence of compiled option specs
605616
(documented at #'clojure.tools.cli/compile-option-specs), and
606617
returns a custom option summary string.
607618
"
608619
[args option-specs & options]
609-
(let [{:keys [in-order no-defaults strict summary-fn]} (apply hash-map options)
620+
(let [{:keys [in-order no-defaults strict subcommand summary-fn]}
621+
(apply hash-map options)
622+
;; handle deprecation of in-order here:
623+
subcommand (if subcommand
624+
(do
625+
(when in-order
626+
(throw (ex-info ":in-order is deprecated and cannot be used if :subcommand is present"
627+
{:in-order in-order
628+
:subcommand subcommand})))
629+
subcommand)
630+
(when in-order :explicit))
610631
specs (compile-option-specs option-specs)
611632
req (required-arguments specs)
612-
[tokens rest-args] (tokenize-args req args :in-order in-order)
613-
[opts errors] (parse-option-tokens specs tokens
614-
:no-defaults no-defaults :strict strict)]
633+
[tokens rest-args] (tokenize-args req args :subcommand subcommand)
634+
[opts errors implicit-args]
635+
(parse-option-tokens specs tokens
636+
:no-defaults no-defaults
637+
:strict strict
638+
:subcommand subcommand)]
615639
{:options opts
616-
:arguments rest-args
640+
:arguments (into rest-args implicit-args)
617641
:summary ((or summary-fn summarize) specs)
618642
:errors (when (seq errors) errors)}))
619643

src/test/clojure/clojure/tools/cli_test.cljc

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
;; Refer private vars
77
(def tokenize-args #'cli/tokenize-args)
88
(def compile-option-specs #'cli/compile-option-specs)
9-
(def parse-option-tokens #'cli/parse-option-tokens)
9+
(def parse-option-tokens' #'cli/parse-option-tokens)
10+
(defn- parse-option-tokens
11+
"To avoid changing all the tests that assume parse-option-tokens returns
12+
only [opts errors]"
13+
[specs tokens & options]
14+
(let [[opts errors _args] (apply #'cli/parse-option-tokens specs tokens options)]
15+
[opts errors]))
1016

1117
(deftest test-tokenize-args
1218
(testing "expands clumped short options"
@@ -20,10 +26,12 @@
2026
(testing "stops option processing on double dash"
2127
(is (= (tokenize-args #{} ["-a" "--" "-b"])
2228
[[[:short-opt "-a"]] ["-b"]])))
23-
(testing "finds trailing options unless :in-order is true"
29+
(testing "finds trailing options unless :subcommand is present"
2430
(is (= (tokenize-args #{} ["-a" "foo" "-b"])
2531
[[[:short-opt "-a"] [:short-opt "-b"]] ["foo"]]))
26-
(is (= (tokenize-args #{} ["-a" "foo" "-b"] :in-order true)
32+
(is (= (tokenize-args #{} ["-a" "foo" "-b"] :subcommand :explicit)
33+
[[[:short-opt "-a"]] ["foo" "-b"]]))
34+
(is (= (tokenize-args #{} ["-a" "foo" "-b"] :subcommand :implicit)
2735
[[[:short-opt "-a"]] ["foo" "-b"]])))
2836
(testing "does not interpret single dash as an option"
2937
(is (= (tokenize-args #{} ["-"]) [[] ["-"]]))))
@@ -100,7 +108,7 @@
100108
#?(:clj (binding [*err* *out*]
101109
(compile-option-specs [[nil "--alpha" :validate nil :flag true]]))
102110
:cljr (binding [*err* *out*]
103-
(compile-option-specs [[nil "--alpha" :validate nil :flag true]]))
111+
(compile-option-specs [[nil "--alpha" :validate nil :flag true]]))
104112
:cljs (binding [*print-err-fn* *print-fn*]
105113
(compile-option-specs [[nil "--alpha" :validate nil :flag true]]))))))
106114
(is (re-find #"Warning:.* :validate"
@@ -422,11 +430,47 @@
422430
["foo" "bar" "-b" "baz"])))
423431
(testing "provides an option summary at :summary"
424432
(is (re-seq #"-a\W+--alpha" (:summary (parse-opts [] [["-a" "--alpha"]])))))
433+
;; deprecated:
425434
(testing "processes arguments in order when :in-order is true"
426435
(is (= (:arguments (parse-opts ["-a" "foo" "-b"]
427436
[["-a" "--alpha"] ["-b" "--beta"]]
428437
:in-order true))
429438
["foo" "-b"])))
439+
(testing "processes arguments in order when :subcommand is present"
440+
(is (= (:arguments (parse-opts ["-a" "foo" "-b"]
441+
[["-a" "--alpha"] ["-b" "--beta"]]
442+
:subcommand :explicit))
443+
["foo" "-b"]))
444+
(is (= (:arguments (parse-opts ["-a" "foo" "-b"]
445+
[["-a" "--alpha"] ["-b" "--beta"]]
446+
:subcommand :implicit))
447+
["foo" "-b"]))
448+
;; explicit subcommand requires at least one non-option argument:
449+
(is (= (:arguments (parse-opts ["-a" "-b"]
450+
[["-a" "--alpha"]]
451+
:subcommand :explicit))
452+
[]))
453+
(is (= (:errors (parse-opts ["-a" "-b"]
454+
[["-a" "--alpha"]]
455+
:subcommand :explicit))
456+
["Unknown option: \"-b\""]))
457+
;; implicit subcommand is triggered by an unknown option:
458+
(is (= (:arguments (parse-opts ["-a" "-b"]
459+
[["-a" "--alpha"]]
460+
:subcommand :implicit))
461+
["-b"]))
462+
(is (= (:errors (parse-opts ["-a" "-b"]
463+
[["-a" "--alpha"]]
464+
:subcommand :implicit))
465+
nil))
466+
(is (= (:arguments (parse-opts ["-a" "-b"]
467+
[["-b" "--beta"]]
468+
:subcommand :implicit))
469+
["-a" "-b"]))
470+
(is (= (:errors (parse-opts ["-a" "-b"]
471+
[["-b" "--beta"]]
472+
:subcommand :implicit))
473+
nil)))
430474
(testing "does not merge over default values when :no-defaults is true"
431475
(let [option-specs [["-p" "--port PORT" :default 80]
432476
["-H" "--host HOST" :default "example.com"]

0 commit comments

Comments
 (0)