Repository navigation
Expand file tree
/
Copy pathfmt.py
More file actions
386 lines (324 loc) · 13.4 KB
/
Copy pathfmt.py
File metadata and controls
386 lines (324 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is dual-licensed under either the MIT license found in the
# LICENSE-MIT file in the root directory of this source tree or the Apache
# License, Version 2.0 found in the LICENSE-APACHE file in the root directory
# of this source tree. You may select, at your option, one of the
# above-listed licenses.
"""
Formats the Rust and Starlark in this tree exactly as Meta's internal `arc f`
does, so a pull request needs no Rust or Starlark reformatting when it is
imported.
python3 fmt.py # format the whole tree in place
python3 fmt.py --check # exit 1 and show a diff if anything would change
python3 fmt.py prelude/ # only these paths
Rust goes through the nightly `rustfmt` named in `.rustfmt-version` with the
`rustfmt.toml` at the root; that is what Meta's lint runs, and it is not the
`rustfmt` of `rust-toolchain.toml`, which is why `cargo fmt` is not enough. It
is installed through `rustup` on first use. Starlark (`.bzl`, `.bxl`, `BUCK`,
...) goes through `tools/starlark_fmt`, built from this tree with `cargo`, with
the config in `tools/starlark_fmt/config.json`.
Lines carrying an `@oss-disable` or `@oss-enable` marker are rewritten by the
export from Meta's repository (`X # @oss-disable` becomes
`# @oss-disable[end= ]: X`, `# @oss-enable: X` becomes `X # @oss-enable`),
so this tree is not what the formatters saw. Each file is formatted in its
internal form -- the rewrite undone, then redone -- so the result is exactly
what the file will look like once the change is imported and formatted there.
"""
from __future__ import annotations
import argparse
import difflib
import json
import os
import re
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path, PurePosixPath
ROOT = Path(__file__).resolve().parent
RUSTFMT_VERSION_FILE = ROOT / ".rustfmt-version"
RUSTFMT_CONFIG = ROOT / "rustfmt.toml"
STARLARK_FMT_CONFIG = ROOT / "tools" / "starlark_fmt" / "config.json"
# The `starlark_fmt` binary is the `starlark_fmt_lib` package's.
STARLARK_FMT_PACKAGE = "starlark_fmt_lib"
# Which files each formatter covers, as Meta's lint engine selects them
# (tools/arcanist/lint/fbsource-lint-engine.toml, rulesets RUSTFMT and
# BUCKFORMAT), restricted to what occurs in this tree.
STARLARK_NAMES = {"BUCK", "PACKAGE", "TARGETS", "TARGETS.fixture"}
STARLARK_SUFFIXES = {".bzl", ".bxl", ".sky"}
RUST_SUFFIXES = {".rs"}
# Files the lint engine never formats, by a token anywhere in the file.
SKIP_TOKENS = ("@" + "generated", "@" + "nolint")
LINT_IGNORE_EVERY = re.compile(r"@lint-ignore-every\b(.*)")
def is_rust(path: PurePosixPath) -> bool:
return path.suffix in RUST_SUFFIXES and ".claude" not in path.parts
def is_starlark(path: PurePosixPath) -> bool:
return path.name in STARLARK_NAMES or path.suffix in STARLARK_SUFFIXES
def lint_code(path: PurePosixPath) -> str:
return "RUSTFMT" if is_rust(path) else "BUCKFORMAT"
def skipped(path: PurePosixPath, text: str) -> bool:
if "\0" in text or any(token in text for token in SKIP_TOKENS):
return True
return any(
lint_code(path) in m.group(1).split() for m in LINT_IGNORE_EVERY.finditer(text)
)
# -- The export's comment-marker rewrite -------------------------------------
#
# A port of the `ConditionalLinesFilterMutator` that the export applies, so the
# formatting here is of the internal form of each file. `comment_start` is the
# language's, as the export's path rules assign it: `//` for Rust, `#` for
# Starlark files by extension and for `BUCK`/`TARGETS` files, nothing for
# `PACKAGE` files, whose markers the export leaves alone.
DISABLE = "@oss-disable"
ENABLE = "@oss-enable"
def comment_start(path: PurePosixPath) -> str | None:
if is_rust(path):
return "//"
if path.suffix in {".bzl", ".bxl", ".sky", ".star"}:
return "#"
# The rule is `/BUCK` or `/TARGETS` anywhere in the path. Paths here are
# relative to the root; internally the root is itself inside a directory.
if "/BUCK" in f"/{path}" or "/TARGETS" in f"/{path}":
return "#"
return None
def comment_line(line: str, comment: str, marker: str) -> str:
"""`X <comment> <marker>` -> `<comment> <marker>[end=<ws>]: X`."""
m = re.fullmatch(rf"(\s*)(\S.*) {re.escape(comment)} {re.escape(marker)}", line)
if m is None:
return line
prefix, body = m.groups()
content = body.rstrip()
trailing = body[len(content) :]
end = f"[end={trailing}]" if trailing else ""
return f"{prefix}{comment} {marker}{end}: {content}"
def uncomment_line(line: str, comment: str, marker: str) -> str:
"""`<comment> <marker>[end=<ws>]: X` -> `X<ws> <comment> <marker>`."""
m = re.fullmatch(
rf"(\s*){re.escape(comment)} {re.escape(marker)}(\[end=(\s+)\])?: (.+)",
line,
)
if m is None:
return line
prefix, _, trailing, content = m.groups()
return f"{prefix}{content}{trailing or ''} {comment} {marker}"
def to_internal(text: str, comment: str | None) -> str:
if comment is None:
return text
lines = text.split("\n")
lines = [uncomment_line(line, comment, DISABLE) for line in lines]
lines = [comment_line(line, comment, ENABLE) for line in lines]
return "\n".join(lines)
def to_exported(text: str, comment: str | None) -> str:
if comment is None:
return text
lines = text.split("\n")
lines = [comment_line(line, comment, DISABLE) for line in lines]
lines = [uncomment_line(line, comment, ENABLE) for line in lines]
return "\n".join(lines)
# -- Formatters ---------------------------------------------------------------
def run(args: list[str], **kwargs) -> subprocess.CompletedProcess:
return subprocess.run(args, text=True, **kwargs)
def find_rustfmt(explicit: str | None) -> list[str]:
if explicit:
return [explicit]
if not RUSTFMT_VERSION_FILE.exists():
sys.exit(f"{RUSTFMT_VERSION_FILE.name} is missing; run from a full checkout")
toolchain = RUSTFMT_VERSION_FILE.read_text().strip()
rustfmt = ["rustup", "run", toolchain, "rustfmt"]
try:
installed = run(rustfmt + ["--version"], capture_output=True).returncode == 0
except FileNotFoundError:
sys.exit("rustup is not on PATH (https://rustup.rs), and no --rustfmt given")
if not installed:
print(f"Installing rustfmt {toolchain} through rustup (once)", flush=True)
run(
[
"rustup",
"toolchain",
"install",
toolchain,
"--profile=minimal",
"--component=rustfmt",
"--no-self-update",
],
check=True,
)
return rustfmt
def find_starlark_fmt(explicit: str | None) -> str:
if explicit:
return explicit
print("Building starlark_fmt with cargo", flush=True)
result = run(
[
"cargo",
"build",
f"--package={STARLARK_FMT_PACKAGE}",
"--bin=starlark_fmt",
"--message-format=json-render-diagnostics",
],
cwd=ROOT,
stdout=subprocess.PIPE,
)
if result.returncode != 0:
sys.exit("building starlark_fmt failed")
for line in result.stdout.splitlines():
message = json.loads(line)
if message.get("reason") == "compiler-artifact" and message.get("executable"):
return message["executable"]
sys.exit("cargo did not report the starlark_fmt executable")
def chunks(items: list, size: int) -> list[list]:
return [items[i : i + size] for i in range(0, len(items), size)]
def format_rust(rustfmt: list[str], mirror: Path, files: list[Path]) -> None:
"""Formats `files` (under `mirror`) in place, each as a standalone module."""
if not files:
return
command = rustfmt + [
f"--config-path={RUSTFMT_CONFIG}",
"--unstable-features",
"--skip-children",
"--quiet",
]
# Command lines are bounded (Windows: 32 KiB).
with ThreadPoolExecutor(os.cpu_count() or 1) as pool:
results = pool.map(
lambda chunk: run(command + [str(f) for f in chunk], capture_output=True),
chunks(files, 50),
)
failed = False
for result in results:
if result.returncode != 0:
failed = True
# The files are under the mirror; name them as in the tree.
sys.stderr.write(result.stderr.replace(f"{mirror}{os.sep}", ""))
if failed:
sys.exit("rustfmt failed")
def format_starlark(starlark_fmt: str, mirror: Path, files: list[Path]) -> None:
"""Formats `files` (under `mirror`) in place; paths select config overrides."""
if not files:
return
argfile = mirror / "starlark_fmt_files.txt"
argfile.write_text("".join(f"{f.relative_to(mirror)}\n" for f in files))
result = run(
[starlark_fmt, f"--config={STARLARK_FMT_CONFIG}", "fmt", f"@{argfile}"],
cwd=mirror,
)
if result.returncode != 0:
sys.exit("starlark_fmt failed")
# -- Driver -------------------------------------------------------------------
def tracked_files(paths: list[str]) -> list[PurePosixPath]:
"""Tracked and untracked files, minus ignored ones, as `arc f` sees them."""
result = run(
[
"git",
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
*paths,
],
cwd=ROOT,
stdout=subprocess.PIPE,
check=True,
)
return [PurePosixPath(p) for p in result.stdout.split("\0") if p]
def collect(paths: list[str]) -> dict[PurePosixPath, str]:
"""The files to format, with their current contents."""
files = {}
for path in tracked_files(paths):
if not (is_rust(path) or is_starlark(path)):
continue
file = ROOT / path
if file.is_symlink() or not file.is_file():
continue
try:
text = file.read_bytes().decode("utf-8")
except UnicodeDecodeError:
continue
if not skipped(path, text):
files[path] = text
return files
def format_all(
originals: dict[PurePosixPath, str], rustfmt: str | None, starlark_fmt: str | None
) -> dict[PurePosixPath, str]:
"""
The formatted contents of `originals`, for the files that change. Each
file is formatted in its internal form, under a mirror of the tree, and
rewritten back to the exported form.
"""
rust_files = [p for p in originals if is_rust(p)]
starlark_files = [p for p in originals if not is_rust(p)]
rustfmt_command = find_rustfmt(rustfmt) if rust_files else []
starlark_fmt_path = find_starlark_fmt(starlark_fmt) if starlark_files else ""
with tempfile.TemporaryDirectory(prefix="buck2-fmt-") as tmp:
mirror = Path(tmp)
for path, text in originals.items():
target = mirror / path
target.parent.mkdir(parents=True, exist_ok=True)
internal = to_internal(text.replace("\r\n", "\n"), comment_start(path))
target.write_bytes(internal.encode("utf-8"))
format_rust(rustfmt_command, mirror, [mirror / p for p in rust_files])
format_starlark(starlark_fmt_path, mirror, [mirror / p for p in starlark_files])
changed = {}
for path, text in originals.items():
formatted = (mirror / path).read_bytes().decode("utf-8")
formatted = to_exported(formatted, comment_start(path))
if "\r\n" in text:
formatted = formatted.replace("\n", "\r\n")
if formatted != text:
changed[path] = formatted
return changed
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument(
"--check",
action="store_true",
help="Report files that would change instead of rewriting them",
)
parser.add_argument(
"--rustfmt",
help="rustfmt executable to use instead of the pinned nightly through rustup",
)
parser.add_argument(
"--starlark-fmt",
help="starlark_fmt executable to use instead of building it with cargo",
)
parser.add_argument(
"paths", nargs="*", help="Files or directories to format (default: all)"
)
args = parser.parse_args()
for required in [RUSTFMT_CONFIG, STARLARK_FMT_CONFIG]:
if not required.exists():
sys.exit(
f"{required.relative_to(ROOT)} is missing; run from a full checkout"
)
originals = collect(args.paths)
changed = format_all(originals, args.rustfmt, args.starlark_fmt)
if not changed:
print(f"{len(originals)} files already formatted")
return 0
if not args.check:
for path, formatted in changed.items():
(ROOT / path).write_bytes(formatted.encode("utf-8"))
print(f"Reformatted {len(changed)} file(s)")
return 0
for path, formatted in changed.items():
sys.stdout.writelines(
difflib.unified_diff(
originals[path].splitlines(keepends=True),
formatted.splitlines(keepends=True),
f"a/{path}",
f"b/{path}",
)
)
print(
f"\n{len(changed)} file(s) are not formatted; "
f"run `python3 {Path(__file__).name}` to fix them:"
)
print("".join(f" {p}\n" for p in changed), end="")
return 1
if __name__ == "__main__":
sys.exit(main())