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

207 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-09-06 02:59 +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 

34from footman import _encoding, _paths 

35 

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

37 

38 

39class InstallError(Exception): 

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

41 

42 

43_BASH = """\ 

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

45_{fn}_complete() {{ 

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

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

48 local -a words=() 

49 local i 

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

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

52 done 

53 COMPREPLY=() 

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

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

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

57 local out ret 

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

59 ret=$? 

60 # Exit 100 = a path value. With no stdout that is "every file": leave 

61 # COMPREPLY empty and let `-o default` (below) hand off to readline's 

62 # filename completion — works on bash 3.2. One line of stdout is a 

63 # `matching()` glob, and readline cannot be told about one, so build the 

64 # list here instead. Directories come along whatever the pattern says, or 

65 # a match one level down would be unreachable. 

66 if (( ret == 100 )); then 

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

68 # A `matching()` glob, and readline cannot be told about one — so 

69 # build the list here. The head through the last `=` stays put and 

70 # rides every candidate, exactly as the comma branch below does: 

71 # whether bash split the word there depends on COMP_WORDBREAKS, and 

72 # this reading is right either way. Directories come along whatever 

73 # the pattern says, or a match one level down is unreachable. 

74 local cur=${{COMP_WORDS[COMP_CWORD]}} glob=${{out%%$'\\n'*}} 

75 local head="" tail=$cur f 

76 [[ $cur == *=* ]] && {{ head="${{cur%=*}}="; tail="${{cur##*=}}"; }} 

77 compopt +o default 2>/dev/null 

78 while IFS= read -r f; do 

79 [[ -n $f ]] || continue 

80 [[ -d $f ]] && f+=/ 

81 printf -v f '%q' "$f" 

82 COMPREPLY+=("$head$f") 

83 done < <(compgen -d -- "$tail"; compgen -f -X "!$glob" -- "$tail") 

84 # A lone directory keeps the cursor in place for its children. 

85 (( ${{#COMPREPLY[@]}} == 1 )) && [[ ${{COMPREPLY[0]}} == */ ]] && \\ 

86 compopt -o nospace 2>/dev/null 

87 return 0 

88 fi 

89 if (( ret == 101 )); then 

90 # Exit 101 = a comma-splitting path value mid-list (`--paths=a,b`): 

91 # readline would match the whole word, comma and all, so complete 

92 # the segment after the last comma here and re-attach the typed 

93 # items on every candidate. 

94 local cur=${{COMP_WORDS[COMP_CWORD]}} 

95 [[ $cur == *,* ]] || return 0 

96 compopt +o default 2>/dev/null 

97 local head="${{cur%,*}}," f 

98 while IFS= read -r f; do 

99 [[ -d $f ]] && f+=/ 

100 printf -v f '%q' "$f" 

101 COMPREPLY+=("$head$f") 

102 done < <(compgen -f -- "${{cur##*,}}") 

103 # A lone directory match keeps the cursor in place for its children. 

104 (( ${{#COMPREPLY[@]}} == 1 )) && [[ ${{COMPREPLY[0]}} == */ ]] && \\ 

105 compopt -o nospace 2>/dev/null 

106 return 0 

107 fi 

108 # Exit 103 = the tasks file failed to import; the reason is on stderr. 

109 # bash has no way to display a line without offering it for insertion, 

110 # so stay silent — but switch the filename fallback off first, or the 

111 # silence fills with files offered as if they were tasks. 

112 if (( ret == 103 )); then 

113 compopt +o default 2>/dev/null 

114 return 0 

115 fi 

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

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

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

119 compopt +o default 2>/dev/null 

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

121 # Exit 102 = the candidates continue a comma-splitting value: glue the 

122 # cursor (no trailing space), so the comma — or the closing space — is 

123 # the next keystroke instead of a deletion. bash 3.2 has no compopt and 

124 # keeps the space, exactly as before. 

125 (( ret == 102 )) && compopt -o nospace 2>/dev/null 

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

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

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

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

130 local line 

131 while IFS= read -r line; do 

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

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

134 COMPREPLY+=("$line") 

135 done <<< "$out" 

136}} 

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

138""" 

139 

140_ZSH = """\ 

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

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

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

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

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

146 autoload -Uz compinit && compinit -u 

147fi 

148_{fn}_complete() {{ 

149 local -a items sfx 

150 local raw line ret 

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

152 # space — unquoted expansion would drop it. 

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

154 ret=$? 

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

156 # honours the user's file colours and list settings. compset strips an 

157 # attached `--opt=` prefix first so the value part completes in place. 

158 # One line of stdout is a `matching()` glob — `_files -g` narrows to it 

159 # and still offers directories, so a match one level down stays reachable. 

160 (( ret == 100 )) && {{ 

161 compset -P '*=' 

162 if [[ -n $raw ]]; then _files -g "${{raw%%$'\\n'*}}"; else _files; fi 

163 return 

164 }} 

165 # Exit 101 = a comma-splitting path value mid-list: strip through the 

166 # last `=` or comma too, so each list item completes as its own path. 

167 (( ret == 101 )) && {{ compset -P '*[=,]'; _files; return; }} 

168 # Exit 103 = the tasks file failed to import (stdout stays empty so 

169 # older hooks show plain silence). One more call with `--why` fetches 

170 # the reason on stdout — the one channel every shell captures the same 

171 # way — and `_message` displays it without offering anything to insert. 

172 (( ret == 103 )) && {{ 

173 local why 

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

175 [[ -n $why ]] && _message -r "${{why%%$'\\n'*}}" 

176 return 

177 }} 

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

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

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

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

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

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

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

185 else 

186 items+=("$line") 

187 fi 

188 done 

189 # Exit 102 = the candidates continue a comma-splitting value: append the 

190 # comma as an auto-removable suffix (extra compadd options ride through 

191 # _describe) — accepting `eu` writes `eu,`, and a space or Enter takes 

192 # the comma back off. 

193 (( ret == 102 )) && sfx=(-q -S ',') 

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

195}} 

196compdef _{fn}_complete {prog} 

197""" 

198 

199_FISH = """\ 

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

201function __{fn}_complete 

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

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

204 # `test` overwrites $status, so read the resolver's exit code once. 

205 set -l ret $status 

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

207 # attached `--opt=path` token completes by stripping the prefix for the 

208 # path walk and re-attaching it on each candidate (fish replaces the 

209 # whole token). 

210 if test $ret -eq 100 

211 set -l tok (commandline -ct) 

212 set -l pre (string match -r -- '^-[^=]*=' $tok) 

213 set -l paths 

214 if test -n "$pre" 

215 set paths (__fish_complete_path (string replace -- $pre '' $tok)) 

216 else 

217 set paths (__fish_complete_path $tok) 

218 end 

219 # One line of stdout is a `matching()` glob. Keep directories 

220 # whatever it says — a match one level down has to stay reachable — 

221 # and otherwise keep only names the pattern matches. Each candidate 

222 # is `path\\tdescription`, so match on the path half alone. 

223 if set -q out[1] 

224 set -l keep 

225 for cand in $paths 

226 set -l p (string split -m 1 \\t -- $cand)[1] 

227 if string match -q -- '*/' $p 

228 set -a keep $cand 

229 else if string match -q -- $out[1] (basename $p) 

230 set -a keep $cand 

231 end 

232 end 

233 set paths $keep 

234 end 

235 printf '%s\\n' $paths | string replace -r -- '^' "$pre" 

236 else if test $ret -eq 101 

237 # Exit 101 = a comma-splitting path value mid-list: the head through 

238 # the last `=` or comma stays, the tail completes as its own path, 

239 # and each candidate re-attaches the head. 

240 set -l tok (commandline -ct) 

241 set -l pre (string match -r -- '^.*[=,]' $tok) 

242 if test -n "$pre" 

243 __fish_complete_path (string replace -- $pre '' $tok) | \\ 

244 string replace -r -- '^' $pre 

245 else 

246 __fish_complete_path $tok 

247 end 

248 else if test $ret -eq 103 

249 # A broken tasks file (stdout stays empty so older hooks show plain 

250 # silence). One more call with `--why` fetches the reason on stdout; 

251 # fish has no message row, so re-offer the typed word — a no-op 

252 # insert — carrying the reason as its description; a bare Tab stays 

253 # silent. 

254 set -l tok (commandline -ct) 

255 if test -n "$tok" 

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

257 if set -q why[1] 

258 printf '%s\\t%s\\n' $tok $why[1] 

259 end 

260 end 

261 else if test $ret -eq 102; and set -q out[1] 

262 # Exit 102 = the candidates continue a comma-splitting value: fish 

263 # inserts a candidate that ends in a comma with no trailing space, 

264 # so the comma rides each value half and the next element is one 

265 # Tab away. The description (after the tab) stays untouched. 

266 printf '%s\\n' $out | string replace -r -- '^([^\\t]*)' '$1,' 

267 else if set -q out[1] 

268 printf '%s\n' $out 

269 end 

270end 

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

272""" 

273 

274_PWSH = """\ 

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

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

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

278 $words = @($commandAst.CommandElements | 

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

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

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

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

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

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

285 if ($LASTEXITCODE -eq 100) {{ 

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

287 # head through the last `=` is stripped first and re-attached on each 

288 # candidate — `CompleteFilename('--env-file=')` looks for a file by 

289 # that literal name and finds nothing, so an attached `--opt=path` 

290 # completed to silence here until this was written. Same reading the 

291 # comma branch below already used. 

292 $m = [regex]::Match($wordToComplete, '^(.*=)?(.*)$') 

293 $head = $m.Groups[1].Value 

294 $files = [System.Management.Automation.CompletionCompleters]::CompleteFilename( 

295 $m.Groups[2].Value) 

296 # One line of stdout is a `matching()` glob. Containers come along 

297 # whatever it says, or a match one level down is unreachable. 

298 if ($out.Count -gt 0 -and $out[0]) {{ 

299 $glob = $out[0] 

300 $files = $files | Where-Object {{ 

301 $_.ResultType -eq 'ProviderContainer' -or 

302 (Split-Path -Leaf $_.CompletionText.Trim("'", '"')) -like $glob 

303 }} 

304 }} 

305 return $files | ForEach-Object {{ 

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

307 $head + $_.CompletionText, $head + $_.ListItemText, 

308 $_.ResultType, $_.ToolTip) 

309 }} 

310 }} 

311 if ($LASTEXITCODE -eq 101) {{ 

312 # A comma-splitting path value mid-list: the head through the last 

313 # `=` or comma stays; the tail completes as a filename and each 

314 # result re-attaches the head (completions replace the whole word). 

315 $m = [regex]::Match($wordToComplete, '^(.*[=,])?([^,]*)$') 

316 $head = $m.Groups[1].Value 

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

318 $m.Groups[2].Value) | ForEach-Object {{ 

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

320 $head + $_.CompletionText, $head + $_.ListItemText, 

321 $_.ResultType, $_.ToolTip) 

322 }} 

323 }} 

324 if ($LASTEXITCODE -eq 103) {{ 

325 # A broken tasks file (stdout stays empty so older hooks show plain 

326 # silence). One more call with `--why` fetches the reason on stdout 

327 # — the completion host swallows a native stderr merge outright. 

328 # Re-offer the typed word — a no-op insert — with the reason as its 

329 # tooltip; a bare Tab keeps PowerShell's default behaviour (an 

330 # empty word cannot be a result). 

331 if ($wordToComplete) {{ 

332 $why = (@(& {prog} --complete --why $empty -- @words 2>$null) ` 

333 -join ' ').Trim() 

334 if (-not $why) {{ $why = ' ' }} 

335 return ,[System.Management.Automation.CompletionResult]::new( 

336 $wordToComplete, $wordToComplete, 'ParameterValue', $why) 

337 }} 

338 return 

339 }} 

340 if ($out.Count -eq 0 -or -not $out[0]) {{ 

341 # No candidates. Yielding nothing hands the position to PowerShell's 

342 # default completion — filesystem entries offered as if they were 

343 # tasks — so re-offer the typed word itself: a visual no-op that 

344 # keeps the line exactly as it is. An empty word has nothing to 

345 # re-offer (CompletionResult refuses ''), so the bare-Tab shape 

346 # keeps PowerShell's default behaviour. 

347 if ($wordToComplete) {{ 

348 return ,[System.Management.Automation.CompletionResult]::new( 

349 $wordToComplete, $wordToComplete, 'ParameterValue', ' ') 

350 }} 

351 return 

352 }} 

353 $out | ForEach-Object {{ 

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

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

356 $val = $parts[0] 

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

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

359 $val, $val, 'ParameterValue', $tip) 

360 }} 

361}} 

362""" 

363 

364_NU = """\ 

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

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

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

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

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

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

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

372 if $r.exit_code == 100 or $r.exit_code == 101 {{ 

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

374 # (101 marks a comma-splitting value mid-list; a version-stable 

375 # comma-aware spelling isn't available, so nushell completes the 

376 # first list item only.) 

377 # 

378 # A `matching()` glob arrives on stdout and is deliberately NOT 

379 # applied here: filtering would mean returning a list of our own, 

380 # and that list replaces nushell's file completion outright — 

381 # losing directory descent, and with it any match below the 

382 # current directory. An unfiltered walk that reaches every file 

383 # beats a filtered one that reaches only this directory's. 

384 null 

385 }} else if $r.exit_code == 103 {{ 

386 # A broken tasks file (stdout stays empty so older hooks show 

387 # plain silence). One more call with `--why` fetches the reason 

388 # on stdout — the record's stderr is not reliably a string on 

389 # every host, and stdout capture is. Re-offer the typed word — 

390 # a no-op insert — with the reason in the description column; a 

391 # bare Tab stays silent. 

392 # `into binary | decode`: hosts disagree on the captured type 

393 # (string on some, binary on others) and each coercion alone 

394 # accepts only one of them; binary-then-decode accepts both. 

395 let tok = ($spans | last) 

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

397 | complete).stdout | into binary | decode utf-8 

398 | lines | get 0? | default "") 

399 if $tok != "" and $why != "" {{ 

400 [{{value: $tok, description: $why}}] 

401 }} else {{ 

402 null 

403 }} 

404 }} else {{ 

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

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

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

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

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

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

411 }} else {{ 

412 {{value: $p.0}} 

413 }} 

414 }} 

415 }} 

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

417 do $__{fn}_prev $spans 

418 }} else {{ 

419 null 

420 }} 

421}} 

422""" 

423 

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

425 

426 

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

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

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

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

431 

432 

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

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

435 

436 The marks come from `_encoding.BOM_ENCODINGS`, the one table the config 

437 reader also reads. The append encoder is BOM-free (`utf-8`, 

438 `utf-16-le`/`-be`) so we never inject a second BOM mid-file. With no BOM, 

439 prefer UTF-8 and fall back to latin-1, which round-trips arbitrary bytes 

440 rather than raising — an rc file is somebody's shell, not a format with a 

441 mandatory encoding, so it is read as it lies. 

442 """ 

443 sniffed = _encoding.sniff_bom(raw) 

444 if sniffed is not None: 

445 return sniffed 

446 try: 

447 raw.decode("utf-8") 

448 except UnicodeDecodeError: 

449 return "latin-1", "latin-1" 

450 return "utf-8", "utf-8" 

451 

452 

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

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

455 

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

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

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

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

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

461 path instead of a raw traceback. 

462 """ 

463 try: 

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

465 except OSError as exc: 

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

467 

468 read_enc, append_enc = _sniff_encoding(raw) 

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

470 if line in existing: 

471 return False 

472 

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

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

475 try: 

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

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

478 fh.write(payload) 

479 except OSError as exc: 

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

481 return True 

482 

483 

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

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

486 

487 

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

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

490 

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

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

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

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

495 """ 

496 try: 

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

498 except OSError as exc: 

499 raise InstallError( 

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

501 ) from exc 

502 if not raw: 

503 return False 

504 read_enc, write_enc = _sniff_encoding(raw) 

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

506 if line not in existing: 

507 return False 

508 boms = tuple(bom for bom, _, _ in _encoding.BOM_ENCODINGS) 

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

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

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

512 try: 

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

514 except OSError as exc: 

515 raise InstallError( 

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

517 ) from exc 

518 return True 

519 

520 

521def _zsh_rc() -> Path: 

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

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

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

525 return base / ".zshrc" 

526 

527 

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

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

530 

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

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

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

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

535 existing `.profile`). 

536 """ 

537 home = Path.home() 

538 rcs = [home / ".bashrc"] 

539 if sys.platform == "darwin": 

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

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

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

543 return rcs 

544 

545 

546_PROC_NAMES = { 

547 "bash": "bash", 

548 "zsh": "zsh", 

549 "fish": "fish", 

550 "nu": "nushell", 

551 "pwsh": "pwsh", 

552 "powershell": "pwsh", 

553} 

554 

555 

556def detect_shell() -> str | None: 

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

558 

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

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

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

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

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

564 confidently recognised. 

565 """ 

566 if os.name == "nt": 

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

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

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

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

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

572 return "bash" 

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

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

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

576 # lookup is case-insensitive anyway.) 

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

578 return _detect_posix() 

579 

580 

581def _detect_posix() -> str | None: 

582 pid = os.getppid() 

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

584 if pid <= 1: 

585 break 

586 try: 

587 out = subprocess.run( 

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

589 capture_output=True, 

590 text=True, 

591 timeout=5, 

592 ) 

593 except (OSError, subprocess.TimeoutExpired): 

594 break 

595 line = out.stdout.strip() 

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

597 break 

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

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

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

601 if name in _PROC_NAMES: 

602 return _PROC_NAMES[name] 

603 try: 

604 pid = int(ppid) 

605 except ValueError: 

606 break 

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

608 return _PROC_NAMES.get(login) 

609 

610 

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

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

613 for exe in candidates: 

614 binary = shutil.which(exe) 

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

616 continue 

617 try: 

618 out = subprocess.run( 

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

620 ) 

621 except (OSError, subprocess.TimeoutExpired): 

622 continue 

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

624 return out.stdout.strip() 

625 return None 

626 

627 

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

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

630 

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

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

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

634 """ 

635 profiles: list[Path] = [] 

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

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

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

639 profiles.append(Path(path)) 

640 if not profiles: 

641 raise InstallError( 

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

643 ) 

644 return profiles 

645 

646 

647def _nu_config_path() -> Path: 

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

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

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

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

652 return Path(path) 

653 

654 

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

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

657 

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

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

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

661 produces; elsewhere the path is already right. 

662 

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

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

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

666 otherwise breaks pathlib for everything else in the process. 

667 """ 

668 if windows is None: 

669 windows = os.name == "nt" 

670 text = str(path) 

671 if not windows: 

672 return text 

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

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

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

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

677 return text 

678 

679 

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

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

682 

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

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

685 brand). 

686 """ 

687 script = script_for(shell, prog) 

688 

689 if shell == "fish": 

690 target = _paths.config_home() / "fish" / "completions" / f"{prog}.fish" 

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

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

693 return [ 

694 f"installed {target}", 

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

696 ] 

697 

698 target = hook_path(shell, prog) 

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

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

701 if shell == "pwsh": 

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

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

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

705 elif shell == "nushell": 

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

707 elif shell == "zsh": 

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

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

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

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

712 for rc, line in hooks: 

713 if _append_once(rc, line): 

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

715 else: 

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

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

718 return lines 

719 

720 

721def hook_path(shell: str, prog: str) -> Path: 

722 """Where *shell* keeps *prog*'s completion script. 

723 

724 One spelling, because three copies of it is what the comment in 

725 `uninstall` used to warn about: install writing one path and uninstall 

726 computing another leaves the script removed and its rc line behind, 

727 sourcing nothing. fish owns its own conventional directory; everyone 

728 else gets `<data home>/<prog>/completion.<suffix>`. 

729 

730 Note this is keyed by the **command name**, not the config stem, so it 

731 sits *beside* `data_dir()` rather than inside it — `~/.local/share/fm/` 

732 next to `~/.local/share/footman/`. Anything clearing a runner's 

733 leavings has to know that. 

734 """ 

735 if shell == "fish": 

736 return _paths.config_home() / "fish" / "completions" / f"{prog}.fish" 

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

738 return _paths.data_home() / prog / f"completion.{suffix}" 

739 

740 

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

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

743 

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

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

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

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

748 by hand is one paste. 

749 """ 

750 if shell == "fish": 

751 target = hook_path(shell, prog) 

752 if target.exists(): 

753 target.unlink() 

754 return [f"removed {target}"] 

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

756 

757 target = hook_path(shell, prog) 

758 hook = { 

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

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

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

762 

763 lines: list[str] = [] 

764 if target.exists(): 

765 target.unlink() 

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

767 target.parent.rmdir() 

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

769 

770 try: 

771 if shell == "pwsh": 

772 rcs = _pwsh_profiles() 

773 elif shell == "nushell": 

774 rcs = [_nu_config_path()] 

775 elif shell == "zsh": 

776 rcs = [_zsh_rc()] 

777 else: # bash 

778 rcs = _bash_rcs() 

779 except InstallError as exc: 

780 lines.append( 

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

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

783 ) 

784 return lines 

785 

786 for rc in rcs: 

787 if _remove_once(rc, hook): 

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

789 if not lines: 

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

791 return lines