Coverage for src/footman/_shellcomp.py: 90%

210 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-24 13:38 +0000

1"""Shell completion installers — `fm --install-completion bash|zsh|fish|pwsh`. 

2 

3Each generated script calls `<prog> --complete -- WORD...` on TAB: the 

4stdlib-only hot path that answers from the cached manifest without importing 

5footman or the user's code. Scripts are generated per brand, so a custom CLI 

6(`acme --install-completion zsh`) installs completion for *its* name. 

7 

8Install layout (all idempotent — running twice changes nothing): 

9 

10- bash: script in `$XDG_DATA_HOME/<prog>/completion.bash`, one guarded 

11 `source` line appended to `~/.bashrc`. 

12- zsh: script in `$XDG_DATA_HOME/<prog>/completion.zsh`, one guarded 

13 `source` line appended to `~/.zshrc`. 

14- fish: `~/.config/fish/completions/<prog>.fish` — fish auto-loads the 

15 directory; no rc editing at all. 

16- pwsh: script in `$XDG_DATA_HOME/<prog>/completion.ps1`, one guarded 

17 dot-source line appended to the profile PowerShell itself reports 

18 (`$PROFILE`); works for PowerShell 7+ and Windows PowerShell 5. 

19- nushell: script in `$XDG_DATA_HOME/<prog>/completion.nu`, one guarded 

20 `source` line appended to the config nushell itself reports 

21 (`$nu.config-path`). The hook *wraps* any existing external completer 

22 (carapace, fish-based, …) instead of replacing it. 

23""" 

24 

25from __future__ import annotations 

26 

27import contextlib 

28import os 

29import shutil 

30import subprocess 

31import sys 

32from pathlib import Path 

33 

34SHELLS = ("bash", "zsh", "fish", "pwsh", "nushell") 

35 

36 

37class InstallError(Exception): 

38 """Completion could not be installed; the message says why.""" 

39 

40 

41_BASH = """\ 

42# {prog} shell completion (generated by `{prog} --install-completion bash`) 

43_{fn}_complete() {{ 

44 # An index loop, not "${{COMP_WORDS[@]:1:COMP_CWORD}}": the quoted array 

45 # slice collapses to a single word on macOS's bash 3.2. 

46 local -a words=() 

47 local i 

48 for ((i = 1; i <= COMP_CWORD; i++)); do 

49 words[i - 1]=${{COMP_WORDS[i]}} 

50 done 

51 COMPREPLY=() 

52 # Capture into a variable (not `< <(...)`) so we can read the exit code. 

53 # `local out` then `out=$(...)` on separate lines — `local out=$(...)` 

54 # would make $? the exit of `local`, not of the completion call. 

55 local out 

56 out=$({prog} --complete -- "${{words[@]}}" 2>/dev/null) 

57 # Exit 100 = a path value: leave COMPREPLY empty and let `-o default` 

58 # (below) hand off to readline's filename completion — works on bash 3.2. 

59 (( $? == 100 )) && return 0 

60 # Not a file: on bash 4+ switch the default filename fallback off so a value 

61 # with no candidates stays empty. bash 3.2 has no compopt and keeps the 

62 # blunt fallback — no regression, it already behaved that way. 

63 compopt +o default 2>/dev/null 

64 [[ -n $out ]] || return 0 

65 # Read one candidate per line and %q-quote each, so a candidate like *.md or 

66 # "a b" is inserted literally — never pathname-expanded the way a 

67 # split-and-glob command substitution into the array would. bash has no 

68 # description column, so drop anything after the first tab. 

69 local line 

70 while IFS= read -r line; do 

71 line=${{line%%$'\\t'*}} 

72 printf -v line '%q' "$line" 

73 COMPREPLY+=("$line") 

74 done <<< "$out" 

75}} 

76complete -o default -F _{fn}_complete {prog} 

77""" 

78 

79_ZSH = """\ 

80# {prog} shell completion (generated by `{prog} --install-completion zsh`) 

81if ! typeset -f compdef >/dev/null 2>&1; then 

82 # -u: skip compaudit's interactive prompt — this fallback only fires when 

83 # nothing else initialised completion (fresh HOMEs, non-interactive use), 

84 # exactly where a prompt would wedge instead of asking. 

85 autoload -Uz compinit && compinit -u 

86fi 

87_{fn}_complete() {{ 

88 local -a items 

89 local raw line ret 

90 # (@) + quotes keeps the empty current word when the cursor follows a 

91 # space — unquoted expansion would drop it. 

92 raw="$({prog} --complete -- "${{(@)words[2,CURRENT]}}" 2>/dev/null)" 

93 ret=$? 

94 # Exit 100 = a path value: defer to zsh's own file completion, which 

95 # honours the user's file colours and list settings. 

96 (( ret == 100 )) && {{ _files; return; }} 

97 for line in ${{(f)raw}}; do 

98 # `value:description` feeds _describe, which right-aligns the 

99 # descriptions into a column and honours the user's completion colours 

100 # (list-colors / descriptions format). A tab-less line — an option or a 

101 # choice value — is a bare value with no description. 

102 if [[ $line == *$'\\t'* ]]; then 

103 items+=("${{line%%$'\\t'*}}:${{line#*$'\\t'}}") 

104 else 

105 items+=("$line") 

106 fi 

107 done 

108 (( ${{#items}} )) && _describe -t {fn} '{prog}' items 

109}} 

110compdef _{fn}_complete {prog} 

111""" 

112 

113_FISH = """\ 

114# {prog} shell completion (generated by `{prog} --install-completion fish`) 

115function __{fn}_complete 

116 set -l words (commandline -opc) (commandline -ct) 

117 set -l out ({prog} --complete -- $words[2..-1] 2>/dev/null) 

118 # Exit 100 = a path value: hand off to fish's own path completion. 

119 if test $status -eq 100 

120 __fish_complete_path (commandline -ct) 

121 else if set -q out[1] 

122 printf '%s\n' $out 

123 end 

124end 

125complete -c {prog} -f -a '(__{fn}_complete)' 

126""" 

127 

128_PWSH = """\ 

129# {prog} shell completion (generated by `{prog} --install-completion pwsh`) 

130Register-ArgumentCompleter -Native -CommandName {prog} -ScriptBlock {{ 

131 param($wordToComplete, $commandAst, $cursorPosition) 

132 $words = @($commandAst.CommandElements | 

133 Select-Object -Skip 1 | ForEach-Object {{ $_.ToString() }}) 

134 # WinPS 5.1 and pwsh 7.0-7.2 silently drop an empty-string arg to a native 

135 # command, so a trailing '' partial can't be passed directly. Flag the empty 

136 # position instead; the resolver appends the '' itself. 

137 $empty = if ($wordToComplete -eq '') {{ '--empty-partial' }} else {{ $null }} 

138 $out = @(& {prog} --complete $empty -- @words 2>$null) 

139 if ($LASTEXITCODE -eq 100) {{ 

140 # A path value: defer to PowerShell's own filename completion. 

141 return [System.Management.Automation.CompletionCompleters]::CompleteFilename( 

142 $wordToComplete) 

143 }} 

144 $out | ForEach-Object {{ 

145 # `value` or `value<tab>description`: the tail becomes the tooltip. 

146 $parts = $_ -split "`t", 2 

147 $val = $parts[0] 

148 $tip = if ($parts.Count -gt 1) {{ $parts[1] }} else {{ $val }} 

149 [System.Management.Automation.CompletionResult]::new( 

150 $val, $val, 'ParameterValue', $tip) 

151 }} 

152}} 

153""" 

154 

155_NU = """\ 

156# {prog} shell completion (generated by `{prog} --install-completion nushell`) 

157# Wraps any existing external completer (carapace, …) instead of replacing it. 

158let __{fn}_prev = ($env.config.completions?.external?.completer? | default null) 

159$env.config.completions.external.enable = true 

160$env.config.completions.external.completer = {{|spans| 

161 if $spans.0 == "{prog}" {{ 

162 let r = (^{prog} --complete -- ...($spans | skip 1) | complete) 

163 if $r.exit_code == 100 {{ 

164 # A path value: null defers to nushell's built-in file completion. 

165 null 

166 }} else {{ 

167 # Split `value<tab>description` into a record so nushell renders the 

168 # description column; a tab-less line (option/choice) is a bare value. 

169 $r.stdout | lines | each {{|l| 

170 let p = ($l | split row (char tab)) 

171 if ($p | length) > 1 {{ 

172 {{value: $p.0, description: $p.1}} 

173 }} else {{ 

174 {{value: $p.0}} 

175 }} 

176 }} 

177 }} 

178 }} else if $__{fn}_prev != null {{ 

179 do $__{fn}_prev $spans 

180 }} else {{ 

181 null 

182 }} 

183}} 

184""" 

185 

186_TEMPLATES = {"bash": _BASH, "zsh": _ZSH, "fish": _FISH, "pwsh": _PWSH, "nushell": _NU} 

187 

188 

189def script_for(shell: str, prog: str) -> str: 

190 """The completion script for *shell*, branded for *prog*.""" 

191 fn = prog.replace("-", "_") 

192 return _TEMPLATES[shell].format(prog=prog, fn=fn) 

193 

194 

195def _data_home() -> Path: 

196 return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") 

197 

198 

199def _config_home() -> Path: 

200 return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") 

201 

202 

203def _sniff_encoding(raw: bytes) -> tuple[str, str]: 

204 """(decode, append) encodings for rc bytes, sniffed from any leading BOM. 

205 

206 The append encoder is BOM-free (`utf-8`, `utf-16-le`/`-be`) so we never 

207 inject a second BOM mid-file. With no BOM, prefer UTF-8 and fall back to 

208 latin-1, which round-trips arbitrary bytes rather than raising. 

209 """ 

210 if raw.startswith(b"\xef\xbb\xbf"): 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 return "utf-8-sig", "utf-8" 

212 if raw.startswith(b"\xff\xfe"): 

213 return "utf-16", "utf-16-le" 

214 if raw.startswith(b"\xfe\xff"): 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true

215 return "utf-16", "utf-16-be" 

216 try: 

217 raw.decode("utf-8") 

218 except UnicodeDecodeError: 

219 return "latin-1", "latin-1" 

220 return "utf-8", "utf-8" 

221 

222 

223def _append_once(rc: Path, line: str) -> bool: 

224 """Append *line* to *rc* unless present; True if the file changed. 

225 

226 rc files carry whatever encoding the shell wrote — a Windows PowerShell 5 

227 `$PROFILE` is UTF-16 with a BOM. Read the bytes, sniff the BOM, and append 

228 in the matching (BOM-free) encoding, so a UTF-16 profile is neither crashed 

229 on read nor corrupted by a UTF-8 tail. A residual write failure becomes an 

230 `InstallError` naming the exact line to add, routed to the existing exit-2 

231 path instead of a raw traceback. 

232 """ 

233 try: 

234 raw = rc.read_bytes() if rc.exists() else b"" 

235 except OSError as exc: 

236 raise InstallError(_manual_hint(rc, line, exc)) from exc 

237 

238 read_enc, append_enc = _sniff_encoding(raw) 

239 existing = raw.decode(read_enc, errors="replace") 

240 if line in existing: 

241 return False 

242 

243 joiner = "" if existing.endswith("\n") or not existing else "\n" 

244 payload = f"{joiner}{line}\n".encode(append_enc) 

245 try: 

246 rc.parent.mkdir(parents=True, exist_ok=True) 

247 with rc.open("ab") as fh: # binary: no TextIOWrapper BOM injection 

248 fh.write(payload) 

249 except OSError as exc: 

250 raise InstallError(_manual_hint(rc, line, exc)) from exc 

251 return True 

252 

253 

254def _manual_hint(rc: Path, line: str, exc: Exception) -> str: 

255 return f"could not update {rc} ({exc}) — add this line yourself: {line}" 

256 

257 

258def _remove_once(rc: Path, line: str) -> bool: 

259 """Remove the exact *line* from *rc*; True if the file changed. 

260 

261 The mirror of `_append_once`, encoding-faithful the same way: the file's 

262 own BOM (if any) is kept, the body re-encoded in the matching BOM-free 

263 encoding — so uninstalling from a UTF-16 PowerShell profile leaves it 

264 UTF-16 with exactly one BOM, and a latin-1 rc round-trips untouched. 

265 """ 

266 try: 

267 raw = rc.read_bytes() if rc.exists() else b"" 

268 except OSError as exc: 

269 raise InstallError( 

270 f"could not update {rc} ({exc}) — remove this line yourself: {line}" 

271 ) from exc 

272 if not raw: 

273 return False 

274 read_enc, write_enc = _sniff_encoding(raw) 

275 existing = raw.decode(read_enc, errors="replace") 

276 if line not in existing: 

277 return False 

278 boms = (b"\xef\xbb\xbf", b"\xff\xfe", b"\xfe\xff") 

279 bom = next((b for b in boms if raw.startswith(b)), b"") 

280 kept = [ln for ln in existing.splitlines() if ln.strip() != line.strip()] 

281 body = "\n".join(kept) + ("\n" if kept else "") 

282 try: 

283 rc.write_bytes(bom + body.encode(write_enc)) 

284 except OSError as exc: 

285 raise InstallError( 

286 f"could not update {rc} ({exc}) — remove this line yourself: {line}" 

287 ) from exc 

288 return True 

289 

290 

291def _zsh_rc() -> Path: 

292 """The `.zshrc` zsh actually reads — under `$ZDOTDIR` when set (D11).""" 

293 zdotdir = os.environ.get("ZDOTDIR") 

294 base = Path(zdotdir) if zdotdir else Path.home() 

295 return base / ".zshrc" 

296 

297 

298def _bash_rcs() -> list[Path]: 

299 """The rc files bash actually reads for completion to activate (D11). 

300 

301 `.bashrc` covers interactive non-login shells. On macOS, Terminal opens 

302 *login* shells, which read a login profile (`.bash_profile`/`.bash_login`/ 

303 `.profile`) and not `.bashrc` — so append there too, to the first existing 

304 one, creating `.bash_profile` only when none exists (never shadowing an 

305 existing `.profile`). 

306 """ 

307 home = Path.home() 

308 rcs = [home / ".bashrc"] 

309 if sys.platform == "darwin": 

310 logins = [home / ".bash_profile", home / ".bash_login", home / ".profile"] 

311 existing = next((p for p in logins if p.exists()), None) 

312 rcs.append(existing or home / ".bash_profile") 

313 return rcs 

314 

315 

316_PROC_NAMES = { 

317 "bash": "bash", 

318 "zsh": "zsh", 

319 "fish": "fish", 

320 "nu": "nushell", 

321 "pwsh": "pwsh", 

322 "powershell": "pwsh", 

323} 

324 

325 

326def detect_shell() -> str | None: 

327 """Best-effort, zero-dependency detection of the invoking shell. 

328 

329 Walks the parent-process tree (the immediate parent is often `uv`, not 

330 the shell) looking for a known shell name — what shellingham does for 

331 typer, without the dependency. Windows has no `ps`, so the PowerShell 

332 tell there is the `PSModulePath` variable it exports to children. Falls 

333 back to the login shell in `$SHELL`; returns `None` when nothing is 

334 confidently recognised. 

335 """ 

336 if os.name == "nt": 

337 # git-bash (and MSYS2) export MSYSTEM — checked first, because 

338 # PowerShell's PSModulePath is machine-level environment and is set 

339 # inside git-bash too. Without this a git-bash user is told "pwsh", 

340 # which is a small lie that installs the wrong hook. 

341 if os.environ.get("MSYSTEM"): 

342 return "bash" 

343 # PSModulePath still over-claims from cmd.exe — acceptable, because 

344 # cmd has no completion mechanism and a PowerShell install is the 

345 # most useful answer on that box. (Real spelling kept; Windows env 

346 # lookup is case-insensitive anyway.) 

347 return "pwsh" if os.environ.get("PSModulePath") else None # noqa: SIM112 

348 return _detect_posix() 

349 

350 

351def _detect_posix() -> str | None: 

352 pid = os.getppid() 

353 for _ in range(10): 353 ↛ 377line 353 didn't jump to line 377 because the loop on line 353 didn't complete

354 if pid <= 1: 

355 break 

356 try: 

357 out = subprocess.run( 

358 ["ps", "-p", str(pid), "-o", "ppid=,comm="], 

359 capture_output=True, 

360 text=True, 

361 timeout=5, 

362 ) 

363 except (OSError, subprocess.TimeoutExpired): 

364 break 

365 line = out.stdout.strip() 

366 if out.returncode != 0 or not line: 

367 break 

368 ppid, _, comm = line.partition(" ") 

369 # A login shell reports as "-zsh"; comm may be a full path. 

370 name = Path(comm.strip()).name.lstrip("-") 

371 if name in _PROC_NAMES: 

372 return _PROC_NAMES[name] 

373 try: 

374 pid = int(ppid) 

375 except ValueError: 

376 break 

377 login = Path(os.environ.get("SHELL", "")).name 

378 return _PROC_NAMES.get(login) 

379 

380 

381def _ask_shell(candidates: tuple[str, ...], args: list[str]) -> str | None: 

382 """Run the first available *candidates* binary and return its stdout.""" 

383 for exe in candidates: 

384 binary = shutil.which(exe) 

385 if binary is None: 385 ↛ 387line 385 didn't jump to line 387 because the condition on line 385 was always true

386 continue 

387 try: 

388 out = subprocess.run( 

389 [binary, *args], capture_output=True, text=True, timeout=30 

390 ) 

391 except (OSError, subprocess.TimeoutExpired): 

392 continue 

393 if out.returncode == 0 and out.stdout.strip(): 

394 return out.stdout.strip() 

395 return None 

396 

397 

398def _pwsh_profiles() -> list[Path]: 

399 """Every distinct `$PROFILE` among the PowerShells on PATH. 

400 

401 PowerShell 7 (`pwsh`) and Windows PowerShell (`powershell`) keep 

402 *different* profile files; on a machine with both, completion should work 

403 in whichever one the user opens — so the installer targets them all. 

404 """ 

405 profiles: list[Path] = [] 

406 for exe in ("pwsh", "powershell"): 

407 path = _ask_shell((exe,), ["-NoProfile", "-Command", "$PROFILE"]) 

408 if path is not None and Path(path) not in profiles: 

409 profiles.append(Path(path)) 

410 if not profiles: 

411 raise InstallError( 

412 "pwsh (or powershell) not found on PATH — install PowerShell first" 

413 ) 

414 return profiles 

415 

416 

417def _nu_config_path() -> Path: 

418 """The config file nushell itself reports (`$nu.config-path`).""" 

419 path = _ask_shell(("nu",), ["-c", "$nu.config-path"]) 

420 if path is None: 420 ↛ 422line 420 didn't jump to line 422 because the condition on line 420 was always true

421 raise InstallError("nu not found on PATH — install nushell first") 

422 return Path(path) 

423 

424 

425def _bash_path(path: Path | str, *, windows: bool | None = None) -> str: 

426 """*path* as the bash on this platform can read it. 

427 

428 A Windows path is unusable inside a bash rc: backslashes are escapes, 

429 so `source C:\\Users\\me\\...` silently sources nothing. git-bash 

430 understands the MSYS spelling — `/c/Users/me/...` — which is what this 

431 produces; elsewhere the path is already right. 

432 

433 *windows* is explicit so the translation can be tested from any 

434 platform: string surgery only, because building a Windows `Path` on a 

435 POSIX box is impossible and monkeypatching `os.name` to pretend 

436 otherwise breaks pathlib for everything else in the process. 

437 """ 

438 if windows is None: 

439 windows = os.name == "nt" 

440 text = str(path) 

441 if not windows: 

442 return text 

443 text = text.replace("\\", "/") 

444 drive, colon, rest = text.partition(":") 

445 if colon and len(drive) == 1: 445 ↛ 447line 445 didn't jump to line 447 because the condition on line 445 was always true

446 return f"/{drive.lower()}{rest}" 

447 return text 

448 

449 

450def install(shell: str, prog: str) -> list[str]: 

451 """Install completion for *shell*; return the lines to tell the user. 

452 

453 Raises `KeyError` for an unsupported shell and `InstallError` when the 

454 shell itself is missing — the caller owns the taught error (it knows the 

455 brand). 

456 """ 

457 script = script_for(shell, prog) 

458 

459 if shell == "fish": 

460 target = _config_home() / "fish" / "completions" / f"{prog}.fish" 

461 target.parent.mkdir(parents=True, exist_ok=True) 

462 target.write_text(script, encoding="utf-8") 

463 return [ 

464 f"installed {target}", 

465 "fish picks it up automatically — start a new shell and TAB away.", 

466 ] 

467 

468 suffix = {"pwsh": "ps1", "nushell": "nu"}.get(shell, shell) 

469 target = _data_home() / prog / f"completion.{suffix}" 

470 target.parent.mkdir(parents=True, exist_ok=True) 

471 target.write_text(script, encoding="utf-8") 

472 if shell == "pwsh": 

473 # The hook runs on PowerShell 5+ unchanged, so it goes into every 

474 # PowerShell profile present — both shells complete, whoever answers. 

475 hooks = [(rc, f'. "{target}"') for rc in _pwsh_profiles()] 

476 elif shell == "nushell": 

477 hooks = [(_nu_config_path(), f'source "{target}"')] 

478 elif shell == "zsh": 

479 hooks = [(_zsh_rc(), f"source {_bash_path(target)}")] 

480 else: # bash — git-bash included, hence the MSYS-spelled path 

481 hooks = [(rc, f"source {_bash_path(target)}") for rc in _bash_rcs()] 

482 lines = [f"installed {target}"] 

483 for rc, line in hooks: 

484 if _append_once(rc, line): 

485 lines.append(f"added `{line}` to {rc}") 

486 else: 

487 lines.append(f"{rc} already sources it") 

488 lines.append("restart your shell (or source the rc file) and TAB away.") 

489 return lines 

490 

491 

492def uninstall(shell: str, prog: str) -> list[str]: 

493 """Remove *shell*'s completion for *prog*: the script file and the rc line. 

494 

495 The mirror of `install()`, idempotent the same way — uninstalling what was 

496 never installed reports what it looked for rather than failing. When the 

497 shell itself is gone (its rc/profile can't be located any more), the 

498 script file is still removed and the leftover line is named, so finishing 

499 by hand is one paste. 

500 """ 

501 if shell == "fish": 

502 target = _config_home() / "fish" / "completions" / f"{prog}.fish" 

503 if target.exists(): 

504 target.unlink() 

505 return [f"removed {target}"] 

506 return [f"nothing to remove ({target} was not installed)"] 

507 

508 suffix = {"pwsh": "ps1", "nushell": "nu"}.get(shell, shell) 

509 target = _data_home() / prog / f"completion.{suffix}" 

510 # bash/zsh must match install()'s spelling exactly, or uninstall would 

511 # remove the script and leave its rc line behind, sourcing nothing. 

512 hook = { 

513 "pwsh": f'. "{target}"', 

514 "nushell": f'source "{target}"', 

515 }.get(shell, f"source {_bash_path(target)}") 

516 

517 lines: list[str] = [] 

518 if target.exists(): 

519 target.unlink() 

520 with contextlib.suppress(OSError): # only when nothing else lives there 

521 target.parent.rmdir() 

522 lines.append(f"removed {target}") 

523 

524 try: 

525 if shell == "pwsh": 

526 rcs = _pwsh_profiles() 

527 elif shell == "nushell": 527 ↛ 528line 527 didn't jump to line 528 because the condition on line 527 was never true

528 rcs = [_nu_config_path()] 

529 elif shell == "zsh": 

530 rcs = [_zsh_rc()] 

531 else: # bash 

532 rcs = _bash_rcs() 

533 except InstallError as exc: 

534 lines.append( 

535 f"could not locate the rc file ({exc}) — " 

536 f"if it still sources the hook, remove this line yourself: {hook}" 

537 ) 

538 return lines 

539 

540 for rc in rcs: 

541 if _remove_once(rc, hook): 

542 lines.append(f"removed `{hook}` from {rc}") 

543 if not lines: 

544 lines.append(f"nothing to remove ({target} was not installed)") 

545 return lines