Coverage for src/cc_liquid/completion.py: 0%

1"""Utilities to install Click shell completion for the ``cc-liquid`` CLI.

2

3 This module generates shell completion scripts for Bash, Zsh, and Fish by

4 invoking the current executable in Click's completion mode and writes them to

5 standard user locations. For Bash/Zsh it also appends a ``source`` line to the

6 user's shell rc file idempotently.

7

8 Design 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"""

13

14 from __future__ import annotations

15

16 import os

17 import shutil

18 import subprocess

19 from dataclasses import dataclass

20 from pathlib import Path

21 from typing import Literal

22

23

24 ShellName = Literal["bash", "zsh", "fish"]

25

26

27@dataclass

28 class InstallResult:

29 shell: ShellName

30 script_path: Path

31 rc_path: Path | None

32 rc_line_added: bool

33 script_written: bool

34

35

36 def detect_shell_from_env(env: dict[str, str] | None = None) -> ShellName | None:

37 """Detect the user's shell from the SHELL environment variable.

38

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

50

51

52 def _compute_env_var_name_for_prog(prog_name: str) -> str:

53 """Compute the Click completion env var name for a given program name.

54

55 Example: ``cc-liquid`` -> ``_CC_LIQUID_COMPLETE``.

56 """

57 safe = prog_name.replace("-", "_").upper()

58 return f"_{safe}_COMPLETE"

59

60

61 def _resolve_executable_to_invoke(prog_name: str) -> str:

62 """Resolve which executable name to invoke to generate completion.

63

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

69

70

71 def generate_completion_source(prog_name: str, shell: ShellName) -> str:

72 """Generate the completion script text for ``prog_name`` and ``shell``.

73

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"

80

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

100

101 return result.stdout

102

103

104 def _paths_for_shell(shell: ShellName) -> tuple[Path, Path | None, str | None]:

105 """Return (script_path, rc_path, rc_line) for the given shell.

106

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

125

126

127 def _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

139

140

141 def _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

152

153

154 def install_completion(prog_name: str, shell: ShellName) -> InstallResult:

155 """Generate and install completion for ``prog_name`` and the given shell.

156

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)

161

162 wrote_script = _write_text_if_changed(script_path, script_text)

163

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)

167

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 )