Coverage for plugin/scripts/install_completions.py: 100%
87 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Install shell tab-completion for make targets — the engine behind `/rhiza:completions`.
4Copies the bundled completion scripts (``scripts/completions/``) into the user's
5completion directory, so ``make <TAB>`` lists the targets of whatever project the shell
6is sitting in.
8**This is a per-machine concern that used to be driven by repo content.** The rhiza
9template shipped these two scripts into every managed repo and gave each one a
10``make install-completions`` target that copied them to
11``${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion/completions/make`` — a *global*
12path for the ``make`` command in general. So N synced repos each carried an identical
13copy of a script that installs to one shared location, and the last copy to run won.
14Nothing in the scripts varies per repository: they discover targets by parsing the make
15database in the current directory. The plugin is installed once per machine, which is
16where a machine-wide file belongs.
18Two things this does that ``cp`` did not:
20* **It refuses to clobber a completion it did not write.** The installed filenames stay
21 ``make`` and ``_make`` — namespacing them would be politer and would never fire for a
22 bare ``make <TAB>``, which is the entire point — so the destination is exactly where a
23 generic make completion from some other source would sit. A destination whose contents
24 don't carry the ``_rhiza_make`` marker needs ``--force``, reported as exit code 3.
25* **It can say what it would do.** ``--dry-run`` prints the destinations and the verdict
26 for each shell without writing anything.
28Usage:
29 uv run --python 3.12 --no-project python \
30 scripts/install_completions.py [--shell bash|zsh|both] [--dry-run] [--force] \
31 [--json]
33``--shell both`` is the default, for the same reason the retired make target defaulted
34its ``SHELL_KIND`` to ``both``: nothing here can tell which shell the user actually logs
35in with, and an unused completion file is inert. Exit code 3 means a destination holds a
36foreign completion and ``--force`` was not given.
37"""
39from __future__ import annotations
41import argparse
42import json
43import os
44import sys
45from collections.abc import Mapping
46from pathlib import Path
47from typing import Any, NamedTuple
49# The bundled completion scripts. A sibling directory rather than a location of their
50# own, for the same reason every script lives in `scripts/`: the tree is the unit that
51# gates and ships, not the file.
52_ASSETS = Path(__file__).resolve().parent / "completions"
54MARKER = "_rhiza_make"
55"""The function-name prefix both scripts define — how a destination is recognised as ours."""
57NEEDS_FORCE = 3
58"""Exit code for "a destination holds a completion this plugin did not write"."""
60BOTH = "both"
61"""The ``--shell`` value that selects every supported shell, and the default."""
64class Shell(NamedTuple):
65 """One shell: its bundled asset, where the asset installs, and the follow-up step.
67 ``installed_as`` is deliberately the *generic* name — ``make`` for bash, ``_make``
68 for zsh — because both shells resolve a completion by the name of the command it
69 completes. There is no spelling that both avoids shadowing another make completion
70 and still fires for a bare ``make <TAB>``.
71 """
73 kind: str
74 asset: str
75 subdir: str
76 installed_as: str
77 hint: str
80SHELLS: tuple[Shell, ...] = (
81 Shell(
82 kind="bash",
83 asset="rhiza-completion.bash",
84 subdir="bash-completion/completions",
85 installed_as="make",
86 hint="start a new shell, or run: source {path}",
87 ),
88 Shell(
89 kind="zsh",
90 asset="rhiza-completion.zsh",
91 subdir="zsh/site-functions",
92 installed_as="_make",
93 hint=(
94 "if completion does not activate, add to ~/.zshrc: "
95 "fpath=({parent} $fpath); autoload -U compinit && compinit"
96 ),
97 ),
98)
100# What happened, and what would have happened. Two maps rather than one verb plus a
101# prefix: "would installed" is not English, and the dry-run wording is the half a
102# reader is most likely to mistake for a change that landed.
103_DONE = {
104 "install": "installed",
105 "update": "updated",
106 "replace": "replaced",
107 "unchanged": "already up to date",
108}
109_PLANNED = {
110 "install": "would install",
111 "update": "would update",
112 "replace": "would replace",
113 "unchanged": "already up to date",
114}
117def shells(kind: str) -> list[Shell]:
118 """The shells ``--shell KIND`` selects — one of them, or all of them.
120 >>> [shell.kind for shell in shells("zsh")]
121 ['zsh']
122 >>> [shell.kind for shell in shells(BOTH)]
123 ['bash', 'zsh']
124 """
125 return [shell for shell in SHELLS if kind in (shell.kind, BOTH)]
128def data_home(env: Mapping[str, str] | None = None) -> Path:
129 """The XDG data home, with the same fallback the shell idiom uses.
131 ``${XDG_DATA_HOME:-$HOME/.local/share}`` treats an *empty* value as unset, so an
132 exported-but-blank variable falls back rather than resolving to the current
133 directory:
135 >>> data_home({"XDG_DATA_HOME": "/opt/data"}).as_posix()
136 '/opt/data'
137 >>> data_home({"XDG_DATA_HOME": "", "HOME": "/home/ada"}).as_posix()
138 '/home/ada/.local/share'
139 """
140 environ = os.environ if env is None else env
141 explicit = environ.get("XDG_DATA_HOME", "")
142 if explicit:
143 return Path(explicit)
144 return Path(environ.get("HOME") or "~").expanduser() / ".local" / "share"
147def destination(shell: Shell, env: Mapping[str, str] | None = None) -> Path:
148 """Where *shell*'s completion is installed."""
149 return data_home(env) / shell.subdir / shell.installed_as
152def asset(shell: Shell) -> Path:
153 """The bundled completion script for *shell*."""
154 return _ASSETS / shell.asset
157def is_ours(text: str) -> bool:
158 """Was *text* written from one of the bundled scripts?
160 Both define functions prefixed ``_rhiza_make``, which a generic make completion
161 from another source will not:
163 >>> is_ours("_rhiza_make_completion() { :; }")
164 True
165 >>> is_ours("# some other make completion\\ncomplete -W 'all' make")
166 False
167 """
168 return MARKER in text
171def classify(dest: Path, body: str, *, force: bool) -> str:
172 """What installing *body* at *dest* would amount to.
174 One of ``install`` (nothing there yet), ``unchanged`` (byte-identical),
175 ``update`` (an older copy of ours), ``replace`` (a foreign file, forced) or
176 ``blocked`` (a foreign file, not forced).
177 """
178 if dest.is_dir(): # not ours to replace, whatever --force says
179 return "blocked"
180 if not dest.exists():
181 return "install"
182 # `errors="replace"` rather than a guard: a destination that isn't valid UTF-8 is
183 # certainly not one of ours, and it should reach the `blocked` verdict below by the
184 # normal route rather than as a traceback.
185 current = dest.read_text(encoding="utf-8", errors="replace")
186 if current == body:
187 return "unchanged"
188 if is_ours(current):
189 return "update"
190 return "replace" if force else "blocked"
193def install_one(
194 shell: Shell, *, env: Mapping[str, str] | None, force: bool, dry_run: bool
195) -> dict[str, Any]:
196 """Install *shell*'s completion, or work out what doing so would mean."""
197 dest = destination(shell, env)
198 body = asset(shell).read_text(encoding="utf-8")
199 action = classify(dest, body, force=force)
200 written = action in ("install", "update", "replace") and not dry_run
201 if written:
202 dest.parent.mkdir(parents=True, exist_ok=True)
203 dest.write_text(body, encoding="utf-8")
204 return {
205 "shell": shell.kind,
206 "source": str(asset(shell)),
207 "path": str(dest),
208 "action": action,
209 "written": written,
210 "hint": shell.hint.format(path=dest, parent=dest.parent),
211 }
214def install(
215 kind: str = BOTH,
216 *,
217 env: Mapping[str, str] | None = None,
218 force: bool = False,
219 dry_run: bool = False,
220) -> dict[str, Any]:
221 """Install the completion for *kind* (``bash``, ``zsh`` or ``both``); return a summary."""
222 entries = [install_one(shell, env=env, force=force, dry_run=dry_run) for shell in shells(kind)]
223 return {
224 "dry_run": dry_run,
225 "force": force,
226 "shells": entries,
227 "needs_force": any(entry["action"] == "blocked" for entry in entries),
228 }
231def report(summary: dict[str, Any]) -> None:
232 """Print the human summary — one line per shell, plus its follow-up step."""
233 for entry in summary["shells"]:
234 if entry["action"] == "blocked":
235 print(
236 f"{entry['shell']}: {entry['path']} holds a completion this plugin did "
237 "not write — pass --force to replace it",
238 file=sys.stderr,
239 )
240 continue
241 verbs = _DONE if entry["written"] or entry["action"] == "unchanged" else _PLANNED
242 print(f"{entry['shell']}: {verbs[entry['action']]} {entry['path']}")
243 if not summary["dry_run"]:
244 print(f" next: {entry['hint']}")
247def missing_assets(kind: str) -> list[str]:
248 """Bundled completion scripts *kind* needs that aren't shipped — a packaging fault."""
249 return [shell.asset for shell in shells(kind) if not asset(shell).is_file()]
252def main(argv: list[str] | None = None) -> int:
253 """Entry point: install the selected completions and return an exit code."""
254 parser = argparse.ArgumentParser(description="Install make tab-completion for your shell.")
255 parser.add_argument(
256 "--shell",
257 choices=[*(shell.kind for shell in SHELLS), BOTH],
258 default=BOTH,
259 help="Which shell to install for (default: both — the login shell is not detectable).",
260 )
261 parser.add_argument(
262 "--force", action="store_true", help="Replace a completion this plugin did not write."
263 )
264 parser.add_argument(
265 "--dry-run",
266 dest="dry_run",
267 action="store_true",
268 help="Report the destinations and the verdict for each shell; write nothing.",
269 )
270 parser.add_argument(
271 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
272 )
273 args = parser.parse_args(argv)
275 absent = missing_assets(args.shell)
276 if absent:
277 parser.error(f"bundled completion script(s) missing from {_ASSETS}: {', '.join(absent)}")
279 summary = install(args.shell, force=args.force, dry_run=args.dry_run)
280 if args.json_output:
281 print(json.dumps(summary, indent=2))
282 else:
283 report(summary)
284 return NEEDS_FORCE if summary["needs_force"] else 0
287if __name__ == "__main__":
288 raise SystemExit(main())