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", {})