Files
scriptc/scripts/split/move-state.mjs
T
Chris Tate f1d694b47b Move the emitter's loop optimization state into loop-state.ts
LlEmitter's field list and constructor are where most concurrent edits to
emitter.ts collide. A feature now defines its state in its own file with
defineModuleState (emitter-state.ts) and reads it through the returned key,
so it no longer edits the class. The loop optimizations' twelve fields and
their per-function setup move to loop-state.ts; the emitted IR is unchanged.

scripts/split holds the transforms used for the emitter and lowering
splits, and rehome-split.mjs, which carries an open branch across a split.
2026-10-20 18:58:42 -05:00

203 lines
8.8 KiB
JavaScript

#!/usr/bin/env node
/* Move a group of LlEmitter fields into a feature's state object, as a
* deterministic rewrite (the same config gives the same result on any
* version of the tree, which the rehoming tool relies on).
*
* node move-state.mjs apply <config.json> [--repo <dir>] [--tools <dir>]
*
* <config.json>:
* { "module": "packages/compiler/src/backend/llvm/emitter.ts",
* "class": "LlEmitter",
* "dir": "packages/compiler/src/backend/llvm",
* "groups": [{ "file": "loop-state.ts", "key": "LOOPS",
* "fields": ["denseLoop", ...] }],
* "create": { "loop-state.ts": "loop-state.template.ts" },
* "replacements": [{ "file": "emit-function.ts", "find": "...", "replace": "...", "count": 1 }],
* "receivers": ["this", "emitter"] }
*
* For each group: the fields' declarations leave the class (their doc
* comments and initializers move into the state object's interface and
* factory in the group's file), every `this.<field>` and `emitter.<field>`
* (or the config's receivers) in the directory's sources becomes
* `<KEY>.of(this).<field>` / `<KEY>.of(emitter).<field>`, and each
* rewritten file imports the key.
* The group's file must already exist and hold everything else (header,
* setup function); the tool appends the interface and key to it. */
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { execFileSync } from "node:child_process";
const [command, configPath, ...flags] = process.argv.slice(2);
if (command !== "apply") throw new Error("usage: move-state.mjs apply <config.json>");
const config = JSON.parse(readFileSync(configPath, "utf8"));
const flag = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? flags[i + 1] : undefined;
};
const repo = resolve(flag("--repo") ?? process.cwd());
const toolsDir = resolve(flag("--tools") ?? process.cwd());
const require = createRequire(join(toolsDir, "package.json"));
const ts = require("typescript");
const modulePath = join(repo, config.module);
const dir = join(repo, config.dir);
// Adds an import line after the file's first block of imports (up to a
// blank line or other code).
const addImport = (text, line) => {
const sf = ts.createSourceFile("x.ts", text, ts.ScriptTarget.Latest, true);
let last;
for (const s of sf.statements) {
if (!ts.isImportDeclaration(s)) break;
if (last && /\n[ \t]*\n/.test(text.slice(last.getEnd(), s.getStart()))) break;
last = s;
}
const at = last ? last.getEnd() + 1 : 0;
return text.slice(0, at) + line + "\n" + text.slice(at);
};
const configDir = dirname(resolve(configPath));
// The names the class's instance goes by in the directory's sources.
const receivers = config.receivers ?? ["this", "emitter"];
const touched = new Set();
// Files the change creates, from templates next to the config (kept when a
// branch already has the file).
for (const [file, template] of Object.entries(config.create ?? {})) {
const path = join(dir, file);
if (existsSync(path)) continue;
writeFileSync(path, readFileSync(join(configDir, template), "utf8"));
touched.add(path);
}
let moduleText = readFileSync(modulePath, "utf8");
for (const group of config.groups) {
const sf = ts.createSourceFile(modulePath, moduleText, ts.ScriptTarget.Latest, true);
const cls = sf.statements.find((s) => ts.isClassDeclaration(s) && s.name?.text === config.class);
if (!cls) throw new Error(`no class ${config.class}`);
const wanted = new Set(group.fields);
const members = [];
for (const m of cls.members) {
if (ts.isPropertyDeclaration(m) && ts.isIdentifier(m.name) && wanted.has(m.name.text))
members.push(m);
}
// A field the tree does not have yet (an older merge base) is left out, so
// the move still applies there.
const missing = group.fields.filter((f) => !members.some((m) => m.name.text === f));
if (missing.length)
console.warn(`${group.file}: fields not in ${config.class}: ${missing.join(", ")}; skipped`);
group.present = members.map((m) => m.name.text);
// Interface members and factory entries, in the class's order.
const iface = [];
const factory = [];
for (const m of members) {
const lead = moduleText.slice(m.getFullStart(), m.getStart()).replace(/^\s*\n/, "");
const readonly = m.modifiers?.some((x) => x.kind === ts.SyntaxKind.ReadonlyKeyword)
? "readonly "
: "";
const name = m.name.text;
if (!m.initializer)
throw new Error(
`${name}: a field without an initializer needs its constructor line moved by hand`,
);
// An unannotated field takes its initializer's type: a boolean,
// number or string literal, or `new C<T>(...)`.
const init = m.initializer;
const type = m.type
? m.type.getText(sf)
: init.kind === ts.SyntaxKind.TrueKeyword || init.kind === ts.SyntaxKind.FalseKeyword
? "boolean"
: ts.isNumericLiteral(init)
? "number"
: ts.isStringLiteral(init)
? "string"
: ts.isNewExpression(init) && init.typeArguments?.length
? `${init.expression.getText(sf)}<${init.typeArguments.map((t) => t.getText(sf)).join(", ")}>`
: null;
if (type === null) throw new Error(`${name}: give the field a type annotation first`);
iface.push(`${lead} ${readonly}${name}: ${type};`);
factory.push(` ${name}: ${m.initializer.getText(sf)},`);
}
// Remove the declarations (with their leading comments) from the class.
const cuts = members.map((m) => [m.getFullStart(), m.getEnd()]).sort((a, b) => b[0] - a[0]);
for (const [s, e] of cuts) moduleText = moduleText.slice(0, s) + moduleText.slice(e);
const iName = group.interface ?? `${group.key[0]}${group.key.slice(1).toLowerCase()}State`;
const stateText =
`\n/** The state ${group.key} holds on each emitter. */\n` +
`export interface ${group.interfaceName ?? iName} {\n${iface.join("\n")}\n}\n\n` +
`export const ${group.key} = defineModuleState<${group.interfaceName ?? iName}>(() => ({\n${factory.join("\n")}\n}));\n`;
const groupPath = join(dir, group.file);
let groupText = readFileSync(groupPath, "utf8");
if (!groupText.includes('from "./emitter-state.js"'))
groupText = addImport(groupText, `import { defineModuleState } from "./emitter-state.js";`);
groupText = groupText.trimEnd() + "\n" + stateText;
writeFileSync(groupPath, groupText);
touched.add(groupPath);
group.rename = group.present.length
? new RegExp(`(?<![\\w$.])(${receivers.join("|")})\\.(${group.present.join("|")})\\b`, "g")
: null;
}
writeFileSync(modulePath, moduleText);
// Rewrite accesses across the directory.
for (const file of readdirSync(dir).filter((f) => f.endsWith(".ts"))) {
const path = join(dir, file);
let text = path === modulePath ? moduleText : readFileSync(path, "utf8");
let changed = false;
for (const group of config.groups) {
if (!group.rename) continue;
const next = text.replace(
group.rename,
(_, recv, field) => `${group.key}.of(${recv}).${field}`,
);
if (next === text) continue;
text = next;
changed = true;
if (file === group.file) continue;
const spec = `./${group.file.replace(/\.ts$/, ".js")}`;
const existing = new RegExp(
`^import \\{([^}]*)\\} from "${spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}";$`,
"m",
);
const m = existing.exec(text);
if (m) {
const names = new Set(
m[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean),
);
names.add(group.key);
text = text.replace(existing, `import { ${[...names].sort().join(", ")} } from "${spec}";`);
} else {
text = addImport(text, `import { ${group.key} } from "${spec}";`);
}
}
if (changed) {
writeFileSync(path, text);
touched.add(path);
}
}
// The change's remaining edits, as literal replacements in order (the
// setup function call, imports, narrowing the rename loses). One whose text
// is not found as often as expected is reported and skipped: the branch
// changed that code, and the rehome's cherry-pick shows the difference.
let skipped = 0;
for (const r of config.replacements ?? []) {
const path = join(dir, r.file);
const text = existsSync(path) ? readFileSync(path, "utf8") : "";
const count = text.split(r.find).length - 1;
if (count !== (r.count ?? 1)) {
console.warn(
`${r.file}: expected ${r.count ?? 1} of ${JSON.stringify(r.find.slice(0, 60))}, found ${count}; skipped`,
);
skipped++;
continue;
}
writeFileSync(path, text.split(r.find).join(r.replace));
touched.add(path);
}
const oxfmt = join(toolsDir, "node_modules/.bin/oxfmt");
execFileSync(oxfmt, [...touched], { cwd: repo, stdio: "inherit" });
if (skipped) console.warn(`${skipped} replacement(s) skipped`);
console.log(
`moved ${config.groups.map((g) => g.present.length).reduce((a, b) => a + b, 0)} fields; rewrote ${touched.size} files`,
);