180 lines
7.4 KiB
JavaScript
180 lines
7.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// tests/helpers/red-mutate.mjs
|
|
// The mutate helper of the shared red script (tests/helpers/red-round.sh).
|
|
// A mutation module is a plain ES module (no ".test." in its name, so
|
|
// `npm test` never runs it) that exports
|
|
// MUTATIONS = [{ key, id, what, spans: [{ file, find, replace }] }]
|
|
// key unique in the module; id one of the round's ids; file relative to the
|
|
// tree; find a string or a RegExp with the g flag. A string find is replaced
|
|
// literally; a RegExp find uses String.prototype.replace, $-patterns included.
|
|
//
|
|
// node tests/helpers/red-mutate.mjs list <module>
|
|
// one "<key> <id>" line per entry, in module order.
|
|
// node tests/helpers/red-mutate.mjs apply <module> <key> <tree> <backup-dir>
|
|
// counts every span of the entry first (spans on one file count on the
|
|
// text the earlier spans left); writes only when every span matched
|
|
// exactly once: each touched file is backed up into <backup-dir> (which
|
|
// must not exist yet), then written. Exit 1, nothing written and no
|
|
// backup made, when a span matched 0 or 2+ times (one stderr line each).
|
|
// node tests/helpers/red-mutate.mjs restore <backup-dir> <tree>
|
|
// copies every backed-up file back into the tree and removes the backup.
|
|
// Exit 2: a usage error, an unreadable module or file, a bad entry, a span
|
|
// file outside the tree, a failed write or restore (never 0).
|
|
|
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
const USAGE = 'usage: red-mutate.mjs list <module> | apply <module> <key> <tree> <backup-dir> | restore <backup-dir> <tree>';
|
|
const MANIFEST = 'manifest.json';
|
|
|
|
/** Print a problem and exit 2. */
|
|
function fail(msg) {
|
|
process.stderr.write(`red-mutate: ${msg}\n${USAGE}\n`);
|
|
process.exit(2);
|
|
}
|
|
|
|
/** The module's MUTATIONS, checked; exit 2 on any defect. */
|
|
async function load(modulePath) {
|
|
let mod;
|
|
try {
|
|
mod = await import(pathToFileURL(resolve(modulePath)).href);
|
|
} catch (e) {
|
|
return fail(`cannot load the mutation module ${modulePath}: ${e.message}`);
|
|
}
|
|
const list = mod.MUTATIONS;
|
|
if (!Array.isArray(list)) fail(`${modulePath} exports no MUTATIONS array`);
|
|
const keys = new Set();
|
|
list.forEach((m, i) => {
|
|
const where = `MUTATIONS[${i}]`;
|
|
if (!m || typeof m.key !== 'string' || !/^\S+$/.test(m.key)) fail(`${where}: key must be one word`);
|
|
if (typeof m.id !== 'string' || !/^\S+$/.test(m.id)) fail(`${where} (${m.key}): id must be one word`);
|
|
if (keys.has(m.key)) fail(`${where}: key ${m.key} is used twice`);
|
|
keys.add(m.key);
|
|
if (!Array.isArray(m.spans) || m.spans.length === 0) fail(`${where} (${m.key}): spans must be a non-empty array`);
|
|
m.spans.forEach((s, j) => {
|
|
const at = `${m.key} span ${j + 1}`;
|
|
if (!s || typeof s.file !== 'string' || s.file === '' || isAbsolute(s.file)) fail(`${at}: file must be a relative path`);
|
|
if (typeof s.replace !== 'string') fail(`${at}: replace must be a string`);
|
|
if (s.find instanceof RegExp) {
|
|
if (!s.find.global) fail(`${at}: a RegExp find needs the g flag`);
|
|
} else if (typeof s.find !== 'string' || s.find === '') {
|
|
fail(`${at}: find must be a non-empty string or a RegExp`);
|
|
}
|
|
});
|
|
});
|
|
return list;
|
|
}
|
|
|
|
/** The absolute path of a tree-relative file, or exit 2 when it leaves the tree. */
|
|
function inTree(tree, rel) {
|
|
const root = resolve(tree);
|
|
const abs = resolve(root, rel);
|
|
const back = relative(root, abs);
|
|
if (back === '' || back === '..' || back.startsWith(`..${sep}`) || isAbsolute(back)) fail(`${rel} is outside the tree ${tree}`);
|
|
return abs;
|
|
}
|
|
|
|
/** How often a find matches in a text. */
|
|
function count(text, find) {
|
|
if (typeof find === 'string') return text.split(find).length - 1;
|
|
return [...text.matchAll(find)].length;
|
|
}
|
|
|
|
/** The text with its one match replaced. */
|
|
function replaceOnce(text, find, by) {
|
|
if (typeof find === 'string') return text.replace(find, () => by);
|
|
return text.replace(find, by);
|
|
}
|
|
|
|
/** One line for a span's find. */
|
|
const show = (find) => (typeof find === 'string' ? JSON.stringify(find) : String(find)).replace(/\s+/g, ' ').slice(0, 80);
|
|
|
|
async function list(argv) {
|
|
if (argv.length !== 1) fail('list takes one argument');
|
|
const entries = await load(argv[0]);
|
|
process.stdout.write(entries.map((m) => `${m.key} ${m.id}\n`).join(''));
|
|
}
|
|
|
|
async function apply(argv) {
|
|
if (argv.length !== 4) fail('apply takes four arguments');
|
|
const [modulePath, key, tree, backup] = argv;
|
|
const entry = (await load(modulePath)).find((m) => m.key === key);
|
|
if (!entry) fail(`no mutation with key ${key} in ${modulePath}`);
|
|
if (!existsSync(tree)) fail(`the tree ${tree} does not exist`);
|
|
if (existsSync(backup)) fail(`the backup directory ${backup} already exists (a mutation not restored?)`);
|
|
|
|
const texts = new Map();
|
|
const originals = new Map();
|
|
const misses = [];
|
|
for (const span of entry.spans) {
|
|
const abs = inTree(tree, span.file);
|
|
if (!texts.has(span.file)) {
|
|
if (!existsSync(abs)) {
|
|
misses.push(`${key}: ${span.file} does not exist in the tree`);
|
|
continue;
|
|
}
|
|
try {
|
|
const t = readFileSync(abs, 'utf8');
|
|
texts.set(span.file, t);
|
|
originals.set(span.file, t);
|
|
} catch (e) {
|
|
fail(`cannot read ${abs}: ${e.code || e.message}`);
|
|
}
|
|
}
|
|
const text = texts.get(span.file);
|
|
const n = count(text, span.find);
|
|
if (n !== 1) {
|
|
misses.push(`${key}: ${show(span.find)} matched ${n} times in ${span.file}, not once`);
|
|
continue;
|
|
}
|
|
texts.set(span.file, replaceOnce(text, span.find, span.replace));
|
|
}
|
|
if (misses.length > 0) {
|
|
process.stderr.write(`${misses.map((m) => `red-mutate: ${m}`).join('\n')}\n`);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
mkdirSync(join(backup, 'files'), { recursive: true });
|
|
for (const rel of texts.keys()) {
|
|
const dest = join(backup, 'files', rel);
|
|
mkdirSync(dirname(dest), { recursive: true });
|
|
writeFileSync(dest, originals.get(rel));
|
|
}
|
|
writeFileSync(join(backup, MANIFEST), `${JSON.stringify([...texts.keys()])}\n`);
|
|
} catch (e) {
|
|
fail(`cannot write the backup in ${backup}: ${e.code || e.message}`);
|
|
}
|
|
try {
|
|
for (const [rel, text] of texts) writeFileSync(inTree(tree, rel), text);
|
|
} catch (e) {
|
|
fail(`cannot write the mutated file: ${e.code || e.message} (restore from ${backup})`);
|
|
}
|
|
process.stdout.write(`red-mutate: ${key} [${entry.id}] applied, ${entry.spans.length} span(s) in ${texts.size} file(s)\n`);
|
|
}
|
|
|
|
function restore(argv) {
|
|
if (argv.length !== 2) fail('restore takes two arguments');
|
|
const [backup, tree] = argv;
|
|
let files;
|
|
try {
|
|
files = JSON.parse(readFileSync(join(backup, MANIFEST), 'utf8'));
|
|
} catch (e) {
|
|
return fail(`cannot read the backup manifest in ${backup}: ${e.code || e.message}`);
|
|
}
|
|
if (!Array.isArray(files)) fail(`the backup manifest in ${backup} is not a list`);
|
|
try {
|
|
for (const rel of files) copyFileSync(join(backup, 'files', rel), inTree(tree, rel));
|
|
rmSync(backup, { recursive: true, force: true });
|
|
} catch (e) {
|
|
fail(`cannot restore from ${backup}: ${e.code || e.message}`);
|
|
}
|
|
process.stdout.write(`red-mutate: restored ${files.length} file(s)\n`);
|
|
}
|
|
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
if (cmd === 'list') await list(rest);
|
|
else if (cmd === 'apply') await apply(rest);
|
|
else if (cmd === 'restore') restore(rest);
|
|
else fail(cmd ? `unknown command ${cmd}` : 'no command');
|