From 61989d75c484cf378ea87157949415576e6c7ca7 Mon Sep 17 00:00:00 2001 From: itscloud0 Date: Sun, 28 Jun 2026 00:43:06 +0300 Subject: [PATCH 1/2] Fix zsh namespaced command completion --- news/153.bugfix.md | 1 + src/cleo/commands/completions/templates.py | 18 ++++-- src/cleo/commands/completions_command.py | 34 +++++++++-- tests/commands/completion/fixtures/zsh.txt | 34 +++++++---- .../completion/test_completions_command.py | 58 +++++++++++++++++++ 5 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 news/153.bugfix.md diff --git a/news/153.bugfix.md b/news/153.bugfix.md new file mode 100644 index 00000000..64fadb26 --- /dev/null +++ b/news/153.bugfix.md @@ -0,0 +1 @@ +Fixed zsh completions for namespaced commands so subcommands and subcommand options are suggested correctly. diff --git a/src/cleo/commands/completions/templates.py b/src/cleo/commands/completions/templates.py index 7cf7c54e..fa8963e6 100644 --- a/src/cleo/commands/completions/templates.py +++ b/src/cleo/commands/completions/templates.py @@ -58,29 +58,35 @@ %(function)s() { local state com cur + local -a command_words local -a opts local -a coms - cur=${words[${#words[@]}]} + cur=${words[$CURRENT]} # lookup for command - for word in ${words[@]:1}; do + for word in ${words[@]:1:$((CURRENT - 2))}; do if [[ $word != -* ]]; then - com=$word - break + command_words+=$word fi done + com=${(j: :)command_words} if [[ ${cur} == --* ]]; then state="option" opts+=(%(opts)s) - elif [[ $cur == $com ]]; then + else state="command" - coms+=(%(cmds)s) fi case $state in (command) + case "$com" in + +%(cmds)s + + esac + _describe 'command' coms ;; (option) diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 0b60fca8..adc9bb69 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -229,29 +229,53 @@ def sanitize(s: str) -> str: ] # Commands + options - cmds = [] + cmds_by_prefix: dict[str, dict[str, str | None]] = {"": {}} cmds_opts = [] for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""): if cmd.hidden or not (cmd.enabled and cmd.name): continue - command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name - cmds.append(self._zsh_describe(command_name, sanitize(cmd.description))) + parts = cmd.name.split(" ") + prefix = "" + for idx, part in enumerate(parts): + cmds_by_prefix.setdefault(prefix, {}) + description = ( + sanitize(cmd.description) if idx == len(parts) - 1 else None + ) + existing = cmds_by_prefix[prefix].get(part) + if existing is None or description is not None: + cmds_by_prefix[prefix][part] = description + + prefix = f"{prefix} {part}".strip() + options = " ".join( self._zsh_describe(f"--{opt.name}", sanitize(opt.description)) for opt in sorted(cmd.definition.options, key=lambda o: o.name) ) cmds_opts += [ - f" ({command_name})", + f' ("{cmd.name}")', f" opts+=({options})", " ;;", "", # newline ] + cmds = [] + for prefix, entries in cmds_by_prefix.items(): + descriptions = " ".join( + self._zsh_describe(name, description) + for name, description in sorted(entries.items()) + ) + cmds += [ + f' ("{prefix}")', + f" coms+=({descriptions})", + " ;;", + "", # newline + ] + return TEMPLATES["zsh"] % { "script_name": script_name, "function": function, "opts": " ".join(opts), - "cmds": " ".join(cmds), + "cmds": "\n".join(cmds[:-1]), "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline "compdefs": "\n".join(f"compdef {function} {alias}" for alias in aliases), } diff --git a/tests/commands/completion/fixtures/zsh.txt b/tests/commands/completion/fixtures/zsh.txt index df9f9266..a226738b 100644 --- a/tests/commands/completion/fixtures/zsh.txt +++ b/tests/commands/completion/fixtures/zsh.txt @@ -3,51 +3,63 @@ _my_function() { local state com cur + local -a command_words local -a opts local -a coms - cur=${words[${#words[@]}]} + cur=${words[$CURRENT]} # lookup for command - for word in ${words[@]:1}; do + for word in ${words[@]:1:$((CURRENT - 2))}; do if [[ $word != -* ]]; then - com=$word - break + command_words+=$word fi done + com=${(j: :)command_words} if [[ ${cur} == --* ]]; then state="option" opts+=("--ansi:Force ANSI output." "--help:Display help for the given command. When no command is given display help for the list command." "--no-ansi:Disable ANSI output." "--no-interaction:Do not ask any interactive question." "--quiet:Do not output any message." "--verbose:Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug." "--version:Display this application version.") - elif [[ $cur == $com ]]; then + else state="command" - coms+=("command\:with\:colons:Test." "hello:Complete me please." "help:Displays help for a command." "list:Lists commands." "'spaced command':Command with space in name.") fi case $state in (command) + case "$com" in + + ("") + coms+=("command\:with\:colons:Test." "hello:Complete me please." "help:Displays help for a command." "list:Lists commands." "spaced") + ;; + + ("spaced") + coms+=("command:Command with space in name.") + ;; + + esac + _describe 'command' coms ;; (option) case "$com" in - (command:with:colons) + ("command:with:colons") opts+=("--goodbye") ;; - (hello) + ("hello") opts+=("--dangerous-option:This \$hould be \`escaped\`." "--option-without-description") ;; - (help) + ("help") opts+=() ;; - (list) + ("list") opts+=() ;; - ('spaced command') + ("spaced command") opts+=("--goodbye") ;; diff --git a/tests/commands/completion/test_completions_command.py b/tests/commands/completion/test_completions_command.py index 095e398c..c8aaa659 100644 --- a/tests/commands/completion/test_completions_command.py +++ b/tests/commands/completion/test_completions_command.py @@ -1,5 +1,7 @@ from __future__ import annotations +import subprocess + from pathlib import Path from typing import TYPE_CHECKING @@ -75,6 +77,62 @@ def test_zsh(mocker: MockerFixture) -> None: assert expected == tester.io.fetch_output().replace("\r\n", "\n") +@pytest.mark.skipif(WINDOWS, reason="Only test linux shells") +def test_zsh_handles_namespaced_commands(mocker: MockerFixture) -> None: + mocker.patch( + "cleo.io.inputs.string_input.StringInput.script_name", + new_callable=mocker.PropertyMock, + return_value="/path/to/my/script", + ) + mocker.patch( + "cleo.commands.completions_command.CompletionsCommand._generate_function_name", + return_value="_my_function", + ) + + command = app.find("completions") + tester = CommandTester(command) + tester.execute("zsh") + script = tester.io.fetch_output().replace("\r\n", "\n") + + probe = ( + "setopt no_nomatch\n" + "compdef(){ :; }\n" + "_arguments(){ :; }\n" + "_describe(){\n" + " local label=$1\n" + " local array_name=$2\n" + " local -a values\n" + ' values=("${(@P)array_name}")\n' + ' print -- "LABEL:$label"\n' + " print -rl -- $values\n" + "}\n" + "words=(script '')\n" + "CURRENT=2\n" + f"{script}\n" + 'print -- "--command-state--"\n' + "words=(script spaced '')\n" + "CURRENT=3\n" + "_my_function\n" + 'print -- "--option-state--"\n' + "words=(script spaced command --g)\n" + "CURRENT=4\n" + "_my_function\n" + ) + result = subprocess.run( + ["zsh", "-fc", probe], + check=True, + text=True, + capture_output=True, + ) + + assert ( + "--command-state--\nLABEL:command\ncommand:Command with space in name.\n" + in result.stdout + ) + assert "--option-state--\nLABEL:option\n" in result.stdout + assert result.stdout.rstrip().endswith("--goodbye") + + @pytest.mark.skipif(WINDOWS, reason="Only test linux shells") def test_fish(mocker: MockerFixture) -> None: mocker.patch( From e595e691c3b1627369911e6977a14a825af104bb Mon Sep 17 00:00:00 2001 From: itscloud0 Date: Sun, 28 Jun 2026 08:09:04 +0300 Subject: [PATCH 2/2] Handle missing zsh in completion tests --- .../completion/test_completions_command.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/commands/completion/test_completions_command.py b/tests/commands/completion/test_completions_command.py index c8aaa659..e73d4d8b 100644 --- a/tests/commands/completion/test_completions_command.py +++ b/tests/commands/completion/test_completions_command.py @@ -1,5 +1,6 @@ from __future__ import annotations +import shutil import subprocess from pathlib import Path @@ -19,6 +20,7 @@ from pytest_mock import MockerFixture FIXTURES_PATH = Path(__file__).parent / "fixtures" +ZSH = shutil.which("zsh") app = Application() @@ -94,6 +96,15 @@ def test_zsh_handles_namespaced_commands(mocker: MockerFixture) -> None: tester.execute("zsh") script = tester.io.fetch_output().replace("\r\n", "\n") + assert ( + ' ("spaced")\n coms+=("command:Command with space in name.")' + in script + ) + assert ' ("spaced command")\n opts+=("--goodbye")' in script + + if ZSH is None: + return + probe = ( "setopt no_nomatch\n" "compdef(){ :; }\n" @@ -119,10 +130,11 @@ def test_zsh_handles_namespaced_commands(mocker: MockerFixture) -> None: "_my_function\n" ) result = subprocess.run( - ["zsh", "-fc", probe], + [ZSH, "-fc", probe], check=True, text=True, capture_output=True, + encoding="utf-8", ) assert (