Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
44 changes: 36 additions & 8 deletions src/click/shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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],
Expand Down Expand Up @@ -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.
Expand All @@ -648,33 +670,39 @@ 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)

# If the last complete arg is an option name with an 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
60 changes: 60 additions & 0 deletions tests/test_shell_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading