diff --git a/.gitignore b/.gitignore index 8441e5a64..47b0bed4e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ htmlcov/ .tox/ docs/_build/ +.venv diff --git a/CHANGES.rst b/CHANGES.rst index 493cf2d88..5ff98fb27 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 8f1564c49..b574f4b69 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,19 @@ 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}" + """Format completion item for Fish shell. - return f"{item.type},{item.value}" + 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") + 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 20cff238f..492231ea9 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") @@ -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")