Skip to content
File

Blob: tools/cross/format.py

python315 lines
1#!/usr/bin/env python3
2 
3import json
4import logging
5import os
6import subprocess
7from argparse import ArgumentParser, Namespace
8from concurrent.futures import ThreadPoolExecutor, as_completed
9from dataclasses import dataclass
10from pathlib import Path
11from sys import exit
12from typing import Callable, Optional
13 
14# This file is symlinked into the internal repo as tools/format.py, so the root may be two levels up
15# or three.
16ROOT = Path(__file__).parents[1]
17if not (ROOT / ".git").exists():
18 ROOT = ROOT.parent
19BAZEL_BIN = ROOT / "bazel-bin"
20 
21 
22def parse_args() -> Namespace:
23 parser = ArgumentParser()
24 parser.add_argument(
25 "--check",
26 help="only check for files requiring formatting; don't actually format them",
27 action="store_true",
28 default=False,
29 )
30 subparsers = parser.add_subparsers(dest="subcommand")
31 git_parser = subparsers.add_parser(
32 "git", help="Apply format to changes tracked by git"
33 )
34 git_parser.add_argument(
35 "--source",
36 help=(
37 "consider files modified in the specified commit-ish; "
38 "if not specified, defaults to all changes in the working directory"
39 ),
40 type=str,
41 required=False,
42 default=None,
43 )
44 git_parser.add_argument(
45 "--target",
46 help="consider files modified since the specified commit-ish; defaults to HEAD",
47 type=str,
48 required=False,
49 default="HEAD",
50 )
51 git_parser.add_argument(
52 "--staged",
53 help="consider files with staged modifications only",
54 action="store_true",
55 default=False,
56 )
57 options = parser.parse_args()
58 if (
59 options.subcommand == "git"
60 and options.staged
61 and (options.source is not None or options.target != "HEAD")
62 ):
63 logging.error(
64 "--staged cannot be used with --source or --target; "
65 "use --staged with --source=HEAD"
66 )
67 exit(1)
68 return options
69 
70 
71def filter_files_by_globs(
72 files: list[Path], dir_path: Path, globs: tuple[str, ...], excludes: tuple[str, ...]
73) -> list[Path]:
74 return [
75 file
76 for file in files
77 if file.is_relative_to(dir_path)
78 and matches_any_glob(globs, file)
79 and not relative_to_any(excludes, file)
80 ]
81 
82 
83def relative_to_any(excludes: tuple[str, ...], file: Path) -> bool:
84 return any(file.is_relative_to(exclude) for exclude in excludes)
85 
86 
87def matches_any_glob(globs: tuple[str, ...], file: Path) -> bool:
88 return any(file.match(glob) for glob in globs)
89 
90 
91def _ensure_bazel_tool(tool_name: str, build_target: str | None = None) -> Path:
92 """Ensure a bazel-built formatter tool exists and return its path."""
93 tool_suffix = Path("build") / "deps" / "formatters" / tool_name
94 internal_tool_path = (
95 BAZEL_BIN / "external" / "+local_repository+workerd" / tool_suffix
96 )
97 workerd_tool_path = BAZEL_BIN / tool_suffix
98 
99 if internal_tool_path.exists():
100 return internal_tool_path
101 if workerd_tool_path.exists():
102 return workerd_tool_path
103 
104 # Tool not cached; build it once.
105 if build_target is None:
106 build_target = f"@workerd//build/deps/formatters:{tool_name}@rule"
107 download_result = subprocess.run(["bazel", "build", build_target])
108 if download_result.returncode != 0:
109 raise RuntimeError(f"Failed to download {tool_name}")
110 
111 if internal_tool_path.exists():
112 return internal_tool_path
113 return workerd_tool_path
114 
115 
116def run_bazel_tool(
117 tool_name: str, args: list[str], build_target: str | None = None
118) -> subprocess.CompletedProcess:
119 tool_path = _ensure_bazel_tool(tool_name, build_target)
120 return subprocess.run([tool_path, *args], cwd=ROOT)
121 
122 
123def _run_parallel(
124 run_fn: Callable, files: list[Path], cmd: list, max_workers: int = 16
125) -> bool:
126 """Split files across parallel invocations of run_fn(cmd + chunk)."""
127 n_workers = min(os.cpu_count() or 1, len(files), max_workers)
128 if n_workers <= 1:
129 return run_fn(cmd + files).returncode == 0
130 
131 chunks = [files[i::n_workers] for i in range(n_workers)]
132 with ThreadPoolExecutor(max_workers=n_workers) as pool:
133 results = list(pool.map(lambda chunk: run_fn(cmd + chunk), chunks))
134 return all(r.returncode == 0 for r in results)
135 
136 
137def clang_format(files: list[Path], check: bool = False) -> bool:
138 cmd = ["--dry-run", "--Werror"] if check else ["-i"]
139 tool = _ensure_bazel_tool("clang-format")
140 return _run_parallel(
141 lambda args: subprocess.run([tool, *args], cwd=ROOT), files, cmd
142 )
143 
144 
145def prettier(files: list[Path], check: bool = False) -> bool:
146 PRETTIER = BAZEL_BIN / "node_modules/prettier/bin/prettier.cjs"
147 
148 if not PRETTIER.exists():
149 subprocess.run(["bazel", "build", "//:node_modules/prettier"])
150 cmd = [PRETTIER, "--log-level=warn", "--check" if check else "--write"]
151 return _run_parallel(
152 lambda args: subprocess.run(args, cwd=ROOT), files, cmd, max_workers=8
153 )
154 
155 
156def buildifier(files: list[Path], check: bool = False) -> bool:
157 cmd = ["--mode=check" if check else "--mode=fix"]
158 result = run_bazel_tool("buildifier", cmd + files)
159 return result.returncode == 0
160 
161 
162def rustfmt(files: list[Path], check: bool = False) -> bool:
163 if not files:
164 return True
165 cmd = ["--edition", "2024"]
166 if check:
167 cmd.append("--check")
168 result = run_bazel_tool("rustfmt", cmd + files)
169 return result.returncode == 0
170 
171 
172def ruff(files: list[Path], check: bool = False) -> bool:
173 if not files:
174 return True
175 
176 cmd = ["check"]
177 if not check:
178 cmd.append("--fix")
179 result1 = run_bazel_tool("ruff", cmd + files)
180 
181 # format
182 cmd = ["format"]
183 if check:
184 cmd.append("--diff")
185 
186 result2 = run_bazel_tool("ruff", cmd + files)
187 return result1.returncode == 0 and result2.returncode == 0
188 
189 
190def git_get_modified_files(
191 target: str, source: Optional[str], staged: bool
192) -> list[Path]:
193 if staged:
194 files_in_diff = subprocess.check_output(
195 ["git", "diff", "--diff-filter=d", "--name-only", "--cached"],
196 encoding="utf-8",
197 cwd=ROOT,
198 ).splitlines()
199 return [Path(file) for file in files_in_diff]
200 else:
201 merge_base = subprocess.check_output(
202 ["git", "merge-base", target, source or "HEAD"],
203 encoding="utf-8",
204 cwd=ROOT,
205 ).strip()
206 files_in_diff = subprocess.check_output(
207 ["git", "diff", "--diff-filter=d", "--name-only", merge_base]
208 + ([source] if source else []),
209 encoding="utf-8",
210 cwd=ROOT,
211 ).splitlines()
212 return [Path(file) for file in files_in_diff]
213 
214 
215def git_get_all_files() -> list[Path]:
216 files = subprocess.check_output(
217 ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
218 encoding="utf-8",
219 cwd=ROOT,
220 ).splitlines()
221 return [Path(file) for file in files]
222 
223 
224@dataclass
225class FormatConfig:
226 directory: str
227 globs: tuple[str, ...]
228 formatter: str
229 excludes: tuple[str, ...] = ()
230 
231 
232FORMATTERS = {
233 "clang-format": clang_format,
234 "prettier": prettier,
235 "ruff": ruff,
236 "buildifier": buildifier,
237 "rustfmt": rustfmt,
238}
239 
240 
241def format(config: FormatConfig, files: list[Path], check: bool) -> tuple[bool, str]:
242 matching_files = filter_files_by_globs(
243 files, Path(config.directory), config.globs, config.excludes
244 )
245 
246 if not matching_files:
247 return (
248 True,
249 f"No matching files for {config.directory} ({', '.join(config.globs)})",
250 )
251 
252 result = FORMATTERS[config.formatter](matching_files, check)
253 message = (
254 f"{len(matching_files)} files in {config.directory} ({', '.join(config.globs)})"
255 )
256 return (
257 result,
258 f"{'Checked' if check else 'Formatted'} {message}",
259 )
260 
261 
262def main() -> None:
263 options = parse_args()
264 
265 if options.subcommand == "git":
266 files = git_get_modified_files(options.target, options.source, options.staged)
267 else:
268 files = git_get_all_files()
269 
270 with (Path(__file__).parent / "format.json").open() as fp:
271 configs = json.load(fp, object_hook=lambda o: FormatConfig(**o))
272 
273 # Ensure all required tools are downloaded before running formatters in
274 # parallel. Otherwise a slow `bazel build` for a missing tool races with
275 # the other formatters and its output gets interleaved.
276 needed_formatters = set()
277 for config in configs:
278 matched = filter_files_by_globs(
279 files, Path(config.directory), config.globs, config.excludes
280 )
281 if matched:
282 needed_formatters.add(config.formatter)
283 for name in needed_formatters:
284 if name in ("clang-format", "buildifier", "ruff", "rustfmt"):
285 _ensure_bazel_tool(name)
286 
287 all_ok = True
288 
289 with ThreadPoolExecutor() as executor:
290 future_to_config = {
291 executor.submit(format, config, files, options.check): config
292 for config in configs
293 }
294 for future in as_completed(future_to_config):
295 config = future_to_config[future]
296 try:
297 result, message = future.result()
298 all_ok &= result
299 logging.info(message)
300 except Exception:
301 logging.exception(
302 f"Formatter for {config.directory} generated an exception"
303 )
304 all_ok = False
305 
306 if not all_ok:
307 logging.error(
308 "Code has linting issues. Fix with python ./tools/cross/format.py"
309 )
310 exit(1)
311 
312 
313if __name__ == "__main__":
314 main()