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

1"""Completion hot path — the code every TAB press runs. 

2 

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. 

6 

7Two ways in: 

8 

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. 

16 

17WORDs are the command line after the program name; the last word is the partial 

18being completed ("" when the cursor follows a space). 

19""" 

20 

21from __future__ import annotations 

22 

23import json 

24import os 

25import sys 

26import time 

27 

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. 

31 

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 

38 

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 

86 

87 

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} 

110 

111 

112def _rejoin(words: list[str]) -> tuple[list[str], bool]: 

113 """Undo bash's `=` word-splitting: `--opt`, `=`, `val` → `--opt=val`. 

114 

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. 

120 

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 

143 

144 

145def _csv_head(p: dict[str, Any], value: str) -> tuple[str, str]: 

146 """Split *value* at its last comma when *p* comma-splits. 

147 

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 

157 

158 

159def _files_sentinel(p: dict[str, Any], head: str) -> str: 

160 """The hand-off sentinel for a path value, carrying any `matching()` glob. 

161 

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 

177 

178 

179def _choice_tokens(p: dict[str, Any], partial: str) -> list[str]: 

180 """*p*'s choice values as whole completion tokens against *partial*. 

181 

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] 

191 

192 

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. 

203 

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 

229 

230 

231def _consume_globals(prior: list[str]) -> list[str]: 

232 """Strip the leading global options; the rest is the task chain. 

233 

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:] 

242 

243 

244class _Segment: 

245 """Walk state for one chain segment (mirrors the splitter's rules).""" 

246 

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 ) 

271 

272 

273def _has_any(node: dict[str, Any]) -> bool: 

274 """Whether a group leads anywhere — whether TAB has something to offer. 

275 

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. 

280 

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()) 

293 

294 

295def _cand(address: str, summary: str) -> str: 

296 """`address\\tdescription` when there is a help line, else the address. 

297 

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 

303 

304 

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. 

307 

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. 

316 

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. 

322 

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)] 

337 

338 

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 

359 

360 

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. 

365 

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 

382 

383 

384def _leaf_fallback(tree: dict[str, Any], partial: str) -> list[str]: 

385 """Nested candidates whose *last* segment starts with *partial*. 

386 

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] = [] 

394 

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}.") 

409 

410 walk(tree, "") 

411 return out 

412 

413 

414def _address_candidates(tree: dict[str, Any], partial: str) -> list[str]: 

415 """Path-style completion over the tree: the `.` is footman's `/`. 

416 

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.`. 

424 

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 

498 

499 

500def complete(tree: dict[str, Any], words: list[str]) -> list[str]: 

501 """Resolve completion candidates for *words* against a manifest *tree*. 

502 

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 [""] 

519 

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 

525 

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] 

542 

543 node, seg = tree, _Segment() 

544 path: list[str] = [] # the dotted segments of the current head 

545 

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 

584 

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 

625 

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) 

636 

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 

653 

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 

698 

699 

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 

707 

708 

709def _broken_line(data: Any) -> str | None: 

710 """The broken-tree marker's message, or None for anything else. 

711 

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 

721 

722 

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 

747 

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) 

759 

760 

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 

774 

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) 

792 

793 

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). 

798 

799 Stdlib-only, like everything here, so both the hot path and the 

800 execution path may call it. 

801 """ 

802 import subprocess 

803 

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 

830 

831 

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. 

836 

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 

871 

872 

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. 

875 

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 

886 

887 

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")) 

891 

892 

893def _emit(lines: list[str]) -> None: 

894 """Write completion candidates, one per line, LF-terminated. 

895 

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() 

912 

913 

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. 

916 

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 

924 

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 

954 

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] 

964 

965 

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, ""] 

992 

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 

1007 

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 

1054 

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 

1077 

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 

1150 

1151 

1152def main() -> int: 

1153 return complete_cli(sys.argv[1:]) 

1154 

1155 

1156if __name__ == "__main__": 

1157 raise SystemExit(main())