Skip to content

Commit 69ac086

Browse files
authored
Merge pull request #6 from gabriel376/gabriel376/issue-4
Add `:required` for `:flags`
2 parents 89b8fe7 + 43f59f1 commit 69ac086

4 files changed

Lines changed: 133 additions & 113 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Added
44

5+
- Added `:required` for `:flags`
6+
57
## Fixed
68

79
## Changed
@@ -87,4 +89,4 @@ approaching the envisioned scope for this library.
8789

8890
- subcommand handling
8991
- rudimentary flag handling
90-
- help text generation
92+
- help text generation

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ If you are explicit about which flags you accept, then you may prefer not to let
241241
you can set `:strict? true`. In this mode only explicitly configured flags are
242242
accepted, others throw an error.
243243

244-
A final possibility is to set `:middleware` for a flag, this is a function or
244+
Another possibility is to set `:middleware` for a flag, this is a function or
245245
list of functions that get wrapped around the final command.
246246

247247
```clj
@@ -257,6 +257,18 @@ list of functions that get wrapped around the final command.
257257
(cmd opts))))]}]}]})
258258
```
259259

260+
Finally, it's possible to set `:required`, to indicate for users that a flag
261+
must always be passed:
262+
263+
```clj
264+
(cli/dispatch
265+
{:command #'cli-test
266+
:flags ["-v, --verbose" "Increases verbosity"
267+
"--input FILE" "Specify the input file"
268+
"--env=<dev|prod|staging>" {:doc "Select an environment"
269+
:required true}] })
270+
```
271+
260272
### Commands
261273

262274
`lambdaisland/cli` is specifically meant for CLI tools with multiple subcommands

src/lambdaisland/cli.clj

Lines changed: 81 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns lambdaisland.cli
2-
(:require [clojure.string :as str]))
2+
(:require [clojure.string :as str]
3+
[clojure.set :as set]))
34

45
;; I've tried to be somewhat consistent with variable naming
56

@@ -60,7 +61,7 @@
6061
(let [has-short? (some short? (mapcat (comp :flags second) flagpairs))
6162
has-long? (some long? (mapcat (comp :flags second) flagpairs))]
6263
(print-table
63-
(for [[_ {:keys [flags argdoc] :as flagopts}] flagpairs]
64+
(for [[_ {:keys [flags argdoc required] :as flagopts}] flagpairs]
6465
(let [short (some short? flags)
6566
long (some long? flags)]
6667
[(str (cond
@@ -72,7 +73,9 @@
7273
"")
7374
long
7475
argdoc)
75-
(desc flagopts)]))))
76+
(desc flagopts)
77+
(if required "(required)" "")
78+
]))))
7679
(println))
7780
(print-table
7881
(for [[cmd cmdopts] command-pairs]
@@ -82,9 +85,7 @@
8285
(desc cmdopts)]))))
8386

8487
(defn parse-error! [& msg]
85-
(println "[FATAL]" (str/join " " msg))
86-
(System/exit 1)
87-
#_(throw (ex-info (str/join " " msg) {:type ::parse-error})))
88+
(throw (ex-info (str/join " " msg) {:type ::parse-error})))
8889

8990
(defn add-middleware [opts {mw :middleware}]
9091
(let [mw (if (or (nil? mw) (sequential? mw)) mw [mw])]
@@ -150,7 +151,7 @@
150151
:else
151152
[(drop argcnt cli-args) args (assoc-flag flags flagspec (map parse (take argcnt cli-args)))])
152153
(if strict?
153-
(parse-error! "Unknown flag: " f)
154+
(parse-error! "Unknown flag:" f)
154155
[cli-args args (update-flag flags {:key (keyword (str/replace f #"^-+" ""))} #(or arg ((fnil inc 0) %)))]))))
155156
[cli-args args flags]
156157
(if (re-find #"^-\w+" flag)
@@ -304,6 +305,71 @@
304305
:else
305306
(recur (dissoc cmdspec :flags) cli-args (conj args (str/replace arg #"^\\(.)" (fn [[_ o]] o))) (conj seen-prefixes args) flags)))))
306307

308+
(defn missing-flags
309+
"Return a set of required flags in `flagmap` not present in `opts`, or `nil` if
310+
all required flags are present."
311+
[flagmap opts]
312+
(let [required (->> flagmap vals (filter (comp true? :required)) (map :key) set)
313+
received (->> opts keys set)
314+
missing (map (fn [key]
315+
(->> flagmap vals (map #(vector (:key %) (:flags %))) (into {}) key))
316+
(set/difference required received))]
317+
(seq missing)))
318+
319+
(defn dispatch*
320+
([cmdspec]
321+
(dispatch* (to-cmdspec cmdspec) *command-line-args*))
322+
([{:keys [flags init] :as cmdspec} cli-args]
323+
(let [init (if (or (fn? init) (var? init)) (init) init)
324+
[cmdspec pos-args flags] (split-flags cmdspec cli-args init)
325+
flagpairs (get cmdspec :flagpairs)]
326+
(dispatch* cmdspec pos-args flags)))
327+
;; Note: this three-arg version of dispatch* is considered private, it's used
328+
;; for internal recursion on subcommands.
329+
([{:keys [commands doc argnames command flags flagpairs flagmap]
330+
:as cmdspec
331+
program-name :name
332+
:or {program-name "cli"}}
333+
pos-args opts]
334+
335+
(cond
336+
command
337+
(if (:help opts)
338+
(print-help program-name doc [] flagpairs)
339+
(binding [*opts* (-> opts
340+
(dissoc ::middleware)
341+
(assoc ::argv pos-args)
342+
(merge (zipmap argnames pos-args)))]
343+
(if-let [missing (missing-flags flagmap opts)]
344+
(parse-error! "Missing required flags:" (->> missing (map #(str/join " " %)) (str/join ", ")))
345+
((reduce #(%2 %1) command (::middleware opts)) *opts*))))
346+
347+
commands
348+
(let [[cmd & pos-args] pos-args
349+
pos-args (vec pos-args)
350+
cmd (when cmd (first (str/split cmd #"[ =]")))
351+
opts (if cmd (update opts ::command (fnil conj []) cmd) opts)
352+
command-pairs (prepare-cmdpairs commands)
353+
command-map (into {} command-pairs)
354+
command-match (get command-map cmd)]
355+
356+
(cond
357+
command-match
358+
(dispatch* (assoc (merge (dissoc cmdspec :command :commands) command-match)
359+
:name (str program-name " " cmd)) pos-args opts)
360+
361+
(or (nil? command-match)
362+
(nil? commands)
363+
(:help opts))
364+
(print-help program-name doc (for [[k v] command-pairs]
365+
[k (if (:commands v)
366+
(update v :commands prepare-cmdpairs)
367+
v)])
368+
flagpairs)
369+
370+
:else
371+
(parse-error! "Expected either :command or :commands key in" cmdspec))))))
372+
307373
(defn dispatch
308374
"Main entry point for com.lambdaisland/cli.
309375
@@ -347,59 +413,18 @@
347413
take an argument.
348414
- `:middleware` Function or sequence of functions that will wrap the command
349415
function if this flag is present.
416+
- `:required` Boolean value to indicate if the flag is required.
350417
351418
This docstring is just a summary, see the `com.lambdaisland/cli` README for
352419
details.
353420
"
354-
([cmdspec]
355-
(dispatch (to-cmdspec cmdspec) *command-line-args*))
356-
([{:keys [flags init] :as cmdspec} cli-args]
357-
(let [init (if (or (fn? init) (var? init)) (init) init)
358-
[cmdspec pos-args flags] (split-flags cmdspec cli-args init)
359-
flagpairs (get cmdspec :flagpairs)]
360-
(dispatch cmdspec pos-args flags)))
361-
;; Note: this three-arg version of dispatch is considered private, it's used
362-
;; for internal recursion on subcommands.
363-
([{:keys [commands doc argnames command flags flagpairs flagmap]
364-
:as cmdspec
365-
program-name :name
366-
:or {program-name "cli"}}
367-
pos-args opts]
368-
(cond
369-
command
370-
(if (:help opts)
371-
(print-help program-name doc [] flagpairs)
372-
(binding [*opts* (-> opts
373-
(dissoc ::middleware)
374-
(assoc ::argv pos-args)
375-
(merge (zipmap argnames pos-args)))]
376-
((reduce #(%2 %1) command (::middleware opts)) *opts*)))
377-
378-
commands
379-
(let [[cmd & pos-args] pos-args
380-
pos-args (vec pos-args)
381-
cmd (when cmd (first (str/split cmd #"[ =]")))
382-
opts (if cmd (update opts ::command (fnil conj []) cmd) opts)
383-
command-pairs (prepare-cmdpairs commands)
384-
command-map (into {} command-pairs)
385-
command-match (get command-map cmd)]
386-
387-
(cond
388-
command-match
389-
(dispatch (assoc (merge (dissoc cmdspec :command :commands) command-match)
390-
:name (str program-name " " cmd)) pos-args opts)
391-
392-
(or (nil? command-match)
393-
(nil? commands)
394-
(:help opts))
395-
(print-help program-name doc (for [[k v] command-pairs]
396-
[k (if (:commands v)
397-
(update v :commands prepare-cmdpairs)
398-
v)])
399-
flagpairs)
400-
401-
:else
402-
(parse-error! "Expected either :command or :commands key in" cmdspec))))))
421+
[& args]
422+
(try
423+
(apply dispatch* args)
424+
(catch Exception e
425+
(binding [*out* *err*]
426+
(println "[FATAL]" (.getMessage e)))
427+
(System/exit 1))))
403428

404429

405430
;;

test/lambdaisland/cli_test.clj

Lines changed: 36 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,41 @@
22
(:require
33
[clojure.string :as str]
44
[clojure.test :refer :all]
5-
[lambdaisland.cli :as cli :refer :all]))
5+
[lambdaisland.cli :as cli]))
66

7-
;; (deftest flagstr-parsing-test
8-
;; (is (= {"--help" {:key :help :value true}
9-
;; "-C" {:flag "-C"
10-
;; :key :C
11-
;; :argcnt 0
12-
;; :short? true
13-
;; :description "Change working directory"}
14-
;; "-v" {:flag "-v"
15-
;; :key :verbose
16-
;; :argcnt 0
17-
;; :short? true
18-
;; :description "Increase verbosity"}
19-
;; "--verbose" {:flag "--verbose"
20-
;; :key :verbose
21-
;; :argcnt 0
22-
;; :description "Increase verbosity"}
23-
;; "-i" {:flag "-i"
24-
;; :key :input
25-
;; :argcnt 1
26-
;; :short? true
27-
;; :args [:input-file]
28-
;; :description "Set input file"}
29-
;; "--input" {:flag "--input"
30-
;; :key :input
31-
;; :argcnt 1
32-
;; :args [:input-file]
33-
;; :description "Set input file"}
34-
;; "--output" {:flag "--output"
35-
;; :key :output
36-
;; :argcnt 1
37-
;; :args [:output-file]
38-
;; :description "Set output file"}
39-
;; "--capture-output" {:flag "--capture-output"
40-
;; :key :capture-output
41-
;; :argcnt 0
42-
;; :value true
43-
;; :description "Enable/disable output capturing"}
44-
;; "--no-capture-output" {:flag "--no-capture-output"
45-
;; :key :capture-output
46-
;; :argcnt 0
47-
;; :value false
48-
;; :description "Enable/disable output capturing"}}
49-
;; (parse-flagstrs ["-C" "Change working directory"
50-
;; "-v, --verbose" "Increase verbosity"
51-
;; "-i, --input INPUT-FILE" {:description "Set input file"}
52-
;; "--output=<output-file>" {:description "Set output file"}
53-
;; "--[no-]capture-output" "Enable/disable output capturing"] ))))
7+
(defn cmdspec-1
8+
"Builds a cmdspec with a single command."
9+
[required]
10+
{:command #'identity
11+
:flags ["-x" {:doc "flag x"
12+
:required required}]})
5413

55-
;; (deftest command-argument-parsing
56-
;; (is (= {"run" {:description "Run the thing" :argnames []}
57-
;; "remove" {:description "remove with id" :argnames [:id]}
58-
;; "add" {:description "Add with id" :argnames [:id]}}
59-
;; (prepare-cmdmap ["run" {:description "Run the thing"}
60-
;; "add ID" {:description "Add with id"}
61-
;; "remove <id>" {:description "remove with id"}]))))
14+
(defn cmdspec-n
15+
"Builds a cmdspec with multiple commands."
16+
[required]
17+
{:commands ["run" {:command #'identity
18+
:flags ["-x" {:doc "flag x"
19+
:required required}]}]})
20+
21+
(deftest required-flag
22+
(testing "successful exit"
23+
(are [input args expected]
24+
(is (= expected (cli/dispatch* input args)))
25+
(cmdspec-1 false) [] {:lambdaisland.cli/argv []}
26+
(cmdspec-1 true) ["-x"] {:lambdaisland.cli/argv [] :x 1}
27+
(cmdspec-n false) ["run"] {:lambdaisland.cli/argv [] :lambdaisland.cli/command ["run"]}
28+
(cmdspec-n true) ["run" "-x"] {:lambdaisland.cli/argv [] :lambdaisland.cli/command ["run"] :x 1}))
29+
30+
(testing "help exit"
31+
(are [input args expected]
32+
(is (= expected (with-out-str (cli/dispatch* input args))))
33+
(cmdspec-1 false) ["-h"] "Usage: cli [-x] [<args>...]\n\n -x, flag x \n\n"
34+
(cmdspec-1 true) ["-hx"] "Usage: cli [-x] [<args>...]\n\n -x, flag x (required)\n\n"
35+
(cmdspec-n false) ["run" "-h"] "Usage: cli run [-x] [<args>...]\n\nReturns its argument.\n\n -x, flag x \n\n"
36+
(cmdspec-n true) ["run" "-hx"] "Usage: cli run [-x] [<args>...]\n\nReturns its argument.\n\n -x, flag x (required)\n\n"))
37+
38+
(testing "unsuccessful exit"
39+
(are [input args expected]
40+
(is (thrown-with-msg? Exception expected (cli/dispatch* input args)))
41+
(cmdspec-1 true) [] #"Missing required flags: -x"
42+
(cmdspec-n true) ["run"] #"Missing required flags: -x")))

0 commit comments

Comments
 (0)