From bdd89391de3d009c5df673b74cf101a5248ece85 Mon Sep 17 00:00:00 2001 From: ReneSoltes Date: Sat, 25 Oct 2025 15:02:12 +0200 Subject: [PATCH 1/3] Progress --- .gitignore | 1 + src/click/shell_completion.py | 16 ++++++++++------ tests/test_shell_completion.py | 6 +++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 8441e5a64f..47b0bed4ef 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ htmlcov/ .tox/ docs/_build/ +.venv diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 8f1564c49b..d4a4c92a34 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -180,14 +180,18 @@ def __getattr__(self, name: str) -> t.Any: COMP_CWORD=(commandline -t) %(prog_name)s); for completion in $response; - set -l metadata (string split "," $completion); + set -l metadata (string split \n $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"; - echo $metadata[2]; + if test $metadata[3] != "_"; + echo $metadata[2]\t$metadata[3]; + else; + echo $metadata[2]; + end; end; end; end; @@ -417,10 +421,10 @@ def get_completion_args(self) -> tuple[list[str], str]: return args, incomplete def format_completion(self, item: CompletionItem) -> str: - if item.help: - return f"{item.type},{item.value}\t{item.help}" - - return f"{item.type},{item.value}" + 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}" ShellCompleteType = t.TypeVar("ShellCompleteType", bound="type[ShellComplete]") diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 20cff238f7..ec17ec4ee1 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -357,9 +357,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,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"), + ("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"), ], ) @pytest.mark.usefixtures("_patch_for_completion") From 6a3de994f3d778871288a5c27ef8077a511ffd8e Mon Sep 17 00:00:00 2001 From: ReneSoltes Date: Sun, 26 Oct 2025 16:04:20 +0100 Subject: [PATCH 2/3] Done --- tests/test_shell_completion.py | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index ec17ec4ee1..492231ea9e 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -559,3 +559,50 @@ def cli(ctx, config_file): assert not current_warnings, "There should be no warnings to start" _get_completions(cli, args=[], incomplete="") 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") From 9e4366c4aece899f0158001e7df20abe84e7f79b Mon Sep 17 00:00:00 2001 From: ReneSoltes Date: Sun, 26 Oct 2025 17:01:03 +0100 Subject: [PATCH 3/3] CHANGES --- CHANGES.rst | 2 ++ src/click/shell_completion.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 493cf2d880..5ff98fb273 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,8 @@ Version 8.3.x Unreleased +- Fix Fish shell completion errors when option help text contains newlines. + :issue:`3043` - Don't discard pager arguments by correctly using ``subprocess.Popen``. :issue:`3039` :pr:`3055` - Replace ``Sentinel.UNSET`` default values by ``None`` as they're passed through diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index d4a4c92a34..b574f4b69c 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -421,6 +421,15 @@ def get_completion_args(self) -> tuple[list[str], str]: return args, incomplete def format_completion(self, item: CompletionItem) -> str: + """Format completion item for Fish shell. + + Escapes newlines in both value and help text to prevent + Fish shell parsing errors. + + .. versionchanged:: 8.3 + Escape newlines in help text to fix completion errors + with multi-line help strings. + """ help_ = item.help or "_" value = item.value.replace("\n", r"\n") help_escaped = help_.replace("\n", r"\n")