diff --git a/CHANGES.rst b/CHANGES.rst index 7488c0c2f..ddbb9a27f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -11,6 +11,8 @@ Version 8.4.1 Unreleased +- Shell completion preserves long option names when completing values after + ``=``. :issue:`2847` - Zsh completion scripts parse correctly on Windows. :issue:`3277` - Shell completion of `Choice` `Enum` values produces a valid completion result. :issue:`3015` diff --git a/src/click/shell_completion.py b/src/click/shell_completion.py index 9a4b80da1..8093eaa02 100644 --- a/src/click/shell_completion.py +++ b/src/click/shell_completion.py @@ -282,8 +282,9 @@ def get_completions(self, args: list[str], incomplete: str) -> list[CompletionIt :param incomplete: Value being completed. May be empty. """ ctx = _resolve_context(self.cli, self.ctx_args, self.prog_name, args) - obj, incomplete = _resolve_incomplete(ctx, args, incomplete) - return obj.shell_complete(ctx, incomplete) + obj, incomplete, completion_prefix = _resolve_incomplete(ctx, args, incomplete) + completions = obj.shell_complete(ctx, incomplete) + return _add_completion_prefix(completions, completion_prefix) def format_completion(self, item: CompletionItem) -> str: """Format a completion item into the form recognized by the @@ -547,6 +548,13 @@ def _start_of_option(ctx: Context, value: str) -> bool: return c in ctx._opt_prefixes +def _long_option_assignment_prefix(value: str) -> str | None: + if value.startswith("--"): + return f"{value}=" + + return None + + def _is_incomplete_option(ctx: Context, args: list[str], param: Parameter) -> bool: """Determine if the given parameter is an option that needs a value. @@ -572,6 +580,19 @@ def _is_incomplete_option(ctx: Context, args: list[str], param: Parameter) -> bo return last_option is not None and last_option in param.opts +def _add_completion_prefix( + completions: list[CompletionItem], prefix: str | None +) -> list[CompletionItem]: + if prefix is None: + return completions + + for item in completions: + if item.type == "plain": + item.value = f"{prefix}{item.value}" + + return completions + + def _resolve_context( cli: Command, ctx_args: cabc.MutableMapping[str, t.Any], @@ -635,9 +656,10 @@ def _resolve_context( def _resolve_incomplete( ctx: Context, args: list[str], incomplete: str -) -> tuple[Command | Parameter, str]: +) -> tuple[Command | Parameter, str, str | None]: """Find the Click object that will handle the completion of the - incomplete value. Return the object and the incomplete value. + incomplete value. Return the object, the incomplete value, and any + completion value prefix. :param ctx: Invocation context for the command represented by the parsed complete args. @@ -648,18 +670,24 @@ def _resolve_incomplete( # value differently. Might keep the value joined, return the "=" # as a separate item, or return the split name and value. Always # split and discard the "=" to make completion easier. + completion_prefix = None + if incomplete == "=": + if args and _start_of_option(ctx, args[-1]): + completion_prefix = _long_option_assignment_prefix(args[-1]) + incomplete = "" elif "=" in incomplete and _start_of_option(ctx, incomplete): name, _, incomplete = incomplete.partition("=") args.append(name) + completion_prefix = _long_option_assignment_prefix(name) # The "--" marker tells Click to stop treating values as options # even if they start with the option character. If it hasn't been # given and the incomplete arg looks like an option, the current # command will provide option name completions. if "--" not in args and _start_of_option(ctx, incomplete): - return ctx.command, incomplete + return ctx.command, incomplete, None params = ctx.command.get_params(ctx) @@ -667,14 +695,14 @@ def _resolve_incomplete( # value, the option will provide value completions. for param in params: if _is_incomplete_option(ctx, args, param): - return param, incomplete + return param, incomplete, completion_prefix # It's not an option name or value. The first argument without a # parsed value will provide value completions. for param in params: if _is_incomplete_argument(ctx, param): - return param, incomplete + return param, incomplete, None # There were no unparsed arguments, the command may be a group that # will provide command name completions. - return ctx.command, incomplete + return ctx.command, incomplete, None diff --git a/tests/test_shell_completion.py b/tests/test_shell_completion.py index 6d9a58305..28fb164f2 100644 --- a/tests/test_shell_completion.py +++ b/tests/test_shell_completion.py @@ -148,6 +148,66 @@ def test_type_choice(): assert _get_words(cli, ["-c"], "a2") == ["a2"] +@pytest.mark.parametrize( + ("args", "incomplete", "expected"), + [ + ([], "--color=", ["--color=auto", "--color=always", "--color=never"]), + ([], "--color=a", ["--color=auto", "--color=always"]), + ([], "--color=al", ["--color=always"]), + ([], "-c=a", ["auto", "always"]), + (["--color"], "a", ["auto", "always"]), + ], +) +def test_long_option_equals_value_completion(args, incomplete, expected): + cli = Command( + "cli", + params=[ + Option( + ["-c", "--color"], + type=Choice(["auto", "always", "never"]), + ) + ], + ) + assert _get_words(cli, args, incomplete) == expected + + +def test_long_option_equals_path_completion_keeps_file_marker(): + cli = Command("cli", params=[Option(["--path"], type=Path())]) + out = _get_completions(cli, [], "--path=ab") + assert len(out) == 1 + assert out[0].value == "ab" + assert out[0].type == "file" + + +@pytest.mark.parametrize( + ("shell", "expected"), + [ + ("bash", "plain,--color=auto\nplain,--color=always\n"), + ("zsh", "plain\n--color=auto\n_\nplain\n--color=always\n_\n"), + ], +) +@pytest.mark.usefixtures("_patch_for_completion") +def test_long_option_equals_full_completion(runner, shell, expected): + cli = Command( + "cli", + params=[ + Option( + ["--color"], + type=Choice(["auto", "always", "never"]), + ) + ], + ) + result = runner.invoke( + cli, + env={ + "COMP_WORDS": "cli --color=a", + "COMP_CWORD": "1", + "_CLI_COMPLETE": f"{shell}_complete", + }, + ) + assert result.output == expected + + def test_choice_special_characters(): cli = Command("cli", params=[Option(["-c"], type=Choice(["!1", "!2", "+3"]))]) assert _get_words(cli, ["-c"], "") == ["!1", "!2", "+3"]