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