Repository: clap-rs/clap
Pull request #6027 (2025-06-09 13:55:21)

Fix: external_subcommand does not 'tab complete' in ZSH
Proposal for #6016.

Same fix is applied as in the linked issue. Added zsh only test since the issue only persists on zsh.

This fix allows default completions on external commands. Right now the completions are disabled, making for an annoying DX. The behaviour is now in line with bash, fish.




Patch:
diff --git a/clap_complete/src/aot/shells/zsh.rs b/clap_complete/src/aot/shells/zsh.rs
index 92fed062..931d421d 100644
--- a/clap_complete/src/aot/shells/zsh.rs
+++ b/clap_complete/src/aot/shells/zsh.rs
@@ -353,7 +353,11 @@ fn get_args_of(parent: &Command, p_global: Option<&Command>) -> String {
 
         let subcommand_text = format!("\"*::: :->{name}\" \\", name = parent.get_name());
         segments.push(subcommand_text);
-    };
+    } else if parent.is_allow_external_subcommands_set() {
+        // If the command has an external subcommand value parser, we need to
+        // add a catch-all for the subcommand. Otherwise there would be no autocompletion whatsoever.
+        segments.push(String::from("\"*::external_command:_default\" \\"));
+    }
 
     segments.push(String::from("&& ret=0"));
     segments.join("\n")


Test patch:
diff --git a/clap_complete/tests/snapshots/external_subcommands.bash b/clap_complete/tests/snapshots/external_subcommands.bash
new file mode 100644
index 00000000..924187b6
--- /dev/null
+++ b/clap_complete/tests/snapshots/external_subcommands.bash
@@ -0,0 +1,114 @@
+_my-app() {
+    local i cur prev opts cmd
+    COMPREPLY=()
+    if [[ "${BASH_VERSINFO[0]}" -ge 4 ]]; then
+        cur="$2"
+    else
+        cur="${COMP_WORDS[COMP_CWORD]}"
+    fi
+    prev="$3"
+    cmd=""
+    opts=""
+
+    for i in "${COMP_WORDS[@]:0:COMP_CWORD}"
+    do
+        case "${cmd},${i}" in
+            ",$1")
+                cmd="my__app"
+                ;;
+            my__app,external)
+                cmd="my__app__external"
+                ;;
+            my__app,help)
+                cmd="my__app__help"
+                ;;
+            my__app__help,external)
+                cmd="my__app__help__external"
+                ;;
+            my__app__help,help)
+                cmd="my__app__help__help"
+                ;;
+            *)
+                ;;
+        esac
+    done
+
+    case "${cmd}" in
+        my__app)
+            opts="-h --help external help"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        my__app__external)
+            opts="-h --help"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        my__app__help)
+            opts="external help"
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        my__app__help__external)
+            opts=""
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+        my__app__help__help)
+            opts=""
+            if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then
+                COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+                return 0
+            fi
+            case "${prev}" in
+                *)
+                    COMPREPLY=()
+                    ;;
+            esac
+            COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
+            return 0
+            ;;
+    esac
+}
+
+if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then
+    complete -F _my-app -o nosort -o bashdefault -o default my-app
+else
+    complete -F _my-app -o bashdefault -o default my-app
+fi
diff --git a/clap_complete/tests/snapshots/external_subcommands.elvish b/clap_complete/tests/snapshots/external_subcommands.elvish
new file mode 100644
index 00000000..c80b241a
--- /dev/null
+++ b/clap_complete/tests/snapshots/external_subcommands.elvish
@@ -0,0 +1,40 @@
+
+use builtin;
+use str;
+
+set edit:completion:arg-completer[my-app] = {|@words|
+    fn spaces {|n|
+        builtin:repeat $n ' ' | str:join ''
+    }
+    fn cand {|text desc|
+        edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc
+    }
+    var command = 'my-app'
+    for word $words[1..-1] {
+        if (str:has-prefix $word '-') {
+            break
+        }
+        set command = $command';'$word
+    }
+    var completions = [
+        &'my-app'= {
+            cand -h 'Print help'
+            cand --help 'Print help'
+            cand external 'An external subcommand'
+            cand help 'Print this message or the help of the given subcommand(s)'
+        }
+        &'my-app;external'= {
+            cand -h 'Print help'
+            cand --help 'Print help'
+        }
+        &'my-app;help'= {
+            cand external 'An external subcommand'
+            cand help 'Print this message or the help of the given subcommand(s)'
+        }
+        &'my-app;help;external'= {
+        }
+        &'my-app;help;help'= {
+        }
+    ]
+    $completions[$command]
+}
diff --git a/clap_complete/tests/snapshots/external_subcommands.fish b/clap_complete/tests/snapshots/external_subcommands.fish
new file mode 100644
index 00000000..00372631
--- /dev/null
+++ b/clap_complete/tests/snapshots/external_subcommands.fish
@@ -0,0 +1,32 @@
+# Print an optspec for argparse to handle cmd's options that are independent of any subcommand.
+function __fish_my_app_global_optspecs
+	string join \n h/help
+end
+
+function __fish_my_app_needs_command
+	# Figure out if the current invocation already has a command.
+	set -l cmd (commandline -opc)
+	set -e cmd[1]
+	argparse -s (__fish_my_app_global_optspecs) -- $cmd 2>/dev/null
+	or return
+	if set -q argv[1]
+		# Also print the command, so this can be used to figure out what it is.
+		echo $argv[1]
+		return 1
+	end
+	return 0
+end
+
+function __fish_my_app_using_subcommand
+	set -l cmd (__fish_my_app_needs_command)
+	test -z "$cmd"
+	and return 1
+	contains -- $cmd[1] $argv
+end
+
+complete -c my-app -n "__fish_my_app_needs_command" -s h -l help -d 'Print help'
+complete -c my-app -n "__fish_my_app_needs_command" -f -a "external" -d 'An external subcommand'
+complete -c my-app -n "__fish_my_app_needs_command" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
+complete -c my-app -n "__fish_my_app_using_subcommand external" -s h -l help -d 'Print help'
+complete -c my-app -n "__fish_my_app_using_subcommand help; and not __fish_seen_subcommand_from external help" -f -a "external" -d 'An external subcommand'
+complete -c my-app -n "__fish_my_app_using_subcommand help; and not __fish_seen_subcommand_from external help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)'
diff --git a/clap_complete/tests/snapshots/external_subcommands.ps1 b/clap_complete/tests/snapshots/external_subcommands.ps1
new file mode 100644
index 00000000..caa5fc9b
--- /dev/null
+++ b/clap_complete/tests/snapshots/external_subcommands.ps1
@@ -0,0 +1,50 @@
+
+using namespace System.Management.Automation
+using namespace System.Management.Automation.Language
+
+Register-ArgumentCompleter -Native -CommandName 'my-app' -ScriptBlock {
+    param($wordToComplete, $commandAst, $cursorPosition)
+
+    $commandElements = $commandAst.CommandElements
+    $command = @(
+        'my-app'
+        for ($i = 1; $i -lt $commandElements.Count; $i++) {
+            $element = $commandElements[$i]
+            if ($element -isnot [StringConstantExpressionAst] -or
+                $element.StringConstantType -ne [StringConstantType]::BareWord -or
+                $element.Value.StartsWith('-') -or
+                $element.Value -eq $wordToComplete) {
+                break
+        }
+        $element.Value
+    }) -join ';'
+
+    $completions = @(switch ($command) {
+        'my-app' {
+            [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
+            [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
+            [CompletionResult]::new('external', 'external', [CompletionResultType]::ParameterValue, 'An external subcommand')
+            [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
+            break
+        }
+        'my-app;external' {
+            [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')
+            [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')
+            break
+        }
+        'my-app;help' {
+            [CompletionResult]::new('external', 'external', [CompletionResultType]::ParameterValue, 'An external subcommand')
+            [CompletionResult]::new('help', 'help', [CompletionResultType]::ParameterValue, 'Print this message or the help of the given subcommand(s)')
+            break
+        }
+        'my-app;help;external' {
+            break
+        }
+        'my-app;help;help' {
+            break
+        }
+    })
+
+    $completions.Where{ $_.CompletionText -like "$wordToComplete*" } |
+        Sort-Object -Property ListItemText
+}
diff --git a/clap_complete/tests/snapshots/external_subcommands.zsh b/clap_complete/tests/snapshots/external_subcommands.zsh
new file mode 100644
index 00000000..49029913
--- /dev/null
+++ b/clap_complete/tests/snapshots/external_subcommands.zsh
@@ -0,0 +1,101 @@
+#compdef my-app
+
+autoload -U is-at-least
+
+_my-app() {
+    typeset -A opt_args
+    typeset -a _arguments_options
+    local ret=1
+
+    if is-at-least 5.2; then
+        _arguments_options=(-s -S -C)
+    else
+        _arguments_options=(-s -C)
+    fi
+
+    local context curcontext="$curcontext" state line
+    _arguments "${_arguments_options[@]}" : \
+'-h[Print help]' \
+'--help[Print help]' \
+":: :_my-app_commands" \
+"*::: :->my-app" \
+&& ret=0
+    case $state in
+    (my-app)
+        words=($line[1] "${words[@]}")
+        (( CURRENT += 1 ))
+        curcontext="${curcontext%:*:*}:my-app-command-$line[1]:"
+        case $line[1] in
+            (external)
+_arguments "${_arguments_options[@]}" : \
+'-h[Print help]' \
+'--help[Print help]' \
+"*::external_command:_default" \
+&& ret=0
+;;
+(help)
+_arguments "${_arguments_options[@]}" : \
+":: :_my-app__help_commands" \
+"*::: :->help" \
+&& ret=0
+
+    case $state in
+    (help)
+        words=($line[1] "${words[@]}")
+        (( CURRENT += 1 ))
+        curcontext="${curcontext%:*:*}:my-app-help-command-$line[1]:"
+        case $line[1] in
+            (external)
+_arguments "${_arguments_options[@]}" : \
+&& ret=0
+;;
+(help)
+_arguments "${_arguments_options[@]}" : \
+&& ret=0
+;;
+        esac
+    ;;
+esac
+;;
+        esac
+    ;;
+esac
+}
+
+(( $+functions[_my-app_commands] )) ||
+_my-app_commands() {
+    local commands; commands=(
+'external:An external subcommand' \
+'help:Print this message or the help of the given subcommand(s)' \
+    )
+    _describe -t commands 'my-app commands' commands "$@"
+}
+(( $+functions[_my-app__external_commands] )) ||
+_my-app__external_commands() {
+    local commands; commands=()
+    _describe -t commands 'my-app external commands' commands "$@"
+}
+(( $+functions[_my-app__help_commands] )) ||
+_my-app__help_commands() {
+    local commands; commands=(
+'external:An external subcommand' \
+'help:Print this message or the help of the given subcommand(s)' \
+    )
+    _describe -t commands 'my-app help commands' commands "$@"
+}
+(( $+functions[_my-app__help__external_commands] )) ||
+_my-app__help__external_commands() {
+    local commands; commands=()
+    _describe -t commands 'my-app help external commands' commands "$@"
+}
+(( $+functions[_my-app__help__help_commands] )) ||
+_my-app__help__help_commands() {
+    local commands; commands=()
+    _describe -t commands 'my-app help help commands' commands "$@"
+}
+
+if [ "$funcstack[1]" = "_my-app" ]; then
+    _my-app "$@"
+else
+    compdef _my-app my-app
+fi
diff --git a/clap_complete/tests/testsuite/bash.rs b/clap_complete/tests/testsuite/bash.rs
index 4a030bf3..ce18f78f 100644
--- a/clap_complete/tests/testsuite/bash.rs
+++ b/clap_complete/tests/testsuite/bash.rs
@@ -82,6 +82,18 @@ fn sub_subcommands() {
     );
 }
 
+	#[test]
+	fn external_subcommands() {
+		let name = "my-app";
+		let cmd = common::external_subcommand(name);
+		common::assert_matches(
+			snapbox::file!["../snapshots/external_subcommands.bash"],
+			clap_complete::shells::Bash,
+			cmd,
+			name,
+		);
+	}
+
 #[test]
 fn custom_bin_name() {
     let name = "my-app";
diff --git a/clap_complete/tests/testsuite/common.rs b/clap_complete/tests/testsuite/common.rs
index 39b1941a..7cda741e 100644
--- a/clap_complete/tests/testsuite/common.rs
+++ b/clap_complete/tests/testsuite/common.rs
@@ -75,6 +75,15 @@ pub(crate) fn special_commands_command(name: &'static str) -> clap::Command {
         .subcommand(clap::Command::new("some-hidden-cmd").hide(true))
 }
 
+pub(crate) fn external_subcommand(name: &'static str) -> clap::Command {
+	clap::Command::new(name)
+		.subcommand(
+			clap::Command::new("external")
+				.allow_external_subcommands(true)
+				.about("An external subcommand")
+		)
+}
+
 pub(crate) fn quoting_command(name: &'static str) -> clap::Command {
     clap::Command::new(name)
         .version("3.0")
diff --git a/clap_complete/tests/testsuite/elvish.rs b/clap_complete/tests/testsuite/elvish.rs
index c9dc59b6..8cf7edea 100644
--- a/clap_complete/tests/testsuite/elvish.rs
+++ b/clap_complete/tests/testsuite/elvish.rs
@@ -81,6 +81,18 @@ fn sub_subcommands() {
     );
 }
 
+#[test]
+fn external_subcommands() {
+    let name = "my-app";
+    let cmd = common::external_subcommand(name);
+    common::assert_matches(
+        snapbox::file!["../snapshots/external_subcommands.elvish"],
+        clap_complete::shells::Elvish,
+        cmd,
+        name,
+    );
+}
+
 #[test]
 fn custom_bin_name() {
     let name = "my-app";
diff --git a/clap_complete/tests/testsuite/fish.rs b/clap_complete/tests/testsuite/fish.rs
index 0b4d3ece..ed36f4ae 100644
--- a/clap_complete/tests/testsuite/fish.rs
+++ b/clap_complete/tests/testsuite/fish.rs
@@ -81,6 +81,18 @@ fn sub_subcommands() {
     );
 }
 
+#[test]
+fn external_subcommands() {
+    let name = "my-app";
+    let cmd = common::external_subcommand(name);
+    common::assert_matches(
+        snapbox::file!["../snapshots/external_subcommands.fish"],
+        clap_complete::shells::Fish,
+        cmd,
+        name,
+    );
+}
+
 #[test]
 fn custom_bin_name() {
     let name = "my-app";
diff --git a/clap_complete/tests/testsuite/powershell.rs b/clap_complete/tests/testsuite/powershell.rs
index be7d0675..3a586030 100644
--- a/clap_complete/tests/testsuite/powershell.rs
+++ b/clap_complete/tests/testsuite/powershell.rs
@@ -72,6 +72,18 @@ fn sub_subcommands() {
     );
 }
 
+#[test]
+fn external_subcommands() {
+    let name = "my-app";
+    let cmd = common::external_subcommand(name);
+    common::assert_matches(
+        snapbox::file!["../snapshots/external_subcommands.ps1"],
+        clap_complete::shells::PowerShell,
+        cmd,
+        name,
+    );
+}
+
 #[test]
 fn custom_bin_name() {
     let name = "my-app";
diff --git a/clap_complete/tests/testsuite/zsh.rs b/clap_complete/tests/testsuite/zsh.rs
index 431d1f0a..b60df7d1 100644
--- a/clap_complete/tests/testsuite/zsh.rs
+++ b/clap_complete/tests/testsuite/zsh.rs
@@ -82,6 +82,18 @@ fn sub_subcommands() {
     );
 }
 
+#[test]
+fn external_subcommands() {
+    let name = "my-app";
+    let cmd = common::external_subcommand(name);
+    common::assert_matches(
+        snapbox::file!["../snapshots/external_subcommands.zsh"],
+        clap_complete::shells::Zsh,
+        cmd,
+        name,
+    );
+}
+
 #[test]
 fn custom_bin_name() {
     let name = "my-app";