From ff31b5b4f6eb219174fe5fd8d3c332f8e1bb69ac Mon Sep 17 00:00:00 2001 From: linhongkuan Date: Thu, 25 Jun 2026 09:03:42 +0800 Subject: [PATCH] Fix zsh completion for spaced commands --- news/153.bugfix.md | 1 + src/cleo/commands/completions/templates.py | 21 ++++++++++++--- src/cleo/commands/completions_command.py | 16 +++++++++-- tests/commands/completion/fixtures/zsh.txt | 23 ++++++++++++---- .../completion/test_completions_command.py | 27 +++++++++++++++++++ 5 files changed, 77 insertions(+), 11 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..47add463 --- /dev/null +++ b/news/153.bugfix.md @@ -0,0 +1 @@ +Fix zsh completion generation for commands with spaces. diff --git a/src/cleo/commands/completions/templates.py b/src/cleo/commands/completions/templates.py index 7cf7c54e..8a652944 100644 --- a/src/cleo/commands/completions/templates.py +++ b/src/cleo/commands/completions/templates.py @@ -57,20 +57,33 @@ %(function)s() { - local state com cur + local state com cur words_prefix command_name local -a opts local -a coms + local -a command_names + + command_names=(%(cmd_names)s) cur=${words[${#words[@]}]} + words_prefix="${words[@]:1}" # lookup for command - for word in ${words[@]:1}; do - if [[ $word != -* ]]; then - com=$word + for command_name in ${command_names[@]}; do + if [[ $words_prefix == ${command_name} || $words_prefix == ${command_name}\\ * ]]; then + com=$command_name break fi done + if [[ -z $com ]]; then + for word in ${words[@]:1}; do + if [[ $word != -* ]]; then + com=$word + break + fi + done + fi + if [[ ${cur} == --* ]]; then state="option" opts+=(%(opts)s) diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 0b60fca8..f9e2eb15 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -230,18 +230,23 @@ def sanitize(s: str) -> str: # Commands + options cmds = [] + command_names = [] 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 + command_name = cmd.name + command_pattern = ( + shell_quote(command_name) if " " in command_name else command_name + ) + command_names.append(command_name) cmds.append(self._zsh_describe(command_name, sanitize(cmd.description))) 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" ({command_pattern})", f" opts+=({options})", " ;;", "", # newline @@ -252,6 +257,13 @@ def sanitize(s: str) -> str: "function": function, "opts": " ".join(opts), "cmds": " ".join(cmds), + "cmd_names": " ".join( + shell_quote(name) if " " in name else name + for name in sorted( + command_names, + key=lambda name: (-len(name.split()), name), + ) + ), "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..60703390 100644 --- a/tests/commands/completion/fixtures/zsh.txt +++ b/tests/commands/completion/fixtures/zsh.txt @@ -2,26 +2,39 @@ _my_function() { - local state com cur + local state com cur words_prefix command_name local -a opts local -a coms + local -a command_names + + command_names=('spaced command' command:with:colons hello help list) cur=${words[${#words[@]}]} + words_prefix="${words[@]:1}" # lookup for command - for word in ${words[@]:1}; do - if [[ $word != -* ]]; then - com=$word + for command_name in ${command_names[@]}; do + if [[ $words_prefix == ${command_name} || $words_prefix == ${command_name}\ * ]]; then + com=$command_name break fi done + if [[ -z $com ]]; then + for word in ${words[@]:1}; do + if [[ $word != -* ]]; then + com=$word + break + fi + done + fi + 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 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.") + 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 diff --git a/tests/commands/completion/test_completions_command.py b/tests/commands/completion/test_completions_command.py index 095e398c..f3326093 100644 --- a/tests/commands/completion/test_completions_command.py +++ b/tests/commands/completion/test_completions_command.py @@ -2,6 +2,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from unittest import mock import pytest @@ -75,6 +76,32 @@ def test_zsh(mocker: MockerFixture) -> None: assert expected == tester.io.fetch_output().replace("\r\n", "\n") +def test_zsh_spaced_command_completion_value_is_not_quoted() -> None: + script_name = mock.patch( + "cleo.io.inputs.string_input.StringInput.script_name", + new_callable=mock.PropertyMock, + return_value="/path/to/my/script", + ) + function_name = mock.patch( + "cleo.commands.completions_command.CompletionsCommand._generate_function_name", + return_value="_my_function", + ) + + with script_name, function_name: + command = app.find("completions") + tester = CommandTester(command) + tester.execute("zsh") + + output = tester.io.fetch_output().replace("\r\n", "\n") + + assert '"spaced command:Command with space in name."' in output + assert "\"'spaced command':Command with space in name.\"" not in output + assert ( + "\n ('spaced command')\n" in output + or '\n ("spaced command")\n' in output + ) + + @pytest.mark.skipif(WINDOWS, reason="Only test linux shells") def test_fish(mocker: MockerFixture) -> None: mocker.patch(