From df2f4db723af7a99e8692d4abbb0ddde93130f26 Mon Sep 17 00:00:00 2001 From: "Howard M. Lewis Ship" Date: Thu, 3 Sep 2026 11:57:12 -0700 Subject: [PATCH] fix: emit valid zsh completions for nested groups Use the next path segment in group completions, bind #compdef to the tool name, and include --version when configured. --- CHANGES.md | 4 + .../net/lewisship/cli_tools/top-level.tpl | 2 +- src/net/lewisship/cli_tools.clj | 9 +- src/net/lewisship/cli_tools/completions.clj | 27 ++--- src/net/lewisship/cli_tools/impl.clj | 4 + test-resources/expected/messy-completions.txt | 2 +- .../expected/nested-group-completions.txt | 106 ++++++++++++++++++ .../expected/simple-completions.txt | 2 +- .../expected/subgroup-completions.txt | 2 +- test-resources/expected/tool-options.txt | 2 +- test-resources/expected/version-option.txt | 39 +++++++ .../lewisship/cli_tools/completions_test.clj | 18 +++ 12 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 test-resources/expected/nested-group-completions.txt create mode 100644 test-resources/expected/version-option.txt diff --git a/CHANGES.md b/CHANGES.md index 7c30886..0fec599 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,7 @@ +# 1.0.3 -- 3 Sep 2026 + +* fix: Nested group completions used multi-word names that zsh could not parse; also `#compdef` and `--version` [#72](https://github.com/hlship/cli-tools/issues/72) + # 1.0.2 -- 3 Sep 2026 * fix: Completions generation fails when tool options include extra tools.cli keys [#70](https://github.com/hlship/cli-tools/issues/70) diff --git a/resources/net/lewisship/cli_tools/top-level.tpl b/resources/net/lewisship/cli_tools/top-level.tpl index 21ed316..6eb5aaa 100644 --- a/resources/net/lewisship/cli_tools/top-level.tpl +++ b/resources/net/lewisship/cli_tools/top-level.tpl @@ -1,4 +1,4 @@ -#compdef _{{tool}} {{tool}} +#compdef {{tool}} _{{tool}}() { local line state diff --git a/src/net/lewisship/cli_tools.clj b/src/net/lewisship/cli_tools.clj index ca61d30..b63b37c 100644 --- a/src/net/lewisship/cli_tools.clj +++ b/src/net/lewisship/cli_tools.clj @@ -209,7 +209,8 @@ (merge {:tool-name tool-name' :cache-digest digest :command-root command-root} - (select-keys options [:doc :arguments :tool-summary :pre-dispatch :pre-invoke :extra-tool-options])))) + (select-keys options [:doc :arguments :tool-summary :pre-dispatch :pre-invoke + :extra-tool-options :version])))) (defn- dispatch* "Called (indirectly/anonymously) from a tool handler to process remaining command line arguments." @@ -297,9 +298,9 @@ (default-dispatch-options) dispatch-options) {:keys [extra-tool-options tool-options-handler version]} merged-options - version-option (when version - [["-V" "--version" "Display version"]]) - full-options (concat extra-tool-options version-option impl/default-tool-options) + full-options (concat extra-tool-options + (when version [impl/version-tool-option]) + impl/default-tool-options) {:keys [options arguments summary errors]} (cli/parse-opts (:arguments merged-options) full-options diff --git a/src/net/lewisship/cli_tools/completions.clj b/src/net/lewisship/cli_tools/completions.clj index 08a84e2..f1a951b 100644 --- a/src/net/lewisship/cli_tools/completions.clj +++ b/src/net/lewisship/cli_tools/completions.clj @@ -59,19 +59,14 @@ title (-> (impl/extract-command-title command-map) ansi/compose string/trim) - fn-name (simplify fn-prefix command-name)] + fn-name (simplify fn-prefix command-name) + base {:name command-name + :fn-name fn-name + :title title}] ;; TODO: Support messy group/command combos (if fn - {:name command-name - :fn-name fn-name - :title title - :options (options command-map)} - {:name (->> command-map - :command-path - (string/join " ")) - :title title - :fn-name fn-name - :subs (map #(extract-command fn-name %) (:subs command-map))}))) + (assoc base :options (options command-map)) + (assoc base :subs (map #(extract-command fn-name %) (:subs command-map)))))) (defn- render-commands [tool-name commands] @@ -85,9 +80,11 @@ :command command})))) (defn- print-tool - [tool-name command-root extra-options] + [tool-name command-root extra-options version] (let [prefix (str "_" tool-name) - options (map to-opt (concat extra-options impl/default-tool-options)) + options (map to-opt (concat extra-options + (when version [impl/version-tool-option]) + impl/default-tool-options)) commands (->> command-root (keep #(extract-command prefix %)))] (selmer.util/without-escaping @@ -103,9 +100,9 @@ output-path ["PATH" "File to write completions to." :optional true]] (binding [impl/*introspection-mode* true] - (let [{:keys [command-root tool-name extra-tool-options]} impl/*tool-options* + (let [{:keys [command-root tool-name extra-tool-options version]} impl/*tool-options* generator #(binding [ansi/*color-enabled* false] - (print-tool tool-name command-root extra-tool-options))] + (print-tool tool-name command-root extra-tool-options version))] (if output-path (do (with-open [w (-> output-path diff --git a/src/net/lewisship/cli_tools/impl.clj b/src/net/lewisship/cli_tools/impl.clj index 53f8523..abc9f8a 100644 --- a/src/net/lewisship/cli_tools/impl.clj +++ b/src/net/lewisship/cli_tools/impl.clj @@ -1075,6 +1075,10 @@ (cond->> root transformer (transformer dispatch-options)))) +(def version-tool-option + "Tool option added when dispatch is given a :version." + ["-V" "--version" "Display version"]) + (def default-tool-options "Default tool command line options." [["-C" "--color" "Enable ANSI color output"] diff --git a/test-resources/expected/messy-completions.txt b/test-resources/expected/messy-completions.txt index 9d661fc..6e2185a 100644 --- a/test-resources/expected/messy-completions.txt +++ b/test-resources/expected/messy-completions.txt @@ -1,4 +1,4 @@ -#compdef _messy messy +#compdef messy _messy() { local line state diff --git a/test-resources/expected/nested-group-completions.txt b/test-resources/expected/nested-group-completions.txt new file mode 100644 index 0000000..ded2dc7 --- /dev/null +++ b/test-resources/expected/nested-group-completions.txt @@ -0,0 +1,106 @@ +#compdef nested + +_nested() { + local line state + + _arguments -C \ + '(-C --color)'{-C,--color}$'[Enable ANSI color output]' \ + '(-N --no-color)'{-N,--no-color}$'[Disable ANSI color output]' \ + '(-h --help)'{-h,--help}$'[This command summary]' \ + "1: :->cmds" \ + "*::args:->args" + + case "$state" in + cmds) + _values "nested command" \ + "completions[Generate zsh command completions]" \ + "help[List available commands]" \ + "group[Grouped commands]" + ;; + args) + case $line[1] in + completions) _nested_completions ;; + + help) _nested_help ;; + + group) _nested_group ;; + + esac + ;; + esac +} +_nested_completions() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_nested_help() { + _arguments -s \ + '(-c --commands)'{-c,--commands}$'[Print commands: all, none, root]':FILTER \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_nested_group() { + local state line + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + + case "$state" in + cmds) + _values "nested group subcommands" \ + "edit[Edit a whatever]" \ + "echo[Echo a string]" \ + "nested[Nested commands inside group]" + ;; + args) + case $line[1] in + edit) _nested_group_edit ;; + echo) _nested_group_echo ;; + nested) _nested_group_nested ;; + esac + ;; + esac +} +_nested_group_edit() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_nested_group_echo() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_nested_group_nested() { + local state line + + _arguments -C \ + "1: :->cmds" \ + "*::arg:->args" + + case "$state" in + cmds) + _values "nested nested subcommands" \ + "butterfly[Nested command butterfly]" \ + "leaf[Nested command leaf]" + ;; + args) + case $line[1] in + butterfly) _nested_group_nested_butterfly ;; + leaf) _nested_group_nested_leaf ;; + esac + ;; + esac +} +_nested_group_nested_butterfly() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_nested_group_nested_leaf() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + diff --git a/test-resources/expected/simple-completions.txt b/test-resources/expected/simple-completions.txt index e5f3a3d..e7444f6 100644 --- a/test-resources/expected/simple-completions.txt +++ b/test-resources/expected/simple-completions.txt @@ -1,4 +1,4 @@ -#compdef _simple simple +#compdef simple _simple() { local line state diff --git a/test-resources/expected/subgroup-completions.txt b/test-resources/expected/subgroup-completions.txt index 66fa378..8279798 100644 --- a/test-resources/expected/subgroup-completions.txt +++ b/test-resources/expected/subgroup-completions.txt @@ -1,4 +1,4 @@ -#compdef _subgroup subgroup +#compdef subgroup _subgroup() { local line state diff --git a/test-resources/expected/tool-options.txt b/test-resources/expected/tool-options.txt index e5b334f..c2bc577 100644 --- a/test-resources/expected/tool-options.txt +++ b/test-resources/expected/tool-options.txt @@ -1,4 +1,4 @@ -#compdef _options options +#compdef options _options() { local line state diff --git a/test-resources/expected/version-option.txt b/test-resources/expected/version-option.txt new file mode 100644 index 0000000..da9a267 --- /dev/null +++ b/test-resources/expected/version-option.txt @@ -0,0 +1,39 @@ +#compdef versioned + +_versioned() { + local line state + + _arguments -C \ + '(-V --version)'{-V,--version}$'[Display version]' \ + '(-C --color)'{-C,--color}$'[Enable ANSI color output]' \ + '(-N --no-color)'{-N,--no-color}$'[Disable ANSI color output]' \ + '(-h --help)'{-h,--help}$'[This command summary]' \ + "1: :->cmds" \ + "*::args:->args" + + case "$state" in + cmds) + _values "versioned command" \ + "completions[Generate zsh command completions]" \ + "help[List available commands]" + ;; + args) + case $line[1] in + completions) _versioned_completions ;; + + help) _versioned_help ;; + + esac + ;; + esac +} +_versioned_completions() { + _arguments -s \ + '(-h --help)'{-h,--help}$'[This command summary]' +} + +_versioned_help() { + _arguments -s \ + '(-c --commands)'{-c,--commands}$'[Print commands: all, none, root]':FILTER \ + '(-h --help)'{-h,--help}$'[This command summary]' +} diff --git a/test/net/lewisship/cli_tools/completions_test.clj b/test/net/lewisship/cli_tools/completions_test.clj index d7f0028..ba4fec5 100644 --- a/test/net/lewisship/cli_tools/completions_test.clj +++ b/test/net/lewisship/cli_tools/completions_test.clj @@ -41,6 +41,17 @@ :groups {"subgroup" {:namespaces [net.lewisship.cli-tools.completion-group]}}})))) +(deftest nested-group-completion + (is (match? (expected "nested-group-completions.txt") + (dispatch + '{:tool-name "nested" + :namespaces [net.lewisship.cli-tools.completions] + :groups + {"group" {:namespaces [net.lewisship.group-ns] + :doc "Grouped commands" + :groups {"nested" {:namespaces [net.lewisship.cli-tools.group-nested] + :doc "Nested commands inside group"}}}}})))) + (deftest messy-completions ;; where command name and group name collide ;; Not sure the current behavior is correct @@ -64,3 +75,10 @@ :default "-" :parse-fn identity :validate [some? "Must be provided"]]]})))) + +(deftest version-option + (is (match? (expected "version-option.txt") + (dispatch + {:tool-name "versioned" + :namespaces '[net.lewisship.cli-tools.completions] + :version "1.2.3"}))))