diff --git a/CHANGES.rst b/CHANGES.rst index 8c058d566..f63e68be8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,11 @@ Version 8.4.2 Unreleased +- Fix Fish shell completion broken in ``8.4.0`` by :pr:`3126`. Newlines and + tabs in option help text are now escaped, keeping the original completion + format while still supporting multi-line help. :issue:`3502` + :issue:`3043` :pr:`3504` :pr:`3508` + Version 8.4.1 ------------- diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index b01e290a9..f9da4a3aa 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -181,18 +181,14 @@ def __getattr__(self, name: str) -> t.Any: COMP_CWORD=(commandline -t) %(prog_name)s); for completion in $response; - set -l metadata (string split \n $completion); + set -l metadata (string split "," $completion); if test $metadata[1] = "dir"; __fish_complete_directories $metadata[2]; else if test $metadata[1] = "file"; __fish_complete_path $metadata[2]; else if test $metadata[1] = "plain"; - if test $metadata[3] != "_"; - echo $metadata[2]\t$metadata[3]; - else; - echo $metadata[2]; - end; + echo $metadata[2]; end; end; end; @@ -423,19 +419,19 @@ def get_completion_args(self) -> tuple[list[str], str]: def format_completion(self, item: CompletionItem) -> str: """ - .. versionchanged:: 8.4.0 - Escape newlines in value and help to fix completion errors with - multi-line help strings. + .. versionchanged:: 8.4.2 + Escape newlines and replace tabs with spaces in the help text to + fix completion errors with multi-line help strings. """ - # The fish completion script splits each response line on literal - # newlines, so any newline in the value or help would corrupt the - # frame. Replace them with the two-character escape "\n" so the text - # round-trips through fish without breaking the format. The "_" - # sentinel for missing help mirrors :class:`ZshComplete`. - help_ = item.help or "_" - value = item.value.replace("\n", r"\n") - help_escaped = help_.replace("\n", r"\n") - return f"{item.type}\n{value}\n{help_escaped}" + # According to https://fishshell.com/docs/current/cmds/complete.html + # Command substitutions found in ARGUMENTS should return a newline- + # separated list of arguments, and each argument may optionally have a tab + # character followed by the argument description. + if item.help: + help_ = item.help.replace("\n", "\\n").replace("\t", " ") + return f"{item.type},{item.value}\t{help_}" + + return f"{item.type},{item.value}" ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]") diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 6d9a58305..1e3fd9909 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -12,6 +12,7 @@ from click.core import Option from click.shell_completion import add_completion_class from click.shell_completion import CompletionItem +from click.shell_completion import FishComplete from click.shell_completion import shell_complete from click.shell_completion import ShellComplete from click.types import Choice @@ -359,9 +360,9 @@ def test_full_source(runner, shell): ("bash", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain,b\n"), ("zsh", {"COMP_WORDS": "", "COMP_CWORD": "0"}, "plain\na\n_\nplain\nb\nbee\n"), ("zsh", {"COMP_WORDS": "a b", "COMP_CWORD": "1"}, "plain\nb\nbee\n"), - ("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain\na\n_\nplain\nb\nbee\n"), - ("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain\nb\nbee\n"), - ("fish", {"COMP_WORDS": 'a "b', "COMP_CWORD": '"b'}, "plain\nb\nbee\n"), + ("fish", {"COMP_WORDS": "", "COMP_CWORD": ""}, "plain,a\nplain,b\tbee\n"), + ("fish", {"COMP_WORDS": "a b", "COMP_CWORD": "b"}, "plain,b\tbee\n"), + ("fish", {"COMP_WORDS": 'a "b', "COMP_CWORD": '"b'}, "plain,b\tbee\n"), ], ) @pytest.mark.usefixtures("_patch_for_completion") @@ -578,48 +579,9 @@ def cli(ctx, config_file): assert not current_warnings, "There should be no warnings after either" -@pytest.mark.usefixtures("_patch_for_completion") -def test_fish_multiline_help_complete(runner): - """Test Fish completion with multi-line help text doesn't cause errors.""" - cli = Command( - "cli", - params=[ - Option( - ["--at", "--attachment-type"], - type=(str, str), - multiple=True, - help=( - "\b\nAttachment with explicit mimetype,\n--at image.jpg image/jpeg" - ), - ), - Option(["--other"], help="Normal help"), - ], - ) - - result = runner.invoke( - cli, - env={ - "COMP_WORDS": "cli --", - "COMP_CWORD": "--", - "_CLI_COMPLETE": "fish_complete", - }, - ) - - # Should not fail - assert result.exit_code == 0 - - # Output should contain escaped newlines, not literal newlines - # Fish expects: plain\n--at\n{help_with_\\n} - lines = result.output.split("\n") - - # Find the --at completion block (3 lines: type, value, help) - for i in range(0, len(lines) - 2, 3): - if lines[i] == "plain" and lines[i + 1] in ("--at", "--attachment-type"): - help_line = lines[i + 2] - # Help should have escaped newlines (\\n), not actual newlines - assert "\\n" in help_line - # Should contain the example text - assert "image.jpg" in help_line.replace("\\n", " ") - break - else: - pytest.fail("--at completion not found in output") +def test_fish_format_completion_escapes_help(): + fc = FishComplete(Command("x"), {}, "x", "_X_COMPLETE") + item = CompletionItem("--at", help="first\nsecond\tthird") + # The newline is escaped to the literal characters backslash-n and the tab + # becomes a space, so each completion stays on one line for fish. + assert fc.format_completion(item) == "plain,--at\tfirst\\nsecond third"