From f2b8075b1264d6885eb43fe6f42c32f648837fc5 Mon Sep 17 00:00:00 2001 From: Branch Vincent Date: Wed, 25 Oct 2023 06:37:39 -0700 Subject: [PATCH] Various fixes to completion system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartek Sokorski Co-authored-by: Jürn Brodersen --- news/357.bugfix.md | 1 + src/cleo/commands/completions_command.py | 80 +++++++++++++++++---- tests/commands/completion/fixtures/fish.txt | 15 ++-- 3 files changed, 75 insertions(+), 21 deletions(-) create mode 100644 news/357.bugfix.md diff --git a/news/357.bugfix.md b/news/357.bugfix.md new file mode 100644 index 00000000..3efdd799 --- /dev/null +++ b/news/357.bugfix.md @@ -0,0 +1 @@ +Fixed subcommand completions for Fish. diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index b7d682ff..11735cde 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -10,11 +10,13 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import ClassVar +from typing import cast from cleo import helpers from cleo._compat import shell_quote from cleo.commands.command import Command from cleo.commands.completions.templates import TEMPLATES +from cleo.exceptions import CleoRuntimeError if TYPE_CHECKING: @@ -138,10 +140,32 @@ def render(self, shell: str) -> str: raise RuntimeError(f"Unrecognized shell: {shell}") + @staticmethod + def _get_prog_name_from_stack() -> str: + package_name = "" + frame = inspect.currentframe() + f_back = frame.f_back if frame is not None else None + f_globals = f_back.f_globals if f_back is not None else None + # break reference cycle + # https://docs.python.org/3/library/inspect.html#the-interpreter-stack + del frame + + if f_globals is not None: + package_name = cast(str, f_globals.get("__name__")) + + if package_name == "__main__": + package_name = cast(str, f_globals.get("__package__")) + + if package_name: + package_name = package_name.partition(".")[0] + + if not package_name: + raise CleoRuntimeError("Can not determine package name") + + return package_name + def _get_script_name_and_path(self) -> tuple[str, str]: - # FIXME: when generating completions via `python -m script completions`, - # we incorrectly infer `script_name` as `__main__.py` - script_name = self._io.input.script_name or inspect.stack()[-1][1] + script_name = self._io.input.script_name or self._get_prog_name_from_stack() script_path = posixpath.realpath(script_name) script_name = Path(script_path).name @@ -250,26 +274,54 @@ def sanitize(s: str) -> str: # Commands + options cmds = [] cmds_opts = [] - cmds_names = [] + namespaces = set() for cmd in sorted(self.application.all().values(), key=lambda c: c.name or ""): if cmd.hidden or not cmd.enabled or not cmd.name: continue - command_name = shell_quote(cmd.name) if " " in cmd.name else cmd.name - cmds.append( - f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " - f"-a {command_name} -d '{sanitize(cmd.description)}'" - ) + cmd_path = cmd.name.split(" ") + namespace = cmd_path[0] + cmd_name = cmd_path[-1] if " " in cmd.name else cmd.name + + # We either have a command like `poetry add` or a nested (namespaced) + # command like `poetry cache clear`. + if len(cmd_path) == 1: + cmds.append( + f"complete -c {script_name} -f -n '__fish{function}_no_subcommand' " + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" + ) + condition = f"__fish_seen_subcommand_from {cmd_name}" + else: + # Complete the namespace first + if namespace not in namespaces: + cmds.append( + f"complete -c {script_name} -f -n " + f"'__fish{function}_no_subcommand' -a {namespace}" + ) + # Now complete the command + subcmds = [ + name.split(" ")[-1] for name in self.application.all(namespace) + ] + cmds.append( + f"complete -c {script_name} -f -n '__fish_seen_subcommand_from " + f"{namespace}; and not __fish_seen_subcommand_from {' '.join(subcmds)}' " + f"-a {cmd_name} -d '{sanitize(cmd.description)}'" + ) + condition = ( + f"__fish_seen_subcommand_from {namespace}; " + f"and __fish_seen_subcommand_from {cmd_name}" + ) + cmds_opts += [ - f"# {command_name}", + f"# {cmd.name}", *[ - f"complete -c {script_name} -A " - f"-n '__fish_seen_subcommand_from {sanitize(command_name)}' " + f"complete -c {script_name} " + f"-n '{condition}' " f"-l {opt.name} -d '{sanitize(opt.description)}'" for opt in sorted(cmd.definition.options, key=lambda o: o.name) ], "", # newline ] - cmds_names.append(command_name) + namespaces.add(namespace) return TEMPLATES["fish"] % { "script_name": script_name, @@ -277,7 +329,7 @@ def sanitize(s: str) -> str: "opts": "\n".join(opts), "cmds": "\n".join(cmds), "cmds_opts": "\n".join(cmds_opts[:-1]), # trim trailing newline - "cmds_names": " ".join(cmds_names), + "cmds_names": " ".join(sorted(namespaces)), } def get_shell_type(self) -> str: diff --git a/tests/commands/completion/fixtures/fish.txt b/tests/commands/completion/fixtures/fish.txt index e47611d3..eb7cf3bd 100644 --- a/tests/commands/completion/fixtures/fish.txt +++ b/tests/commands/completion/fixtures/fish.txt @@ -1,6 +1,6 @@ function __fish_my_function_no_subcommand for i in (commandline -opc) - if contains -- $i command:with:colons hello help list 'spaced command' + if contains -- $i command:with:colons hello help list spaced return 1 end end @@ -21,20 +21,21 @@ complete -c script -f -n '__fish_my_function_no_subcommand' -a command:with:colo complete -c script -f -n '__fish_my_function_no_subcommand' -a hello -d 'Complete me please.' complete -c script -f -n '__fish_my_function_no_subcommand' -a help -d 'Displays help for a command.' complete -c script -f -n '__fish_my_function_no_subcommand' -a list -d 'Lists commands.' -complete -c script -f -n '__fish_my_function_no_subcommand' -a 'spaced command' -d 'Command with space in name.' +complete -c script -f -n '__fish_my_function_no_subcommand' -a spaced +complete -c script -f -n '__fish_seen_subcommand_from spaced; and not __fish_seen_subcommand_from command' -a command -d 'Command with space in name.' # command options # command:with:colons -complete -c script -A -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d '' +complete -c script -n '__fish_seen_subcommand_from command:with:colons' -l goodbye -d '' # hello -complete -c script -A -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.' -complete -c script -A -n '__fish_seen_subcommand_from hello' -l option-without-description -d '' +complete -c script -n '__fish_seen_subcommand_from hello' -l dangerous-option -d 'This $hould be `escaped`.' +complete -c script -n '__fish_seen_subcommand_from hello' -l option-without-description -d '' # help # list -# 'spaced command' -complete -c script -A -n '__fish_seen_subcommand_from \'spaced command\'' -l goodbye -d '' +# spaced command +complete -c script -n '__fish_seen_subcommand_from spaced; and __fish_seen_subcommand_from command' -l goodbye -d ''