diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc069b..a877c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased +- Add ordered `:enum` values for validation, help and completion +- Show sorted set-valued `:validate` values in `--help` +- Show `:args->opts` entries under `Arguments:` in `--help` +- Validate repeatable option values individually - Support `:doc` and `:epilog` as a vector of lines, joined with newlines ## v0.12.79 diff --git a/README.md b/README.md index 1cfd0b9..e636030 100644 --- a/README.md +++ b/README.md @@ -1338,12 +1338,14 @@ To complete an option's value, give it one of: - `:complete` - a static collection of values (or `{:value .. :description ..}` maps) -- A set-valued `:validate`, whose members double as completions +- `:enum` - allowed values in declared order (see [Enum](#enum)) +- A set-valued `:validate`, whose members double as completions (sorted) - `:complete-fn` - a function for dynamic completion ``` clojure {:env {:coerce :string :complete ["dev" "staging" "prod"]} ; static list + :mode {:enum ["fast" "safe"]} :level {:coerce :keyword :validate #{:local :global :system}} ; reused as completions :branch {:coerce :string @@ -1443,6 +1445,40 @@ Execution error (ExceptionInfo) at babashka.cli/parse-opts (cli.cljc:378). Not a positive number: 0 ``` +Use a set-valued `:validate` to restrict allowed values. The values are sorted in +`--help`, [completion](#completing-option-values) and validation errors: + +``` clojure +(cli/parse-args ["--env" "qa"] {:spec {:env {:validate #{"dev" "staging" "prod"}}}}) +;;=> +Invalid value for option --env: qa. Expected one of: dev, prod, staging +``` + +## Enum + +Use `:enum` to preserve the declared order of allowed values. It controls +validation, `--help`, [completion](#completing-option-values) and validation +errors: + +``` clojure +(def spec {:env {:desc "Target environment" + :enum ["dev" "staging" "prod"] + :require true}}) + +(cli/format-opts {:spec spec}) +;; --env Target environment (one of: dev, staging, prod) (required) + +(cli/parse-args ["--env" "qa"] {:spec spec}) +;;=> +Invalid value for option --env: qa. Expected one of: dev, staging, prod +``` + +Use `:coerce :keyword` with keyword values. Keywords render without the leading +colon. + +Use an explicit `:validate` to override `:enum` validation. `:enum` still +controls the displayed choices. + ## Error handling By default, an exception will be thrown in the following situations: diff --git a/doc/ai/adr/0001-help-show-validate-choices.md b/doc/ai/adr/0001-help-show-validate-choices.md new file mode 100644 index 0000000..85538eb --- /dev/null +++ b/doc/ai/adr/0001-help-show-validate-choices.md @@ -0,0 +1,55 @@ +# 0001 - Show allowed choices in help and add `:enum` + +AI-generated design record. Read as AI prose. + +> STATUS: IMPLEMENTED (unreleased). Raised by lread during the bb tasks CLI +> review (2026-07). `:enum` was proposed by borkdude in the same thread. + +## Context + +Fixed choices are available when rendering help. Completion and validation +errors displayed them, but `--help` did not: + +``` console +$ bb deploy --environment qa +Error: Invalid value for option --environment: qa. Expected one of: dev, prod, staging +``` + +## Decision + +1. Show set-valued `:validate` values in `--help`. Use the sorted order already + used by completion and validation errors. + +2. Add `:enum` for ordered allowed values. Sets require sorting, which displays + `#{"dev" "staging" "prod"}` as `dev, prod, staging`. `:enum` preserves the + declared order. + +``` text +Arguments: + Target environment (one of: dev, staging, prod) +``` + +Keyword values render without the leading colon. Use `:coerce :keyword` to match +keyword values. `:enum` does not infer coercion. + +Render `:args->opts` entries under `Arguments:` in help. Their values are +consumed positionally. + +## Prior art + +Surveyed argparse, click, clap, cobra/pflag, kingpin, commander, yargs, +picocli, tools.cli, malli. + +- Naming: `choices` is common in argparse, click, commander, yargs and kingpin. + `enum` is used by clap, picocli and malli. `:enum` matches malli. +- Help format: most append a comma-separated list to the option description + (clap `[possible values: ...]`, commander `(choices: ...)`, yargs + `[choices: ...]`). Our `(one of: ...)` matches that shape and reuses the + phrase from our own error (`Expected one of: ...`), so help and error agree. + argparse/click instead put choices in the usage/metavar line. +- Order: all surveyed tools preserve declared order. None sort. + +## Rejected + +Reject `:choices` because `:enum` matches malli and is shorter. Keep coercion +explicit instead of inferring it from `:enum` element types. diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 0b03d80..312ff09 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -205,22 +205,24 @@ ([spec] (spec->opts spec nil)) ([spec {:keys [exec-args]}] (reduce - (fn [acc [k {:keys [coerce collect alias default require validate positional]}]] - (cond-> acc - coerce (update :coerce assoc k coerce) - collect (update :collect assoc k collect) - positional (update :positional (fnil #(conj % k) #{})) - alias (update :alias - (fn [aliases] - (when (contains? aliases alias) - (throw (ex-info (str "Conflicting alias " alias " between " (get aliases alias) " and " k) - {:alias alias}))) - (assoc aliases alias k))) - require (update :require (fnil #(conj % k) #{})) - validate (update :validate assoc k validate) - (some? default) (update :exec-args - (fn [new-exec-args] - (assoc new-exec-args k (get exec-args k default)))))) + (fn [acc [k {:keys [coerce collect alias default require validate positional enum]}]] + (let [validate (or validate (when enum (set enum)))] + (cond-> acc + coerce (update :coerce assoc k coerce) + collect (update :collect assoc k collect) + positional (update :positional (fnil #(conj % k) #{})) + enum (update :enum assoc k enum) + alias (update :alias + (fn [aliases] + (when (contains? aliases alias) + (throw (ex-info (str "Conflicting alias " alias " between " (get aliases alias) " and " k) + {:alias alias}))) + (assoc aliases alias k))) + require (update :require (fnil #(conj % k) #{})) + validate (update :validate assoc k validate) + (some? default) (update :exec-args + (fn [new-exec-args] + (assoc new-exec-args k (get exec-args k default))))))) {} spec))) @@ -310,6 +312,23 @@ (sort s) (sort-by str s))) +(defn- render-choices + "Returns comma-separated choices with keyword colons removed." + [choices] + (str/join ", " (map #(if (keyword? %) (kw->str %) (str %)) choices))) + +(defn- entry-choices + "Returns `:enum` values in declared order or sorted `:validate` values." + [v] + (cond (:enum v) (:enum v) + (set? (:validate v)) (sort-set-values (:validate v)))) + +(defn- repeatable-opt? + "Returns true when option `k` may occur more than once." + [opts k] + (or (coll? (get-in opts [:coerce k])) + (contains? (:collect opts) k))) + (defn- decorate-label "Decorate a positional argument label `raw` (its `:ref` or key name). Wraps in `<...>` unless `raw` already starts with `<` or `[`. Appends `...` when @@ -462,6 +481,7 @@ (some-> restrict set)) require (:require opts) validate (:validate opts) + enum (:enum opts) ;; options parsed at a parent `dispatch` level (shared options) are ;; passed down via ::dispatch-inherited and must not be flagged as ;; unknown at child levels. Internal, not a public option. @@ -534,24 +554,29 @@ (:pred vf)) vf)] (when-let [[_ v] (find m k)] - (when-not (if (set? f) (contains? f v) (f v)) - (let [arg (when (contains? positional k) (arg-label spec-map k)) - ex-msg-fn (or (:ex-msg vf) - (fn [{:keys [flag value arg]}] - (str "Invalid value for " (if arg (str "argument " arg) (str "option " flag)) ": " value - (when (set? f) - (str ". Expected one of: " - (str/join ", " (map #(if (keyword? %) (kw->str %) (str %)) (sort-set-values f)))))))) - flag (get opt->flag k)] - (error-fn (cond-> {:cause :validate - :msg (ex-msg-fn (cond-> {:option k :value v :flag (flag-for k)} - arg (assoc :arg arg))) - :validate validate - :option k - :value v - :opts m} - arg (assoc :arg arg) - flag (assoc :flag flag))))))))) + (let [check (fn [x] (if (set? f) (contains? f x) (f x))) + failed (if (repeatable-opt? opts k) + (seq (remove check v)) + (when-not (check v) [v]))] + (when failed + (let [bad (first failed) + arg (when (contains? positional k) (arg-label spec-map k)) + choices (or (get enum k) (when (set? f) (sort-set-values f))) + ex-msg-fn (or (:ex-msg vf) + (fn [{:keys [flag value arg]}] + (str "Invalid value for " (if arg (str "argument " arg) (str "option " flag)) ": " value + (when choices + (str ". Expected one of: " (render-choices choices)))))) + flag (get opt->flag k)] + (error-fn (cond-> {:cause :validate + :msg (ex-msg-fn (cond-> {:option k :value bad :flag (flag-for k)} + arg (assoc :arg arg))) + :validate validate + :option k + :value bad + :opts m} + arg (assoc :arg arg) + flag (assoc :flag flag)))))))))) m))) (defn apply-defaults @@ -1033,6 +1058,17 @@ (if desc desc ""))])) (spec-entries spec order)))) +(defn- choices-note + "Returns a choice note for spec entry `v`." + [v] + (when-let [cs (entry-choices v)] + (str "(one of: " (render-choices cs) ")"))) + +(defn- join-desc + "Joins non-blank description parts." + [& parts] + (str/join " " (remove str/blank? parts))) + (defn- opts->help-rows "Rows for [[format-opts]]: the conventional two-column layout `option | desc`. The alias, `--option` and `:ref` are one invocation column (`-f, --foo `, @@ -1049,7 +1085,7 @@ required (set required) short (fn [[_ {:keys [alias]}]] (if alias (str "-" (kw->str alias) ", ") "")) sw (transduce (map (comp count short)) max 0 entries)] - (mapv (fn [[long-opt {:keys [default default-desc ref desc negatable] req :require}] sh] + (mapv (fn [[long-opt {:keys [default default-desc ref desc negatable] req :require :as v}] sh] (let [dflt (or default-desc (when (some? default) (str default))) ;; folded into the description, in the same slot: a required ;; option has no default, so `(required)` / `(default: ...)` @@ -1059,8 +1095,7 @@ inv (str sh (apply str (repeat (- sw (count sh)) \space)) "--" (when negatable "[no-]") (kw->str long-opt) (when ref (str " " ref))) - desc (str desc (when note - (str (when (seq desc) " ") note)))] + desc (join-desc desc (choices-note v) note)] [inv desc])) entries (map short entries)))) @@ -1116,18 +1151,26 @@ (= k prev) (conj (pop acc) (label prev true)) :else (recur (next s) (conj acc (label k false)) k (inc n))))))))) +(defn- arg-keys + "Returns spec keys rendered under `Arguments:`." + [spec-map args->opts] + (into (into #{} (keep (fn [[k v]] (when (:positional v) k))) spec-map) + (filter (fn [k] (contains? spec-map k))) + (take 64 args->opts))) + (defn- positional-help-rows - "Rows `[label desc]` for the `Arguments:` help section: the `:positional` keys - of `spec-map`, ordered by `:args->opts` (matching the usage line), then `order` + "Rows `[label desc]` for the `Arguments:` help section: the `arg-keys` of + `spec-map`, ordered by `:args->opts` (matching the usage line), then `order` / spec order for any not consumed positionally. `:no-doc` keys are skipped. Bounded against an infinite `:args->opts`." - [spec-map order args->opts] - (let [pos? (fn [k] (:positional (get spec-map k))) - ordered (->> (concat (take 64 args->opts) (or order (keys spec-map))) - (filter pos?) + [spec-map order args->opts arg?] + (let [ordered (->> (concat (take 64 args->opts) (or order (keys spec-map))) + (filter arg?) distinct (remove (fn [k] (:no-doc (get spec-map k)))))] - (mapv (fn [k] [(arg-label spec-map k) (or (:desc (get spec-map k)) "")]) ordered))) + (mapv (fn [k] (let [v (get spec-map k)] + [(arg-label spec-map k) (join-desc (:desc v) (choices-note v))])) + ordered))) (defn #?(:cljd ^:no-doc cmd-children :squint ^:no-doc cmd-children :default ^:private cmd-children) "Visible `[name child]` pairs of `node`'s commands, for display (help, @@ -1179,12 +1222,10 @@ (let [spec (:spec node) ; map or vec-of-pairs spec-map (->spec-map spec) order (:order node) ; display order (see node-with-help) - ;; positional (arg-only) keys are rendered under `Arguments:`, not in - ;; the `Options:` table - positional-keys (into #{} (keep (fn [[k v]] (when (:positional v) k))) spec-map) - opt-spec (remove (fn [[k]] (contains? positional-keys k)) + arg-key-set (arg-keys spec-map (:args->opts node)) + opt-spec (remove (fn [[k]] (contains? arg-key-set k)) (spec-entries spec order)) - arg-rows (positional-help-rows spec-map order (:args->opts node)) + arg-rows (positional-help-rows spec-map order (:args->opts node) arg-key-set) ;; drop inherited options this node redefines (child wins); mapify for the ;; key set, since a standalone format-command-help spec may be a vec inherited (apply dissoc inherited (keys spec-map)) @@ -1414,15 +1455,16 @@ "Value candidates for spec `entry` (key `k`): `:complete-fn` (called with `{:to-complete :opts :option}`), `:complete`, or a set-valued `:validate`. Normalized to `{:value :description}` maps and prefix-filtered against - `to-complete` (powershell does not filter shell-side). A `:complete` coll - keeps its author-defined order; a set is unordered, so its candidates are - sorted (some shells display emission order as-is)." + `to-complete` (powershell does not filter shell-side). A `:complete` coll and + an `:enum` keep their declared order. A set-valued `:validate` is + unordered, so its candidates are sorted (some shells display emission order + as-is)." [entry k to-complete parsed] (let [candidates (cond (:complete-fn entry) ((:complete-fn entry) {:to-complete to-complete :opts parsed :option k}) (:complete entry) (:complete entry) - (set? (:validate entry)) (sort-set-values (:validate entry)))] + :else (entry-choices entry))] (->> candidates (map normalize-value-candidate) (filter #(str/starts-with? (:value %) to-complete))))) @@ -1433,7 +1475,7 @@ [spec opts prev to-complete parsed] (let [k (option-key prev opts) entry (get spec k)] - (if (or (:complete entry) (:complete-fn entry) (set? (:validate entry))) + (if (or (:complete entry) (:complete-fn entry) (entry-choices entry)) (candidates-for-entry entry k to-complete parsed) (when-not (false? (:complete entry)) [{:file-completion true}])))) @@ -1462,13 +1504,6 @@ (select-keys node parse-keys)) :spec spec))) -(defn- repeatable-opt? - "True when option `k` may appear more than once (collection-valued `:coerce` - or `:collect`)." - [opts k] - (or (coll? (get-in opts [:coerce k])) - (contains? (:collect opts) k))) - (defn- safe-parse "Parse `args` for completion: `:exec-args` dropped so a `:default` does not count as already typed, no-op `:error-fn` so partial input (e.g. a missing @@ -1511,7 +1546,7 @@ (let [k (nth a->o (count pos-args) nil) entry (when k (get spec k))] (when k - (if (or (:complete entry) (:complete-fn entry) (set? (:validate entry))) + (if (or (:complete entry) (:complete-fn entry) (entry-choices entry)) (candidates-for-entry entry k to-complete parsed) (when-not (false? (:complete entry)) [{:file-completion true}])))))) diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 9a55cda..76cb7c4 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -417,10 +417,10 @@ (usage {:f {:positional true :require true}} (cons :f (repeat :f))))) (is (= "Usage: p [...]" (usage {:f {:positional true}} (cons :f (repeat :f))))) - (testing "an :args->opts key honors :ref and is bracketed when not required, with or without :positional" - (is (= "Usage: p [options] []" (usage {:src {:ref "source"}} [:src]))) - (is (= "Usage: p [options] [] []" (usage {:a {} :b {}} [:a :b]))) - (is (= "Usage: p [options] " (usage {:a {:require true}} [:a])))))) + (testing ":args->opts keys render as arguments" + (is (= "Usage: p []" (usage {:src {:ref "source"}} [:src]))) + (is (= "Usage: p [] []" (usage {:a {} :b {}} [:a :b]))) + (is (= "Usage: p " (usage {:a {:require true}} [:a])))))) (testing "variadic required positional label in usage" (let [help (cli/format-command-help {:prog "mycli" @@ -873,6 +873,61 @@ (cli/parse-opts ["--n" "5"] {:spec {:n {:coerce :long :validate #{10 1 2}}}}))))) +(deftest enum-test + (testing ":enum accepts declared values" + (is (submap? {:env "staging"} + (cli/parse-opts ["--env" "staging"] + {:spec {:env {:enum ["dev" "staging" "prod"]}}})))) + (testing ":enum errors list choices in declared order" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) + #"Invalid value for option --env: qa\. Expected one of: dev, staging, prod" + (cli/parse-opts ["--env" "qa"] + {:spec {:env {:enum ["dev" "staging" "prod"]}}})))) + (testing ":validate takes precedence over :enum" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) + #"Invalid value for option --env: dev" + (cli/parse-opts ["--env" "dev"] + {:spec {:env {:enum ["dev" "prod"] :validate #{"prod"}}}})))) + (testing "keyword choices render as bare names" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) + #"Expected one of: edn, json, table" + (cli/parse-opts ["--as" "xml"] + {:spec {:as {:enum [:edn :json :table] :coerce :keyword}}})))) + (testing "repeatable options validate each element" + (is (submap? {:env ["dev" "prod"]} + (cli/parse-opts ["--env" "dev" "--env" "prod"] + {:spec {:env {:coerce [:string] :enum ["dev" "staging" "prod"]}}}))) + (testing "validation errors identify the invalid element" + (is (thrown-with-msg? + #?(:cljd Object :default Exception) + #"Invalid value for option --env: qa\. Expected one of: dev, staging, prod" + (cli/parse-opts ["--env" "dev" "--env" "qa"] + {:spec {:env {:coerce [:string] :enum ["dev" "staging" "prod"]}}})))) + (testing "function validators check each element" + (is (submap? {:n [1 2]} + (cli/parse-opts ["--n" "1" "--n" "2"] + {:spec {:n {:coerce [:long] :validate pos?}}}))) + (is (thrown-with-msg? + #?(:cljd Object :default Exception) + #"Invalid value for option --n: -5" + (cli/parse-opts ["--n" "1" "--n" "-5"] + {:spec {:n {:coerce [:long] :validate pos?}}}))))) + (testing ":enum choices preserve declared order in help" + (let [help (cli/format-command-help + {:prog "bb" :cmds ["run"] + :table [{:cmds ["run"] :fn identity + :spec {:env {:desc "Env" :enum ["dev" "staging" "prod"]}}}]})] + (is (str/includes? help "Env (one of: dev, staging, prod)")))) + (testing "set-valued :validate choices are sorted in help" + (let [help (cli/format-command-help + {:prog "bb" :cmds ["run"] + :table [{:cmds ["run"] :fn identity + :spec {:env {:desc "Env" :validate #{"dev" "staging" "prod"}}}}]})] + (is (str/includes? help "Env (one of: dev, prod, staging)"))))) + (deftest restrict-args-dispatch-tree-test (let [tree {:fn (fn [{:keys [opts]}] [:root opts]) :restrict-args true