mirror of
https://github.com/kucherenko/jscpd.git
synced 2026-09-19 08:11:03 +08:00
7e7ae9aa44
CodeQL flagged the cell escaper for handling '|' but not '\'. Escape both, and unescape when descriptions are carried over from the previous file so the generator stays idempotent.
140 lines
5.8 KiB
JavaScript
140 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Regenerates FORMATS.md at the repository root from the tokenizer's format
|
|
// table (crates/cpd-tokenizer/src/formats.rs) and its shebang detection.
|
|
//
|
|
// node rust/scripts/gen-formats-md.mjs
|
|
//
|
|
// Extensions and format names come from the Rust source, so the document can
|
|
// never drift from what the binary accepts. The one-line descriptions are not
|
|
// in the source: they are read back from the existing FORMATS.md (if any) so
|
|
// they survive regeneration, and a new format without a description gets an
|
|
// empty cell until someone fills it in.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const rustRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const repoRoot = path.resolve(rustRoot, "..");
|
|
const formatsRs = path.join(rustRoot, "crates", "cpd-tokenizer", "src", "formats.rs");
|
|
const outFile = path.join(repoRoot, "FORMATS.md");
|
|
|
|
const source = fs.readFileSync(formatsRs, "utf8");
|
|
|
|
// ── Format table ──
|
|
const entryRe = /FormatEntry\s*\{\s*name:\s*"([^"]+)",\s*extensions:\s*&\[([^\]]*)\],/g;
|
|
const formats = [];
|
|
for (const m of source.matchAll(entryRe)) {
|
|
const extensions = [...m[2].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
|
|
formats.push({ name: m[1], extensions });
|
|
}
|
|
if (formats.length === 0) {
|
|
console.error(`no FormatEntry items parsed from ${formatsRs}`);
|
|
process.exit(1);
|
|
}
|
|
formats.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
// ── Shebang table ──
|
|
// `get_format_by_shebang` is a chain of `first_line.contains("x")` checks;
|
|
// pull every (interpreter → format) pair out of it in order.
|
|
const shebangFn = source.slice(source.indexOf("pub fn get_format_by_shebang"));
|
|
const shebangBody = shebangFn.slice(0, shebangFn.indexOf("\n}\n"));
|
|
const shebangs = [];
|
|
const branchRe = /if\s+([^{]+)\{\s*Some\("([^"]+)"\)/g;
|
|
for (const m of shebangBody.matchAll(branchRe)) {
|
|
const interpreters = [...m[1].matchAll(/contains\("([^"]+)"\)/g)].map((x) => x[1]);
|
|
if (interpreters.length > 0) shebangs.push({ interpreters, format: m[2] });
|
|
}
|
|
|
|
// ── Synonyms accepted by --format ──
|
|
const synonyms = [...source.matchAll(/map\.insert\("([^"]+)",\s*"([^"]+)"\)/g)]
|
|
.map((m) => ({ alias: m[1], format: m[2] }))
|
|
.sort((a, b) => a.alias.localeCompare(b.alias));
|
|
|
|
// Table cells: escape backslashes first, then pipes, so that `uncell` can
|
|
// reverse it exactly when descriptions are read back from the previous file.
|
|
const cell = (s) => s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
|
|
const uncell = (s) => s.replace(/\\([\\|])/g, "$1");
|
|
|
|
// ── Descriptions carried over from the previous FORMATS.md ──
|
|
const descriptions = new Map();
|
|
if (fs.existsSync(outFile)) {
|
|
// Only the format tables carry descriptions; the alias and shebang tables
|
|
// that follow have `| name | format |` rows that would be misread as one.
|
|
const full = fs.readFileSync(outFile, "utf8");
|
|
const cut = full.search(/\n## (Format name aliases|Shebang)/i);
|
|
const prev = cut === -1 ? full : full.slice(0, cut);
|
|
// Table rows, two or three cells: | `name` | [extensions |] description |
|
|
for (const m of prev.matchAll(/^\| `([^`]+)` \| (?:[^|]*\| )?(.*?) \|$/gm)) {
|
|
if (m[2].trim()) descriptions.set(m[1], uncell(m[2].trim()));
|
|
}
|
|
// Legacy bullet form: - **`name`** (...) — description.
|
|
for (const m of prev.matchAll(/^- \*\*`([^`]+)`\*\*[^—\n]*— (.*)$/gm)) {
|
|
if (!descriptions.has(m[1])) descriptions.set(m[1], uncell(m[2].trim()));
|
|
}
|
|
}
|
|
|
|
const withExt = formats.filter((f) => f.extensions.length > 0);
|
|
const withoutExt = formats.filter((f) => f.extensions.length === 0);
|
|
|
|
const extList = (exts) => exts.map((e) => `\`.${e}\``).join(", ");
|
|
|
|
let md = `# jscpd Supported Formats
|
|
|
|
<!-- Generated by rust/scripts/gen-formats-md.mjs from rust/crates/cpd-tokenizer/src/formats.rs. Do not edit the tables by hand; edit the Rust source (or a description cell) and re-run the script. -->
|
|
|
|
jscpd recognizes **${formats.length} formats**. This list is generated from the tokenizer's format table, the same one \`jscpd --list\` prints.
|
|
|
|
- **${withExt.length} formats** are detected automatically from the file extension.
|
|
- **${withoutExt.length} formats** are registered but have no built-in extension: map one with \`--formats-exts\` (\`jscpd --formats-exts "abap:abap" .\`) or a filename with \`--formats-names\` (\`jscpd --formats-names "Makefile:makefile" .\`) to scan them.
|
|
- Extensionless scripts are classified by their \`#!\` line ([shebang detection](#shebang-detection)).
|
|
|
|
Format names are what \`--format\` accepts (\`jscpd --format javascript,typescript .\`); file extensions are accepted there too (\`--format js,ts\`).
|
|
|
|
## Auto-detected formats
|
|
|
|
| Format | Extensions | Description |
|
|
|--------|------------|-------------|
|
|
`;
|
|
for (const f of withExt) {
|
|
md += `| \`${f.name}\` | ${extList(f.extensions)} | ${cell(descriptions.get(f.name) ?? "")} |\n`;
|
|
}
|
|
|
|
md += `
|
|
## Registered formats without an extension mapping
|
|
|
|
Activate one of these with \`--formats-exts "<format>:<ext>[,<ext>]"\`.
|
|
|
|
| Format | Description |
|
|
|--------|-------------|
|
|
`;
|
|
for (const f of withoutExt) {
|
|
md += `| \`${f.name}\` | ${cell(descriptions.get(f.name) ?? "")} |\n`;
|
|
}
|
|
|
|
md += `
|
|
## Format name aliases
|
|
|
|
\`--format\` also accepts these aliases:
|
|
|
|
| Alias | Format |
|
|
|-------|--------|
|
|
`;
|
|
for (const s of synonyms) md += `| \`${s.alias}\` | \`${s.format}\` |\n`;
|
|
|
|
md += `
|
|
## Shebang detection
|
|
|
|
Files without an extension are classified by the interpreter named on their first \`#!\` line:
|
|
|
|
| Interpreter | Detected as |
|
|
|-------------|-------------|
|
|
`;
|
|
for (const s of shebangs) {
|
|
md += `| ${s.interpreters.map((i) => `\`${i}\``).join(", ")} | \`${s.format}\` |\n`;
|
|
}
|
|
|
|
fs.writeFileSync(outFile, md);
|
|
console.log(
|
|
`Wrote ${path.relative(repoRoot, outFile)}: ${formats.length} formats (${withExt.length} with extensions, ${withoutExt.length} without), ${shebangs.length} shebang rules, ${descriptions.size} descriptions carried over`,
|
|
);
|