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