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