Skip to content
Draft
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 @@
Fixed zsh completions for namespaced commands so subcommands and subcommand options are suggested correctly.
18 changes: 12 additions & 6 deletions src/cleo/commands/completions/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 29 additions & 5 deletions src/cleo/commands/completions_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down
34 changes: 23 additions & 11 deletions tests/commands/completion/fixtures/zsh.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
;;

Expand Down
70 changes: 70 additions & 0 deletions tests/commands/completion/test_completions_command.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

import shutil
import subprocess

from pathlib import Path
from typing import TYPE_CHECKING

Expand All @@ -17,6 +20,7 @@
from pytest_mock import MockerFixture

FIXTURES_PATH = Path(__file__).parent / "fixtures"
ZSH = shutil.which("zsh")


app = Application()
Expand Down Expand Up @@ -75,6 +79,72 @@ 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")

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"
"_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,
encoding="utf-8",
)

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(
Expand Down