1"""
2Oxc standalone binary management for plain-code.
3
4Downloads and manages oxlint (linter) and oxfmt (formatter) binaries
5from the oxc-project/oxc GitHub releases.
6"""
7
8import io
9import os
10import platform
11import subprocess
12import sys
13import tarfile
14import tomllib
15import zipfile
16from pathlib import Path
17
18import click
19import httpx
20import tomlkit
21from plain.runtime import PLAIN_CACHE_PATH, PLAIN_TEMP_PATH
22from plain.utils.version import compare_versions
23
24TAG_PREFIX = "apps_v"
25
26# Older versions resolve ignore patterns differently, so we don't support them.
27MIN_VERSION = (1, 75, 0)
28
29
30def check_min_version(version: str) -> None:
31 """Raise when `version` predates the oldest Oxc we support."""
32 minimum = ".".join(str(part) for part in MIN_VERSION)
33 if compare_versions(version, minimum) < 0:
34 raise RuntimeError(
35 f"Oxc {version} is too old (minimum is {minimum}) — run `plain code update`"
36 )
37
38
39# Committed third-party code that we don't want to lint or format. Everything
40# else worth skipping (node_modules, .venv, htmlcov, .pytest_cache) is already
41# gitignored, and both tools read .gitignore on their own.
42#
43# These go on the command line rather than in a config file, because config-file
44# `ignorePatterns` only match files underneath the config file's own directory
45# and ours ships inside the installed package.
46IGNORE_PATTERNS = [
47 "**/vendor/**",
48 "**/*.min.*",
49]
50
51
52class OxcTool:
53 """Download, install, and invoke an Oxc CLI binary (oxlint or oxfmt)."""
54
55 def __init__(self, name: str) -> None:
56 if name not in ("oxlint", "oxfmt"):
57 raise ValueError(f"Unknown Oxc tool: {name}")
58 self.name = name
59
60 def binary_path(self, version: str) -> Path:
61 """Machine-level cache path for a specific Oxc version."""
62 exe = ".exe" if platform.system() == "Windows" else ""
63 return PLAIN_CACHE_PATH / "oxc" / version / f"{self.name}{exe}"
64
65 def is_installed(self) -> bool:
66 version = self.get_version_from_config()
67 return bool(version) and self.binary_path(version).exists()
68
69 @staticmethod
70 def get_version_from_config() -> str:
71 project_root = os.path.dirname(str(PLAIN_TEMP_PATH))
72 pyproject = os.path.join(project_root, "pyproject.toml")
73 if not os.path.exists(pyproject):
74 return ""
75 with open(pyproject, "rb") as f:
76 doc = tomllib.load(f)
77 return (
78 doc.get("tool", {})
79 .get("plain", {})
80 .get("code", {})
81 .get("oxc", {})
82 .get("version", "")
83 )
84
85 @staticmethod
86 def set_version_in_config(version: str) -> None:
87 project_root = os.path.dirname(str(PLAIN_TEMP_PATH))
88 pyproject = os.path.join(project_root, "pyproject.toml")
89 if not os.path.exists(pyproject):
90 return
91 with open(pyproject) as f:
92 doc = tomlkit.load(f)
93 doc.setdefault("tool", {}).setdefault("plain", {}).setdefault(
94 "code", {}
95 ).setdefault("oxc", {})["version"] = version
96 with open(pyproject, "w") as f:
97 tomlkit.dump(doc, f)
98
99 def detect_platform_slug(self) -> str:
100 system = platform.system()
101 arch = platform.machine()
102 if system == "Windows":
103 if arch.lower() in ("arm64", "aarch64"):
104 return "aarch64-pc-windows-msvc"
105 return "x86_64-pc-windows-msvc"
106 if system == "Linux":
107 if arch == "aarch64":
108 return "aarch64-unknown-linux-gnu"
109 return "x86_64-unknown-linux-gnu"
110 if system == "Darwin":
111 if arch == "arm64":
112 return "aarch64-apple-darwin"
113 return "x86_64-apple-darwin"
114 raise RuntimeError(f"Unsupported platform for Oxc: {system}/{arch}")
115
116 @staticmethod
117 def get_latest_version() -> str:
118 """Find the latest apps_v release tag via the GitHub API."""
119 try:
120 resp = httpx.get(
121 "https://api.github.com/repos/oxc-project/oxc/releases",
122 params={"per_page": 20},
123 headers={"Accept": "application/vnd.github+json"},
124 follow_redirects=True,
125 )
126 resp.raise_for_status()
127 except httpx.HTTPError as e:
128 raise click.ClickException(
129 "Couldn't reach github.com to look up the latest oxlint/oxfmt "
130 f"release ({e}). If this environment can't reach GitHub, pin a "
131 "version in pyproject.toml under [tool.plain.code.oxc], or pass "
132 "--skip-oxc."
133 ) from e
134 for release in resp.json():
135 tag = release["tag_name"]
136 if tag.startswith(TAG_PREFIX):
137 return tag[len(TAG_PREFIX) :]
138 raise RuntimeError("No apps_v release found on GitHub")
139
140 def download(self, version: str = "") -> str:
141 if not version:
142 version = self.get_latest_version()
143
144 slug = self.detect_platform_slug()
145 is_windows = platform.system() == "Windows"
146 ext = "zip" if is_windows else "tar.gz"
147 asset = f"{self.name}-{slug}.{ext}"
148 url = f"https://github.com/oxc-project/oxc/releases/download/{TAG_PREFIX}{version}/{asset}"
149
150 # Download into memory for extraction
151 data = io.BytesIO()
152 try:
153 with httpx.stream("GET", url, follow_redirects=True) as resp:
154 resp.raise_for_status()
155 total = int(resp.headers.get("Content-Length", 0))
156 if total:
157 with click.progressbar(
158 length=total,
159 label=f"Downloading {self.name}",
160 width=0,
161 ) as bar:
162 for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
163 data.write(chunk)
164 bar.update(len(chunk))
165 else:
166 for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
167 data.write(chunk)
168 except httpx.HTTPError as e:
169 raise click.ClickException(
170 f"Couldn't download {self.name} from github.com ({e}). If this "
171 "environment can't reach GitHub, pass --skip-oxc."
172 ) from e
173
174 data.seek(0)
175
176 # Extract to a temp file first, then atomically move it into the
177 # versioned cache path (parallel checkouts can download concurrently).
178 resolved = version.lstrip("v")
179 binary_path = self.binary_path(resolved)
180 binary_path.parent.mkdir(parents=True, exist_ok=True)
181 tmp_path = binary_path.parent / f".{self.name}-download-{os.getpid()}"
182
183 try:
184 if is_windows:
185 with zipfile.ZipFile(data) as zf:
186 # Find the binary inside the archive
187 members = zf.namelist()
188 binary_name = next(m for m in members if m.startswith(self.name))
189 with (
190 zf.open(binary_name) as src,
191 open(tmp_path, "wb") as dst,
192 ):
193 dst.write(src.read())
194 else:
195 with tarfile.open(fileobj=data, mode="r:gz") as tf:
196 members = tf.getnames()
197 binary_name = next(m for m in members if m.startswith(self.name))
198 extracted = tf.extractfile(binary_name)
199 if extracted is None:
200 raise RuntimeError(
201 f"Failed to extract {binary_name} from archive"
202 )
203 with open(tmp_path, "wb") as dst:
204 dst.write(extracted.read())
205
206 os.chmod(tmp_path, 0o755)
207 try:
208 os.replace(tmp_path, binary_path)
209 except PermissionError:
210 # Windows raises this if another process has binary_path open
211 # (e.g. another checkout sharing this machine-wide cache is
212 # currently running it). If it's already there, another
213 # process finished installing this exact version — nothing
214 # left to do.
215 if not binary_path.exists():
216 raise
217 finally:
218 tmp_path.unlink(missing_ok=True)
219
220 return resolved
221
222 def invoke(self, *args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
223 version = self.get_version_from_config()
224 if not version:
225 raise RuntimeError(
226 "No Oxc version configured in pyproject.toml — run `plain code install`"
227 )
228 check_min_version(version)
229 if self.name == "oxlint":
230 # oxlint takes ignores as repeated --ignore-pattern flags.
231 ignore_args = []
232 for pattern in IGNORE_PATTERNS:
233 ignore_args += ["--ignore-pattern", pattern]
234 else:
235 # oxfmt has no --ignore-pattern; it excludes via `!`-prefixed paths.
236 ignore_args = [f"!{pattern}" for pattern in IGNORE_PATTERNS]
237 result = subprocess.run(
238 [
239 self.binary_path(version),
240 *args,
241 # Both tools exit non-zero when the paths match no files, which
242 # is the normal state for a project with no JS/TS in it.
243 "--no-error-on-unmatched-pattern",
244 *ignore_args,
245 ],
246 cwd=cwd,
247 capture_output=True,
248 text=True,
249 check=False,
250 )
251 if result.stdout:
252 print(result.stdout, end="")
253 if result.stderr:
254 # We deliberately don't pass a config file, so drop oxfmt's nudge to
255 # add one — projects can still add their own `.oxfmtrc.json`.
256 stderr = "".join(
257 line
258 for line in result.stderr.splitlines(keepends=True)
259 if "No config found, using defaults" not in line
260 )
261 if stderr:
262 print(stderr, end="", file=sys.stderr)
263 return result
264
265
266def install_oxc(version: str = "") -> str:
267 """Install both oxlint and oxfmt, return the resolved version."""
268 if version:
269 # Before downloading, so a pin below the minimum is rejected instead of
270 # installing, reporting success, and then failing on first use — which
271 # `plain fix` would repeat on every run, since installing "worked".
272 check_min_version(version)
273
274 oxlint = OxcTool("oxlint")
275 oxfmt = OxcTool("oxfmt")
276
277 resolved = oxlint.download(version)
278 oxfmt.download(resolved)
279
280 OxcTool.set_version_in_config(resolved)
281 return resolved