Plain is headed towards 1.0! Subscribe for development updates →

  1from __future__ import annotations
  2
  3import subprocess
  4import sys
  5import tomllib
  6from pathlib import Path
  7from typing import Any
  8
  9import click
 10
 11from plain.cli import register_cli
 12from plain.cli.print import print_event
 13from plain.cli.runtime import common_command, without_runtime_setup
 14
 15from .biome import Biome
 16
 17DEFAULT_RUFF_CONFIG = Path(__file__).parent / "ruff_defaults.toml"
 18
 19
 20@without_runtime_setup
 21@register_cli("code")
 22@click.group()
 23def cli() -> None:
 24    """Code formatting and linting"""
 25    pass
 26
 27
 28@without_runtime_setup
 29@cli.command()
 30@click.option("--force", is_flag=True, help="Reinstall even if up to date")
 31@click.pass_context
 32def install(ctx: click.Context, force: bool) -> None:
 33    """Install or update Biome binary"""
 34    config = get_code_config()
 35
 36    if not config.get("biome", {}).get("enabled", True):
 37        click.secho("Biome is disabled in configuration", fg="yellow")
 38        return
 39
 40    biome = Biome()
 41
 42    if force or not biome.is_installed() or biome.needs_update():
 43        version_to_install = config.get("biome", {}).get("version", "")
 44        if version_to_install:
 45            click.secho(
 46                f"Installing Biome standalone version {version_to_install}...",
 47                bold=True,
 48                nl=False,
 49            )
 50            installed = biome.install(version_to_install)
 51            click.secho(f"Biome {installed} installed", fg="green")
 52        else:
 53            ctx.invoke(update)
 54    else:
 55        click.secho("Biome already installed", fg="green")
 56
 57
 58@without_runtime_setup
 59@cli.command()
 60def update() -> None:
 61    """Update Biome to latest version"""
 62    config = get_code_config()
 63
 64    if not config.get("biome", {}).get("enabled", True):
 65        click.secho("Biome is disabled in configuration", fg="yellow")
 66        return
 67
 68    biome = Biome()
 69    click.secho("Updating Biome standalone...", bold=True)
 70    version = biome.install()
 71    click.secho(f"Biome {version} installed", fg="green")
 72
 73
 74@without_runtime_setup
 75@cli.command()
 76@click.pass_context
 77@click.argument("path", default=".")
 78@click.option("--skip-ruff", is_flag=True, help="Skip Ruff checks")
 79@click.option("--skip-ty", is_flag=True, help="Skip ty type checks")
 80@click.option("--skip-biome", is_flag=True, help="Skip Biome checks")
 81def check(
 82    ctx: click.Context,
 83    path: str,
 84    skip_ruff: bool,
 85    skip_ty: bool,
 86    skip_biome: bool,
 87) -> None:
 88    """Check for formatting and linting issues"""
 89    ruff_args = ["--config", str(DEFAULT_RUFF_CONFIG)]
 90    config = get_code_config()
 91
 92    for e in config.get("exclude", []):
 93        ruff_args.extend(["--exclude", e])
 94
 95    def maybe_exit(return_code: int) -> None:
 96        if return_code != 0:
 97            click.secho(
 98                "\nCode check failed. Run `plain fix` and/or fix issues manually.",
 99                fg="red",
100                err=True,
101            )
102            sys.exit(return_code)
103
104    if not skip_ruff:
105        print_event(
106            click.style("Ruff lint:", bold=True) + click.style(" ruff check", dim=True)
107        )
108        result = subprocess.run(["ruff", "check", path, *ruff_args])
109        maybe_exit(result.returncode)
110
111        print_event(
112            click.style("Ruff format:", bold=True)
113            + click.style(" ruff format --check", dim=True)
114        )
115        result = subprocess.run(["ruff", "format", path, "--check", *ruff_args])
116        maybe_exit(result.returncode)
117
118    if not skip_ty and config.get("ty", {}).get("enabled", True):
119        print_event(click.style("Ty:", bold=True) + click.style(" ty check", dim=True))
120        result = subprocess.run(["ty", "check", path])
121        maybe_exit(result.returncode)
122
123    if not skip_biome and config.get("biome", {}).get("enabled", True):
124        biome = Biome()
125
126        if biome.needs_update():
127            ctx.invoke(install)
128
129        print_event(
130            click.style("Biome:", bold=True) + click.style(" biome check", dim=True)
131        )
132        result = biome.invoke("check", path)
133        maybe_exit(result.returncode)
134
135
136@common_command
137@without_runtime_setup
138@register_cli("fix", shortcut_for="code fix")
139@cli.command()
140@click.pass_context
141@click.argument("path", default=".")
142@click.option("--unsafe-fixes", is_flag=True, help="Apply ruff unsafe fixes")
143@click.option("--add-noqa", is_flag=True, help="Add noqa comments to suppress errors")
144def fix(ctx: click.Context, path: str, unsafe_fixes: bool, add_noqa: bool) -> None:
145    """Fix formatting and linting issues"""
146    ruff_args = ["--config", str(DEFAULT_RUFF_CONFIG)]
147    config = get_code_config()
148
149    for e in config.get("exclude", []):
150        ruff_args.extend(["--exclude", e])
151
152    if unsafe_fixes and add_noqa:
153        raise click.UsageError("Cannot use both --unsafe-fixes and --add-noqa")
154
155    if unsafe_fixes:
156        print_event(
157            click.style("Ruff lint:", bold=True)
158            + click.style(" ruff check --fix --unsafe-fixes", dim=True)
159        )
160        result = subprocess.run(
161            ["ruff", "check", path, "--fix", "--unsafe-fixes", *ruff_args]
162        )
163    elif add_noqa:
164        print_event(
165            click.style("Ruff lint:", bold=True)
166            + click.style(" ruff check --add-noqa", dim=True)
167        )
168        result = subprocess.run(["ruff", "check", path, "--add-noqa", *ruff_args])
169    else:
170        print_event(
171            click.style("Ruff lint:", bold=True)
172            + click.style(" ruff check --fix", dim=True)
173        )
174        result = subprocess.run(["ruff", "check", path, "--fix", *ruff_args])
175
176    if result.returncode != 0:
177        sys.exit(result.returncode)
178
179    print_event(
180        click.style("Ruff format:", bold=True) + click.style(" ruff format", dim=True)
181    )
182    result = subprocess.run(["ruff", "format", path, *ruff_args])
183    if result.returncode != 0:
184        sys.exit(result.returncode)
185
186    if config.get("biome", {}).get("enabled", True):
187        biome = Biome()
188
189        if biome.needs_update():
190            ctx.invoke(install)
191
192        args = ["check", path, "--write"]
193
194        if unsafe_fixes:
195            args.append("--unsafe")
196            print_event(
197                click.style("Biome:", bold=True)
198                + click.style(" biome check --write --unsafe", dim=True)
199            )
200        else:
201            print_event(
202                click.style("Biome:", bold=True)
203                + click.style(" biome check --write", dim=True)
204            )
205
206        result = biome.invoke(*args)
207
208        if result.returncode != 0:
209            sys.exit(result.returncode)
210
211
212def get_code_config() -> dict[str, Any]:
213    pyproject = Path("pyproject.toml")
214    if not pyproject.exists():
215        return {}
216    with pyproject.open("rb") as f:
217        return tomllib.load(f).get("tool", {}).get("plain", {}).get("code", {})