diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index a702e4f24..ab7c8e5fc 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -913,7 +913,63 @@ export function planStaticObjectRewrites( // key reads the table as written. The members do not use `this`, the // table has no accessors, and comparing a string runs no code, so only // the representation of the call changes. - const tableCalls: { sf: ts.SourceFile; call: ts.CallExpression; arms: [string, string][] }[] = []; + const tableCalls: { + sf: ts.SourceFile; + call: ts.CallExpression; + arms: [string, string][]; + exhaustive: boolean; + }[] = []; + // Whether a key parameter only ever holds one of the table's keys: its + // function never escapes, every call of it passes a string literal key + // of the table, and its body neither assigns the parameter nor uses + // `arguments` (which aliases parameters in sloppy code). The dispatch + // then needs no arm for other keys. + const literalKeysOnly = (keyDecl: Node, keys: ReadonlySet): boolean => { + if (process.env["SCRIPTC_SHAPE_EXHAUSTIVE_TABLES"] === "0") return false; + if (!ts.isParameter(keyDecl) || !ts.isIdentifier(keyDecl.name)) return false; + const fn = keyDecl.parent; + if (fn === undefined || !ts.isFunctionLike(fn) || escapedFns.has(fn)) return false; + for (const a of analysis.atoms.atoms) + if (a.kind === "function" && a.node === fn && analysis.escaped.has(a)) return false; + const index = (fn as ts.FunctionLikeDeclaration).parameters.nodes.indexOf(keyDecl); + const name = keyDecl.name.text; + let touched = false; + const visit = (n: Node): void => { + if (touched) return; + if (ts.isIdentifier(n) && n.text === "arguments") touched = true; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + ts.isIdentifier(n.left) && + n.left.text === name + ) + touched = true; + else if ( + (ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && + ts.isIdentifier(n.operand) && + n.operand.text === name + ) + touched = true; + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + const body = (fn as { body?: Node }).body; + if (body === undefined) return false; + visit(body); + if (touched) return false; + const edges = analysis.calls.filter((c) => c.callee.node === fn); + if (edges.length === 0) return false; + return edges.every((e) => { + if (!ts.isCallExpression(e.site)) return false; + const args = e.site.arguments.nodes; + if (args.slice(0, index + 1).some((a) => ts.isSpreadElement(a))) return false; + const arg = args[index]; + return arg !== undefined && ts.isStringLiteral(arg) && keys.has(arg.text); + }); + }; for (const { op, atoms } of mergedOps) { if (op.kind !== "computedRead") continue; const node = op.node; @@ -954,16 +1010,34 @@ export function planStaticObjectRewrites( kept.push(`${where}: a member is not a hoisted function of this file`); continue; } - tableCalls.push({ sf: call.getSourceFile(), call, arms }); + tableCalls.push({ + sf: call.getSourceFile(), + call, + arms, + exhaustive: literalKeysOnly(keyDecl, new Set(arms.map(([k]) => k))), + }); } - for (const { sf, call, arms } of tableCalls) { + for (const { sf, call, arms, exhaustive } of tableCalls) { const node = call.expression as ts.ElementAccessExpression; const key = node.argumentExpression.getText(); const args = call.arguments.nodes.map((a) => sliceWithCalls(sf, a)).join(", "); const table = sliceWithCalls(sf, node.expression); - const text = arms + const tested = exhaustive ? arms.slice(0, -1) : arms; + const text = tested .map(([k, name]) => `${key} === ${JSON.stringify(k)} ? ${name}(${args}) : `) .join(""); + if (exhaustive) { + callEdits.push({ + sf, + edit: { + start: ts.tokenStartOfNode(call), + end: call.end, + text: `(${text}${arms[arms.length - 1]![1]}(${args}))`, + }, + }); + tableDispatches++; + continue; + } callEdits.push({ sf, edit: { diff --git a/tests/fixtures/npm/cases/static-stores/js-main.cjs b/tests/fixtures/npm/cases/static-stores/js-main.cjs index c844f5b74..9925086f2 100644 --- a/tests/fixtures/npm/cases/static-stores/js-main.cjs +++ b/tests/fixtures/npm/cases/static-stores/js-main.cjs @@ -5,4 +5,6 @@ let near = 0; for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; console.log(near, lib.name(), lib.early, lib.after); console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +const key = process.argv.length > 99 ? "dbl" : "inc"; +console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3)); console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/main.ts b/tests/fixtures/npm/cases/static-stores/main.ts index ad0e3eefc..ee05b1adf 100644 --- a/tests/fixtures/npm/cases/static-stores/main.ts +++ b/tests/fixtures/npm/cases/static-stores/main.ts @@ -6,4 +6,6 @@ let near = 0; for (let i = 0; i < 20; i++) if (lib.near((i - 10) / 10)) near++; console.log(near, lib.name(), lib.early, lib.after); console.log(lib.counter.inc(), lib.counter.inc(), lib.twice(21), lib.tagOf.call({ tag: "t" })); +const key = process.argv.length > 99 ? "dbl" : "inc"; +console.log(lib.apply("inc", 1), lib.apply("dbl", 4), lib.applyAny("dbl", 5), lib.applyAny(key, 3)); console.log(other.seen, other.check()); diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts index 4a98d27dd..88a4d6aa8 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts @@ -6,5 +6,7 @@ export declare const lib: { counter: { n: number; inc(): number }; twice(a: number): number; tagOf(this: { tag: string }): string; + apply(op: "inc" | "dbl", v: number): number; + applyAny(op: string, v: number): number | string; }; export declare const other: { seen: number; check(): boolean }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js index 429116787..8e65f2fc7 100644 --- a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js @@ -30,6 +30,23 @@ lib.tagOf = function () { const read = () => this.tag; return read(); }; +// A constant function table: `apply` is only ever called with literal keys +// of the table, so its dispatch needs no arm for other keys; `applyAny` is +// called with a computed key and keeps reading the table for other keys. +lib.ops = { + inc: function (v) { + return v + 1; + }, + dbl: function (v) { + return v * 2; + }, +}; +lib.apply = function (op, v) { + return lib.ops[op](v); +}; +lib.applyAny = function (op, v) { + return lib.ops[op](v); +}; // A store after a call: code ran before it, so no read folds. var other = {}; diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts index 050905371..303237392 100644 --- a/tests/harness/static-stores.test.ts +++ b/tests/harness/static-stores.test.ts @@ -33,7 +33,7 @@ test( if (!result.ok) throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); const report = (result.warnings ?? []).find((w) => w.code === "SC6103"); - expect(report?.message).toMatch(/, 5 constant reads\)/); + expect(report?.message).toMatch(/2 function table calls, 5 constant reads\)/); const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); pending.child.stdin?.end(); const { stdout } = await pending;