1from __future__ import annotations
2
3import json
4import subprocess
5import sys
6import tomllib
7from pathlib import Path
8from typing import Any
9
10import click
11
12from plain.cli import register_cli
13from plain.cli.print import print_event
14from plain.cli.runtime import common_command, without_runtime_setup
15
16from .annotations import AnnotationResult, check_annotations
17from .biome import Biome
18
19DEFAULT_RUFF_CONFIG = Path(__file__).parent / "ruff_defaults.toml"
20
21
22@without_runtime_setup
23@register_cli("code")
24@click.group()
25def cli() -> None:
26 """Code formatting and linting"""
27 pass
28
29
30@without_runtime_setup
31@cli.command()
32@click.option("--force", is_flag=True, help="Reinstall even if up to date")
33@click.pass_context
34def install(ctx: click.Context, force: bool) -> None:
35 """Install or update Biome binary"""
36 config = get_code_config()
37
38 if not config.get("biome", {}).get("enabled", True):
39 click.secho("Biome is disabled in configuration", fg="yellow")
40 return
41
42 biome = Biome()
43
44 if force or not biome.is_installed() or biome.needs_update():
45 version_to_install = config.get("biome", {}).get("version", "")
46 if version_to_install:
47 click.secho(
48 f"Installing Biome standalone version {version_to_install}...",
49 bold=True,
50 nl=False,
51 )
52 installed = biome.install(version_to_install)
53 click.secho(f"Biome {installed} installed", fg="green")
54 else:
55 ctx.invoke(update)
56 else:
57 click.secho("Biome already installed", fg="green")
58
59
60@without_runtime_setup
61@cli.command()
62def update() -> None:
63 """Update Biome to latest version"""
64 config = get_code_config()
65
66 if not config.get("biome", {}).get("enabled", True):
67 click.secho("Biome is disabled in configuration", fg="yellow")
68 return
69
70 biome = Biome()
71 click.secho("Updating Biome standalone...", bold=True)
72 version = biome.install()
73 click.secho(f"Biome {version} installed", fg="green")
74
75
76@without_runtime_setup
77@cli.command()
78@click.pass_context
79@click.argument("path", default=".")
80@click.option("--skip-ruff", is_flag=True, help="Skip Ruff checks")
81@click.option("--skip-ty", is_flag=True, help="Skip ty type checks")
82@click.option("--skip-biome", is_flag=True, help="Skip Biome checks")
83@click.option("--skip-annotations", is_flag=True, help="Skip type annotation checks")
84def check(
85 ctx: click.Context,
86 path: str,
87 skip_ruff: bool,
88 skip_ty: bool,
89 skip_biome: bool,
90 skip_annotations: bool,
91) -> None:
92 """Check for formatting and linting issues"""
93 ruff_args = ["--config", str(DEFAULT_RUFF_CONFIG)]
94 config = get_code_config()
95
96 for e in config.get("exclude", []):
97 ruff_args.extend(["--exclude", e])
98
99 def maybe_exit(return_code: int) -> None:
100 if return_code != 0:
101 click.secho(
102 "\nCode check failed. Run `plain fix` and/or fix issues manually.",
103 fg="red",
104 err=True,
105 )
106 sys.exit(return_code)
107
108 if not skip_ruff:
109 print_event("ruff check...", newline=False)
110 result = subprocess.run(["ruff", "check", path, *ruff_args])
111 maybe_exit(result.returncode)
112
113 print_event("ruff format --check...", newline=False)
114 result = subprocess.run(["ruff", "format", path, "--check", *ruff_args])
115 maybe_exit(result.returncode)
116
117 if not skip_ty and config.get("ty", {}).get("enabled", True):
118 print_event("ty check...", newline=False)
119 ty_args = ["ty", "check", path, "--no-progress"]
120 for e in config.get("exclude", []):
121 ty_args.extend(["--exclude", e])
122 result = subprocess.run(ty_args)
123 maybe_exit(result.returncode)
124
125 if not skip_biome and config.get("biome", {}).get("enabled", True):
126 biome = Biome()
127
128 if biome.needs_update():
129 ctx.invoke(install)
130
131 print_event("biome check...", newline=False)
132 result = biome.invoke("check", path)
133 maybe_exit(result.returncode)
134
135 if not skip_annotations and config.get("annotations", {}).get("enabled", True):
136 print_event("annotations...", newline=False)
137 # Combine top-level exclude with annotation-specific exclude
138 exclude_patterns = list(config.get("exclude", []))
139 exclude_patterns.extend(config.get("annotations", {}).get("exclude", []))
140 ann_result = check_annotations(path, exclude_patterns or None)
141 if ann_result.missing_count > 0:
142 click.secho(
143 f"{ann_result.missing_count} functions are untyped",
144 fg="red",
145 )
146 click.secho("Run 'plain code annotations --details' for details")
147 maybe_exit(1)
148 else:
149 click.secho("All functions typed!", fg="green")
150
151
152@without_runtime_setup
153@cli.command()
154@click.argument("path", default=".")
155@click.option("--details", is_flag=True, help="List untyped functions")
156@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
157def annotations(path: str, details: bool, as_json: bool) -> None:
158 """Check type annotation status"""
159 config = get_code_config()
160 # Combine top-level exclude with annotation-specific exclude
161 exclude_patterns = list(config.get("exclude", []))
162 exclude_patterns.extend(config.get("annotations", {}).get("exclude", []))
163 result = check_annotations(path, exclude_patterns or None)
164 if as_json:
165 _print_annotations_json(result)
166 else:
167 _print_annotations_report(result, show_details=details)
168
169
170def _print_annotations_report(
171 result: AnnotationResult,
172 show_details: bool = False,
173) -> None:
174 """Print the annotation report with colors."""
175 if result.total_functions == 0:
176 click.echo("No functions found")
177 return
178
179 # Detailed output first (if enabled and there are untyped functions)
180 if show_details and result.missing_count > 0:
181 # Collect all untyped functions with full paths
182 untyped_items: list[tuple[str, str, int, list[str]]] = []
183
184 for stats in result.file_stats:
185 for func in stats.functions:
186 if not func.is_fully_typed:
187 issues = []
188 if not func.has_return_type:
189 issues.append("return type")
190 missing_params = func.total_params - func.typed_params
191 if missing_params > 0:
192 param_word = "param" if missing_params == 1 else "params"
193 issues.append(f"{missing_params} {param_word}")
194 untyped_items.append((stats.path, func.name, func.line, issues))
195
196 # Sort by file path, then line number
197 untyped_items.sort(key=lambda x: (x[0], x[2]))
198
199 # Print each untyped function
200 for file_path, func_name, line, issues in untyped_items:
201 location = click.style(f"{file_path}:{line}", fg="cyan")
202 issue_str = click.style(f"({', '.join(issues)})", dim=True)
203 click.echo(f"{location} {func_name} {issue_str}")
204
205 click.echo()
206
207 # Summary line
208 pct = result.coverage_percentage
209 color = "green" if result.missing_count == 0 else "red"
210 click.secho(
211 f"{pct:.1f}% typed ({result.fully_typed_functions}/{result.total_functions} functions)",
212 fg=color,
213 )
214
215 # Code smell indicators (only if present)
216 smells = []
217 if result.total_ignores > 0:
218 smells.append(f"{result.total_ignores} ignore")
219 if result.total_casts > 0:
220 smells.append(f"{result.total_casts} cast")
221 if result.total_asserts > 0:
222 smells.append(f"{result.total_asserts} assert")
223 if smells:
224 click.secho(f"{', '.join(smells)}", fg="yellow")
225
226
227def _print_annotations_json(result: AnnotationResult) -> None:
228 """Print the annotation report as JSON."""
229 output = {
230 "overall_coverage": result.coverage_percentage,
231 "total_functions": result.total_functions,
232 "fully_typed_functions": result.fully_typed_functions,
233 "total_ignores": result.total_ignores,
234 "total_casts": result.total_casts,
235 "total_asserts": result.total_asserts,
236 }
237 click.echo(json.dumps(output))
238
239
240@common_command
241@without_runtime_setup
242@register_cli("fix", shortcut_for="code fix")
243@cli.command()
244@click.pass_context
245@click.argument("path", default=".")
246@click.option("--unsafe-fixes", is_flag=True, help="Apply ruff unsafe fixes")
247@click.option("--add-noqa", is_flag=True, help="Add noqa comments to suppress errors")
248def fix(ctx: click.Context, path: str, unsafe_fixes: bool, add_noqa: bool) -> None:
249 """Fix formatting and linting issues"""
250 ruff_args = ["--config", str(DEFAULT_RUFF_CONFIG)]
251 config = get_code_config()
252
253 for e in config.get("exclude", []):
254 ruff_args.extend(["--exclude", e])
255
256 if unsafe_fixes and add_noqa:
257 raise click.UsageError("Cannot use both --unsafe-fixes and --add-noqa")
258
259 if unsafe_fixes:
260 print_event("ruff check --fix --unsafe-fixes...", newline=False)
261 result = subprocess.run(
262 ["ruff", "check", path, "--fix", "--unsafe-fixes", *ruff_args]
263 )
264 elif add_noqa:
265 print_event("ruff check --add-noqa...", newline=False)
266 result = subprocess.run(["ruff", "check", path, "--add-noqa", *ruff_args])
267 else:
268 print_event("ruff check --fix...", newline=False)
269 result = subprocess.run(["ruff", "check", path, "--fix", *ruff_args])
270
271 if result.returncode != 0:
272 sys.exit(result.returncode)
273
274 print_event("ruff format...", newline=False)
275 result = subprocess.run(["ruff", "format", path, *ruff_args])
276 if result.returncode != 0:
277 sys.exit(result.returncode)
278
279 if config.get("biome", {}).get("enabled", True):
280 biome = Biome()
281
282 if biome.needs_update():
283 ctx.invoke(install)
284
285 args = ["check", path, "--write"]
286
287 if unsafe_fixes:
288 args.append("--unsafe")
289 print_event("biome check --write --unsafe...", newline=False)
290 else:
291 print_event("biome check --write...", newline=False)
292
293 result = biome.invoke(*args)
294
295 if result.returncode != 0:
296 sys.exit(result.returncode)
297
298
299def get_code_config() -> dict[str, Any]:
300 pyproject = Path("pyproject.toml")
301 if not pyproject.exists():
302 return {}
303 with pyproject.open("rb") as f:
304 return tomllib.load(f).get("tool", {}).get("plain", {}).get("code", {})