1#!/usr/bin/env node
2// autofmt v2 by paper clover
3// https://git.paperclover.net/nix/config/src/branch/main/packages/autofmt/autofmt.js
4//
5// Different codebases use different formatters. Autofmt looks for project
6// configuration to pick the correct formatter, allowing an editor to simply
7// point to this script and ambiguities resolved. This single file program
8// depends only on Node.js v22.
9//
10// When using this repository's Nix-based Neovim configuration, autofmt is
11// automatically configured as the default formatter. To configure manually,
12// set the editor's formatter to run `autofmt --stdio=<filename>`. The filename
13// is used to determine which formatter to invoke.
14//
15// With `conform.nvim`:
16// formatters = {
17// autofmt = { command = "autofmt" }
18// }
19//
20// With Zed:
21// "formatter": {
22// "external": {
23// "command": "autofmt",
24// "arguments": ["--stdio", "{buffer_path}"]
25// }
26// },
27//
28// @ts-nocheck
29
30// -- definitions --
31const extensions = {
32 c: [".c", ".h"],
33 cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".hh"],
34 css: [".css"],
35 html: [".html"],
36 javascript: [".js", ".ts", ".cjs", ".cts", ".mjs", ".mts", ".jsx", ".tsx"],
37 json: [".json", ".jsonc"],
38 markdown: [".md", ".markdown"],
39 mdx: [".mdx"],
40 nix: [".nix"],
41 rust: [".rs"],
42 toml: [".toml"],
43 yaml: [".yml", ".yaml"],
44 zig: [".zig"],
45};
46const formatters = {
47 // this object is sorted by priority
48 //
49 // `languages: true` are multiplexers that apply to all listed
50 // languages, as it is assumed a project will setup their multiplexer
51 // correctly for all tracked files.
52 //
53 // if a formatter doesnt match a config file, the first one is picked
54 // as a default, making things like `deno fmt file.ts` or `clang-format`
55 // the default when there are no config files.
56 dprint: {
57 languages: true,
58 files: ["dprint.json", "dprint.jsonc"],
59 cmd: ["dprint", "fmt", "--", "$files"],
60 stdin: (file) => ["dprint", "fmt", "--stdin", file],
61 },
62 treefmt: {
63 languages: true,
64 files: ["treefmt.toml", ".treefmt.toml"],
65 cmd: ["treefmt", "--", "$files"],
66 stdin: (file) => ["treefmt", "--stdin", file],
67 },
68 vp: {
69 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
70 mustMatchFile: true,
71 files: ["node_modules/.bin/vp"],
72 cmd: ["node_modules/.bin/vp", "fmt", "$files"],
73 stdin: (file) => [
74 "node_modules/.bin/vp",
75 "fmt",
76 "--stdin-filepath",
77 file,
78 ],
79 },
80 // -- web ecosystem --
81 oxfmt: {
82 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
83 mustMatchFile: true,
84 files: ["node_modules/.bin/oxfmt", ".oxfmtrc.json", ".oxfmtrc.jsonc"],
85 cmd: ["node_modules/.bin/oxfmt", "$files"],
86 stdin: (file) => ["node_modules/.bin/oxfmt", `--stdin-filepath=${file}`],
87 },
88 prettierd: {
89 // keep in mind that pretterd never exits.
90 // https://github.com/fsouza/prettierd/issues/645
91 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
92 mustMatchFile: true,
93 files: { "node_modules/.bin/prettier": () => which("prettierd") },
94 cmd: null,
95 stdin: (file) => ["prettierd", file],
96 },
97 prettier: {
98 languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
99 mustMatchFile: true,
100 files: ["node_modules/.bin/prettier"],
101 cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],
102 stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],
103 },
104 biome: {
105 languages: ["javascript", "json", "css"],
106 mustMatchFile: true,
107 files: ["node_modules/.bin/biome"],
108 cmd: ["node_modules/.bin/biome", "format", "--", "$files"],
109 stdin: (file) => [
110 "node_modules/bin/biome",
111 "format",
112 `--stdin-file-path=${file}`,
113 ],
114 },
115 deno: {
116 languages: ["javascript", "markdown", "json", "yaml", "html", "css"],
117 files: ["deno.json", "deno.jsonc"],
118 cmd: ["deno", "fmt", "--", "$files"],
119 stdin: (file) => ["deno", "fmt", "--ext", path.extname(file).slice(1), "-"],
120 },
121 "nixfmt": {
122 languages: ["nix"],
123 files: {
124 "flake.nix": ({ text }) => text.includes("nixfmt"),
125 },
126 cmd: ["nixfmt", "--", "$files"],
127 stdin: (file) => ["nixfmt", `--filename=${file}`],
128 },
129 alejandra: {
130 languages: ["nix"],
131 files: {
132 "flake.nix": ({ text }) => text.includes("alejandra"),
133 },
134 cmd: ["alejandra", "--", "$files"],
135 stdin: () => ["alejandra"],
136 },
137 clang: {
138 languages: ["c", "cpp"],
139 files: true,
140 cmd: ["clang-format", "--", "$files"],
141 stdin: (file) => ["clang-format", `--assume-filename=${file}`],
142 },
143 zig: {
144 languages: ["zig"],
145 files: true,
146 cmd: ["zig", "fmt", "$files"],
147 stdin: (file) => [
148 "zig",
149 "fmt",
150 "--stdin",
151 ...file.endsWith(".zon") ? ["--zon"] : [],
152 ],
153 },
154 rustfmt: {
155 languages: ["rust"],
156 files: true,
157 cmd: ["rustfmt", "--", "$files"],
158 stdin: () => ["rustfmt"],
159 },
160 taplo: {
161 languages: ["toml"],
162 files: ["taplo.toml"],
163 cmd: ["taplo", "format", "--", "$files"],
164 cmd: () => ["taplo", "format", "-"],
165 },
166};
167
168// -- cli --
169if (!fs.globSync) {
170 console.error(`error: autofmt must be run with Node.js v22 or newer`);
171 process.exit(1);
172}
173const [, bin, ...argv] = process.argv;
174let inputs = [];
175let globalExclude = [];
176let gitignore = true;
177let dryRun = false;
178let stdio = null;
179let excludes = [".git"];
180while (argv.length > 0) {
181 const arg = argv.shift();
182 if (arg === "-h" || arg === "--help") usage();
183 else if (arg === "--no-gitignore") gitignore = false;
184 else if (arg === "--dry-run") dryRun = true;
185 else if (arg.match(/^--stdio=./)) {
186 if (stdio) {
187 console.error("error: can only pass --stdio once");
188 usage();
189 }
190 stdio = arg.slice("--stdio=".length);
191 } else if (arg === "--stdio") {
192 const value = argv.shift();
193 if (!value) {
194 console.error("error: missing value for --stdio");
195 usage();
196 }
197 if (stdio) {
198 console.error("error: can only pass --stdio once");
199 usage();
200 }
201 stdio = value;
202 } else if (arg.match(/^--exclude=./)) {
203 excludes.push(arg.slice("--exclude=".length));
204 } else if (arg === "--") {
205 inputs.push(...argv);
206 break;
207 } else if (arg.startsWith("-")) {
208 console.error("error: unknown option " + JSON.stringify(arg));
209 usage();
210 } else inputs.push(arg);
211}
212function usage() {
213 const exe = path.basename(bin);
214 console.error(`usage: ${exe} [...files or directories]`);
215 console.error(``);
216 console.error(`uses the right formatter for the job (by scanning config)`);
217 console.error(`when autofmt reads dirs, it will respect gitignore`);
218 console.error(``);
219 console.error(`to format current directory recursively, run '${exe} .'`);
220 console.error(``);
221 console.error(`options:`);
222 console.error(` --dry-run print commands instead of running them`);
223 console.error(` --no-gitignore do not read '.gitignore'`);
224 console.error(` --exclude=<glob> add an exclusion glob`);
225 console.error(` --stdio=<filename> read/write contents via stdin/stdout`);
226 console.error(``);
227 process.exit(1);
228}
229if (inputs.length === 0 && !stdio) usage();
230if (stdio && inputs.length > 0) {
231 console.error("error: stdio mode only operates on one file");
232 process.exit(1);
233}
234const { sep } = path;
235
236// -- disable warnings --
237const { emit: originalEmit } = process;
238const warnings = ["ExperimentalWarning"];
239process.emit = function (event, error) {
240 return event === "warning" && warnings.includes(error.name)
241 ? false
242 : originalEmit.apply(process, arguments);
243};
244
245// -- vars --
246const extToLanguage = Object.fromEntries(
247 Object.entries(extensions).flatMap(([lang, exts]) =>
248 exts.map((ext) => [ext, lang])
249 ),
250);
251const multis = Object.keys(formatters).filter(
252 (x) => formatters[x].languages === true,
253);
254const files = [];
255const gitignores = new Map();
256const dirs = new Map();
257
258// -- stdin mode --
259if (stdio) {
260 const fmtWithPath = pickFormatter(stdio);
261 if (!fmtWithPath) {
262 console.error(`No formatter configured for ${path.relative(".", stdio)}`);
263 process.exit(1);
264 }
265 let [fmt, cwd] = fmtWithPath.split("\0");
266 let cmd = formatters[fmt].stdin(stdio);
267 cwd ??= process.cwd();
268 if (dryRun) {
269 console.info(cmd.join(" "));
270 process.exit(0);
271 }
272 const proc = child_process.spawn(cmd[0], cmd.slice(1), {
273 stdio: ["inherit", "inherit", "inherit"],
274 });
275 proc.on("error", (e) => {
276 let message = "";
277 if (e?.code === "ENOENT") {
278 message = `${cmd[0]} is not installed`;
279 } else {
280 message = String(e?.message ?? e);
281 }
282 console.error(`error: ${message}`);
283 process.exit(1);
284 });
285 const [code] = await events.once(proc, "exit");
286 process.exit(code ?? 1);
287}
288
289// -- decide what formatters to run
290inputs = inputs.map((x) => path.resolve(x));
291inputs.forEach(walk);
292const toRun = new Map();
293for (const file of new Set(files)) {
294 const fmt = pickFormatter(file);
295 if (!fmt) {
296 if (inputs.includes(file)) {
297 console.warn(`No formatter configured for ${path.relative(".", file)}`);
298 }
299 continue;
300 }
301 let list = toRun.get(fmt);
302 list ?? toRun.set(fmt, list = []);
303 list.push(file);
304}
305
306// -- create a list of commands --
307const commands = [];
308let totalFiles = 0;
309for (const [fmtWithPath, files] of toRun) {
310 let [fmt, cwd] = fmtWithPath.split("\0");
311 let { cmd } = formatters[fmt];
312 if (cwd) cmd = [path.join(cwd, cmd[0]), ...cmd.slice(1)];
313 cwd ??= process.cwd();
314 let i = 0;
315 totalFiles += files.length;
316 if ((i = cmd.indexOf("$files")) != -1) {
317 const c = cmd.slice();
318 c.splice(i, 1, ...files);
319 commands.push({ cmd: c, cwd, files });
320 } else if ((i = cmd.indexOf("$file")) != -1) {
321 for (const file of files) {
322 const c = cmd.slice();
323 c.splice(i, 1, file);
324 commands.push({ cmd: c, cwd, files: [file] });
325 }
326 } else {
327 throw new Error(`Formatter ${fmt} has incorrectly configured command.`);
328 }
329}
330if (commands.length === 0) {
331 console.error("No formattable files");
332 process.exit(0);
333}
334
335// -- dry run mode --
336if (dryRun) {
337 for (const { cmd } of commands) {
338 console.info(cmd);
339 }
340 process.exit(0);
341}
342
343// -- user interface --
344let filesComplete = 0;
345let lastFile = commands[0].cmd;
346const syncStart = "\u001B[?2026h";
347const syncEnd = "\u001B[?2026l";
348let buffer = "";
349let statusVisible = false;
350const tty = process.stderr.isTTY;
351function writeStatus() {
352 if (!tty) return;
353 clearStatus();
354 buffer ||= syncStart;
355 buffer += `${filesComplete}/${totalFiles} - ${lastFile}`;
356 statusVisible = true;
357}
358function clearStatus() {
359 if (!tty) return;
360 if (!statusVisible) return;
361 buffer ||= syncStart;
362 buffer += "\r\x1b[2K\r";
363 statusVisible = false;
364}
365function flush() {
366 if (!buffer) return;
367 const width = Math.max(1, process.stderr.columns - 1);
368 process.stderr.write(
369 buffer.split("\n").map((x) => x.slice(0, width)).join("\n") + syncEnd,
370 );
371 buffer = "";
372}
373
374// -- async process queue --
375let running = 0;
376/** @param cmd {{ cmd: string, cwd: string, files: string[] }} */
377function run({ cmd, cwd, files }) {
378 running += 1;
379 let c = child_process.spawn(cmd[0], cmd.slice(1), {
380 stdio: ["ignore", "pipe", "pipe"],
381 cwd,
382 });
383 const relatives = new Set(
384 files.map((file) => file.startsWith(cwd) ? path.relative(cwd, file) : file),
385 );
386 function onLine(line) {
387 for (const file of relatives) {
388 if (line.includes(file)) {
389 filesComplete += 1;
390 relatives.delete(file);
391 lastFile = path.relative(process.cwd(), path.resolve(cwd, file));
392 if (tty) {
393 writeStatus();
394 flush();
395 } else {
396 console.info(lastFile);
397 }
398 return;
399 }
400 }
401 }
402 let errBuffer = "";
403 let exited = false;
404 c.on("error", (e) => {
405 let message = "";
406 if (e?.code === "ENOENT") {
407 if (cmd[0].includes("/")) {
408 running -= 1;
409 run({ cmd: [path.basename(cmd[0]), ...cmd.slice(1)], cwd, files });
410 return;
411 }
412 message = `${cmd[0]} is not installed`;
413 } else {
414 message = String(e?.message ?? e);
415 }
416 clearStatus();
417 const filesConcise =
418 path.relative(".", path.resolve(cwd, relatives.keys().next().value)) + (
419 relatives.size > 1 ? ` and ${relatives.size - 1} more` : ""
420 );
421 buffer += errBuffer + `error: ${message}, cannot format ${filesConcise}.\n`;
422 flush();
423 exited = true;
424 runNext();
425 });
426 readline.createInterface(c.stderr).addListener("line", (line) => {
427 errBuffer += line + "\n";
428 onLine(line);
429 });
430 readline.createInterface(c.stdout).addListener("line", onLine);
431 c.on("exit", (code, signal) => {
432 if (exited) return;
433 exited = true;
434 if (code !== 0) {
435 clearStatus();
436 const exitStatus = code != null ? `code ${code}` : `signal ${signal}`;
437 buffer += errBuffer + `error: ${cmd[0]} exited with ${exitStatus}\n`;
438 flush();
439 } else {
440 filesComplete += relatives.size;
441 if (relatives.size) {
442 lastFile = path.relative(
443 ".",
444 path.resolve(cwd, relatives.keys().next().value),
445 );
446 }
447 writeStatus();
448 flush();
449 }
450 runNext();
451 });
452 function runNext() {
453 running -= 1;
454 const next = commands.pop();
455 if (next) run(next);
456 else if (running == 0) {
457 clearStatus();
458 flush();
459 console.info(
460 `Formatted ${filesComplete} file${filesComplete !== 1 ? "s" : ""}`,
461 );
462 }
463 }
464}
465
466for (let i = 0; i < navigator.hardwareConcurrency; i++) {
467 const cmd = commands.pop();
468 if (cmd) run(cmd);
469 else break;
470}
471
472// -- library functions --
473
474/** @param file {string} */
475function walk(file) {
476 file = path.resolve(file);
477 try {
478 if (fs.statSync(file).isDirectory()) {
479 const exclude = getGitIgnores(file);
480 const read = fs
481 .globSync(escapeGlob(file) + "/{**,.**}", {
482 exclude,
483 withFileTypes: true,
484 })
485 .filter((file) => !file.isDirectory())
486 .map((file) => path.join(file.parentPath, file.name));
487 files.push(...read);
488 } else {
489 files.push(file);
490 }
491 } catch (err) {
492 console.error(
493 `Failed to stat ${file}: ${err?.code ?? err?.message ?? err}`,
494 );
495 process.exit(1);
496 }
497}
498
499/** @param dir {string} @returns {Array<string>} */
500function readDir(dir) {
501 dir = path.resolve(dir);
502 let contents = dirs.get(dir);
503 if (!contents) {
504 try {
505 contents = fs.readdirSync(dir);
506 } catch {
507 contents = [];
508 }
509 dirs.set(dir, contents);
510 }
511 return contents;
512}
513
514/** @param dir {string} @returns {string} */
515function pickFormatter(file) {
516 const lang = extToLanguage[path.extname(file)];
517 const dir = path.dirname(file);
518 const possible = Object.keys(formatters).filter(
519 (x) =>
520 Array.isArray(formatters[x].languages) &&
521 formatters[x].languages.includes(lang),
522 );
523 const order = [...multis, ...possible];
524 for (const fmt of order) {
525 if (formatters[fmt][stdio ? "stdin" : "cmd"] == null) continue;
526 const found = walkUp(dir, (x) => {
527 const children = readDir(x);
528 let matches = false;
529 if (formatters[fmt].files === true) {
530 matches = true;
531 }
532 if (!matches) {
533 const filesToCheck = Array.isArray(formatters[fmt].files)
534 ? formatters[fmt].files.map((base) => ({ base, check: true }))
535 : Object.entries(formatters[fmt].files)
536 .map(([base, check]) => ({ base, check }));
537 for (const { base, check } of filesToCheck) {
538 let fileMatches;
539 if (base.includes("/")) {
540 fileMatches = readDir(path.join(x, path.dirname(base))) //
541 .includes(path.basename(base));
542 } else {
543 fileMatches = children.includes(base);
544 }
545 if (fileMatches && typeof check === "function") {
546 let text;
547 fileMatches = !!check({
548 get text() {
549 return text ??= fs.readFileSync(path.join(x, base), "utf-8");
550 },
551 });
552 }
553 matches ||= fileMatches;
554 }
555 }
556 if (matches) return `${fmt}\0${x}`;
557 });
558 if (found) return found;
559 }
560 return possible.find((fmt) => !formatters[fmt].mustMatchFile);
561}
562
563/** @param dir {string} @param find {(x: string) => any} */
564function walkUp(dir, find) {
565 do {
566 const found = find(dir);
567 if (found != null) return found;
568 const parent = path.dirname(dir);
569 if (parent === dir) break;
570 dir = parent;
571 } while (true);
572 return null;
573}
574
575/** @param dir {string} */
576function getGitIgnores(dir) {
577 if (!gitignore) return [];
578 if (dir.endsWith(sep)) dir = dir.slice(0, -1);
579 const files = fs.globSync(`${dir}${sep}{**${sep}*${sep},}.gitignore`, {});
580 const referenced = [];
581 for (const abs of files) {
582 const dir = path.dirname(abs);
583 referenced.push(dir);
584 if (!gitignores.has(dir)) gitignores.set(dir, readGitIgnore(dir));
585 }
586 do {
587 const parent = path.dirname(dir);
588 if (parent === dir || fs.existsSync(path.join(dir, ".git"))) break;
589 referenced.push(parent);
590 if (!gitignores.has(parent)) gitignores.set(parent, readGitIgnore(parent));
591 dir = parent;
592 } while (true);
593 return referenced.flatMap((root) =>
594 (gitignores.get(root) ?? [])
595 .filter((x) => x[0] !== "!")
596 .map(
597 (rule) => `${root}${sep}${rule[0] === "/" ? "" : `**${sep}`}${rule}`,
598 )
599 );
600}
601
602/** @param dir {string} */
603function readGitIgnore(dir) {
604 try {
605 return fs
606 .readFileSync(`${dir}${sep}.gitignore`, "utf-8")
607 .split("\n")
608 .map((line) => line.replace(/#.*$/, "").trim())
609 .filter(Boolean);
610 } catch {
611 return [];
612 }
613}
614
615/** @param {string} name @returns {string | null} */
616export function which(name) {
617 const paths =
618 process.env.PATH?.split(process.platform === "win32" ? ";" : ":") ?? [];
619 const exts = process.platform === "win32"
620 ? (process.env.PATHEXT?.split(";") ?? [".exe", ".cmd", ".bat", ".com"])
621 : [""];
622
623 for (const dir of paths) {
624 if (!dir) continue;
625 try {
626 const entries = readDir(dir);
627 for (const ext of exts) {
628 const target = name + ext;
629 if (entries.includes(target)) {
630 return dir + sep + target;
631 }
632 }
633 } catch {}
634 }
635 return null;
636}
637
638function escapeGlob(str) {
639 return str.replace(/[\\*,{}]/g, "\\$&");
640}
641
642import * as child_process from "node:child_process";
643import * as fs from "node:fs";
644import * as path from "node:path";
645import * as readline from "node:readline";
646import * as events from "node:events";
647import process from "node:process";
648import assert from "node:assert";