diff --git a/packages/compiler/src/frontend/lowering/module-member-this.ts b/packages/compiler/src/frontend/lowering/module-member-this.ts index 205cc63e9..592136bec 100644 --- a/packages/compiler/src/frontend/lowering/module-member-this.ts +++ b/packages/compiler/src/frontend/lowering/module-member-this.ts @@ -25,24 +25,228 @@ function exportedFunction(decl: ts.Node): ts.Node | null { return null; } -/** Whether a function's own body reads `this` (arrows share it; other - * functions and classes have their own). */ -function readsOwnThis(fn: ts.Node): boolean { - let found = false; +/** Whether `node` is `f.apply(this, ...)` or `f.call(this, ...)`, which + * passes the receiver on to `f`. */ +function forwardsThis(node: ts.Node): node is ts.CallExpression & { + expression: ts.PropertyAccessExpression; +} { + if (!ts.isCallExpression(node)) return false; + const callee = node.expression; + if (!ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.name)) return false; + if (callee.name.text !== "apply" && callee.name.text !== "call") return false; + return node.arguments.nodes[0]?.kind === ts.SyntaxKind.ThisKeyword; +} + +/* The arguments a call gave a function's parameters, while the analysis + * follows the call into the function: each with the bindings of its own + * scope. */ +type Bindings = ReadonlyMap; +const noBindings: Bindings = new Map(); + +// How many calls and functions deep the analysis follows a forwarded +// `this`: enough for a wrapper over a function factory, as Effect's +// `dual(3, body).apply(this, arguments)`. +const maxDepth = 6; + +/** Whether `fn`'s own body uses `this` only to pass it on with `.apply` or + * `.call` to functions that ignore it (and so also when it doesn't use + * `this` at all). */ +function ignoresOwnThis(lowerer: Lowerer, fn: ts.Node, bindings: Bindings, depth: number): boolean { + let ignores = true; const visit = (node: ts.Node): void => { - if (found) return; + if (!ignores) return; if (node.kind === ts.SyntaxKind.ThisKeyword) { - found = true; + ignores = false; return; } if ((ts.isFunctionLike(node) && !ts.isArrowFunction(node)) || ts.isClassLikeDeclaration(node)) return; + if (forwardsThis(node)) { + if (!ignoresThis(lowerer, node.expression.expression, bindings, depth + 1)) { + ignores = false; + return; + } + visit(node.expression.expression); + for (const arg of node.arguments.nodes.slice(1)) visit(arg); + return; + } node.forEachChild(visit); }; fn.forEachChild(visit); + return ignores; +} + +/** The function `name` provably holds: a function declaration, a const + * initialized with a function, or an import of one. */ +function functionOfName( + lowerer: Lowerer, + name: ts.Identifier, + bindings: Bindings, +): { fn: ts.Node | null; bound?: { expr: ts.Expression; bindings: Bindings } } { + let symbol = lowerer.checker.getSymbolAtLocation(name); + if (symbol === undefined) return { fn: null }; + const bound = bindings.get(symbol); + if (bound) return { fn: null, bound }; + if ((symbol.flags & ts.SymbolFlags.Alias) !== 0) + symbol = lowerer.checker.getAliasedSymbol(symbol); + const decls = symbol.declarations; + if (decls.length !== 1) return { fn: null }; + const decl = decls[0]!; + if (ts.isFunctionDeclaration(decl)) return { fn: decl }; + if ( + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 + ) { + let init: ts.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + if (ts.isFunctionExpression(init) || ts.isArrowFunction(init)) return { fn: init }; + } + return { fn: null }; +} + +/** + * Whether `fn` (with its nested functions) may change what its parameter + * `param` holds: an assignment, update, destructuring or for-in/for-of + * target that writes it, a declaration of the same name, a write to an + * element of `arguments` (which aliases parameters outside strict mode), or + * a call of `eval`. + */ +function mayRebind(lowerer: Lowerer, fn: ts.Node, param: ts.Identifier): boolean { + const symbol = lowerer.checker.getSymbolAtLocation(param); + const refersTo = (node: ts.Node): boolean => + ts.isIdentifier(node) && + (node.text === param.text || lowerer.checker.getSymbolAtLocation(node) === symbol); + // Whether writing `target` writes the parameter: the name itself, or the + // name inside a destructuring pattern. + const writes = (target: ts.Node): boolean => { + while (ts.isParenthesizedExpression(target)) target = target.expression; + if (refersTo(target)) return true; + if ( + ts.isElementAccessExpression(target) && + ts.isIdentifier(target.expression) && + target.expression.text === "arguments" + ) + return true; + if (ts.isArrayLiteralExpression(target)) return target.elements.nodes.some(writes); + if (ts.isObjectLiteralExpression(target)) + return target.properties.nodes.some((p) => + ts.isShorthandPropertyAssignment(p) + ? refersTo(p.name) + : ts.isPropertyAssignment(p) + ? writes(p.initializer) + : ts.isSpreadAssignment(p) && writes(p.expression), + ); + if (ts.isSpreadElement(target)) return writes(target.expression); + // A default in a pattern: `[body = other] = values`. + if (ts.isBinaryExpression(target) && target.operatorToken.kind === ts.SyntaxKind.EqualsToken) + return writes(target.left); + return false; + }; + const declaresName = (node: ts.Node): boolean => { + const name = (node as { name?: ts.Node }).name; + return name !== undefined && ts.isIdentifier(name) && name.text === param.text; + }; + let found = false; + const visit = (node: ts.Node): void => { + if (found) return; + if ( + (ts.isBinaryExpression(node) && + node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && + node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && + writes(node.left)) || + ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && + (node.operator === ts.SyntaxKind.PlusPlusToken || + node.operator === ts.SyntaxKind.MinusMinusToken) && + writes(node.operand)) || + ((ts.isForOfStatement(node) || ts.isForInStatement(node)) && + !ts.isVariableDeclarationList(node.initializer) && + writes(node.initializer)) || + ((ts.isVariableDeclaration(node) || + ts.isBindingElement(node) || + ts.isParameter(node) || + ts.isFunctionDeclaration(node) || + ts.isClassDeclaration(node)) && + declaresName(node)) || + (ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "eval") + ) { + found = true; + return; + } + node.forEachChild(visit); + }; + // The function's own parameters declare the name; their defaults can write it. + const params = (fn as { parameters?: ts.NodeArray }).parameters; + for (const p of params?.nodes ?? []) if (p.initializer) visit(p.initializer); + const body = (fn as { body?: ts.Node }).body; + if (body !== undefined) visit(body); return found; } +/** The expressions `fn` returns from its own body. */ +function returnedExpressions(fn: ts.Node): ts.Expression[] | null { + const body = (fn as { body?: ts.Node }).body; + if (body === undefined) return null; + if (!ts.isBlock(body)) return [body as ts.Expression]; + const result: ts.Expression[] = []; + const visit = (node: ts.Node): void => { + if (ts.isFunctionLike(node) || ts.isClassLikeDeclaration(node)) return; + if (ts.isReturnStatement(node) && node.expression) result.push(node.expression); + node.forEachChild(visit); + }; + body.forEachChild(visit); + return result; +} + +/** Whether the function `expr` evaluates to provably ignores the `this` + * it is called with. */ +function ignoresThis( + lowerer: Lowerer, + expr: ts.Expression, + bindings: Bindings, + depth: number, +): boolean { + if (depth > maxDepth) return false; + while (ts.isParenthesizedExpression(expr)) expr = expr.expression; + if (ts.isArrowFunction(expr)) return true; + if (ts.isFunctionExpression(expr)) return ignoresOwnThis(lowerer, expr, bindings, depth); + if (ts.isIdentifier(expr)) { + const { fn, bound } = functionOfName(lowerer, expr, bindings); + if (bound) return ignoresThis(lowerer, bound.expr, bound.bindings, depth + 1); + if (fn === null) return false; + return ts.isArrowFunction(fn) || ignoresOwnThis(lowerer, fn, noBindings, depth + 1); + } + // A call of a known function: what it returns, with its parameters bound + // to this call's arguments. + if (ts.isCallExpression(expr) && ts.isIdentifier(expr.expression)) { + const { fn } = functionOfName(lowerer, expr.expression, bindings); + if (fn === null) return false; + const params = (fn as { parameters?: ts.NodeArray }).parameters; + const inner = new Map(bindings); + for (const [i, param] of (params?.nodes ?? []).entries()) { + if (!ts.isIdentifier(param.name) || param.dotDotDotToken) return false; + const arg = expr.arguments.nodes[i]; + const symbol = lowerer.checker.getSymbolAtLocation(param.name); + if (arg === undefined || ts.isSpreadElement(arg) || symbol === undefined) return false; + // The parameter holds the argument for as long as the function runs. + if (mayRebind(lowerer, fn, param.name)) return false; + inner.set(symbol, { expr: arg, bindings }); + } + const returned = returnedExpressions(fn); + return returned !== null && returned.every((r) => ignoresThis(lowerer, r, inner, depth + 1)); + } + return false; +} + +/** Whether calling `fn` with another `this` than Node's can change what it + * does: it reads `this`, or passes it on to a function that may. */ +function readsOwnThis(lowerer: Lowerer, fn: ts.Node): boolean { + return !ignoresOwnThis(lowerer, fn, noBindings, 0); +} + export function refuseModuleMemberThis( lowerer: Lowerer, call: ts.CallExpression, @@ -54,7 +258,7 @@ export function refuseModuleMemberThis( if (symbol === undefined) return; for (const decl of symbol.declarations) { const fn = exportedFunction(decl); - if (fn === null || !readsOwnThis(fn)) continue; + if (fn === null || !readsOwnThis(lowerer, fn)) continue; lowerer.unsupported( "SC1090", call, diff --git a/tests/harness/errors.test.ts b/tests/harness/errors.test.ts index 26dfd3a5c..eea541912 100644 --- a/tests/harness/errors.test.ts +++ b/tests/harness/errors.test.ts @@ -1150,6 +1150,244 @@ export function plain(x) { return x + 1; } /^Uncaught Error: calling a module export that reads 'this' through its module binding \('who': .*\) is not supported yet \[SC1090 at .*namespace-member-this\.mjs:3\]\n$/, ); }); + + test("a namespace import's export that passes this on with apply or call compiles", async () => { + // Effect 4's provideService: `dual(...).apply(this, arguments)` hands the + // receiver to a function that `dual` returns, which reads it only to + // pass it on to an arrow, as here. + const r = await compileAndRun( + "namespace-member-forwards-this", + `import * as ns from './lib.mjs'; +export const added = ns.provide(1, 2, 3); +console.log(added, ns.sum(3, 4)); +`, + "mjs", + { + "lib.mjs": `const dual = function (arity, body) { + switch (arity) { + case 2: + return function (a, b) { return arguments.length >= 2 ? body(a, b) : (self) => body(self, a); }; + default: + return function () { return body.apply(this, arguments); }; + } +}; +export const provide = function () { return dual(3, (a, b, c) => a + b + c).apply(this, arguments); }; +const twice = function (a, b) { return (a + b) * 2; }; +export const sum = function (a, b) { return twice.call(this, a, b); }; +`, + }, + ); + expect(r).toEqual({ stdout: "6 14\n", stderr: "", exitCode: 0 }); + }); + + test("a require binding's export that passes this on with apply compiles", async () => { + const r = await compileAndRun( + "require-member-forwards-this", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.scale(5)); +`, + "cjs", + { + "lib.cjs": `'use strict'; +const triple = function (x) { return x * 3; }; +exports.scale = function () { return triple.apply(this, arguments); }; +`, + }, + ); + expect(r).toEqual({ stdout: "15\n", stderr: "", exitCode: 0 }); + }); + + test.each([ + [ + "a namespace import's export that passes this on to a function that reads it", + "namespace-member-forwards-to-reader", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaCall('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +function read(suffix) { return this.tag + suffix; } +export function viaCall(suffix) { return read.call(this, suffix); } +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaCall", + 3, + ], + [ + "a namespace import's export that passes this on through a factory to a function that reads it", + "namespace-member-forwards-through-factory", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaDual('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const dual = function (arity, body) { + switch (arity) { + case 2: + return function (a, b) { return arguments.length >= 2 ? body(a, b) : (self) => body(self, a); }; + default: + return function () { return body.apply(this, arguments); }; + } +}; +export const viaDual = function () { return dual(9, function (s) { return this.tag + s; }).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaDual", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that reassigns its parameter", + "namespace-member-forwards-reassigned", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + body = function (s) { return this.tag + s; }; + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a destructuring", + "namespace-member-forwards-destructured", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + [body] = [function (s) { return this.tag + s; }]; + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a object destructuring", + "namespace-member-forwards-object-destructured", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + ({ body } = { body: function (s) { return this.tag + s; } }); + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a namespace import's export that passes this on through a factory that replaces its parameter as a for-of target", + "namespace-member-forwards-for-of", + "mjs", + `import * as ns from './lib.mjs'; +console.log(ns.plain(1)); +console.log(ns.viaWrap('?')); +`, + { + "lib.mjs": `export const tag = 'ns'; +const wrap = function (body) { + for (body of [function (s) { return this.tag + s; }]); + return function () { return body.apply(this, arguments); }; +}; +export const viaWrap = function () { return wrap((s) => s).apply(this, arguments); }; +export function plain(x) { return x + 1; } +`, + }, + "2\n", + "viaWrap", + 3, + ], + [ + "a require binding's export that passes this on to a function that reads it", + "require-member-forwards-to-reader", + "cjs", + `'use strict'; +const m = require('./lib.cjs'); +console.log(m.plain(21)); +console.log(m.viaApply('!')); +`, + { + "lib.cjs": `'use strict'; +exports.name = 'lib'; +const read = function (suffix) { return this.name + suffix; }; +exports.viaApply = function () { return read.apply(this, arguments); }; +exports.plain = function (x) { return x * 2; }; +`, + }, + "42\n", + "viaApply", + 4, + ], + ] as const)( + "%s is a named deferred fence", + async (_, name, ext, source, files, stdout, member, line) => { + // Node passes the module object on to `read`, which reads it; the + // direct call has none to pass. + const r = await compileAndRun(name, source, ext, files); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(stdout); + expect(r.stderr).toMatch( + new RegExp( + `^Uncaught Error: calling a module export that reads 'this' through its module binding \\('${member}': .*\\) is not supported yet \\[SC1090 at .*${name}\\.${ext}:${line}\\]\\n$`, + ), + ); + }, + ); + + test("an export that also reads this beside passing it on is a named deferred fence", async () => { + const r = await compileAndRun( + "namespace-member-forwards-and-reads-this", + `import * as ns from './lib.mjs'; +console.log(ns.tag('x')); +`, + "mjs", + { + "lib.mjs": `const join = function (a, b) { return a + ':' + b; }; +export function tag(x) { return join.call(this, x, typeof this); } +`, + }, + ); + expect(r.exitCode).toBe(1); + expect(r.stdout).toBe(""); + expect(r.stderr).toMatch( + /^Uncaught Error: calling a module export that reads 'this' through its module binding \('tag': .*\) is not supported yet \[SC1090 at .*namespace-member-forwards-and-reads-this\.mjs:2\]\n$/, + ); + }); }); // CommonJS forms around the module's exports that keep a refusal name the