Skip to content
Open
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist/
htmlcov/
.tox/
docs/_build/
.venv
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 18 additions & 5 deletions src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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]")
Expand Down
53 changes: 50 additions & 3 deletions tests/test_shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")