Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/153.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix zsh completion generation for commands with spaces.
21 changes: 17 additions & 4 deletions src/cleo/commands/completions/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 14 additions & 2 deletions src/cleo/commands/completions_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
}
Expand Down
23 changes: 18 additions & 5 deletions tests/commands/completion/fixtures/zsh.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/commands/completion/test_completions_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pathlib import Path
from typing import TYPE_CHECKING
from unittest import mock

import pytest

Expand Down Expand Up @@ -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(
Expand Down
Loading