From 0eb07229ff9434095e4f678244e30e26d402e5e3 Mon Sep 17 00:00:00 2001 From: greymoth <246701683+greymoth-jp@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:55:06 +0900 Subject: [PATCH] Escape backslashes in fish completion descriptions The fish completions generator escapes single quotes in descriptions but leaves backslashes untouched. Since a backslash inside fish single quotes still escapes the next character, a doubled backslash collapses and a trailing backslash escapes the closing quote, breaking the generated completion line. Escape backslashes first, as the zsh path already does. --- news/536.bugfix.md | 1 + src/cleo/commands/completions_command.py | 10 +++++- .../completion/test_completions_command.py | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 news/536.bugfix.md diff --git a/news/536.bugfix.md b/news/536.bugfix.md new file mode 100644 index 00000000..00adc4a3 --- /dev/null +++ b/news/536.bugfix.md @@ -0,0 +1 @@ +Escape backslashes in generated fish completion descriptions. diff --git a/src/cleo/commands/completions_command.py b/src/cleo/commands/completions_command.py index 0b60fca8..8160b9a3 100644 --- a/src/cleo/commands/completions_command.py +++ b/src/cleo/commands/completions_command.py @@ -261,7 +261,15 @@ def render_fish(self) -> str: function = self._generate_function_name(script_name, script_path) def sanitize(s: str) -> str: - return self._io.output.formatter.remove_format(s).replace("'", "\\'") + # Descriptions are emitted inside single-quoted fish arguments + # (`-d '...'`). In fish, a backslash inside single quotes still + # escapes a following backslash or single quote, so it has to be + # escaped before the single quote is. + return ( + self._io.output.formatter.remove_format(s) + .replace("\\", "\\\\") + .replace("'", "\\'") + ) # Global options assert self.application diff --git a/tests/commands/completion/test_completions_command.py b/tests/commands/completion/test_completions_command.py index 095e398c..7bfd324c 100644 --- a/tests/commands/completion/test_completions_command.py +++ b/tests/commands/completion/test_completions_command.py @@ -7,6 +7,7 @@ from cleo._compat import WINDOWS from cleo.application import Application +from cleo.commands.command import Command from cleo.testers.command_tester import CommandTester from tests.commands.completion.fixtures.command_with_colons import CommandWithColons from tests.commands.completion.fixtures.command_with_space_in_name import SpacedCommand @@ -94,3 +95,33 @@ def test_fish(mocker: MockerFixture) -> None: expected = (FIXTURES_PATH / "fish.txt").read_text(encoding="utf-8") assert expected == tester.io.fetch_output().replace("\r\n", "\n") + + +@pytest.mark.skipif(WINDOWS, reason="Only test linux shells") +def test_fish_escapes_backslashes_in_descriptions(mocker: MockerFixture) -> None: + mocker.patch( + "cleo.io.inputs.string_input.StringInput.script_name", + new_callable=mocker.PropertyMock, + return_value="/path/to/my/script", + ) + mocker.patch( + "cleo.commands.completions_command.CompletionsCommand._generate_function_name", + return_value="_my_function", + ) + + class BackslashCommand(Command): + name = "winpath" + description = "Use C:\\temp and it's fun" + + local_app = Application() + local_app.add(BackslashCommand()) + + command = local_app.find("completions") + tester = CommandTester(command) + tester.execute("fish") + output = tester.io.fetch_output().replace("\r\n", "\n") + + # The description is emitted inside `-d '...'`. A backslash inside fish + # single quotes still escapes the next character, so it must be doubled; + # otherwise the closing quote can be escaped and the script breaks. + assert "-d 'Use C:\\\\temp and it\\'s fun'" in output