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
« 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`.
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.
8Install layout (all idempotent — running twice changes nothing):
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"""
25from __future__ import annotations
27import contextlib
28import os
29import shutil
30import subprocess
31import sys
32from pathlib import Path
34from footman import _encoding, _paths
36SHELLS = ("bash", "zsh", "fish", "pwsh", "nushell")
39class InstallError(Exception):
40 """Completion could not be installed; the message says why."""
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"""
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"""
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"""
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"""
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"""
424_TEMPLATES = {"bash": _BASH, "zsh": _ZSH, "fish": _FISH, "pwsh": _PWSH, "nushell": _NU}
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)
433def _sniff_encoding(raw: bytes) -> tuple[str, str]:
434 """(decode, append) encodings for rc bytes, sniffed from any leading BOM.
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"
453def _append_once(rc: Path, line: str) -> bool:
454 """Append *line* to *rc* unless present; True if the file changed.
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
468 read_enc, append_enc = _sniff_encoding(raw)
469 existing = raw.decode(read_enc, errors="replace")
470 if line in existing:
471 return False
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
484def _manual_hint(rc: Path, line: str, exc: Exception) -> str:
485 return f"could not update {rc} ({exc}) — add this line yourself: {line}"
488def _remove_once(rc: Path, line: str) -> bool:
489 """Remove the exact *line* from *rc*; True if the file changed.
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
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"
528def _bash_rcs() -> list[Path]:
529 """The rc files bash actually reads for completion to activate (D11).
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
546_PROC_NAMES = {
547 "bash": "bash",
548 "zsh": "zsh",
549 "fish": "fish",
550 "nu": "nushell",
551 "pwsh": "pwsh",
552 "powershell": "pwsh",
553}
556def detect_shell() -> str | None:
557 """Best-effort, zero-dependency detection of the invoking shell.
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()
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)
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
628def _pwsh_profiles() -> list[Path]:
629 """Every distinct `$PROFILE` among the PowerShells on PATH.
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
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)
655def _bash_path(path: Path | str, *, windows: bool | None = None) -> str:
656 """*path* as the bash on this platform can read it.
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.
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
680def install(shell: str, prog: str) -> list[str]:
681 """Install completion for *shell*; return the lines to tell the user.
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)
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 ]
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
721def hook_path(shell: str, prog: str) -> Path:
722 """Where *shell* keeps *prog*'s completion script.
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>`.
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}"
741def uninstall(shell: str, prog: str) -> list[str]:
742 """Remove *shell*'s completion for *prog*: the script file and the rc line.
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)"]
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)}")
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}")
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
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