Skip to content

Commit c17f397

Browse files
committed
Fix Carapace dynamic completion for subcommand parameters
1 parent 35f8aa0 commit c17f397

3 files changed

Lines changed: 63 additions & 22 deletions

File tree

changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
> [!WARNING]
66
> This version is **not released yet** and is under active development.
77
8+
- Fix Carapace dynamic completion for subcommand parameters, which resolved against the root command and returned the wrong candidates.
9+
810
## [`8.1.4` (2026-06-27)](https://github.com/kdeldycke/click-extra/compare/v8.1.3...v8.1.4)
911

1012
- Skip the `EnumChoice` shell-completion case-folding test on Click 8.3.

click_extra/carapace.py

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def _env_var(prog_name: str) -> str:
171171
return f"_{prog_name}_COMPLETE".replace("-", "_").upper()
172172

173173

174-
def _dynamic_action(prog_name: str) -> str:
174+
def _dynamic_action(command_path: tuple[str, ...]) -> str:
175175
"""A Carapace shell-macro action that calls back into the CLI for completion.
176176
177177
Carapace's default ``$(...)`` macro runs ``sh -c '<script>' -- <words>``,
@@ -181,14 +181,27 @@ def _dynamic_action(prog_name: str) -> str:
181181
shell), which prints exactly that. Carapace prefix-filters the result, so the
182182
callback returns the full candidate set.
183183
184-
.. warning::
185-
The macro string is derived from ``carapace-spec``'s ``core.go`` shell
186-
macro rather than a live run. It is the one part of the export that
187-
warrants a smoke test against an installed ``carapace`` binary.
184+
``command_path`` is the chain of command names from the root program down to
185+
the command owning the completed parameter, like ``("weather", "forecast")``.
186+
It is baked into ``COMP_WORDS`` so :class:`CarapaceComplete` can rebuild the
187+
command line Click needs to resolve the subcommand.
188+
189+
.. note::
190+
Baking the whole path is required, not cosmetic. Carapace's ``traverse``
191+
descends into each subcommand with only the remaining words, stripping the
192+
parent command names from the ``c.Args`` it hands the macro. So ``$*``
193+
carries only the leaf command's own words: for ``weather forecast --city``
194+
Carapace passes just ``--city``. Without the baked ``weather forecast``
195+
prefix, the callback would reconstruct ``weather --city`` and resolve
196+
against the root command, completing the wrong thing. The env var and the
197+
invoked binary stay rooted at ``command_path[0]``, the executable Carapace
198+
dispatches and the name Click derives its completion variable from.
188199
"""
200+
root = command_path[0]
201+
words = " ".join(command_path)
189202
return (
190-
f'$(env "{_env_var(prog_name)}=carapace_complete" '
191-
f'"COMP_WORDS={prog_name} $*" {prog_name} 2>/dev/null)'
203+
f'$(env "{_env_var(root)}=carapace_complete" '
204+
f'"COMP_WORDS={words} $*" {root} 2>/dev/null)'
192205
)
193206

194207

@@ -221,7 +234,7 @@ def _overrides_shell_complete(param_type: click.ParamType) -> bool:
221234
return type(param_type).shell_complete is not click.ParamType.shell_complete
222235

223236

224-
def _param_action(param: Parameter, prog_name: str) -> list[str]:
237+
def _param_action(param: Parameter, command_path: tuple[str, ...]) -> list[str]:
225238
"""Resolve the Carapace completion action for one parameter.
226239
227240
An explicit ``shell_complete=`` callback always routes to the dynamic macro;
@@ -230,12 +243,12 @@ def _param_action(param: Parameter, prog_name: str) -> list[str]:
230243
yields an empty action (no completion offered).
231244
"""
232245
if getattr(param, "_custom_shell_complete", None) is not None:
233-
return [_dynamic_action(prog_name)]
246+
return [_dynamic_action(command_path)]
234247
static = _static_action(param.type)
235248
if static is not None:
236249
return static
237250
if _overrides_shell_complete(param.type):
238-
return [_dynamic_action(prog_name)]
251+
return [_dynamic_action(command_path)]
239252
return []
240253

241254

@@ -378,14 +391,15 @@ def _add_option(
378391
param: Parameter,
379392
*,
380393
persistent: bool,
381-
root_name: str,
394+
command_path: tuple[str, ...],
382395
) -> None:
383396
"""Encode one Click option into ``flags``/``persistentflags`` and completion.
384397
385398
A boolean flag with a secondary spelling (``--foo`` / ``--no-foo``) is split
386399
into two independent Carapace flags, since the spec has no negation primitive.
387400
Only value-taking options contribute a ``completion.flag`` action. Dynamic
388-
actions reference ``root_name``, the binary Carapace dispatches on.
401+
actions reference ``command_path``, the chain of command names down to this
402+
command (see :func:`_dynamic_action`).
389403
"""
390404
flags = node.persistentflags if persistent else node.flags
391405
description = _clean_description(getattr(param, "help", None))
@@ -404,7 +418,7 @@ def _add_option(
404418
)
405419

406420
if value:
407-
action = _param_action(param, root_name)
421+
action = _param_action(param, command_path)
408422
if action:
409423
node.completion.flag[_flag_name(param.opts)] = action
410424

@@ -416,7 +430,7 @@ def extract_carapace_command(
416430
is_root: bool,
417431
default_opts: frozenset[str],
418432
inherited_opts: frozenset[str],
419-
root_name: str,
433+
command_path: tuple[str, ...],
420434
) -> CarapaceCommand:
421435
"""Build a :class:`CarapaceCommand` from a Click command and its context.
422436
@@ -428,8 +442,10 @@ def extract_carapace_command(
428442
command; on the root, options drawn from it become ``persistentflags``.
429443
``inherited_opts`` is what an ancestor actually published as persistent, so a
430444
subcommand drops exactly those (Carapace already offers them) and keeps the
431-
rest, including a same-named option the root never carried. ``root_name`` is
432-
the binary Carapace dispatches on, used to build dynamic callback macros.
445+
rest, including a same-named option the root never carried. ``command_path``
446+
is the chain of command names from the root down to this command, grown by one
447+
name per recursion and baked into dynamic callback macros (see
448+
:func:`_dynamic_action`).
433449
"""
434450
node = CarapaceCommand(
435451
name=ctx.info_name or command.name or "",
@@ -442,7 +458,7 @@ def extract_carapace_command(
442458
persistent_spellings = set(inherited_opts)
443459
for param in command.get_params(ctx):
444460
if isinstance(param, click.Argument):
445-
action = _param_action(param, root_name)
461+
action = _param_action(param, command_path)
446462
if param.nargs == -1:
447463
node.completion.positionalany = action
448464
else:
@@ -452,13 +468,13 @@ def extract_carapace_command(
452468
if is_root and set(param.opts) <= default_opts:
453469
# A root default option: publish it once as persistent so every
454470
# subcommand inherits it, and remember its spellings to skip below.
455-
_add_option(node, param, persistent=True, root_name=root_name)
471+
_add_option(node, param, persistent=True, command_path=command_path)
456472
persistent_spellings.update(param_spellings(param))
457473
elif set(param.opts) <= inherited_opts:
458474
# Already offered by an ancestor's persistent flags: do not repeat.
459475
continue
460476
else:
461-
_add_option(node, param, persistent=False, root_name=root_name)
477+
_add_option(node, param, persistent=False, command_path=command_path)
462478

463479
node.completion.positional = positional
464480
node.exclusiveflags = _exclusive_flag_groups(command)
@@ -473,7 +489,7 @@ def extract_carapace_command(
473489
is_root=False,
474490
default_opts=default_opts,
475491
inherited_opts=child_inherited,
476-
root_name=root_name,
492+
command_path=command_path + (sub_name,),
477493
)
478494
)
479495

@@ -500,7 +516,7 @@ def to_carapace_spec(
500516
is_root=True,
501517
default_opts=_default_param_opts(),
502518
inherited_opts=frozenset(),
503-
root_name=name,
519+
command_path=(name,),
504520
)
505521
# The root node's name follows the program name, not the context's.
506522
node.name = name

tests/test_carapace.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,10 @@ def test_dynamic_action_emitted(flag_name):
218218
macro = action[0]
219219
assert macro.startswith("$(")
220220
assert "_WEATHER_COMPLETE=carapace_complete" in macro
221-
assert "COMP_WORDS=weather $*" in macro
221+
# The macro bakes the full command path (root + subcommand), not just the
222+
# root: Carapace strips parent command names from the words it forwards to
223+
# the macro, so the path must be restored here for Click to resolve forecast.
224+
assert "COMP_WORDS=weather forecast $*" in macro
222225

223226

224227
# -- persistentflags ----------------------------------------------------------
@@ -319,6 +322,26 @@ def test_complete_reuses_click_machinery():
319322
assert values == {"paris", "oslo"}
320323

321324

325+
def test_subcommand_dynamic_completion_resolves_end_to_end(monkeypatch):
326+
"""The COMP_WORDS a subcommand's generated macro builds must let the backend
327+
resolve that subcommand's callback, not the root command.
328+
329+
Carapace strips parent command names from the words it forwards to the macro,
330+
so for ``weather forecast --city`` it passes only ``--city``. The macro
331+
restores the baked path; rebuilding COMP_WORDS the same way and feeding it
332+
back must yield the forecast callback's values. Pinning only the root name
333+
(the original behavior) would reconstruct ``weather --city``, resolve it
334+
against the root, and complete the subcommand list instead.
335+
"""
336+
macro = FORECAST["completion"]["flag"]["city"][0]
337+
baked_path = macro.split("COMP_WORDS=", 1)[1].split(" $*", 1)[0]
338+
monkeypatch.setenv("COMP_WORDS", f"{baked_path} --city")
339+
comp = CarapaceComplete(weather, {}, "weather", "_WEATHER_COMPLETE")
340+
args, incomplete = comp.get_completion_args()
341+
values = {item.value for item in comp.get_completions(args, incomplete)}
342+
assert values == {"paris", "oslo"}
343+
344+
322345
# -- CLI surface (wrap --carapace) --------------------------------------------
323346

324347

0 commit comments

Comments
 (0)