mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Compile module exports that pass this on with apply or call (#1444)
This commit is contained in:
@@ -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<ts.Symbol, { expr: ts.Expression; bindings: Bindings }>;
|
||||
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<ts.ParameterDeclaration> }).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<ts.ParameterDeclaration> }).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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user