From a2b38de886d2835cc92dc5b90f4a906c11ef720f Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Thu, 23 Jul 2026 21:15:40 +0200 Subject: [PATCH 1/3] Add :enum for ordered allowed values; show choices in help --- CHANGELOG.md | 3 + README.md | 40 +++++- doc/ai/adr/0001-help-show-validate-choices.md | 79 +++++++++++ src/babashka/cli.cljc | 126 ++++++++++++------ test/babashka/cli_test.cljc | 44 +++++- 5 files changed, 247 insertions(+), 45 deletions(-) create mode 100644 doc/ai/adr/0001-help-show-validate-choices.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc069b..1f7fa3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased +- Add `:enum`, an ordered list of allowed values: derives validation, lists the choices in `--help`, completion and the validation error, all in declared order +- List a set-valued `:validate`'s members in `--help` too (sorted; `:enum` for a chosen order) +- Render an `:args->opts` argument under `Arguments:` in `--help` instead of as a `--flag` under `Options:` - 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..2d4a31c 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` - the allowed values (see [Enum](#enum)), in declared order +- 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"]} ; allowed values, in order :level {:coerce :keyword :validate #{:local :global :system}} ; reused as completions :branch {:coerce :string @@ -1443,6 +1445,42 @@ Execution error (ExceptionInfo) at babashka.cli/parse-opts (cli.cljc:378). Not a positive number: 0 ``` +A `:validate` can also be a set, whose members are the allowed values. The set +then doubles as the [completion](#completing-option-values) candidates and is +listed in `--help` and in the error: + +``` 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 + +`:enum` is an ordered list of allowed values. It is the declarative form of the +set-valued `:validate` above: membership validation is derived from it, and the +choices drive `--help`, [completion](#completing-option-values) and the error, +all in the order you wrote them (a set has none, so it is sorted). Prefer `:enum` +when the order carries meaning, like an environment progression: + +``` 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 +``` + +Values may be of any type; keywords render as their bare name (`edn, json`), and +like a keyword-valued `:validate` need `:coerce :keyword` to match user input. +Supplying your own `:validate` alongside `:enum` keeps your predicate; `:enum` +still drives 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..3b06491 --- /dev/null +++ b/doc/ai/adr/0001-help-show-validate-choices.md @@ -0,0 +1,79 @@ +# 0001 - Show allowed choices in help; 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` proposed by borkdude in the same thread. + +## Context + +When an option is restricted to a fixed set of values, the values are known at +help time. Completion offered them and a rejected value printed them, but +`--help` was silent: + +``` +$ bb deploy --environment qa +Error: Invalid value for option --environment: qa. Expected one of: dev, prod, staging +``` + +So, tab completion aside, the only way to discover the allowed values from the +command line was to pass a bogus one. `--help` is where a person looks first. + +## Decision + +Two parts. + +1. A set-valued `:validate` now self-documents in `--help`, as a + `(one of: ...)` note folded into the option's description, alongside the + existing `(required)` / `(default: ...)` notes. Same values the error and + completion already used. + +2. Add `:enum`, an ordered vector of allowed values. A set has no order, so + deriving choices from `:validate #{...}` forces a sort, and the sort is + often wrong: `#{"dev" "staging" "prod"}` displays as `dev, prod, staging`, + backwards from the promotion order. `:enum ["dev" "staging" "prod"]` keeps + the declared order. It also conflates concerns: `:validate` is a predicate + (set *or* function); "here are the N choices, in this order" is a distinct + declaration. + +`:enum` is the canonical form. It derives membership validation (unless the +author also wrote a `:validate`, which is then respected), and drives the help +note, completion and the validation error, all in declared order. A bare +`:validate` set still self-documents (sorted) so nothing regresses; `:enum` +wins when both are present. + +``` +Arguments: + Target environment (one of: dev, staging, prod) +``` + +Values may be of any type; keywords render as their bare name, matching the +error and completion, and (like a keyword `:validate`) need `:coerce :keyword` +to match input. Coercion is otherwise orthogonal - `:enum` does not infer it. + +Related change in the same pass: an `:args->opts` argument now renders under +`Arguments:` rather than as a `--flag` under `Options:`, so a positionally +consumed value is no longer described as an option. + +## Prior art + +Surveyed argparse, click, clap, cobra/pflag, kingpin, commander, yargs, +picocli, tools.cli, malli. + +- Naming: `choices` is the common user-facing name (argparse, click, commander, + yargs, kingpin); `enum` is the type-driven camp (clap `ValueEnum`, picocli, + malli `[:enum ...]`). `:enum` aligns with malli, which matters for the + malli-docs branch. +- 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: preserving declared order is universal; none sort. Our order-preserving + `:enum` is the norm, the sorted `:validate`-set fallback the exception. + +## Rejected + +`:choices` as the attribute name - more common elsewhere, but `:enum` matches +malli and is shorter. Auto-deriving `:coerce` from `:enum` element types - too +magic; kept orthogonal. diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 0b03d80..237069b 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -205,22 +205,26 @@ ([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]}]] + ;; `:enum` is the ordered choice list; membership validation falls out of it + ;; unless the author supplied their own `:validate` + (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 +314,19 @@ (sort s) (sort-by str s))) +(defn- render-choices + "A comma-separated list of choice values (keywords as their bare name), for + help, completion and the validation error. Input is an already-ordered seq." + [choices] + (str/join ", " (map #(if (keyword? %) (kw->str %) (str %)) choices))) + +(defn- entry-choices + "The ordered choices of a spec entry for display: `:enum` in declared order, or + a set-valued `:validate` sorted (a set has no order). nil when neither." + [v] + (cond (:enum v) (:enum v) + (set? (:validate v)) (sort-set-values (:validate v)))) + (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 +479,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. @@ -536,12 +554,14 @@ (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)) + ;; the choices to list, in :enum's declared order when given, + ;; else the validate set sorted + 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 (set? f) - (str ". Expected one of: " - (str/join ", " (map #(if (keyword? %) (kw->str %) (str %)) (sort-set-values f)))))))) + (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 v :flag (flag-for k)} @@ -1033,6 +1053,20 @@ (if desc desc ""))])) (spec-entries spec order)))) +(defn- choices-note + "A `(one of: ...)` note listing a spec entry's choices: `:enum` in declared + order, else a set-valued `:validate` sorted. `:enum` is the canonical ordered + form and wins when both are present; a plain set still self-documents. nil when + neither." + [v] + (when-let [cs (entry-choices v)] + (str "(one of: " (render-choices cs) ")"))) + +(defn- join-desc + "Join a description with trailing parenthetical notes, dropping blanks." + [& 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 +1083,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 +1093,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 +1149,30 @@ (= k prev) (conj (pop acc) (label prev true)) :else (recur (next s) (conj acc (label k false)) k (inc n))))))))) +(defn- arg-keys + "Keys shown positionally under `Arguments:` rather than in `Options:`: the + `:positional` keys plus any key consumed by `:args->opts`. Both appear in the + usage line as ``, so also listing them as `--flags` would be redundant. + Only real spec keys, so an `:args->opts` key with no spec entry is ignored. + Bounded against an infinite `:args->opts`." + [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 +1224,12 @@ (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)) + ;; keys shown positionally (`:positional` or `:args->opts`-consumed) are + ;; rendered under `Arguments:`, not in the `Options:` table + 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 +1459,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 author-defined 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 +1479,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}])))) @@ -1511,7 +1557,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..537065e 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 "an :args->opts key renders as an argument (no phantom [options]) and honors :ref, bracketed when not required" + (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,42 @@ (cli/parse-opts ["--n" "5"] {:spec {:n {:coerce :long :validate #{10 1 2}}}}))))) +(deftest enum-test + (testing ":enum auto-derives validation; a member passes" + (is (submap? {:env "staging"} + (cli/parse-opts ["--env" "staging"] + {:spec {:env {:enum ["dev" "staging" "prod"]}}})))) + (testing "a non-member errors, listing the 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 "an explicit :validate is respected, not overwritten by :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 ":enum choices appear in --help in declared order" + (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 "a bare set :validate still self-documents in --help, sorted (enum > validate)" + (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 From 660ddd7f283e90133d0906bb13f0deab080a26c3 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Thu, 23 Jul 2026 22:49:00 +0200 Subject: [PATCH 2/3] Validate collected options per element --- CHANGELOG.md | 1 + src/babashka/cli.cljc | 62 +++++++++++++++++++++---------------- test/babashka/cli_test.cljc | 19 ++++++++++++ 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7fa3f..0c430ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ For breaking changes, check [here](#breaking-changes). - Add `:enum`, an ordered list of allowed values: derives validation, lists the choices in `--help`, completion and the validation error, all in declared order - List a set-valued `:validate`'s members in `--help` too (sorted; `:enum` for a chosen order) - Render an `:args->opts` argument under `Arguments:` in `--help` instead of as a `--flag` under `Options:` +- Validate a collected option (`:coerce [...]` / `:collect`) per element instead of testing the whole collection (fixes `:validate` sets/predicates and `:enum` on repeatable options) - Support `:doc` and `:epilog` as a vector of lines, joined with newlines ## v0.12.79 diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index 237069b..5f1e495 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -327,6 +327,13 @@ (cond (:enum v) (:enum v) (set? (:validate v)) (sort-set-values (:validate v)))) +(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- decorate-label "Decorate a positional argument label `raw` (its `:ref` or key name). Wraps in `<...>` unless `raw` already starts with `<` or `[`. Appends `...` when @@ -552,26 +559,34 @@ (: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)) - ;; the choices to list, in :enum's declared order when given, - ;; else the validate set sorted - 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 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))) + ;; a collected key (`:coerce [...]` / `:collect`) validates per + ;; element; a scalar as a one-element list, so a nil that fails + ;; still registers + 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)) + ;; the choices to list, in :enum's declared order when given, + ;; else the validate set sorted + 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 @@ -1508,13 +1523,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 diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 537065e..07606c2 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -896,6 +896,25 @@ #"Expected one of: edn, json, table" (cli/parse-opts ["--as" "xml"] {:spec {:as {:enum [:edn :json :table] :coerce :keyword}}})))) + (testing "a collected key (:coerce [...]) validates per element" + (is (submap? {:env ["dev" "prod"]} + (cli/parse-opts ["--env" "dev" "--env" "prod"] + {:spec {:env {:coerce [:string] :enum ["dev" "staging" "prod"]}}}))) + (testing "an offending element is named, not the whole collection" + (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 "a function validator also applies per 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 appear in --help in declared order" (let [help (cli/format-command-help {:prog "bb" :cmds ["run"] From 2202240eac93dbe88e43583a7b56face3440a182 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Thu, 23 Jul 2026 23:05:11 +0200 Subject: [PATCH 3/3] Document enum behavior --- CHANGELOG.md | 8 +-- README.md | 26 ++++---- doc/ai/adr/0001-help-show-validate-choices.md | 64 ++++++------------- src/babashka/cli.cljc | 33 ++-------- test/babashka/cli_test.cljc | 18 +++--- 5 files changed, 52 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c430ee..a877c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,10 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased -- Add `:enum`, an ordered list of allowed values: derives validation, lists the choices in `--help`, completion and the validation error, all in declared order -- List a set-valued `:validate`'s members in `--help` too (sorted; `:enum` for a chosen order) -- Render an `:args->opts` argument under `Arguments:` in `--help` instead of as a `--flag` under `Options:` -- Validate a collected option (`:coerce [...]` / `:collect`) per element instead of testing the whole collection (fixes `:validate` sets/predicates and `:enum` on repeatable options) +- 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 2d4a31c..e636030 100644 --- a/README.md +++ b/README.md @@ -1338,14 +1338,14 @@ To complete an option's value, give it one of: - `:complete` - a static collection of values (or `{:value .. :description ..}` maps) -- `:enum` - the allowed values (see [Enum](#enum)), in declared order +- `: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"]} ; allowed values, in order + :mode {:enum ["fast" "safe"]} :level {:coerce :keyword :validate #{:local :global :system}} ; reused as completions :branch {:coerce :string @@ -1445,9 +1445,8 @@ Execution error (ExceptionInfo) at babashka.cli/parse-opts (cli.cljc:378). Not a positive number: 0 ``` -A `:validate` can also be a set, whose members are the allowed values. The set -then doubles as the [completion](#completing-option-values) candidates and is -listed in `--help` and in the error: +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"}}}}) @@ -1457,11 +1456,9 @@ Invalid value for option --env: qa. Expected one of: dev, prod, staging ## Enum -`:enum` is an ordered list of allowed values. It is the declarative form of the -set-valued `:validate` above: membership validation is derived from it, and the -choices drive `--help`, [completion](#completing-option-values) and the error, -all in the order you wrote them (a set has none, so it is sorted). Prefer `:enum` -when the order carries meaning, like an environment progression: +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" @@ -1476,10 +1473,11 @@ when the order carries meaning, like an environment progression: Invalid value for option --env: qa. Expected one of: dev, staging, prod ``` -Values may be of any type; keywords render as their bare name (`edn, json`), and -like a keyword-valued `:validate` need `:coerce :keyword` to match user input. -Supplying your own `:validate` alongside `:enum` keeps your predicate; `:enum` -still drives the displayed choices. +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 diff --git a/doc/ai/adr/0001-help-show-validate-choices.md b/doc/ai/adr/0001-help-show-validate-choices.md index 3b06491..85538eb 100644 --- a/doc/ai/adr/0001-help-show-validate-choices.md +++ b/doc/ai/adr/0001-help-show-validate-choices.md @@ -1,79 +1,55 @@ -# 0001 - Show allowed choices in help; add `:enum` +# 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` proposed by borkdude in the same thread. +> review (2026-07). `:enum` was proposed by borkdude in the same thread. ## Context -When an option is restricted to a fixed set of values, the values are known at -help time. Completion offered them and a rejected value printed them, but -`--help` was silent: +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 ``` -So, tab completion aside, the only way to discover the allowed values from the -command line was to pass a bogus one. `--help` is where a person looks first. - ## Decision -Two parts. - -1. A set-valued `:validate` now self-documents in `--help`, as a - `(one of: ...)` note folded into the option's description, alongside the - existing `(required)` / `(default: ...)` notes. Same values the error and - completion already used. +1. Show set-valued `:validate` values in `--help`. Use the sorted order already + used by completion and validation errors. -2. Add `:enum`, an ordered vector of allowed values. A set has no order, so - deriving choices from `:validate #{...}` forces a sort, and the sort is - often wrong: `#{"dev" "staging" "prod"}` displays as `dev, prod, staging`, - backwards from the promotion order. `:enum ["dev" "staging" "prod"]` keeps - the declared order. It also conflates concerns: `:validate` is a predicate - (set *or* function); "here are the N choices, in this order" is a distinct - declaration. +2. Add `:enum` for ordered allowed values. Sets require sorting, which displays + `#{"dev" "staging" "prod"}` as `dev, prod, staging`. `:enum` preserves the + declared order. -`:enum` is the canonical form. It derives membership validation (unless the -author also wrote a `:validate`, which is then respected), and drives the help -note, completion and the validation error, all in declared order. A bare -`:validate` set still self-documents (sorted) so nothing regresses; `:enum` -wins when both are present. - -``` +``` text Arguments: Target environment (one of: dev, staging, prod) ``` -Values may be of any type; keywords render as their bare name, matching the -error and completion, and (like a keyword `:validate`) need `:coerce :keyword` -to match input. Coercion is otherwise orthogonal - `:enum` does not infer it. +Keyword values render without the leading colon. Use `:coerce :keyword` to match +keyword values. `:enum` does not infer coercion. -Related change in the same pass: an `:args->opts` argument now renders under -`Arguments:` rather than as a `--flag` under `Options:`, so a positionally -consumed value is no longer described as an option. +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 the common user-facing name (argparse, click, commander, - yargs, kingpin); `enum` is the type-driven camp (clap `ValueEnum`, picocli, - malli `[:enum ...]`). `:enum` aligns with malli, which matters for the - malli-docs branch. +- 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: preserving declared order is universal; none sort. Our order-preserving - `:enum` is the norm, the sorted `:validate`-set fallback the exception. +- Order: all surveyed tools preserve declared order. None sort. ## Rejected -`:choices` as the attribute name - more common elsewhere, but `:enum` matches -malli and is shorter. Auto-deriving `:coerce` from `:enum` element types - too -magic; kept orthogonal. +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 5f1e495..312ff09 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -206,8 +206,6 @@ ([spec {:keys [exec-args]}] (reduce (fn [acc [k {:keys [coerce collect alias default require validate positional enum]}]] - ;; `:enum` is the ordered choice list; membership validation falls out of it - ;; unless the author supplied their own `:validate` (let [validate (or validate (when enum (set enum)))] (cond-> acc coerce (update :coerce assoc k coerce) @@ -315,21 +313,18 @@ (sort-by str s))) (defn- render-choices - "A comma-separated list of choice values (keywords as their bare name), for - help, completion and the validation error. Input is an already-ordered seq." + "Returns comma-separated choices with keyword colons removed." [choices] (str/join ", " (map #(if (keyword? %) (kw->str %) (str %)) choices))) (defn- entry-choices - "The ordered choices of a spec entry for display: `:enum` in declared order, or - a set-valued `:validate` sorted (a set has no order). nil when neither." + "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? - "True when option `k` may appear more than once (collection-valued `:coerce` - or `:collect`)." + "Returns true when option `k` may occur more than once." [opts k] (or (coll? (get-in opts [:coerce k])) (contains? (:collect opts) k))) @@ -560,17 +555,12 @@ vf)] (when-let [[_ v] (find m k)] (let [check (fn [x] (if (set? f) (contains? f x) (f x))) - ;; a collected key (`:coerce [...]` / `:collect`) validates per - ;; element; a scalar as a one-element list, so a nil that fails - ;; still registers 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)) - ;; the choices to list, in :enum's declared order when given, - ;; else the validate set sorted choices (or (get enum k) (when (set? f) (sort-set-values f))) ex-msg-fn (or (:ex-msg vf) (fn [{:keys [flag value arg]}] @@ -1069,16 +1059,13 @@ (spec-entries spec order)))) (defn- choices-note - "A `(one of: ...)` note listing a spec entry's choices: `:enum` in declared - order, else a set-valued `:validate` sorted. `:enum` is the canonical ordered - form and wins when both are present; a plain set still self-documents. nil when - neither." + "Returns a choice note for spec entry `v`." [v] (when-let [cs (entry-choices v)] (str "(one of: " (render-choices cs) ")"))) (defn- join-desc - "Join a description with trailing parenthetical notes, dropping blanks." + "Joins non-blank description parts." [& parts] (str/join " " (remove str/blank? parts))) @@ -1165,11 +1152,7 @@ :else (recur (next s) (conj acc (label k false)) k (inc n))))))))) (defn- arg-keys - "Keys shown positionally under `Arguments:` rather than in `Options:`: the - `:positional` keys plus any key consumed by `:args->opts`. Both appear in the - usage line as ``, so also listing them as `--flags` would be redundant. - Only real spec keys, so an `:args->opts` key with no spec entry is ignored. - Bounded against an infinite `:args->opts`." + "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))) @@ -1239,8 +1222,6 @@ (let [spec (:spec node) ; map or vec-of-pairs spec-map (->spec-map spec) order (:order node) ; display order (see node-with-help) - ;; keys shown positionally (`:positional` or `:args->opts`-consumed) are - ;; rendered under `Arguments:`, not in the `Options:` table arg-key-set (arg-keys spec-map (:args->opts node)) opt-spec (remove (fn [[k]] (contains? arg-key-set k)) (spec-entries spec order)) @@ -1475,7 +1456,7 @@ `{: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 and - an `:enum` keep their author-defined order; a set-valued `:validate` is + 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] diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 07606c2..76cb7c4 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -417,7 +417,7 @@ (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 renders as an argument (no phantom [options]) and honors :ref, bracketed when not required" + (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])))))) @@ -874,17 +874,17 @@ {:spec {:n {:coerce :long :validate #{10 1 2}}}}))))) (deftest enum-test - (testing ":enum auto-derives validation; a member passes" + (testing ":enum accepts declared values" (is (submap? {:env "staging"} (cli/parse-opts ["--env" "staging"] {:spec {:env {:enum ["dev" "staging" "prod"]}}})))) - (testing "a non-member errors, listing the choices in declared order" + (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 "an explicit :validate is respected, not overwritten by :enum" + (testing ":validate takes precedence over :enum" (is (thrown-with-msg? #?(:cljd Object :default Exception) #"Invalid value for option --env: dev" @@ -896,17 +896,17 @@ #"Expected one of: edn, json, table" (cli/parse-opts ["--as" "xml"] {:spec {:as {:enum [:edn :json :table] :coerce :keyword}}})))) - (testing "a collected key (:coerce [...]) validates per element" + (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 "an offending element is named, not the whole collection" + (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 "a function validator also applies per element" + (testing "function validators check each element" (is (submap? {:n [1 2]} (cli/parse-opts ["--n" "1" "--n" "2"] {:spec {:n {:coerce [:long] :validate pos?}}}))) @@ -915,13 +915,13 @@ #"Invalid value for option --n: -5" (cli/parse-opts ["--n" "1" "--n" "-5"] {:spec {:n {:coerce [:long] :validate pos?}}}))))) - (testing ":enum choices appear in --help in declared order" + (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 "a bare set :validate still self-documents in --help, sorted (enum > validate)" + (testing "set-valued :validate choices are sorted in help" (let [help (cli/format-command-help {:prog "bb" :cmds ["run"] :table [{:cmds ["run"] :fn identity