diff --git a/packages/compiler/src/frontend/lowering/lower-functions.ts b/packages/compiler/src/frontend/lowering/lower-functions.ts index c1cca9c64..7b5e9aa46 100644 --- a/packages/compiler/src/frontend/lowering/lower-functions.ts +++ b/packages/compiler/src/frontend/lowering/lower-functions.ts @@ -61,6 +61,29 @@ function declareCallReceiver( }; } +/** Whether a JavaScript function's own `this` can be read: a `this` in its + * parameters or body, outside nested non-arrow functions (which bind their + * own). Conservative inside classes and object literal methods. A function + * that never reads it skips the ambient receiver read at entry. */ +function readsOwnThis(node: ts.FunctionLikeDeclaration): boolean { + let found = false; + const visit = (n: ts.Node): void => { + if (found) return; + if (n.kind === ts.SyntaxKind.ThisKeyword) { + found = true; + return; + } + if (ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n)) return; + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + for (const p of node.parameters.nodes) visit(p); + if (node.body !== undefined) visit(node.body); + return found; +} + /** True when the identifier resolves (through import aliases) to a * top-level function declaration of ANY program file (not merely a * same-named local shadowing one). Functions declared directly in a @@ -335,7 +358,7 @@ export function lowerLambda( try { const receiver = (checkedReceiver || - isJsSourceFile(ts.getSourceFileOfNode(node)) || + (isJsSourceFile(ts.getSourceFileOfNode(node)) && readsOwnThis(node)) || node.parameters.nodes.some(isThisParameter)) && !ts.isArrowFunction(node) ? declareCallReceiver(lowerer, node, isGenerator) @@ -535,7 +558,8 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I lowerer.fnStack.push(ctx); try { const receiver = - decl.parameters.nodes.some(isThisParameter) || isJsSourceFile(ts.getSourceFileOfNode(decl)) + decl.parameters.nodes.some(isThisParameter) || + (isJsSourceFile(ts.getSourceFileOfNode(decl)) && readsOwnThis(decl)) ? declareCallReceiver(lowerer, decl, sig.generator !== undefined) : null; const { params, prologue } = lowerer.declareParams(decl.parameters.nodes, sig.params); diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index 0a7c5abad..a702e4f24 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -167,6 +167,45 @@ function ownLiteralMember(literal: Node, key: string): string | null { return found; } +/** Whether a top-level statement runs no code but its own: no call, `new`, + * tagged template, class (static blocks and field initializers run), getter + * or setter, `await`, `yield` or `delete`. Function bodies do not run. */ +function runsNoCode(stmt: ts.Statement): boolean { + if (ts.isFunctionDeclaration(stmt) || ts.isEmptyStatement(stmt)) return true; + if (!ts.isVariableStatement(stmt) && !ts.isExpressionStatement(stmt)) return false; + let quiet = true; + const visit = (n: Node): void => { + if (!quiet) return; + if (ts.isFunctionExpression(n) || ts.isArrowFunction(n)) return; + if ( + ts.isCallExpression(n) || + ts.isNewExpression(n) || + ts.isTaggedTemplateExpression(n) || + ts.isClassExpression(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isAwaitExpression(n) || + ts.isYieldExpression(n) || + ts.isDeleteExpression(n) || + ts.isMethodDeclaration(n) + ) { + quiet = false; + return; + } + ts.forEachChild(n, (c) => { + visit(c); + return undefined; + }); + }; + visit(stmt); + return quiet; +} + +function enclosingFunctionLike(n: Node): Node | null { + for (let p = n.parent; p !== undefined; p = p.parent) if (ts.isFunctionLike(p)) return p; + return null; +} + function primitiveLiteralText(e: ts.Expression): string | null { if ( ts.isNumericLiteral(e) || @@ -758,19 +797,80 @@ export function planStaticObjectRewrites( // the object does and nothing writes it, so every read sees it. (A // member stored after the object was created could be read before the // store; those stay reads.) + // + // A member stored once by an unconditional top-level statement of the + // object's own file (`app.math.EPSILON = 1e-9;`) folds too, at reads that + // provably run after the store: reads inside functions, when no code can + // run before the store (the statements before it call nothing, so no + // function runs and no other module starts), and top-level reads after + // it in the same file. + const writesOf = new Map>(); + for (const op of analysis.ops) { + if (op.kind !== "write" || op.name === null) continue; + for (const a of analysis.nodes[op.recv]!.atoms) { + let byKey = writesOf.get(a); + if (byKey === undefined) writesOf.set(a, (byKey = new Map())); + let l = byKey.get(op.name); + if (l === undefined) byKey.set(op.name, (l = [])); + if (!l.includes(op.node)) l.push(op.node); + } + } + const quietPrefix = new Map(); + const storedLiteral = ( + atom: Atom, + site: Node, + key: string, + ): { literal: string; stmt: ts.ExpressionStatement; statements: readonly Node[] } | null => { + const writes = writesOf.get(atom)?.get(key) ?? []; + if (writes.length !== 1) return null; + const target = writes[0]!; + const assign = target.parent; + if ( + assign === undefined || + !ts.isBinaryExpression(assign) || + assign.left !== target || + assign.operatorToken.kind !== ts.SyntaxKind.EqualsToken + ) + return null; + const stmt = assign.parent; + if (stmt === undefined || !ts.isExpressionStatement(stmt) || !ts.isSourceFile(stmt.parent)) + return null; + if (stmt.getSourceFile() !== site.getSourceFile()) return null; + const literal = primitiveLiteralText(assign.right); + if (literal === null) return null; + const statements = stmt.parent.statements.nodes; + let quiet = quietPrefix.get(stmt); + if (quiet === undefined) { + quiet = statements.slice(0, statements.indexOf(stmt)).every(runsNoCode); + quietPrefix.set(stmt, quiet); + } + return quiet ? { literal, stmt, statements } : null; + }; let foldedReads = 0; for (const { op, atoms } of mergedOps) { if (op.kind !== "read" || op.name === null) continue; if (atoms.size !== 1) continue; - const o = constantAtoms.get([...atoms][0]!); + const atom = [...atoms][0]!; + const o = constantAtoms.get(atom); if (o === undefined || o.site.kind !== "literal") continue; const field = o.fields.find((f) => f.key === op.name); if (field === undefined || field.stores !== 1) continue; - const literal = ownLiteralMember(o.site.node, op.name); - if (literal === null) continue; const node = op.node; if (!ts.isPropertyAccessExpression(node) && !ts.isElementAccessExpression(node)) continue; if (!isPlainRead(node) || topStatement(node) === null) continue; + let literal = ownLiteralMember(o.site.node, op.name); + if (literal === null && process.env["SCRIPTC_SHAPE_FOLD_STORES"] !== "0") { + const stored = storedLiteral(atom, o.site.node, op.name); + if (stored === null) continue; + const inFunction = enclosingFunctionLike(node) !== null; + const top = topStatement(node)!; + const after = + top.getSourceFile() === stored.stmt.getSourceFile() && + stored.statements.indexOf(top) > stored.statements.indexOf(stored.stmt); + if (!inFunction && !after) continue; + literal = stored.literal; + } + if (literal === null) continue; // An element key needs no parentheses (and a parenthesized literal key // would read as a computed one). const key = diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json new file mode 100644 index 000000000..10384a91b --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/static-stores/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/static-stores/main.ts" + ], + "diags": [] +} diff --git a/tests/fixtures/npm/cases/static-stores/js-main.cjs b/tests/fixtures/npm/cases/static-stores/js-main.cjs new file mode 100644 index 000000000..c844f5b74 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/js-main.cjs @@ -0,0 +1,8 @@ +// The JavaScript consumer of static-stores (main.ts is the TypeScript one). +const { lib, other } = require("static-stores"); + +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" })); +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 new file mode 100644 index 000000000..ad0e3eefc --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/main.ts @@ -0,0 +1,9 @@ +// Primitive members stored after their namespace was created, and functions +// with and without a receiver (js-main.cjs is the JavaScript consumer). +import { lib, other } from "static-stores"; + +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" })); +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 new file mode 100644 index 000000000..4a98d27dd --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.d.ts @@ -0,0 +1,10 @@ +export declare const lib: { + early: number | undefined; + after: number; + near(x: number): boolean; + name(): string; + counter: { n: number; inc(): number }; + twice(a: number): number; + tagOf(this: { tag: string }): 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 new file mode 100644 index 000000000..429116787 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/index.js @@ -0,0 +1,47 @@ +// Primitive members stored after their namespace object was created. Reads +// that provably run after the store read the stored literal; a read that can +// run before it (top-level code before the store, or any read once code has +// run before the store) stays a read. +var lib = lib || {}; +lib.cfg = lib.cfg || {}; +lib.cfg.EPS = 0.5; +lib.cfg.NAME = "v1"; +lib.early = lib.cfg.LATE; +lib.cfg.LATE = 7; +lib.after = lib.cfg.LATE + 1; +lib.near = function (x) { + return Math.abs(x) <= lib.cfg.EPS; +}; +lib.name = function () { + return lib.cfg.NAME + ":" + lib.cfg.LATE; +}; +// Functions that read their own receiver, through an arrow, and not at all. +lib.counter = { + n: 0, + inc: function () { + this.n++; + return this.n; + }, +}; +lib.twice = function (a) { + return a * 2; +}; +lib.tagOf = function () { + const read = () => this.tag; + return read(); +}; + +// A store after a call: code ran before it, so no read folds. +var other = {}; +other.log = []; +other.note = function (s) { + other.log.push(s); + return other.log.length; +}; +other.seen = other.note("start"); +other.flag = true; +other.check = function () { + return other.flag; +}; + +module.exports = { lib, other }; diff --git a/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json new file mode 100644 index 000000000..2d202dde0 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/node_modules/static-stores/package.json @@ -0,0 +1 @@ +{ "name": "static-stores", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" } diff --git a/tests/fixtures/npm/cases/static-stores/package.json b/tests/fixtures/npm/cases/static-stores/package.json new file mode 100644 index 000000000..38edc4090 --- /dev/null +++ b/tests/fixtures/npm/cases/static-stores/package.json @@ -0,0 +1 @@ +{ "name": "static-stores-case", "private": true, "type": "module" } diff --git a/tests/harness/static-stores.test.ts b/tests/harness/static-stores.test.ts new file mode 100644 index 000000000..050905371 --- /dev/null +++ b/tests/harness/static-stores.test.ts @@ -0,0 +1,46 @@ +/* Static-object specialization (shape-inference/static-objects.ts): with the + * shape overlay on, the npm case static-stores (through its JavaScript + * consumer, js-main.cjs) reads primitive members stored after their + * namespace object was created as literals where the read provably runs + * after the store, keeps the reads that can run before it, and prints what + * Node prints. Its functions with and without a receiver read `this` as + * Node does. The npm suites run the TypeScript consumer with and without + * the overlay. */ +import { execFile } from "node:child_process"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; +import { compile } from "@scriptc/compiler"; + +const execFileAsync = promisify(execFile); +const repoRoot = join(import.meta.dirname, "../.."); +const entry = join(repoRoot, "tests/fixtures/npm/cases/static-stores/js-main.cjs"); +const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/static-stores"); +const sanitize = process.env["SCRIPTC_SAN"] === "1"; + +test( + "members stored after their namespace was created read as literals after the store", + { timeout: 600_000 }, + async () => { + mkdirSync(outDir, { recursive: true }); + const result = await compile(entry, { + outPath: join(outDir, "main"), + outDir, + sanitize, + shapeOverlay: true, + }); + 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\)/); + const pending = execFileAsync(result.binaryPath, [], { cwd: dirname(entry), encoding: "utf8" }); + pending.child.stdin?.end(); + const { stdout } = await pending; + const node = await execFileAsync(process.execPath, [entry], { + cwd: dirname(entry), + encoding: "utf8", + }); + expect(stdout).toBe(node.stdout); + }, +);