| 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 | 69 | |
| 70 | 70 | # custom packages |
| 71 | 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 | } |