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