Coverage for src/cc_liquid/completion.py: 0%
85 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-10-13 20:16 +0000
1"""Utilities to install Click shell completion for the ``cc-liquid`` CLI.
3This module generates shell completion scripts for Bash, Zsh, and Fish by
4invoking the current executable in Click's completion mode and writes them to
5standard user locations. For Bash/Zsh it also appends a ``source`` line to the
6user's shell rc file idempotently.
8Design notes:
9- We keep this module UI-free and return structured messages so the CLI layer
10 can print as desired. All file operations are idempotent to avoid duplicate
11 rc entries.
12"""
14from __future__ import annotations
16import os
17import shutil
18import subprocess
19from dataclasses import dataclass
20from pathlib import Path
21from typing import Literal
24ShellName = Literal["bash", "zsh", "fish"]
27@dataclass
28class InstallResult:
29 shell: ShellName
30 script_path: Path
31 rc_path: Path | None
32 rc_line_added: bool
33 script_written: bool
36def detect_shell_from_env(env: dict[str, str] | None = None) -> ShellName | None:
37 """Detect the user's shell from the SHELL environment variable.
39 Returns the lowercase shell name (``bash``, ``zsh``, or ``fish``) or ``None``
40 when detection fails.
41 """
42 e = env or os.environ
43 shell_path = e.get("SHELL", "").strip()
44 if not shell_path:
45 return None
46 name = Path(shell_path).name.lower()
47 if name in {"bash", "zsh", "fish"}:
48 return name
49 return None
52def _compute_env_var_name_for_prog(prog_name: str) -> str:
53 """Compute the Click completion env var name for a given program name.
55 Example: ``cc-liquid`` -> ``_CC_LIQUID_COMPLETE``.
56 """
57 safe = prog_name.replace("-", "_").upper()
58 return f"_{safe}_COMPLETE"
61def _resolve_executable_to_invoke(prog_name: str) -> str:
62 """Resolve which executable name to invoke to generate completion.
64 Prefers an on-PATH executable matching ``prog_name``; otherwise returns
65 ``prog_name`` so the caller still attempts to run it.
66 """
67 resolved = shutil.which(prog_name)
68 return resolved or prog_name
71def generate_completion_source(prog_name: str, shell: ShellName) -> str:
72 """Generate the completion script text for ``prog_name`` and ``shell``.
74 This invokes the current CLI with the appropriate environment variable set
75 to ``{shell}_source`` and captures stdout.
76 """
77 env_var = _compute_env_var_name_for_prog(prog_name)
78 env = os.environ.copy()
79 env[env_var] = f"{shell}_source"
81 exe = _resolve_executable_to_invoke(prog_name)
82 try:
83 result = subprocess.run(
84 [exe],
85 check=True,
86 stdout=subprocess.PIPE,
87 stderr=subprocess.PIPE,
88 text=True,
89 env=env,
90 )
91 except FileNotFoundError as exc:
92 raise RuntimeError(
93 f"Could not find executable '{prog_name}' on PATH. Is it installed?"
94 ) from exc
95 except subprocess.CalledProcessError as exc:
96 raise RuntimeError(
97 "Failed to generate completion source. "
98 f"Return code {exc.returncode}. Stderr: {exc.stderr.strip()}"
99 ) from exc
101 return result.stdout
104def _paths_for_shell(shell: ShellName) -> tuple[Path, Path | None, str | None]:
105 """Return (script_path, rc_path, rc_line) for the given shell.
107 - For Bash: script at ``~/.cc-liquid-complete.bash``, source line appended to ``~/.bashrc``.
108 - For Zsh: script at ``~/.cc-liquid-complete.zsh``, source line appended to ``~/.zshrc``.
109 - For Fish: script at ``~/.config/fish/completions/cc-liquid.fish``, no rc line.
110 """
111 home = Path.home()
112 if shell == "bash":
113 script = home / ".cc-liquid-complete.bash"
114 rc = home / ".bashrc"
115 rc_line = f". {script}"
116 return script, rc, rc_line
117 if shell == "zsh":
118 script = home / ".cc-liquid-complete.zsh"
119 rc = home / ".zshrc"
120 rc_line = f". {script}"
121 return script, rc, rc_line
122 # fish
123 script = home / ".config" / "fish" / "completions" / "cc-liquid.fish"
124 return script, None, None
127def _write_text_if_changed(path: Path, content: str) -> bool:
128 """Write ``content`` to ``path`` if it differs. Returns True if written."""
129 path.parent.mkdir(parents=True, exist_ok=True)
130 if path.exists():
131 try:
132 current = path.read_text(encoding="utf-8")
133 except Exception:
134 current = ""
135 if current == content:
136 return False
137 path.write_text(content, encoding="utf-8")
138 return True
141def _append_line_idempotent(path: Path, line: str) -> bool:
142 """Append ``line`` to ``path`` if not already present. Returns True if added."""
143 path.parent.mkdir(parents=True, exist_ok=True)
144 existing = path.read_text(encoding="utf-8") if path.exists() else ""
145 if line in existing:
146 return False
147 with path.open("a", encoding="utf-8") as f:
148 if existing and not existing.endswith("\n"):
149 f.write("\n")
150 f.write(line + "\n")
151 return True
154def install_completion(prog_name: str, shell: ShellName) -> InstallResult:
155 """Generate and install completion for ``prog_name`` and the given shell.
157 Returns an ``InstallResult`` describing what changed.
158 """
159 script_text = generate_completion_source(prog_name, shell)
160 script_path, rc_path, rc_line = _paths_for_shell(shell)
162 wrote_script = _write_text_if_changed(script_path, script_text)
164 added_rc = False
165 if rc_path is not None and rc_line is not None:
166 added_rc = _append_line_idempotent(rc_path, rc_line)
168 return InstallResult(
169 shell=shell,
170 script_path=script_path,
171 rc_path=rc_path,
172 rc_line_added=added_rc,
173 script_written=wrote_script,
174 )