diff --git a/API.md b/API.md index 61a39a6..1176190 100644 --- a/API.md +++ b/API.md @@ -61,7 +61,7 @@ Terminates the process after `dispatch`'s `:help` option prints an *error* Must exit or throw. Default: `System/exit` (JVM), `js/process.exit` (Node), `throw` (browser). -
+ ## `apply-defaults` ``` clojure @@ -76,7 +76,7 @@ Fills missing keys in `m` from defaults. Existing keys in `m` win. Supported options: * `:exec-args` - map of defaults. Not subject to `:restrict`. * `:spec` - spec; `:default` entries become defaults via `spec->opts`. - + ## `auto-coerce` ``` clojure @@ -136,7 +136,7 @@ The default `:max-width-fn` for [`format-table`](#babashka.cli/format-table)/[`f width or nil: node `process.stdout.columns`, else `$COLUMNS`, else a JLine provider probe (clj, when JLine is on the classpath, e.g. babashka), else nil (the caller then falls back to 80). - + ## `dispatch` ``` clojure @@ -176,6 +176,14 @@ Command dispatcher. what order (like `:order` does for options). A table keeps its entry order automatically. + `:cmd-aliases` declares alternative names for a command: a collection of + strings, symbols or keywords. On a table entry it aliases the entry's last + command, e.g. `{:cmds ["dep" "add"] :fn add :cmd-aliases ["a"]}` makes + `prog dep a` dispatch like `prog dep add`. In the tree format it sits on the + command's node. `:dispatch` always carries the canonical name, the command + index and completion offer only canonical names, and the command's own help + page lists its aliases. + When a match is found, `:fn` called with the return value of [`parse-args`](#babashka.cli/parse-args) applied to `args` enhanced with: @@ -218,11 +226,11 @@ Command dispatcher. Each entry in the table may have additional [`parse-args`](#babashka.cli/parse-args) options. For more information and examples, see [README.md](README.md#commands). - + ## `format-command-error` ``` clojure -(format-command-error {:keys [cause dispatch wrong-input msg prog inherit tree]}) +(format-command-error {:keys [cause dispatch wrong-input msg prog inherit tree], :as data}) ``` Function. @@ -242,11 +250,11 @@ Render a terse, helpful message (a string) for a dispatch error. this, then calls [`*exit-fn*`](#babashka.cli/*exit-fn*)). Call it from a custom `:error-fn` to keep the standard message and add your own output. `--help`/`-h` is not an error - it goes to the `:help-fn`, rendered by [`format-command-help`](#babashka.cli/format-command-help). - + ## `format-command-help` ``` clojure -(format-command-help {:keys [table cmds prog inherit], :or {cmds []}}) +(format-command-help {:keys [table cmds prog inherit spec], :or {cmds []}}) ``` Function. @@ -280,6 +288,9 @@ Render conventional `--help` text (a string) for the command at path `cmds` * `:inherit` - only needed when you pass a dispatch-level `:inherit` to `dispatch`; pass the same value so `Inherited options:` matches. Per-option `:inherit true` is detected automatically. + * `:spec` - the dispatch-level spec, when you pass one to `dispatch`: + options accepted at every level, listed under + `Inherited options:` since no ancestor node declares them. Options are listed in the entry's `:order` when it has one, else in spec order (a vec-of-pairs `:spec` keeps its order; a map follows key order, unreliable @@ -288,7 +299,7 @@ Render conventional `--help` text (a string) for the command at path `cmds` This is the renderer the `:help` option uses; call it from a custom `:help-fn` to render the standard help and then add your own output. An entry may carry `:no-doc true` to be omitted from `Commands:`. - + ## `format-opts` ``` clojure @@ -299,7 +310,7 @@ Function. Formats options into an options usage help string. See [Printing options](/README.md#printing-options). - + ## `format-table` ``` clojure @@ -312,7 +323,7 @@ Function. Formats `rows` into a table (string). See [Printing options](/README.md#printing-options). - + ## `merge-opts` ``` clojure @@ -331,7 +342,7 @@ Function. Converts options to a table of rows. See [Printing options](/README.md#printing-options). - + ## `parse-args` ``` clojure @@ -345,7 +356,7 @@ Same as [`parse-opts`](#babashka.cli/parse-opts) with return data reshaped. Returns a map with: * `:opts` parsed opts * `:args` remaining unparsed `args` - + ## `parse-cmds` ``` clojure @@ -406,7 +417,7 @@ Returns a map of options parsed from command line arguments `args`, a seq of str ;; => throws 'Unknown option --qux' exception b/c there is no :qux key in the spec ``` See also: [`parse-args`](#babashka.cli/parse-args) - + ## `parse-opts*` ``` clojure @@ -428,7 +439,7 @@ Parses CLI `args` into a raw opts map. Returns string values unchanged `:collect`, `:no-keyword-opts`, `:repeated-opts`, `:args->opts`, `:spec`. A `:spec`'s `:coerce`/`:collect`/`:alias` entries steer parsing (e.g. boolean disambiguation) like they do in `parse-opts` - values still come back raw. - + ## `spec->opts` ``` clojure @@ -458,7 +469,7 @@ Converts a `dispatch` table into a tree. Each `:cmds` becomes a path of ``` A tree passed in is normalized and returned, so the function is idempotent. - + ## `validate-opts` ``` clojure diff --git a/CHANGELOG.md b/CHANGELOG.md index da9d0f3..3ee0ff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ For breaking changes, check [here](#breaking-changes). ## Unreleased +- [#219](https://github.com/babashka/cli/issues/219): `:cmd-aliases` on a table entry or tree node gives a command one or more alternative names. An alias dispatches like the command. The `:dispatch` value contains the canonical name. The command index and completion output contain canonical names. The command's `--help` output lists its aliases, as in npm and gh. - A short option that declares a non-boolean `:coerce` takes the rest of its token as its value, like getopt: `-J-Dfoo=bar` binds `"-Dfoo=bar"`, `-p80` binds `80`. One leading `=` is stripped, like `--foo=bar`. Flag letters may precede the valued option in a cluster: with `:b` a flag and `:a` valued, `-ba x` parses as `-b -a x` - [#216](https://github.com/babashka/cli/issues/216): in a cluster of flags, where no letter takes a value, an interior hyphen is an error instead of silently ending option parsing. With an `:error-fn` the remaining letters still parse - Fix: a value bound with `--foo=val` may start with a hyphen: `--foo=-bar` binds `"-bar"` instead of reporting a missing value diff --git a/README.md b/README.md index bb0c312..a2b62b8 100644 --- a/README.md +++ b/README.md @@ -1015,6 +1015,18 @@ of printed output, but are still callable on the command line. "cache" {...}}} ``` +Use `:cmd-aliases` to give a command one or more alternative names: + +``` clojure +[{:cmds ["new"] :fn new :cmd-aliases ["n"]}] +``` + +`prog n` now dispatches like `prog new`. The `:dispatch` value contains the +canonical name. The command index and completion output contain canonical +names. The command's help page lists its aliases (`Aliases: n`), as in npm and +gh. An alias that collides with a command name, or with an alias of a sibling +command, is an error. + ### Help > For a guided walkthrough of automatic help and shell completions, see this diff --git a/src/babashka/cli.cljc b/src/babashka/cli.cljc index a2e7abc..8394cde 100644 --- a/src/babashka/cli.cljc +++ b/src/babashka/cli.cljc @@ -1264,7 +1264,11 @@ (str " " (str/join " " labels)))) :else ""))) +(declare cmd-name) + (defn- help-commands-table [node] + ;; canonical names only: aliases show on the command's own help page, + ;; as npm and gh do, so a long alias list cannot crowd the index (mapv (fn [[cmd subnode]] [(str cmd) (or (help-first-line (:doc subnode)) "")]) (cmd-children node))) @@ -1292,11 +1296,15 @@ inherited (apply dissoc inherited (keys spec-map)) desc (help-description (:doc node)) cmds (help-commands-table node) + aliases (map cmd-name (:cmd-aliases node)) sections (cond-> [(help-usage-line prog node (or (visible-spec? opt-spec) (visible-spec? inherited)))] desc (conj desc) + (seq aliases) + (conj (str "Aliases: " (str/join ", " aliases))) + (seq cmds) (conj (str "Commands:\n" (format-table {:rows cmds :indent 2 :divider " "}))) @@ -1387,6 +1395,37 @@ (:cmd-order node) (update :cmd-order #(mapv cmd-name %))) node)) +(defn- aliases-of + "Returns the `:cmd-aliases` names of child command `child-name` as strings. + If `:cmd-aliases` is not a collection, the function throws an error." + [child-name child] + (when-let [a (:cmd-aliases child)] + (if (coll? a) + (map cmd-name a) + (throw (ex-info (str ":cmd-aliases of command " child-name + " takes a collection of names, got " (pr-str a)) + {:cmd-aliases a :command child-name}))))) + +(defn- cmd-aliases + "Returns a map from each alias to its canonical child name. + Throws an error when an alias matches a command name or another child alias." + [node] + (reduce (fn [acc [alias child-name]] + (cond + (contains? (:cmd node) alias) + (throw (ex-info (str "Alias " alias " of command " child-name + " collides with command " alias) + {:alias alias :command child-name})) + (contains? acc alias) + (throw (ex-info (str "Alias " alias " is claimed by commands " + (get acc alias) " and " child-name) + {:alias alias :commands [(get acc alias) child-name]})) + :else (assoc acc alias child-name))) + {} + (for [[child-name child] (:cmd node) + alias (aliases-of child-name child)] + [alias child-name]))) + (defn- normalize-node "Normalize tree `node`, recursively. Rejects table-entry `:cmds` on a node, dedupes an explicit `:cmd-order` and @@ -1626,14 +1665,16 @@ (loop [node tree, ropts (resolve-node {} tree), inherited {}, toks (seq tokens), level [], eoo? false] (let [head (first toks) - [opts _ known] ropts] + [opts _ known] ropts + ;; head resolved through this level's aliases, computed once + child (when-not eoo? + (get-in node [:cmd (get (cmd-aliases node) head head)]))] (cond (nil? head) [ropts node level eoo?] ;; literal `--`: everything after is positional (= "--" head) (recur node ropts inherited (next toks) (conj level head) true) - (and (not eoo?) (get-in node [:cmd head])) - (let [inherited (merge inherited (inherited-entries (:spec node) inherit-opt)) - child (get-in node [:cmd head])] + child + (let [inherited (merge inherited (inherited-entries (:spec node) inherit-opt))] (recur child (resolve-node inherited child) inherited (next toks) [] false)) (and (not eoo?) (gnu-option? head)) ;; flags consume one token, other options also their value @@ -2157,16 +2198,21 @@ $env.config.completions.external.completer = {|spans| (if error-stash (swap! error-stash conj fire) (fire))))) + aliases (cmd-aliases cmd-info) {:keys [args opts]} (if should-parse-args? (parse-args args (assoc parse-opts ::dispatch-tree true ;; shared options parsed at parent levels: seeded as ;; values and exempt from this level's :restrict ::dispatch-inherited user-opts - ::dispatch-tree-ignored-args (set (keys (:cmd cmd-info))))) + ::dispatch-tree-ignored-args (into (set (keys (:cmd cmd-info))) + (keys aliases)))) {:args args :opts {}}) [arg & rest] args + ;; an alias resolves to its canonical command name, so + ;; `:dispatch` and help always carry the canonical path + arg (get aliases arg arg) user-opts (merge user-opts (user-supplied opts)) opts (vary-meta opts dissoc ::defaulted) all-opts (-> (merge all-opts opts) @@ -2368,6 +2414,14 @@ $env.config.completions.external.completer = {|spans| what order (like `:order` does for options). A table keeps its entry order automatically. + `:cmd-aliases` declares alternative names for a command: a collection of + strings, symbols or keywords. On a table entry it aliases the entry's last + command, e.g. `{:cmds [\"dep\" \"add\"] :fn add :cmd-aliases [\"a\"]}` makes + `prog dep a` dispatch like `prog dep add`. In the tree format it sits on the + command's node. `:dispatch` always carries the canonical name, the command + index and completion offer only canonical names, and the command's own help + page lists its aliases. + When a match is found, `:fn` called with the return value of [[parse-args]] applied to `args` enhanced with: diff --git a/test/babashka/cli/completion_test.cljc b/test/babashka/cli/completion_test.cljc index 2b12a65..2427916 100644 --- a/test/babashka/cli/completion_test.cljc +++ b/test/babashka/cli/completion_test.cljc @@ -225,6 +225,15 @@ (is (= (mapv #(str "cmd" %) (range 10)) (complete table [""])))))) +(deftest cmd-aliases-completion-test + (let [table [{:cmds ["new"] :fn identity :cmd-aliases ["n"]} + {:cmds ["dep" "add"] :fn identity :cmd-aliases ["a"]} + {:cmds ["dep" "add" "git"] :fn identity}]] + (testing "aliases are not offered as candidates" + (is (= ["new" "dep"] (complete table [""])))) + (testing "completion descends through an alias" + (is (= ["git"] (complete table ["dep" "a" ""])))))) + (deftest value-completion-test (testing ":complete as a static coll of strings" (let [o {:spec {:env {:coerce :string :complete ["dev" "prod"]}}}] diff --git a/test/babashka/cli_test.cljc b/test/babashka/cli_test.cljc index 3f56ff3..70ceb25 100644 --- a/test/babashka/cli_test.cljc +++ b/test/babashka/cli_test.cljc @@ -639,6 +639,47 @@ (is (submap? {:type :org.babashka/cli, :dispatch ["foo" "bar"], :wrong-input "wrong", :all-commands '("baz"), :cause :no-match, :msg "Unknown command: wrong", :opts {}} (cli/dispatch [{:cmds ["foo" "bar" "baz"] :fn identity}] ["foo" "bar" "wrong"] {:error-fn identity}))))))) +(deftest dispatch-cmd-aliases-test + (d/deflet + (def table [{:cmds ["new"] :fn identity :doc "Create a project" :cmd-aliases [:n]} + {:cmds ["dep" "add"] :fn identity :cmd-aliases ["a" "ad"]}]) + (testing "an alias dispatches like the command, :dispatch carries the canonical name" + (is (submap? {:dispatch ["new"] :args ["proj"]} + (cli/dispatch table ["n" "proj"]))) + (is (submap? {:dispatch ["dep" "add"]} + (cli/dispatch table ["dep" "a"]))) + (is (submap? {:dispatch ["dep" "add"]} + (cli/dispatch table ["dep" "ad"])))) + (testing "options parse across an alias" + (is (submap? {:dispatch ["new"] :opts {:force true}} + (cli/dispatch table ["n" "--force"] {:coerce {:force :boolean}})))) + (testing "a scalar :cmd-aliases is an error, the key takes a collection" + (is (thrown-with-msg? #?(:cljd Object :default Exception) + #"takes a collection of names" + (cli/dispatch [{:cmds ["new"] :fn identity :cmd-aliases "n"}] + ["new"])))) + (testing "an alias colliding with a command name is an error" + (is (thrown-with-msg? #?(:cljd Object :default Exception) + #"collides with command" + (cli/dispatch [{:cmds ["new"] :fn identity :cmd-aliases ["n"]} + {:cmds ["n"] :fn identity}] + ["n"])))) + (testing "two commands claiming one alias is an error" + (is (thrown-with-msg? #?(:cljd Object :default Exception) + #"is claimed by commands" + (cli/dispatch [{:cmds ["new"] :fn identity :cmd-aliases ["x"]} + {:cmds ["nuke"] :fn identity :cmd-aliases ["x"]}] + ["x"])))) + (testing "the index stays canonical, the command's own page names its aliases" + (is (str/includes? (cli/format-command-help {:table table :prog "p" :cmds ["new"]}) + "Aliases: n")) + (is (str/includes? (cli/format-command-help {:table table :prog "p" :cmds ["dep" "add"]}) + "Aliases: a, ad")) + (is (= (str "Usage: p