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

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

2 

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. 

7 

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

13 

14from __future__ import annotations 

15 

16import os 

17import shutil 

18import subprocess 

19from dataclasses import dataclass 

20from pathlib import Path 

21from typing import Literal 

22 

23 

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

25 

26 

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 

34 

35 

36def 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 

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

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

71def 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 

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

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 

139 

140 

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 

152 

153 

154def 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 )