Coverage for src/footman/_complete.py: 95%
475 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Completion hot path — the code every TAB press runs.
3Hard constraints: standard library only, no framework import, no user-code
4import. The whole budget is ~50 ms including interpreter startup, so the work
5here is one file read + JSON parse + tree walk.
7Two ways in:
9* `footman --complete [--] WORD [WORD ...]` — the portable path. The console
10 script dispatches here *before importing anything else* and derives the cache
11 location from the current directory.
12* `python _complete.py --manifest PATH -- WORD [WORD ...]` — the baked-in
13 path. A generated completion script invokes the interpreter directly on this
14 file with the manifest location hard-coded, skipping the console-script shim
15 and the `footman` package import entirely.
17WORDs are the command line after the program name; the last word is the partial
18being completed ("" when the cursor follows a space).
19"""
21from __future__ import annotations
23import json
24import os
25import sys
26import time
28# No module-level subprocess: it costs ~3 ms to import and only the spawn
29# paths use it — a detached rebuild, a dynamic completer rerun — never a
30# warm TAB. Those functions import it themselves.
32# The literal-False spelling both checkers honour without importing `typing`:
33# annotations here are strings (`from __future__ import annotations`), so the
34# hot path never pays for the import — a TAB press stays one file read away.
35TYPE_CHECKING = False
36if TYPE_CHECKING:
37 from typing import Any
39# Hardcoded mirror of split.GLOBALS — the hot path can't import split (it
40# would mount the whole package). `test_completion_globals_mirror_split`
41# rebuilds this FROM split.GLOBALS, so renaming a global fails CI. The
42# grammar is lexical — every value is `=`-attached, every dash token
43# self-contained — so the walk needs no arity: only the names (to suggest)
44# and the value surfaces below (to complete).
45_GLOBALS = frozenset(
46 {
47 "--help", "-h", "--version", "-V", "--list", "-l", "--tree", "--sort",
48 "--no-sort", "--all", "-a",
49 "--where", "--describe", "--plugins", "--dry-run", "-n",
50 "--keep-going", "-k",
51 "--fail-fast", "--sequential", "-s", "--no-sequential",
52 "--jobs", "-j", "--yes", "-y",
53 "--no-input", "--input", "--quiet", "-q", "--verbose", "-v", "--color",
54 "--no-color", "--no-progress", "--progress", "--no-uv", "--uv",
55 "--json", "--timings",
56 "--directory", "-C", "--tasks-file", "-f", "--config",
57 "--install-completion", "--setup-completion", "--uninstall-completion",
58 }
59) # fmt: skip
60# Value positions that are file paths. footman can't know the filesystem from a
61# cached manifest (and shouldn't try), so the resolver signals these and the
62# shell hooks defer to native file completion.
63_GLOBAL_FILES = frozenset({"--directory", "-C", "--tasks-file", "-f", "--config"})
64_FILES = "\x00files" # internal sentinel: complete() -> complete_cli()
65_EXIT_FILES = 100 # complete_cli exit code the hooks read as "complete files"
66_FILES_CSV = "\x00files-csv" # a comma-splitting path value, mid-list
67_EXIT_FILES_CSV = 101 # "complete files after the last comma" exit code
68# A leading marker on an otherwise-normal reply: the candidates are elements
69# of a comma-splitting value, so more items may follow. `complete_cli` strips
70# it into exit 102 and each hook applies its native spelling — zsh an
71# auto-removable `,` suffix, bash `nospace`, fish a candidate-side comma; a
72# hook that doesn't know 102 parses stdout exactly as before. Marked on
73# shape (`multiple` and not `nosplit`), never on items remaining: duplicates
74# are legal (a list holds what you put in it), so "remaining" was never the
75# question, and zsh takes an unwanted comma back off anyway.
76_MORE = "\x00more"
77_EXIT_MORE = 102 # "insert glued: more items may follow" exit code
78# The tree could not be built at all — the tasks file fails to import. One
79# line on *stderr* says what the import said; stdout stays empty, so a hook
80# from before this code existed shows exactly the silence it always did
81# (every hook reads candidates from stdout with stderr discarded). A hook
82# that knows 103 makes one more call capturing stderr and shows the line the
83# way its shell can: zsh a `_message`, fish/nushell/pwsh a re-offer of the
84# typed word carrying the line as its description, bash stays silent.
85_EXIT_BROKEN = 103
88_DYNAMIC = "\x00dynamic" # internal sentinel: a dynamic completer, recompute fresh
89# Mirror of manifest.SCHEMA_VERSION — the hot path can't import manifest.py.
90# A cache written by a different footman gets rebuilt, never walked: the first
91# TAB after an upgrade serves correct candidates instead of a traceback.
92# `test_completion_schema_mirrors_manifest` keeps the two from drifting.
93_SCHEMA = 11
94_DYNAMIC_TIMEOUT = 2.0 # seconds to wait for a fresh dynamic completer subprocess
95# Seconds to wait for a first-time cwd manifest build. A cold build measures
96# ~100-150 ms — footman's own fat tasks.py included — so this is roughly
97# seven times the slowest thing measured, and the projects it still cannot
98# cover are the ones importing something heavy at module level. There the
99# shorter bound is the kinder failure: a first TAB that answers nothing and a
100# second one that answers instantly (the build was detached and lands
101# anyway), rather than a shell that appears to hang for three seconds.
102_COLD_TIMEOUT = 1.0
103_SHELLS = ("bash", "zsh", "fish", "pwsh", "nushell")
104_GLOBAL_CHOICES = {
105 "--install-completion": _SHELLS,
106 "--setup-completion": _SHELLS,
107 "--uninstall-completion": _SHELLS,
108 "--color": ("always", "never", "auto"),
109}
112def _rejoin(words: list[str]) -> tuple[list[str], bool]:
113 """Undo bash's `=` word-splitting: `--opt`, `=`, `val` → `--opt=val`.
115 bash breaks the completion line on `=` (COMP_WORDBREAKS), so an attached
116 value arrives as two or three words; zsh/fish/pwsh/nushell pass the token
117 whole. Joining here gives every shell one shape — self-contained tokens,
118 exactly the grammar's own reading — with the *partial* folded into its
119 option (`--opt=va`), so one value-completion branch serves every shell.
121 The second return says whether the final word was folded — i.e. the shell
122 split the token, so it is completing the bare value and candidates must
123 not carry the `--opt=` prefix; an unsplit shell replaces the whole token
124 and needs full `--opt=value` candidates.
125 """
126 out: list[str] = []
127 merged_last = False
128 i = 0
129 while i < len(words):
130 word = words[i]
131 if word == "=" and out and out[-1].startswith("-") and "=" not in out[-1]:
132 out[-1] += "="
133 i += 1
134 if i < len(words):
135 out[-1] += words[i]
136 i += 1
137 merged_last = i >= len(words)
138 continue
139 out.append(word)
140 i += 1
141 merged_last = False
142 return out, merged_last
145def _csv_head(p: dict[str, Any], value: str) -> tuple[str, str]:
146 """Split *value* at its last comma when *p* comma-splits.
148 A collection value completes one comma-separated item at a time: the
149 typed items stay in place as a head every candidate re-attaches, and
150 matching runs on the tail alone. A scalar or `nosplit` value (commas
151 literal) keeps the whole token — empty head, the value as the tail.
152 """
153 if p.get("multiple") and not p.get("nosplit") and "," in value:
154 head, _, tail = value.rpartition(",")
155 return head + ",", tail
156 return "", value
159def _files_sentinel(p: dict[str, Any], head: str) -> str:
160 """The hand-off sentinel for a path value, carrying any `matching()` glob.
162 footman never touches the filesystem to complete a path — it answers from
163 a cached manifest — so the shell's own file completion does the walking.
164 The glob rides out with the signal so the shell can narrow what it walks:
165 `complete_cli` prints it on stdout beside exit 100/101, and a hook reads
166 "one line of stdout with exit 100" as "files matching this". An empty
167 stdout is every file, which is what every hook did before there was a
168 pattern to send — so an older hook against a newer footman degrades to
169 exactly its old behaviour rather than breaking.
170 """
171 base = _FILES_CSV if head else _FILES
172 glob = p.get("glob")
173 # Tab-separated, not concatenated: `_FILES_CSV` starts with `_FILES`, so
174 # a glob beginning "-csv" would otherwise turn a plain path value into a
175 # comma-splitting one. The reader compares the tag exactly.
176 return f"{base}\t{glob}" if isinstance(glob, str) and glob else base
179def _choice_tokens(p: dict[str, Any], partial: str) -> list[str]:
180 """*p*'s choice values as whole completion tokens against *partial*.
182 Mid-list in a comma-splitting value, each choice arrives as
183 `head+choice` (minus the items already typed), so the caller's
184 generic startswith filter keeps working on whole tokens.
185 """
186 head, _ = _csv_head(p, partial)
187 if not head:
188 return list(p.get("choices", []))
189 given = partial.split(",")[:-1]
190 return [head + c for c in p.get("choices", []) if c not in given]
193def _attached_value(
194 p: dict[str, Any],
195 optname: str,
196 valpart: str,
197 bash_split: bool,
198 path: list[str],
199) -> list[str]:
200 """Candidates for an attached `--opt=value` partial — one answer for a
201 task option and a mounted plugin's global (its baked entry has the same
202 shapes), which used to be two verbatim copies of this walk.
204 A comma-splitting value mid-list completes its tail item alone, the
205 typed head riding every candidate; a path value hands off to the
206 shell's file completion; a dynamic completer recomputes fresh, its
207 emission prefix chosen by the shell's word shape. *path* is the segment
208 path a task option rides with — empty for a global, which `_suggest`
209 reads as "address the option by name".
210 """
211 head, cur = _csv_head(p, valpart)
212 if "path" in p.get("types", []):
213 return [_files_sentinel(p, head)]
214 # The value reply is pure — nothing but this value's candidates — so a
215 # comma-splitting shape marks it continuable from the first element.
216 csv = bool(p.get("multiple")) and not p.get("nosplit")
217 if p.get("dynamic"): # recompute fresh, never the baked snapshot
218 prefix = head if bash_split else f"{optname}={head}"
219 out = [_DYNAMIC, cur, prefix, p["name"], *path]
220 return [_MORE, *out] if csv else out
221 given = valpart.split(",")[:-1] if head else []
222 choices = [c for c in p.get("choices", ()) if c.startswith(cur) and c not in given]
223 out = (
224 [head + c for c in choices]
225 if bash_split
226 else [f"{optname}={head}{c}" for c in choices]
227 )
228 return [_MORE, *out] if csv and out else out
231def _consume_globals(prior: list[str]) -> list[str]:
232 """Strip the leading global options; the rest is the task chain.
234 Purely lexical, like `split._parse_globals`: every dash token is
235 self-contained (values are `=`-attached), so the walk is a scan for the
236 first bare word — no arity table.
237 """
238 i = 0
239 while i < len(prior) and prior[i].startswith("-") and prior[i] != "--":
240 i += 1
241 return prior[i:]
244class _Segment:
245 """Walk state for one chain segment (mirrors the splitter's rules)."""
247 def __init__(self, task: dict[str, Any] | None = None) -> None:
248 self.task = task
249 self.opts: dict[str, dict[str, Any]] = {}
250 self.fixed: list[dict[str, Any]] = []
251 self.rest: dict[str, Any] | None = None
252 self.filled = 0
253 self.used: set[str] = set() # options already given in this segment
254 if task is not None:
255 params = task["params"]
256 self.opts = {
257 "--" + p["name"]: p for p in params if p["kind"] in ("flag", "option")
258 }
259 self.fixed = [
260 p for p in params if p["kind"] == "positional" and not p.get("multiple")
261 ]
262 self.rest = next(
263 (
264 p
265 for p in params
266 if (p["kind"] == "positional" and p.get("multiple"))
267 or p["kind"] == "variadic"
268 ),
269 None,
270 )
273def _has_any(node: dict[str, Any]) -> bool:
274 """Whether a group leads anywhere — whether TAB has something to offer.
276 `hidden` is not the question. It keeps a task out of the *listings*, the
277 prose a human reads to learn what a repo does; completion is the other
278 thing — you are already typing a name, and a machine-facing address is
279 exactly the one worth being spelled for you.
281 `unexposed` is a different question and does count: the manifest was
282 built where there is no project, so a task marked here cannot run from
283 here, and completing to it would spell out a name that only refuses. So
284 the groups TAB skips are the empty ones and the ones with nothing
285 runnable left. Dict reads over the manifest a TAB already loaded: the hot
286 path imports no framework.
287 """
288 if node.get("default") is not None and not node["default"].get("unexposed"):
289 return True
290 if any(not spec.get("unexposed") for spec in node["tasks"].values()):
291 return True
292 return any(_has_any(sub) for sub in node["groups"].values())
295def _cand(address: str, summary: str) -> str:
296 """`address\\tdescription` when there is a help line, else the address.
298 The tab is the backward-safe wire format: shells that render descriptions
299 (zsh, fish) split on it; bash (and others) keep the first field. Options and
300 choice values carry no help, so they pass through bare.
301 """
302 return f"{address}\t{summary}" if summary else address
305def _opt_rows(name: str, spec: dict[str, Any], doc: str) -> list[tuple[str, str]]:
306 """The spellings of one option, each with the text that tells them apart.
308 A value-taking option has two legal shapes and the menu shows both:
309 `--opt`, the bare mention that stands for its default, and `--opt=`,
310 which is the *only* way to pass a value, because every value in this
311 grammar is attached. Offering the bare name alone left the value path
312 undiscoverable — a dynamic completer's candidates could only be reached
313 by knowing to type `=` first, which is exactly the internal knowledge
314 completion exists to spare you. Offering `--opt=` alone would have hidden
315 bare mention, which is a feature, not an accident.
317 They must not read as the same row twice, though. With one shared
318 `doc("…")` between them the menu showed a duplicate with nothing to
319 choose by — the first question anyone asked of it was why it was listed
320 twice. So the bare one names the value it stands for, which is the
321 difference, and is worth knowing on its own.
323 A flag takes no value at either default — `--fix=true` is a taught
324 refusal and `--no-fix` is the off spelling — so it has one shape only.
325 """
326 if spec.get("kind") != "option":
327 return [(name, doc)]
328 default = spec.get("default")
329 # Only when there is something to name: an empty or absent default says
330 # nothing, and "default: " is worse than silence.
331 shown = "" if default is None or default == "" else f"default: {default}"
332 # Just the default, not the doc as well. The `=` row sits directly
333 # beside it carrying the description, so repeating it there only made
334 # the rows long enough to break a two-column menu onto one — and shells
335 # that wrap descriptions in brackets nested them.
336 return [(name, shown or doc), (f"{name}=", doc)]
339def _walk_address(
340 tree: dict[str, Any], token: str
341) -> tuple[str, dict[str, Any], list[str]] | None:
342 """Resolve one dotted token to `("task"|"group", node, path)`, or None."""
343 parts = token.split(".")
344 if "" in parts: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true
345 return None
346 node, path = tree, []
347 for pos, part in enumerate(parts): 347 ↛ 358line 347 didn't jump to line 358 because the loop on line 347 didn't complete
348 last = pos == len(parts) - 1
349 if part in node["groups"]:
350 node = node["groups"][part]
351 path.append(part)
352 if last:
353 return ("group", node, path)
354 elif part in node["tasks"] and last:
355 return ("task", node["tasks"][part], [*path, part])
356 else:
357 return None
358 return None
361def _open_head(
362 tree: dict[str, Any], word: str
363) -> tuple[dict[str, Any], _Segment, list[str]] | None:
364 """Open one head word into `(node, segment, path)`, or None for a miss.
366 A runnable group *is* its default action — the runner reads `ci` and
367 `ci.default` as the same command — so the bare spelling opens the very
368 same tail. Without that the group name completed no *values* at all:
369 choices, the file hand-off and live completers all hang off a segment,
370 and the group spelling never built one. A namespace group has no action
371 to open, so the walk parks on it and the next bare word is a fresh head.
372 """
373 resolved = _walk_address(tree, word)
374 if resolved is None:
375 return None
376 kind, hit, path = resolved
377 if kind == "task":
378 return tree, _Segment(hit), path
379 if (default := hit.get("default")) is not None:
380 return tree, _Segment(default), [*path, "default"]
381 return hit, _Segment(), path
384def _leaf_fallback(tree: dict[str, Any], partial: str) -> list[str]:
385 """Nested candidates whose *last* segment starts with *partial*.
387 The rescue for "I know the task, not where it lives": when a typed token
388 prefix-matches no top-level segment, complete against last segments over
389 the whole tree instead — `serve` → `docs.serve`. Only nested entries
390 (a top-level match would have answered already, so the zero-match guard
391 means this can never pollute a first tab or a valid descent).
392 """
393 out: list[str] = []
395 def walk(node: dict[str, Any], prefix: str) -> None:
396 for name, spec in node["tasks"].items():
397 if spec.get("unexposed"): 397 ↛ 398line 397 didn't jump to line 398 because the condition on line 397 was never true
398 continue # nothing here can run it; completing to it teases
399 if prefix and name.startswith(partial):
400 out.append(_cand(f"{prefix}{name}", spec.get("help", "")))
401 for name, sub in node["groups"].items():
402 if not _has_any(sub): 402 ↛ 403line 402 didn't jump to line 403 because the condition on line 402 was never true
403 continue # an empty subtree suggests nothing, at any depth
404 if prefix and name.startswith(partial) and "default" in sub: 404 ↛ 405line 404 didn't jump to line 405 because the condition on line 404 was never true
405 out.append(
406 _cand(f"{prefix}{name}", sub["default"].get("help") or sub["help"])
407 )
408 walk(sub, f"{prefix}{name}.")
410 walk(tree, "")
411 return out
414def _address_candidates(tree: dict[str, Any], partial: str) -> list[str]:
415 """Path-style completion over the tree: the `.` is footman's `/`.
417 One emission rule, `ls -F` style: candidates sit one segment beyond the
418 typed prefix, and a namespace-group candidate always carries its trailing
419 dot (the descend-vs-run signal). A runnable group emits itself *plus* its
420 dotted children, so the common prefix is the group and the next keystroke
421 (space or `.`) is the stop-or-descend choice. On a unique namespace match
422 the rule skips ahead to the children — the candidate set stays non-unique,
423 so no shell ever forces a space after `docs.`.
425 Two generosities, both completion-only (the runtime resolver stays
426 strict, so scripts cannot rot): segment-wise abbreviation walks each
427 typed segment by unique prefix, `fm t.sy⇥` → `tools.sync`,
428 the way zsh expands `/u/l/b`; and on zero top-level matches the
429 leaf-name fallback completes against last segments instead
430 (`fm serve⇥` → `docs.serve`).
431 """
432 *bases, leaf = partial.split(".")
433 node, prefix = tree, ""
434 for seg in bases:
435 if seg in node["groups"]: # an exact name always wins
436 node = node["groups"][seg]
437 prefix = f"{prefix}{seg}."
438 continue
439 matches = [n for n in node["groups"] if n.startswith(seg)] if seg else []
440 if len(matches) == 1: # unique abbreviation: expand and keep walking
441 node = node["groups"][matches[0]]
442 prefix = f"{prefix}{matches[0]}."
443 continue
444 if len(matches) > 1: 444 ↛ 448line 444 didn't jump to line 448 because the condition on line 444 was always true
445 # Ambiguous segment: expand up to it and list that level's
446 # matches — the user picks, then keeps tabbing.
447 return [_cand(f"{prefix}{m}.", node["groups"][m]["help"]) for m in matches]
448 return [] # a segment that matches nothing: not an address
449 while True:
450 # `hidden` is a listings word, not a completion one: every address the
451 # runtime will accept is offered here, machine-facing ones included.
452 # What TAB skips is a group leading nowhere (one dict walk — the hot
453 # path holds).
454 groups = [
455 n
456 for n in node["groups"]
457 if n.startswith(leaf) and _has_any(node["groups"][n])
458 ]
459 tasks = [n for n in node["tasks"] if n.startswith(leaf)]
460 if (
461 len(groups) == 1
462 and not tasks
463 and "default" not in node["groups"][groups[0]]
464 ):
465 # Unique namespace match: complete straight through it, the way
466 # zsh descends a lone subdirectory.
467 prefix = f"{prefix}{groups[0]}."
468 node = node["groups"][groups[0]]
469 leaf = ""
470 continue
471 break
472 out: list[str] = []
473 for name in groups:
474 sub = node["groups"][name]
475 default = sub.get("default")
476 if default is not None:
477 # Runnable group: itself (described by what "stop here" runs),
478 # then one level of dotted children for the descent.
479 out.append(_cand(f"{prefix}{name}", default.get("help") or sub["help"]))
480 for child, csub in sub["groups"].items(): 480 ↛ 481line 480 didn't jump to line 481 because the loop on line 480 never started
481 out.append(_cand(f"{prefix}{name}.{child}.", csub["help"]))
482 for child, spec in sub["tasks"].items():
483 if child == "default":
484 continue # the bare row above IS this action
485 if spec.get("unexposed"): 485 ↛ 486line 485 didn't jump to line 486 because the condition on line 485 was never true
486 continue
487 out.append(_cand(f"{prefix}{name}.{child}", spec.get("help", "")))
488 else:
489 out.append(_cand(f"{prefix}{name}.", sub["help"]))
490 for name in tasks:
491 spec = node["tasks"][name]
492 if spec.get("unexposed"):
493 continue
494 out.append(_cand(f"{prefix}{name}", spec.get("help", "")))
495 if not out and not bases and leaf:
496 return _leaf_fallback(tree, leaf)
497 return out
500def complete(tree: dict[str, Any], words: list[str]) -> list[str]:
501 """Resolve completion candidates for *words* against a manifest *tree*.
503 Chain-aware: the walk tracks segments the way the splitter would — a
504 dotted address names each segment's task in one word, then exact
505 positional arity, then a trailing multiple/variadic consumer, then the
506 next bare word starts a new segment from the root. So in
507 `fm format lint --fi<TAB>` the options offered are *lint's*, and once a
508 task's arity is satisfied a bare TAB offers the next task names too.
509 `+` resets a segment explicitly; after `--` everything belongs to the
510 passthrough, so there is nothing to offer.
511 """
512 # bash splits attached values on `=`; rejoin so every shell presents the
513 # grammar's own shape — self-contained tokens (`--opt=val` is one word,
514 # and a value being typed folds into its option as the partial).
515 # `bash_split` remembers the fold: that shell completes the bare value,
516 # the others replace the whole token.
517 rejoined, bash_split = _rejoin(words or [""])
518 *prior, partial = rejoined or [""]
520 # Leading global options bind before the task walk, exactly as the
521 # splitter consumes them: a lexical scan to the first bare word.
522 at_globals = not prior # nothing typed yet but globals (or nothing)
523 prior = _consume_globals(prior)
524 at_globals = at_globals or not prior
526 # An attached global value in progress (`--color=al<TAB>`, `-C=src/`):
527 # the token is self-contained, so the partial says everything.
528 if at_globals and partial.startswith("-") and "=" in partial:
529 optname, _, valpart = partial.partition("=")
530 if optname in _GLOBAL_FILES:
531 return [_FILES] # hand the value part to the shell's file completion
532 entry = next(
533 (g for g in tree.get("globals", ()) if "--" + g["name"] == optname),
534 None,
535 )
536 if entry is not None:
537 # A mounted plugin's global completes from its baked entry — the
538 # same shapes a task parameter bakes, answered by the same walk.
539 return _attached_value(entry, optname, valpart, bash_split, [])
540 choices = [c for c in _GLOBAL_CHOICES.get(optname, ()) if c.startswith(valpart)]
541 return choices if bash_split else [f"{optname}={c}" for c in choices]
543 node, seg = tree, _Segment()
544 path: list[str] = [] # the dotted segments of the current head
546 for word in prior:
547 if word == "--":
548 return [] # passthrough: the words after this aren't ours
549 if word == "+": # explicit segment boundary
550 node, seg, path = tree, _Segment(), []
551 continue
552 if seg.task is None:
553 # A head word is one dotted address, resolved from the root — a
554 # task or a runnable group opens its tail, a namespace group
555 # parks there, anything else is ignored, as the splitter would
556 # err.
557 opened = _open_head(tree, word)
558 if opened is not None:
559 node, seg, path = opened
560 continue
561 # Inside a task's tail: every token is self-contained — an option
562 # word (attached value or not) never consumes its neighbour.
563 name = word.split("=", 1)[0]
564 if name in seg.opts:
565 seg.used.add(name)
566 continue
567 if name.startswith("--no-") and "--" + name[len("--no-") :] in seg.opts:
568 seg.used.add("--" + name[len("--no-") :])
569 continue
570 if word.startswith("-"): 570 ↛ 571line 570 didn't jump to line 571 because the condition on line 570 was never true
571 continue
572 # A bare word: a required positional, then the trailing consumer,
573 # then — arity satisfied — the start of the next segment (chains
574 # always resolve from the root).
575 if seg.filled < len(seg.fixed):
576 seg.filled += 1
577 continue
578 if seg.rest is not None: 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 continue
580 node, seg, path = tree, _Segment(), []
581 opened = _open_head(tree, word)
582 if opened is not None:
583 node, seg, path = opened
585 if seg.task is None:
586 if node is not tree:
587 # A prior word parked on a namespace group. It names no action,
588 # so there is no valid continuation as a fresh word: stay silent,
589 # the way the splitter refuses it. (A runnable group opened a
590 # segment instead, and answers through the task tail below.)
591 return []
592 # Path-style over the whole word: the partial is a dotted address
593 # in progress, and candidates sit one segment beyond it.
594 out = [] if partial.startswith("-") else _address_candidates(tree, partial)
595 # fm's own global options bind before the first task, so offer them when
596 # a flag is being typed at the root (`not prior` ⇒ nothing but globals
597 # preceded). A bare `<TAB>` still lists only tasks — globals would be
598 # noise there.
599 if not prior and partial.startswith("-"):
600 # The core flags are this module's own frozenset (it may not import
601 # `_split`), but their words — and their offerable spellings —
602 # ride in the manifest: `_manifest` writes both from the one
603 # table that declares them, so an option shows `--opt=` beside
604 # its bare mention (and a value-required one shows `--opt=`
605 # alone, its bare spelling being a taught refusal). A manifest
606 # from before the spellings rode along falls back to the names.
607 said = tree.get("global_help")
608 lines: dict[str, str] = said if isinstance(said, dict) else {}
609 spellings = sorted(lines) if lines else sorted(_GLOBALS)
610 out += [
611 _cand(g, lines.get(g, "")) for g in spellings if g.startswith(partial)
612 ]
613 for g in tree.get("globals", ()):
614 summary = str(g.get("help", ""))
615 if (flag := "--" + g["name"]).startswith(partial):
616 out += [_cand(t, d) for t, d in _opt_rows(flag, g, summary)]
617 # A bool answers to `--no-x` too — offer the off spelling
618 # exactly as the splitter accepts it, described by what it
619 # turns off: one option, read from the other end.
620 if g["kind"] == "flag" and (
621 (no := "--no-" + g["name"]).startswith(partial)
622 ):
623 out.append(_cand(no, summary))
624 return out
626 # An attached `--opt=value` partial: the one value position the grammar
627 # has. A Path-typed value hands off to file completion; a dynamic
628 # completer recomputes fresh, its emission prefix chosen by the shell's
629 # word shape; choices likewise come back bare (bash completes the value
630 # word) or as full `--opt=choice` tokens (whole-token shells).
631 if partial.startswith("-") and "=" in partial:
632 optname, _, valpart = partial.partition("=")
633 opt = seg.opts.get(optname)
634 if opt is not None and opt["kind"] == "option": 634 ↛ 641line 634 didn't jump to line 641 because the condition on line 634 was always true
635 return _attached_value(opt, optname, valpart, bash_split, path)
637 # A path-typed positional (or trailing consumer): once the partial is a
638 # value being typed rather than an option, hand it to native file
639 # completion — the same handoff a Path-typed option value gets above.
640 # `-` still reaches the options below, so they stay one keystroke away.
641 if not partial.startswith("-"):
642 pending = seg.fixed[seg.filled] if seg.filled < len(seg.fixed) else seg.rest
643 if pending is not None:
644 head, cur = _csv_head(pending, partial)
645 if "path" in pending.get("types", []):
646 return [_files_sentinel(pending, head)]
647 if pending.get("dynamic"): # recompute fresh, never the baked snapshot
648 out = [_DYNAMIC, cur, head, pending["name"], *path]
649 # Pure like the attached branch, so shape alone marks it.
650 if pending.get("multiple") and not pending.get("nosplit"): 650 ↛ 651line 650 didn't jump to line 651 because the condition on line 650 was never true
651 return [_MORE, *out]
652 return out
654 # Option position: this task's flags/options — minus the ones already
655 # given, unless the param legitimately repeats — plus what the next bare
656 # word could be: the pending positional's choices, the trailing
657 # consumer's choices, or (arity satisfied) the next segment's addresses.
658 candidates = [
659 name
660 for name, p in seg.opts.items()
661 if name not in seg.used or p.get("multiple") or p.get("mapping")
662 ]
663 next_heads: list[str] = []
664 consuming: dict[str, Any] | None = None # the positional the menu feeds
665 if seg.filled < len(seg.fixed):
666 consuming = seg.fixed[seg.filled]
667 candidates += _choice_tokens(consuming, partial)
668 if consuming.get("optional") and not partial.startswith("-"):
669 # The boundary documents itself: an optional positional's slot
670 # offers `+` — run without it, the next word starts a task.
671 candidates.append("+")
672 elif seg.rest is not None:
673 consuming = seg.rest
674 candidates += _choice_tokens(consuming, partial)
675 elif not partial.startswith("-"):
676 next_heads = _address_candidates(tree, partial)
677 seen: dict[str, None] = {}
678 for c in candidates:
679 if c.startswith(partial):
680 seen.setdefault(c)
681 # An option carries its doc("...") text when the task author wrote one;
682 # choice values stay bare; next-segment addresses arrive pre-described.
683 # Same tab-separated wire format either way — `_cand` is that format.
684 rows: list[str] = []
685 for c in seen:
686 spec = seg.opts.get(c)
687 if spec is None: # a choice value, `+`, or a next-segment address
688 rows.append(_cand(c, ""))
689 continue
690 rows += [_cand(t, d) for t, d in _opt_rows(c, spec, spec.get("doc", ""))]
691 # A positional's first element shares this menu with option rows, so it
692 # stays unmarked — a per-reply signal must not glue those. Mid-list the
693 # menu is provably pure (no option name can prefix-match a partial that
694 # holds a comma), and the non-empty csv head says exactly that.
695 if rows and consuming is not None and _csv_head(consuming, partial)[0]:
696 return [_MORE, *rows, *next_heads]
697 return rows + next_heads
700def _load_manifest(path: str) -> dict[str, Any] | None:
701 try:
702 with open(path, "rb") as fh:
703 data = json.load(fh)
704 except (OSError, ValueError):
705 return None
706 return data if isinstance(data, dict) else None
709def _broken_line(data: Any) -> str | None:
710 """The broken-tree marker's message, or None for anything else.
712 The refresh child writes the marker into the manifest slot when the
713 tasks file fails to import — same schema, a `broken` line instead of a
714 `tree` — so the hot path can answer instantly with *why* instead of
715 paying the cold bound into silence on every keystroke."""
716 if isinstance(data, dict) and data.get("schema") == _SCHEMA:
717 line = data.get("broken")
718 if isinstance(line, str) and line:
719 return line
720 return None
723def _maybe_refresh(
724 path: str,
725 data: dict[str, Any],
726 spawn_in: str | None = None,
727 override: str | None = None,
728) -> None:
729 """Stale-while-revalidate: if the manifest is older than its baked
730 `completion_max_age`, bump its mtime and spawn a detached rebuild for *next*
731 time, then return. Never blocks the TAB (the rebuild imports the package and
732 shells completers) and never surfaces an error.
733 """
734 max_age = data.get("completion_max_age")
735 if not isinstance(max_age, int) or isinstance(max_age, bool) or max_age <= 0:
736 return # disabled (off, or an in-memory/`-f` manifest with no age baked)
737 try:
738 # Two triggers, one rebuild. The clock says "this may have drifted";
739 # the user stamp says "a user-level file it was built from has
740 # actually changed" — a config edit or a user tasks file written,
741 # which the clock alone would hide for up to a whole `max_age`, and
742 # which is exactly when someone is watching for their change to
743 # land. An older manifest (or a broken-tree marker) bakes no stamp:
744 # it simply falls back to the clock. Never overrides `max_age =
745 # off`, which is a request for no background rebuilds at all.
746 from footman import _paths
748 stamp = data.get("user_stamp")
749 edited = isinstance(stamp, str) and stamp != _paths.user_stamp()
750 if not edited and time.time() - os.stat(path).st_mtime <= max_age:
751 return
752 # Bump the mtime *before* spawning: resets the clock even if the rebuild
753 # is a no-op (sync_manifest only writes on change), and storm-guards
754 # concurrent TABs so only the first in an aged window spawns.
755 os.utime(path)
756 except OSError:
757 return
758 _spawn_refresh(override, spawn_in)
761def _spawn_refresh(override: str | None = None, spawn_in: str | None = None) -> None:
762 # override set → rebuild that one -f file's (cwd, file) manifest; else the
763 # cwd cascade. The path rides as an argv word (not baked into the -c script),
764 # so a path with spaces or quotes needs no escaping.
765 # The brand's resolved locations ride along as argv words too: the child
766 # inherits the environment but not the brand, and must write this CLI's
767 # cache rather than stock footman's.
768 # `-P` keeps the child's own imports honest: without it `-c` puts the
769 # *completed directory* at the head of sys.path, so a file called
770 # `footman.py` sitting there would answer the child's `import footman` and
771 # run on a keystroke. Nothing legitimate needs that entry — `_discover`
772 # inserts a tasks file's own parent itself, deliberately and briefly.
773 from footman import _paths
775 where = _paths.child_args()
776 if override:
777 script = (
778 "import sys; from footman import _refresh; "
779 "_refresh.refresh_source(*sys.argv[1:])"
780 )
781 cmd = [sys.executable, "-P", "-c", script, override, *where]
782 else:
783 script = (
784 "import sys; from footman import _refresh; "
785 "_refresh.refresh_cwd(*sys.argv[1:])"
786 )
787 cmd = [sys.executable, "-P", "-c", script, *where]
788 # The child keys its build by its own cwd, so a `-C <dir>` completion
789 # stands it in that directory — the manifest it writes is the one the
790 # hot path just tried to read.
791 detach(cmd, cwd=spawn_in)
794def detach(cmd: list[str], cwd: str | None = None) -> None:
795 """Spawn *cmd* fully detached, swallowing failure — the one copy of the
796 background-child dance, shared with the collector's spawn in `_app` (a
797 background child must never break the foreground that spawned it).
799 Stdlib-only, like everything here, so both the hot path and the
800 execution path may call it.
801 """
802 import subprocess
804 null = subprocess.DEVNULL
805 try:
806 if os.name == "nt":
807 # Not DETACHED_PROCESS: with Windows Terminal as the default
808 # terminal (Windows 11), a console-less console app is handed a
809 # *visible* terminal window — one per spawn, popping over the
810 # shell mid-completion — and any console grandchild allocates
811 # another. CREATE_NO_WINDOW gives the child a hidden console
812 # instead: nothing shows, and its children inherit the hiding.
813 flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
814 subprocess, "CREATE_NEW_PROCESS_GROUP", 0
815 )
816 subprocess.Popen(
817 cmd, stdin=null, stdout=null, stderr=null, creationflags=flags, cwd=cwd
818 )
819 else:
820 subprocess.Popen(
821 cmd,
822 stdin=null,
823 stdout=null,
824 stderr=null,
825 start_new_session=True,
826 cwd=cwd,
827 )
828 except OSError:
829 return # a detached child must never break what spawned it
832def _cold_build(
833 manifest: str, override: str | None, spawn_in: str | None = None
834) -> dict[str, Any] | None:
835 """Build a cold-cache manifest once, then load it.
837 The first <kbd>Tab</kbd> in a fresh directory has nothing cached. Rather than
838 answer empty, spawn the same builder a real run uses and wait — bounded — for
839 it to land, then serve it (now cached for next time). *override* picks the
840 tree: a finished `-f <file>` builds that file's (cwd, file) manifest, else the
841 cwd cascade. Import-free on the hot path: it spawns rather than imports. A
842 slow `tasks.py` degrades to empty, and because the build was detached it still
843 finishes for the next TAB, so no keystroke ever hangs on it.
844 """
845 if override is not None:
846 # A relative -f anchors where the run will stand (`-C` moved it).
847 base = spawn_in if spawn_in else os.getcwd()
848 if not os.path.isfile(os.path.join(base, os.path.expanduser(override))):
849 return None # a still-being-typed or missing -f value: nothing to build
850 _spawn_refresh(override, spawn_in)
851 deadline = time.monotonic() + _COLD_TIMEOUT
852 while time.monotonic() < deadline:
853 time.sleep(0.03)
854 data = _load_manifest(manifest)
855 # The schema check matters here too: on the post-upgrade TAB the
856 # *stale* file is still on disk, and without it the poll would win
857 # the race against the rebuild and hand the old tree back.
858 # A broken-tree marker is a landed answer too: the child failed
859 # fast and said why, and waiting the full bound for a tree that is
860 # not coming would only slow the telling.
861 if (
862 isinstance(data, dict)
863 and data.get("schema") == _SCHEMA
864 and (
865 isinstance(data.get("tree"), dict)
866 or isinstance(data.get("broken"), str)
867 )
868 ):
869 return data
870 return None
873def _leading_global_value(args: list[str], names: tuple[str, ...]) -> str | None:
874 """The value of the first of *names* among the leading globals, or None.
876 Walks only the leading globals — stopping at the first bare word — the
877 same lexical scan the resolver uses; values are always `=`-attached
878 (rejoined first, so bash's split forms read the same).
879 """
880 for tok in _rejoin(list(args))[0]:
881 if not tok.startswith("-") or tok == "--":
882 break # the first bare word — a task name (or its partial)
883 if any(tok.startswith(n + "=") for n in names):
884 return tok.split("=", 1)[1]
885 return None
888def _tasks_file_from(args: list[str]) -> str | None:
889 """The `-f`/`--tasks-file` value among the leading globals, or None."""
890 return _leading_global_value(args, ("-f", "--tasks-file"))
893def _emit(lines: list[str]) -> None:
894 """Write completion candidates, one per line, LF-terminated.
896 LF, always. The completion protocol is footman's own, and on Windows
897 text-mode stdout translates every "\\n" to "\\r\\n": a shell that reads lines
898 literally (git-bash's `read`) keeps the carriage return and completes
899 `--fix\\r`, planting a stray CR at the cursor. Writing bytes to the
900 underlying buffer skips the translation and pins UTF-8; captured stdout
901 (tests, some wrappers) has no buffer, so fall back.
902 """
903 if not lines:
904 return
905 payload = "\n".join(lines) + "\n"
906 buffer = getattr(sys.stdout, "buffer", None)
907 if buffer is None: 907 ↛ 908line 907 didn't jump to line 908 because the condition on line 907 was never true
908 sys.stdout.write(payload)
909 else:
910 buffer.write(payload.encode("utf-8"))
911 buffer.flush()
914def _fresh_dynamic(param: str, path: list[str], args: list[str]) -> list[str] | None:
915 """Run *param*'s completer fresh in a subprocess; None on timeout/failure.
917 Isolated on purpose: the subprocess imports the framework and the user's
918 code, which the hot path must never do. A timeout or non-zero exit returns
919 None, and the caller shows nothing rather than a stale snapshot. An empty
920 *path* addresses a plugin's global option by name — a task parameter
921 always rides with the segment path that reached it.
922 """
923 from footman import _paths
925 # Same reasoning as the refresh child: it is told this CLI's locations
926 # rather than re-deriving them from an environment it shares with others.
927 # Length-prefixed, so the flags that follow can never be eaten when the
928 # word count grows — the arity drifted once per release until it was.
929 where = _paths.child_args()
930 # `-P` for the same reason the refresh child carries it: `-m` would put the
931 # completed directory at the head of sys.path, and a `footman.py` planted
932 # there would be imported — and executed — by a TAB press.
933 cmd = [
934 sys.executable,
935 "-P",
936 "-m",
937 "footman._suggest",
938 "--where",
939 str(len(where)),
940 *where,
941 ]
942 if path:
943 cmd += ["--param", param]
944 for name in path:
945 cmd += ["--path", name]
946 else:
947 cmd += ["--global", param]
948 prior = args[:-1]
949 if (tf := _leading_global_value(prior, ("-f", "--tasks-file"))) is not None:
950 cmd += ["--tasks-file", tf]
951 if (cf := _leading_global_value(prior, ("--config",))) is not None:
952 cmd += ["--config", cf]
953 import subprocess
955 try:
956 proc = subprocess.run(
957 cmd, capture_output=True, text=True, timeout=_DYNAMIC_TIMEOUT
958 )
959 except (OSError, subprocess.TimeoutExpired):
960 return None
961 if proc.returncode != 0:
962 return None
963 return [v for v in proc.stdout.splitlines() if v]
966def complete_cli(args: list[str]) -> int:
967 """Entry for `footman --complete` and the standalone resolver."""
968 manifest = None
969 if args and args[0] == "--manifest":
970 if len(args) < 2: 970 ↛ 971line 970 didn't jump to line 971 because the condition on line 970 was never true
971 return 0
972 manifest, args = args[1], args[2:]
973 # WinPS 5.1 and pwsh 7.0-7.2 drop empty-string args to native commands, so
974 # the hook can't pass the trailing "" partial itself — it flags the empty
975 # position and we append the "" here instead.
976 # `--why` re-asks a broken-tree answer with the reason on *stdout*: a
977 # hook that saw exit 103 makes one more call with it, because stdout
978 # capture is the one channel every shell reads identically — stderr
979 # merge semantics vary by host (pwsh's completion engine swallowed the
980 # merge outright on Windows). A hook that never sends it keeps the
981 # stdout-empty silence it always had.
982 why = False
983 if args and args[0] == "--why":
984 why, args = True, args[1:]
985 empty_partial = False
986 if args and args[0] == "--empty-partial":
987 empty_partial, args = True, args[1:]
988 if args and args[0] == "--":
989 args = args[1:]
990 if empty_partial:
991 args = [*args, ""]
993 derived = manifest is None
994 override: str | None = None
995 spawn_in: str | None = None
996 if manifest is None:
997 # Only the derive branch needs the package; keep the standalone
998 # --manifest path free of any `footman` import. The cache is keyed by
999 # cwd — the effective task set is the cascade from the repo root down —
1000 # unless `-f <file>` names one file, which has its own (cwd, file) key.
1001 # Strings and `os.path` throughout, never pathlib: importing it costs
1002 # ~5 ms of the TAB press's budget, and the warm path needs nothing
1003 # pathlib does that `os.path` doesn't. `_paths`' `_*_str` core keys
1004 # byte-identically to its Path wrappers, so both sides of the cache
1005 # keep meeting.
1006 from footman import _paths
1008 # The last word is the partial being completed: `fm -f <TAB>` is a file
1009 # being typed, not a finished override — so read the override from the
1010 # prior words only, leaving `-f`'s own value to native file completion
1011 # (the resolver signals it below). A finished `-f file <TAB>` still keys
1012 # by the pair.
1013 # A finished `-C <dir>` moves the whole question: the run will chdir
1014 # there before discovery, so the manifest key, the cascade walk, and
1015 # every spawned builder must stand in that directory too — otherwise
1016 # `fm -C=<dir> <TAB>` offers the invoking directory's tasks.
1017 cdir = _leading_global_value(args[:-1], ("-C", "--directory"))
1018 if cdir:
1019 stand = os.path.expanduser(cdir)
1020 if not os.path.isdir(stand):
1021 return 0 # a mistyped or half-typed target: nothing to offer
1022 else:
1023 stand = os.getcwd()
1024 spawn_in = stand if cdir else None
1025 override = _tasks_file_from(args[:-1])
1026 config = _leading_global_value(args[:-1], ("--config",))
1027 if override:
1028 # Anchor a relative -f at the stand-in directory, as the run's
1029 # chdir would; joining an absolute (or ~-expanded) value is a
1030 # no-op, so plain invocations key exactly as before.
1031 anchored = os.path.join(stand, os.path.expanduser(override))
1032 manifest = _paths._source_file(stand, anchored)
1033 elif config: 1033 ↛ 1037line 1033 didn't jump to line 1037 because the condition on line 1033 was never true
1034 # An explicit --config keys its own manifest, exactly as -f
1035 # does — so a `--config` run's tree never answers plain TAB,
1036 # and a plain run's never answers this line's.
1037 anchored = os.path.join(stand, os.path.expanduser(config))
1038 manifest = _paths._source_file(stand, anchored)
1039 else:
1040 manifest = _paths._manifest_file(stand)
1041 if not os.path.isfile(manifest):
1042 # No warm cwd manifest: one walk decides what this directory
1043 # even is. A project's first TAB builds its own cascade
1044 # below. A project-less one is global mode — whose manifest
1045 # every such directory shares, cold once per brand version
1046 # rather than once per directory — when the brand has
1047 # built-ins or the user keeps a tasks file; with neither
1048 # there is nothing here to complete, and saying so instantly
1049 # beats spawning a build that can only come back empty after
1050 # the full cold bound. Only on the miss path: a warm
1051 # directory never pays the walk. (The miss path is about to
1052 # spend 100 ms+ on a build, so ITS pathlib is noise.)
1053 from pathlib import Path
1055 name = _paths.tasks_file_name()
1056 files = _paths.task_files(
1057 Path(stand), _paths.find_repo_root(Path(stand)), name
1058 )
1059 if not files:
1060 # Nothing here, nothing built in, no user tasks file —
1061 # and no global manifest a previous run left behind.
1062 # That last check is how a *user-configured* built-in
1063 # set reaches TAB under a brand that declares none of
1064 # its own: the key lives in config, which this path
1065 # must not read (a TOML parse per keystroke), but the
1066 # manifest a real run wrote is one `exists` away. A
1067 # machine with nothing configured has no such file, so
1068 # a bare directory still answers instantly.
1069 shared = _paths._global_file()
1070 if (
1071 not _paths.builtin()
1072 and not _paths.user_tasks_file(name).is_file()
1073 and not os.path.exists(shared)
1074 ):
1075 return 0
1076 manifest = shared
1078 data = _load_manifest(manifest)
1079 if (line := _broken_line(data)) is not None:
1080 # The marker ages fast and stale-while-revalidate owns recovery: a
1081 # fixed file rebuilds behind an aged press and the next one is warm.
1082 # The override rides along so a broken `-f` file's spawn rebuilds
1083 # the (cwd, file) manifest it marks, not the cwd cascade's.
1084 assert data is not None
1085 _maybe_refresh(manifest, data, spawn_in, override=override)
1086 (sys.stdout if why else sys.stderr).write(line + "\n")
1087 return _EXIT_BROKEN
1088 if (
1089 data is None
1090 or not isinstance(data.get("tree"), dict)
1091 or data.get("schema") != _SCHEMA
1092 ):
1093 # Cold cache, or one baked by a different footman: rather than answer
1094 # empty (or walk a reshaped tree into a traceback), build the manifest
1095 # once (bounded) and serve it, so the first TAB in a fresh directory —
1096 # or right after an upgrade — is accurate. Covers the cwd cascade and
1097 # a finished `-f <file>` alike.
1098 data = _cold_build(manifest, override, spawn_in) if derived else None
1099 if (line := _broken_line(data)) is not None:
1100 # The very first TAB against a broken file already says why —
1101 # the child failed fast and left the marker where the tree
1102 # would have been.
1103 (sys.stdout if why else sys.stderr).write(line + "\n")
1104 return _EXIT_BROKEN
1105 if (
1106 not isinstance(data, dict)
1107 or not isinstance(data.get("tree"), dict)
1108 or data.get("schema") != _SCHEMA
1109 ):
1110 return 0 # cold and couldn't build in time — stay silent and fast
1111 out = complete(data["tree"], args)
1112 # The continuation marker leaves as an exit code, so the candidates on
1113 # stdout stay clean for every hook — including the ones that have never
1114 # heard of 102 and just parse them.
1115 more = bool(out) and out[0] == _MORE
1116 if more:
1117 out = out[1:]
1118 if len(out) == 1 and out[0].split("\t", 1)[0] in (_FILES, _FILES_CSV):
1119 # A path value: signal the hook to complete files. `_FILES_CSV` means
1120 # after the last comma. (Only ever raised with a comma already typed,
1121 # so a hook from an older install — which knows just 100 — degrades to
1122 # the silence it always showed there.)
1123 #
1124 # Whatever follows the sentinel is a `matching()` glob for the shell
1125 # to filter by; nothing follows it when the parameter declared none,
1126 # which is the every-file hand-off every hook already knew.
1127 tag, _, glob = out[0].partition("\t")
1128 csv = tag == _FILES_CSV
1129 if glob:
1130 sys.stdout.write(glob + "\n")
1131 _maybe_refresh(manifest, data, spawn_in)
1132 return _EXIT_FILES_CSV if csv else _EXIT_FILES
1133 if out and out[0] == _DYNAMIC:
1134 # A dynamic completer: recompute it fresh in a subprocess rather than
1135 # serve the manifest's baked snapshot — a build-critical answer must not
1136 # be stale. Empty on timeout or failure, never the old values. The
1137 # prefix (`--opt=` for an attached value, "" for a positional) rides
1138 # along so emitted candidates replace the shell's whole word.
1139 partial, prefix, param, seg_path = out[1], out[2], out[3], out[4:]
1140 fresh = _fresh_dynamic(param, seg_path, args)
1141 if fresh is not None: 1141 ↛ 1143line 1141 didn't jump to line 1143 because the condition on line 1141 was always true
1142 _emit([prefix + c for c in fresh if c.startswith(partial)])
1143 _maybe_refresh(manifest, data, spawn_in)
1144 return _EXIT_MORE if more else 0
1145 _emit(out)
1146 _maybe_refresh(
1147 manifest, data, spawn_in
1148 ) # SWR: refresh the baked fallback + structural set
1149 return _EXIT_MORE if more else 0
1152def main() -> int:
1153 return complete_cli(sys.argv[1:])
1156if __name__ == "__main__":
1157 raise SystemExit(main())