594 lines
16 KiB
JavaScript
594 lines
16 KiB
JavaScript
|
#!/usr/bin/env node
|
||
|
// autofmt v1 - https://paperclover.dev/nix/config/branch/main/files/autofmt.js
|
||
|
//
|
||
|
// Different codebases use different formatters. Autofmt looks for project
|
||
|
// configuration to pick the correct formatter, allowing an editor to simply
|
||
|
// point to this script and ambiguities resolved. This single file program
|
||
|
// depends only on Node.js v22.
|
||
|
//
|
||
|
// When using this repository's Nix-based Neovim configuration, autofmt is
|
||
|
// automatically configured as the default formatter. To configure manually,
|
||
|
// set the editor's formatter to run `autofmt --stdio=<filename>`. The filename
|
||
|
// is used to determine which formatter to invoke.
|
||
|
//
|
||
|
// With `conform.nvim`:
|
||
|
// formatters = {
|
||
|
// autofmt = { command = "autofmt" }
|
||
|
// }
|
||
|
//
|
||
|
// With Zed:
|
||
|
// "formatter": {
|
||
|
// "external": {
|
||
|
// "command": "autofmt",
|
||
|
// "arguments": ["--stdio", "{buffer_path}"]
|
||
|
// }
|
||
|
// },
|
||
|
//
|
||
|
// @ts-nocheck
|
||
|
|
||
|
// -- definitions --
|
||
|
const extensions = {
|
||
|
c: [".c", ".h"],
|
||
|
cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".hh"],
|
||
|
css: [".css"],
|
||
|
html: [".html"],
|
||
|
javascript: [".js", ".ts", ".cjs", ".cts", ".mjs", ".mts", ".jsx", ".tsx"],
|
||
|
json: [".json", ".jsonc"],
|
||
|
markdown: [".md", ".markdown"],
|
||
|
mdx: [".mdx"],
|
||
|
nix: [".nix"],
|
||
|
rust: [".rs"],
|
||
|
toml: [".toml"],
|
||
|
yaml: [".yml", ".yaml"],
|
||
|
zig: [".zig"],
|
||
|
};
|
||
|
const formatters = {
|
||
|
// this object is sorted by priority
|
||
|
//
|
||
|
// `languages: true` are multiplexers that apply to all listed
|
||
|
// languages, as it is assumed a project will setup their multiplexer
|
||
|
// correctly for all tracked files.
|
||
|
//
|
||
|
// if a formatter doesnt match a config file, the first one is picked
|
||
|
// as a default, making things like `deno fmt file.ts` or `clang-format`
|
||
|
// the default when there are no config files.
|
||
|
dprint: {
|
||
|
languages: true,
|
||
|
files: ["dprint.json", "dprint.jsonc"],
|
||
|
cmd: ["dprint", "fmt", "--", "$files"],
|
||
|
stdin: (file) => ["dprint", "fmt", "--stdin", file],
|
||
|
},
|
||
|
treefmt: {
|
||
|
languages: true,
|
||
|
files: ["treefmt.toml", ".treefmt.toml"],
|
||
|
cmd: ["treefmt", "--", "$files"],
|
||
|
stdin: (file) => ["treefmt", "--stdin", file],
|
||
|
},
|
||
|
// -- web ecosystem --
|
||
|
deno: {
|
||
|
languages: ["javascript", "markdown", "json", "yaml", "html", "css"],
|
||
|
files: ["dprint.json", "dprint.jsonc"],
|
||
|
cmd: ["deno", "fmt", "--", "$files"],
|
||
|
stdin: (file) => ["deno", "fmt", "--ext", path.extname(file).slice(1), "-"],
|
||
|
},
|
||
|
prettier: {
|
||
|
languages: ["javascript", "markdown", "json", "yaml", "mdx", "html", "css"],
|
||
|
files: ["node_modules/.bin/prettier"],
|
||
|
cmd: ["node_modules/.bin/prettier", "--write", "--", "$files"],
|
||
|
stdin: (file) => ["node_modules/.bin/prettier", "--stdin-filepath", file],
|
||
|
},
|
||
|
biome: {
|
||
|
languages: ["javascript", "json", "css"],
|
||
|
files: ["node_modules/.bin/biome"],
|
||
|
cmd: ["node_modules/.bin/biome", "format", "--", "$files"],
|
||
|
stdin: (file) => [
|
||
|
"node_modules/bin/biome",
|
||
|
"format",
|
||
|
`--stdin-file-path=${file}`,
|
||
|
],
|
||
|
},
|
||
|
"nixfmt-rfc-style": {
|
||
|
languages: ["nix"],
|
||
|
files: {
|
||
|
"flake.nix": (contents) => contents.includes("nixfmt"),
|
||
|
},
|
||
|
cmd: ["nixfmt", "--", "$files"],
|
||
|
stdin: (file) => ["nixfmt", `--filename=${file}`],
|
||
|
},
|
||
|
alejandra: {
|
||
|
languages: ["nix"],
|
||
|
files: {
|
||
|
"flake.nix": (contents) => contents.includes("alejandra"),
|
||
|
},
|
||
|
cmd: ["alejandra", "--", "$files"],
|
||
|
stdin: () => ["alejandra"],
|
||
|
},
|
||
|
clang: {
|
||
|
languages: ["c", "cpp"],
|
||
|
files: true,
|
||
|
cmd: ["clang-format", "--", "$files"],
|
||
|
stdin: (file) => ["clang-format", `--assume-filename=${file}`],
|
||
|
},
|
||
|
zig: {
|
||
|
languages: ["zig"],
|
||
|
files: true,
|
||
|
cmd: ["zig", "fmt", "$files"],
|
||
|
stdin: (file) => [
|
||
|
"zig",
|
||
|
"fmt",
|
||
|
"--stdin",
|
||
|
...file.endsWith(".zon") ? ["--zon"] : [],
|
||
|
],
|
||
|
},
|
||
|
rustfmt: {
|
||
|
languages: ["rust"],
|
||
|
files: true,
|
||
|
cmd: ["rustfmt", "--", "$files"],
|
||
|
stdin: () => ["rustfmt"],
|
||
|
},
|
||
|
taplo: {
|
||
|
languages: ["toml"],
|
||
|
files: ["taplo.toml"],
|
||
|
cmd: ["taplo", "format", "--", "$files"],
|
||
|
cmd: () => ["taplo", "format", "-"],
|
||
|
},
|
||
|
};
|
||
|
|
||
|
// -- cli --
|
||
|
if (!fs.globSync) {
|
||
|
console.error(`error: autofmt must be run with Node.js v22 or newer`);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
const [, bin, ...argv] = process.argv;
|
||
|
let inputs = [];
|
||
|
let globalExclude = [];
|
||
|
let gitignore = true;
|
||
|
let dryRun = false;
|
||
|
let stdio = null;
|
||
|
let excludes = [".git"];
|
||
|
while (argv.length > 0) {
|
||
|
const arg = argv.shift();
|
||
|
if (arg === "-h" || arg === "--help") usage();
|
||
|
else if (arg === "--no-gitignore") gitignore = false;
|
||
|
else if (arg === "--dry-run") dryRun = true;
|
||
|
else if (arg.match(/^--stdio=./)) {
|
||
|
if (stdio) {
|
||
|
console.error("error: can only pass --stdio once");
|
||
|
usage();
|
||
|
}
|
||
|
stdio = arg.slice("--stdio=".length);
|
||
|
} else if (arg === "--stdio") {
|
||
|
const value = argv.shift();
|
||
|
if (!value) {
|
||
|
console.error("error: missing value for --stdio");
|
||
|
usage();
|
||
|
}
|
||
|
if (stdio) {
|
||
|
console.error("error: can only pass --stdio once");
|
||
|
usage();
|
||
|
}
|
||
|
stdio = value;
|
||
|
} else if (arg.match(/^--exclude=./)) {
|
||
|
excludes.push(arg.slice("--exclude=".length));
|
||
|
} else if (arg === "--") {
|
||
|
inputs.push(...argv);
|
||
|
break;
|
||
|
} else if (arg.startsWith("-")) {
|
||
|
console.error("error: unknown option " + JSON.stringify(arg));
|
||
|
usage();
|
||
|
} else inputs.push(arg);
|
||
|
}
|
||
|
function usage() {
|
||
|
const exe = path.basename(bin);
|
||
|
console.error(`usage: ${exe} [...files or directories]`);
|
||
|
console.error(``);
|
||
|
console.error(`uses the right formatter for the job (by scanning config)`);
|
||
|
console.error(`when autofmt reads dirs, it will respect gitignore`);
|
||
|
console.error(``);
|
||
|
console.error(`to format current directory recursively, run '${exe} .'`);
|
||
|
console.error(``);
|
||
|
console.error(`options:`);
|
||
|
console.error(` --dry-run print commands instead of running them`);
|
||
|
console.error(` --no-gitignore do not read '.gitignore'`);
|
||
|
console.error(` --exclude=<glob> add an exclusion glob`);
|
||
|
console.error(` --stdio=<filename> read/write contents via stdin/stdout`);
|
||
|
console.error(``);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
if (inputs.length === 0 && !stdio) usage();
|
||
|
if (stdio && inputs.length > 0) {
|
||
|
console.error("error: stdio mode only operates on one file");
|
||
|
process.exit(1);
|
||
|
}
|
||
|
const { sep } = path;
|
||
|
|
||
|
// -- disable warnings --
|
||
|
const { emit: originalEmit } = process;
|
||
|
const warnings = ["ExperimentalWarning"];
|
||
|
process.emit = function (event, error) {
|
||
|
return event === "warning" && warnings.includes(error.name)
|
||
|
? false
|
||
|
: originalEmit.apply(process, arguments);
|
||
|
};
|
||
|
|
||
|
// -- vars --
|
||
|
const extToLanguage = Object.fromEntries(
|
||
|
Object.entries(extensions).flatMap(([lang, exts]) =>
|
||
|
exts.map((ext) => [ext, lang])
|
||
|
),
|
||
|
);
|
||
|
const multis = Object.keys(formatters).filter(
|
||
|
(x) => formatters[x].languages === true,
|
||
|
);
|
||
|
const files = [];
|
||
|
const gitignores = new Map();
|
||
|
const dirs = new Map();
|
||
|
const cached = new Map();
|
||
|
|
||
|
// -- stdin mode --
|
||
|
if (stdio) {
|
||
|
const fmtWithPath = pickFormatter(stdio);
|
||
|
if (!fmtWithPath) {
|
||
|
console.error(`No formatter configured for ${path.relative(".", stdio)}`);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
let [fmt, cwd] = fmtWithPath.split("\0");
|
||
|
let cmd = formatters[fmt].stdin(stdio);
|
||
|
cwd ??= process.cwd();
|
||
|
if (dryRun) {
|
||
|
console.info(cmd.join(" "));
|
||
|
process.exit(0);
|
||
|
}
|
||
|
const proc = child_process.spawn(cmd[0], cmd.slice(1), {
|
||
|
stdio: ["inherit", "inherit", "inherit"],
|
||
|
});
|
||
|
proc.on("error", (e) => {
|
||
|
let message = "";
|
||
|
if (e?.code === "ENOENT") {
|
||
|
message = `${cmd[0]} is not installed`;
|
||
|
} else {
|
||
|
message = String(e?.message ?? e);
|
||
|
}
|
||
|
console.error(`error: ${message}`);
|
||
|
process.exit(1);
|
||
|
});
|
||
|
const [code] = await events.once(proc, "exit");
|
||
|
process.exit(code ?? 1);
|
||
|
}
|
||
|
|
||
|
// -- decide what formatters to run
|
||
|
inputs = inputs.map((x) => path.resolve(x));
|
||
|
inputs.forEach(walk);
|
||
|
const toRun = new Map();
|
||
|
for (const file of new Set(files)) {
|
||
|
const fmt = pickFormatter(file);
|
||
|
if (!fmt) {
|
||
|
if (inputs.includes(file)) {
|
||
|
console.warn(`No formatter configured for ${path.relative(".", file)}`);
|
||
|
}
|
||
|
continue;
|
||
|
}
|
||
|
let list = toRun.get(fmt);
|
||
|
list ?? toRun.set(fmt, list = []);
|
||
|
list.push(file);
|
||
|
}
|
||
|
|
||
|
// -- create a list of commands --
|
||
|
const commands = [];
|
||
|
let totalFiles = 0;
|
||
|
for (const [fmtWithPath, files] of toRun) {
|
||
|
let [fmt, cwd] = fmtWithPath.split("\0");
|
||
|
let { cmd } = formatters[fmt];
|
||
|
if (cwd) cmd = [path.join(cwd, cmd[0]), ...cmd.slice(1)];
|
||
|
cwd ??= process.cwd();
|
||
|
let i = 0;
|
||
|
totalFiles += files.length;
|
||
|
if ((i = cmd.indexOf("$files")) != -1) {
|
||
|
const c = cmd.slice();
|
||
|
c.splice(i, 1, ...files);
|
||
|
commands.push({ cmd: c, cwd, files });
|
||
|
} else if ((i = cmd.indexOf("$file")) != -1) {
|
||
|
for (const file of files) {
|
||
|
const c = cmd.slice();
|
||
|
c.splice(i, 1, file);
|
||
|
commands.push({ cmd: c, cwd, files: [file] });
|
||
|
}
|
||
|
} else {
|
||
|
throw new Error(`Formatter ${fmt} has incorrectly configured command.`);
|
||
|
}
|
||
|
}
|
||
|
if (commands.length === 0) {
|
||
|
console.error("No formattable files");
|
||
|
process.exit(0);
|
||
|
}
|
||
|
|
||
|
// -- dry run mode --
|
||
|
if (dryRun) {
|
||
|
for (const { cmd } of commands) {
|
||
|
console.info(cmd);
|
||
|
}
|
||
|
process.exit(0);
|
||
|
}
|
||
|
|
||
|
// -- user interface --
|
||
|
let filesComplete = 0;
|
||
|
let lastFile = commands[0].cmd;
|
||
|
const syncStart = "\u001B[?2026h";
|
||
|
const syncEnd = "\u001B[?2026l";
|
||
|
let buffer = "";
|
||
|
let statusVisible = false;
|
||
|
const tty = process.stderr.isTTY;
|
||
|
function writeStatus() {
|
||
|
if (!tty) return;
|
||
|
clearStatus();
|
||
|
buffer ||= syncStart;
|
||
|
buffer += `${filesComplete}/${totalFiles} - ${lastFile}`;
|
||
|
statusVisible = true;
|
||
|
}
|
||
|
function clearStatus() {
|
||
|
if (!tty) return;
|
||
|
if (!statusVisible) return;
|
||
|
buffer ||= syncStart;
|
||
|
buffer += "\r\x1b[2K\r";
|
||
|
statusVisible = false;
|
||
|
}
|
||
|
function flush() {
|
||
|
if (!buffer) return;
|
||
|
const width = Math.max(1, process.stderr.columns - 1);
|
||
|
process.stderr.write(
|
||
|
buffer.split("\n").map((x) => x.slice(0, width)).join("\n") + syncEnd,
|
||
|
);
|
||
|
buffer = "";
|
||
|
}
|
||
|
|
||
|
// -- async process queue --
|
||
|
let running = 0;
|
||
|
/** @param cmd {{ cmd: string, cwd: string, files: string[] }} */
|
||
|
function run({ cmd, cwd, files }) {
|
||
|
running += 1;
|
||
|
let c = child_process.spawn(cmd[0], cmd.slice(1), {
|
||
|
stdio: ["ignore", "pipe", "pipe"],
|
||
|
cwd,
|
||
|
});
|
||
|
const relatives = new Set(
|
||
|
files.map((file) => file.startsWith(cwd) ? path.relative(cwd, file) : file),
|
||
|
);
|
||
|
function onLine(line) {
|
||
|
for (const file of relatives) {
|
||
|
if (line.includes(file)) {
|
||
|
filesComplete += 1;
|
||
|
relatives.delete(file);
|
||
|
lastFile = path.relative(process.cwd(), path.resolve(cwd, file));
|
||
|
if (tty) {
|
||
|
writeStatus();
|
||
|
flush();
|
||
|
} else {
|
||
|
console.info(lastFile);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
let errBuffer = "";
|
||
|
let exited = false;
|
||
|
c.on("error", (e) => {
|
||
|
let message = "";
|
||
|
if (e?.code === "ENOENT") {
|
||
|
if (cmd[0].includes("/")) {
|
||
|
running -= 1;
|
||
|
run({ cmd: [path.basename(cmd[0]), ...cmd.slice(1)], cwd, files });
|
||
|
return;
|
||
|
}
|
||
|
message = `${cmd[0]} is not installed`;
|
||
|
} else {
|
||
|
message = String(e?.message ?? e);
|
||
|
}
|
||
|
clearStatus();
|
||
|
const filesConcise =
|
||
|
path.relative(".", path.resolve(cwd, relatives.keys().next().value)) + (
|
||
|
relatives.size > 1 ? ` and ${relatives.size - 1} more` : ""
|
||
|
);
|
||
|
buffer += errBuffer + `error: ${message}, cannot format ${filesConcise}.\n`;
|
||
|
flush();
|
||
|
exited = true;
|
||
|
runNext();
|
||
|
});
|
||
|
readline.createInterface(c.stderr).addListener("line", (line) => {
|
||
|
errBuffer += line + "\n";
|
||
|
onLine(line);
|
||
|
});
|
||
|
readline.createInterface(c.stdout).addListener("line", onLine);
|
||
|
c.on("exit", (code, signal) => {
|
||
|
if (exited) return;
|
||
|
exited = true;
|
||
|
if (code !== 0) {
|
||
|
clearStatus();
|
||
|
const exitStatus = code != null ? `code ${code}` : `signal ${signal}`;
|
||
|
buffer += errBuffer + `error: ${cmd[0]} exited with ${exitStatus}\n`;
|
||
|
flush();
|
||
|
} else {
|
||
|
filesComplete += relatives.size;
|
||
|
if (relatives.size) {
|
||
|
lastFile = path.relative(
|
||
|
".",
|
||
|
path.resolve(cwd, relatives.keys().next().value),
|
||
|
);
|
||
|
}
|
||
|
writeStatus();
|
||
|
flush();
|
||
|
}
|
||
|
runNext();
|
||
|
});
|
||
|
function runNext() {
|
||
|
running -= 1;
|
||
|
const next = commands.pop();
|
||
|
if (next) run(next);
|
||
|
else if (running == 0) {
|
||
|
clearStatus();
|
||
|
flush();
|
||
|
console.info(
|
||
|
`Formatted ${filesComplete} file${filesComplete !== 1 ? "s" : ""}`,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
for (let i = 0; i < navigator.hardwareConcurrency; i++) {
|
||
|
const cmd = commands.pop();
|
||
|
if (cmd) run(cmd);
|
||
|
else break;
|
||
|
}
|
||
|
|
||
|
// -- library functions --
|
||
|
|
||
|
/** @param file {string} */
|
||
|
function walk(file) {
|
||
|
file = path.resolve(file);
|
||
|
try {
|
||
|
if (fs.statSync(file).isDirectory()) {
|
||
|
const exclude = getGitIgnores(file);
|
||
|
const read = fs
|
||
|
.globSync(escapeGlob(file) + "/{**,.**}", {
|
||
|
exclude,
|
||
|
withFileTypes: true,
|
||
|
})
|
||
|
.filter((file) => !file.isDirectory())
|
||
|
.map((file) => path.join(file.parentPath, file.name));
|
||
|
files.push(...read);
|
||
|
} else {
|
||
|
files.push(file);
|
||
|
}
|
||
|
} catch (err) {
|
||
|
console.error(
|
||
|
`Failed to stat ${file}: ${err?.code ?? err?.message ?? err}`,
|
||
|
);
|
||
|
process.exit(1);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** @param dir {string} @returns {Array<string>} */
|
||
|
function readDir(dir) {
|
||
|
dir = path.resolve(dir);
|
||
|
let contents = dirs.get(dir);
|
||
|
if (!contents) {
|
||
|
try {
|
||
|
contents = fs.readdirSync(dir);
|
||
|
} catch {
|
||
|
contents = [];
|
||
|
}
|
||
|
dirs.set(dir, contents);
|
||
|
}
|
||
|
return contents;
|
||
|
}
|
||
|
|
||
|
/** @param dir {string} @returns {string} */
|
||
|
function pickFormatter(file) {
|
||
|
const lang = extToLanguage[path.extname(file)];
|
||
|
const dir = path.dirname(file);
|
||
|
let c = walkUp(dir, (x) => cached.get(x) ?? cached.get(`${x}:${lang}`));
|
||
|
if (c) return c;
|
||
|
|
||
|
const possible = Object.keys(formatters).filter(
|
||
|
(x) =>
|
||
|
Array.isArray(formatters[x].languages) &&
|
||
|
formatters[x].languages.includes(lang),
|
||
|
);
|
||
|
const order = [...multis, ...possible];
|
||
|
return walkUp(dir, (x) => {
|
||
|
const children = readDir(x);
|
||
|
for (const fmt of order) {
|
||
|
let matches = false;
|
||
|
if (formatters[fmt].files === true) {
|
||
|
matches = true;
|
||
|
}
|
||
|
if (!matches) {
|
||
|
const filesToCheck = Array.isArray(formatters[fmt].files)
|
||
|
? formatters[fmt].files.map((base) => ({ base, contents: true }))
|
||
|
: Object.entries(formatters[fmt].files)
|
||
|
.map(([base, contents]) => ({ base, contents }));
|
||
|
for (const { base, contents } of filesToCheck) {
|
||
|
if (base.includes("/")) {
|
||
|
matches = readDir(path.join(x, path.dirname(base))) //
|
||
|
.includes(path.basename(base));
|
||
|
} else {
|
||
|
matches = children.includes(base);
|
||
|
}
|
||
|
if (matches && typeof contents === "function") {
|
||
|
matches = !!contents(fs.readFileSync(path.join(x, base), "utf-8"));
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (matches) {
|
||
|
const formatterId = `${fmt}\0${x}`;
|
||
|
const k = multis.includes(fmt) ? x : `${x}:${lang}`;
|
||
|
cached.set(k, formatterId);
|
||
|
return formatterId;
|
||
|
}
|
||
|
}
|
||
|
}) ?? possible[0];
|
||
|
}
|
||
|
|
||
|
/** @param dir {string} @param find {(x: string) => any} */
|
||
|
function walkUp(dir, find) {
|
||
|
do {
|
||
|
const found = find(dir);
|
||
|
if (found != null) return found;
|
||
|
const parent = path.dirname(dir);
|
||
|
if (parent === dir) break;
|
||
|
dir = parent;
|
||
|
} while (true);
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/** @param dir {string} */
|
||
|
function getGitIgnores(dir) {
|
||
|
if (!gitignore) return [];
|
||
|
if (dir.endsWith(sep)) dir = dir.slice(0, -1);
|
||
|
const files = fs.globSync(`${dir}${sep}{**${sep}*${sep},}.gitignore`, {});
|
||
|
const referenced = [];
|
||
|
for (const abs of files) {
|
||
|
const dir = path.dirname(abs);
|
||
|
referenced.push(dir);
|
||
|
if (!gitignores.has(dir)) gitignores.set(dir, readGitIgnore(dir));
|
||
|
}
|
||
|
do {
|
||
|
const parent = path.dirname(dir);
|
||
|
if (parent === dir || fs.existsSync(path.join(dir, ".git"))) break;
|
||
|
referenced.push(parent);
|
||
|
if (!gitignores.has(parent)) gitignores.set(parent, readGitIgnore(parent));
|
||
|
dir = parent;
|
||
|
} while (true);
|
||
|
return referenced.flatMap((root) =>
|
||
|
(gitignores.get(root) ?? [])
|
||
|
.filter((x) => x[0] !== "!")
|
||
|
.map(
|
||
|
(rule) => `${root}${sep}${rule[0] === "/" ? "" : `**${sep}`}${rule}`,
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/** @param dir {string} */
|
||
|
function readGitIgnore(dir) {
|
||
|
try {
|
||
|
return fs
|
||
|
.readFileSync(`${dir}${sep}.gitignore`, "utf-8")
|
||
|
.split("\n")
|
||
|
.map((line) => line.replace(/#.*$/, "").trim())
|
||
|
.filter(Boolean);
|
||
|
} catch {
|
||
|
return [];
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function escapeGlob(str) {
|
||
|
return str.replace(/[\\*,{}]/g, "\\$&");
|
||
|
}
|
||
|
|
||
|
import * as child_process from "node:child_process";
|
||
|
import * as fs from "node:fs";
|
||
|
import * as path from "node:path";
|
||
|
import * as readline from "node:readline";
|
||
|
import * as events from "node:events";
|
||
|
import process from "node:process";
|
||
|
import assert from "node:assert";
|