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