@defold-typescript/transpiler 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/companion-closure.d.ts +57 -0
- package/dist/companion-emit.d.ts +21 -0
- package/dist/emitted-requires.d.ts +7 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +2300 -1246
- package/dist/lifecycle-erasure.d.ts +1 -0
- package/dist/lua-require-scan.d.ts +17 -0
- package/dist/output-paths.d.ts +9 -0
- package/dist/require-rewrite.d.ts +13 -0
- package/dist/timers-lowering.d.ts +1 -1
- package/dist/transpile.d.ts +2 -1
- package/package.json +2 -2
- package/src/companion-closure.ts +761 -0
- package/src/companion-emit.ts +660 -0
- package/src/emitted-requires.ts +31 -0
- package/src/index.ts +11 -0
- package/src/lifecycle-erasure.ts +1 -1
- package/src/lua-require-scan.ts +0 -0
- package/src/output-paths.ts +20 -0
- package/src/require-rewrite.ts +36 -0
- package/src/session.ts +32 -24
- package/src/timers-lowering.ts +10 -1
- package/src/timers-runtime.ts +4 -3
- package/src/transpile.ts +27 -5
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { resolvesToFactoryExport } from "./lifecycle-erasure";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The exports-table field a companion exposes its private members on. Reserving
|
|
6
|
+
* one name keeps the field collision-safe without mangling every internal name,
|
|
7
|
+
* which is why a source exporting it is rejected rather than shadowed.
|
|
8
|
+
*/
|
|
9
|
+
export const COMPANION_INTERNAL_FIELD = "____closure";
|
|
10
|
+
|
|
11
|
+
export type ClosureViolationKind =
|
|
12
|
+
| "shared-mutable"
|
|
13
|
+
| "initialization-order"
|
|
14
|
+
| "reserved-internal-name";
|
|
15
|
+
|
|
16
|
+
export interface ClosureSite {
|
|
17
|
+
/** What this position is — the phrase the message uses for it. */
|
|
18
|
+
readonly role: string;
|
|
19
|
+
/** 1-based. */
|
|
20
|
+
readonly line: number;
|
|
21
|
+
/** 1-based. */
|
|
22
|
+
readonly column: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClosureViolation {
|
|
26
|
+
readonly kind: ClosureViolationKind;
|
|
27
|
+
/** The declaration the split cannot preserve. */
|
|
28
|
+
readonly member: string;
|
|
29
|
+
readonly sites: readonly ClosureSite[];
|
|
30
|
+
readonly message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CompanionClosure {
|
|
34
|
+
/** Top-level declarations that would move into the companion, in source order. */
|
|
35
|
+
readonly members: readonly string[];
|
|
36
|
+
/** The runtime value exports that open the companion, in source order. */
|
|
37
|
+
readonly exports: readonly string[];
|
|
38
|
+
/** Members outside `exports` the script still references, in source order. */
|
|
39
|
+
readonly internals: readonly string[];
|
|
40
|
+
/**
|
|
41
|
+
* The top-level statements carrying those members, in source order. A
|
|
42
|
+
* statement, not a name, is what the emit actually moves: one `const a = 1, b
|
|
43
|
+
* = 2;` is two members and one unit of code.
|
|
44
|
+
*/
|
|
45
|
+
readonly statements: readonly ts.Statement[];
|
|
46
|
+
/**
|
|
47
|
+
* Import bindings the closure reaches, in source order. An import is copyable
|
|
48
|
+
* rather than movable — its statement never appears in `statements` — so the
|
|
49
|
+
* companion takes its own `require` of the same module instead.
|
|
50
|
+
*/
|
|
51
|
+
readonly importBindings: readonly string[];
|
|
52
|
+
/** Shapes a split cannot preserve. Empty means the source is splittable. */
|
|
53
|
+
readonly violations: readonly ClosureViolation[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* One runtime value export. The public `name` is what a consumer sees and what
|
|
58
|
+
* the reserved-field rule judges; `symbol` is the local binding the dependency
|
|
59
|
+
* closure walks, and the two differ under an alias.
|
|
60
|
+
*/
|
|
61
|
+
interface ExportEntry {
|
|
62
|
+
readonly symbol: ts.Symbol;
|
|
63
|
+
readonly name: string;
|
|
64
|
+
/** Where the diagnostic points. */
|
|
65
|
+
readonly nameNode: ts.Node;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface TopLevelDecl {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
/** The whole statement, which moves or stays as a unit. */
|
|
71
|
+
readonly statement: ts.Statement;
|
|
72
|
+
/** The individual declaration, walked for the references it reaches. */
|
|
73
|
+
readonly node: ts.Node;
|
|
74
|
+
readonly order: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isAssignmentOperator(kind: ts.SyntaxKind): boolean {
|
|
78
|
+
return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A type annotation naming a value import must not drag it into the closure:
|
|
82
|
+
// the annotation is erased, so it can reach nothing at runtime.
|
|
83
|
+
function isTypePosition(node: ts.Node): boolean {
|
|
84
|
+
let current = node.parent;
|
|
85
|
+
while (current !== undefined && !ts.isSourceFile(current)) {
|
|
86
|
+
if (ts.isTypeNode(current)) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
current = current.parent;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isDeclarationName(node: ts.Identifier): boolean {
|
|
95
|
+
const parent = node.parent;
|
|
96
|
+
if (parent === undefined) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (ts.isPropertyAccessExpression(parent)) {
|
|
100
|
+
return parent.name === node;
|
|
101
|
+
}
|
|
102
|
+
if (ts.isQualifiedName(parent)) {
|
|
103
|
+
return parent.right === node;
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
(ts.isBindingElement(parent) || ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) &&
|
|
107
|
+
parent.propertyName === node
|
|
108
|
+
) {
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const named = parent as ts.Node & { readonly name?: ts.Node };
|
|
112
|
+
if (named.name !== node) {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return (
|
|
116
|
+
ts.isVariableDeclaration(parent) ||
|
|
117
|
+
ts.isFunctionDeclaration(parent) ||
|
|
118
|
+
ts.isClassDeclaration(parent) ||
|
|
119
|
+
ts.isEnumDeclaration(parent) ||
|
|
120
|
+
ts.isEnumMember(parent) ||
|
|
121
|
+
ts.isParameter(parent) ||
|
|
122
|
+
ts.isBindingElement(parent) ||
|
|
123
|
+
ts.isImportClause(parent) ||
|
|
124
|
+
ts.isImportSpecifier(parent) ||
|
|
125
|
+
ts.isNamespaceImport(parent) ||
|
|
126
|
+
ts.isPropertyAssignment(parent) ||
|
|
127
|
+
ts.isPropertyDeclaration(parent) ||
|
|
128
|
+
ts.isPropertySignature(parent) ||
|
|
129
|
+
ts.isMethodDeclaration(parent) ||
|
|
130
|
+
ts.isMethodSignature(parent) ||
|
|
131
|
+
ts.isTypeAliasDeclaration(parent) ||
|
|
132
|
+
ts.isInterfaceDeclaration(parent) ||
|
|
133
|
+
ts.isGetAccessor(parent) ||
|
|
134
|
+
ts.isSetAccessor(parent)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function forEachReference(
|
|
139
|
+
root: ts.Node,
|
|
140
|
+
checker: ts.TypeChecker,
|
|
141
|
+
visit: (node: ts.Identifier, symbol: ts.Symbol) => void,
|
|
142
|
+
): void {
|
|
143
|
+
const walk = (node: ts.Node): void => {
|
|
144
|
+
if (ts.isIdentifier(node) && !isDeclarationName(node) && !isTypePosition(node)) {
|
|
145
|
+
const symbol = resolveReferencedSymbol(node, checker);
|
|
146
|
+
if (symbol !== undefined) {
|
|
147
|
+
visit(node, symbol);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
ts.forEachChild(node, walk);
|
|
151
|
+
};
|
|
152
|
+
walk(root);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// `getSymbolAtLocation` on a shorthand property's name yields the *property*
|
|
156
|
+
// symbol, so `{ count }` would name neither the binding it reads nor the one a
|
|
157
|
+
// destructuring assignment rebinds.
|
|
158
|
+
function resolveReferencedSymbol(
|
|
159
|
+
node: ts.Identifier,
|
|
160
|
+
checker: ts.TypeChecker,
|
|
161
|
+
): ts.Symbol | undefined {
|
|
162
|
+
const parent = node.parent;
|
|
163
|
+
if (parent !== undefined && ts.isShorthandPropertyAssignment(parent) && parent.name === node) {
|
|
164
|
+
return checker.getShorthandAssignmentValueSymbol(parent) ?? checker.getSymbolAtLocation(node);
|
|
165
|
+
}
|
|
166
|
+
return checker.getSymbolAtLocation(node);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every position the source writes to, classified structurally. Recursing
|
|
171
|
+
* through parentheses and destructuring patterns is what separates a rebinding
|
|
172
|
+
* — which a split cannot carry — from a property or element write, which is
|
|
173
|
+
* shared by reference and stays safe.
|
|
174
|
+
*/
|
|
175
|
+
function forEachAssignmentTarget(
|
|
176
|
+
root: ts.Node,
|
|
177
|
+
checker: ts.TypeChecker,
|
|
178
|
+
visit: (node: ts.Identifier, symbol: ts.Symbol) => void,
|
|
179
|
+
): void {
|
|
180
|
+
const classify = (target: ts.Node): void => {
|
|
181
|
+
if (ts.isParenthesizedExpression(target)) {
|
|
182
|
+
classify(target.expression);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (ts.isArrayLiteralExpression(target)) {
|
|
189
|
+
for (const element of target.elements) {
|
|
190
|
+
if (ts.isOmittedExpression(element)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (ts.isSpreadElement(element)) {
|
|
194
|
+
classify(element.expression);
|
|
195
|
+
} else if (
|
|
196
|
+
ts.isBinaryExpression(element) &&
|
|
197
|
+
element.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
|
198
|
+
) {
|
|
199
|
+
classify(element.left);
|
|
200
|
+
} else {
|
|
201
|
+
classify(element);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (ts.isObjectLiteralExpression(target)) {
|
|
207
|
+
for (const property of target.properties) {
|
|
208
|
+
if (ts.isPropertyAssignment(property)) {
|
|
209
|
+
classify(property.initializer);
|
|
210
|
+
} else if (ts.isSpreadAssignment(property)) {
|
|
211
|
+
classify(property.expression);
|
|
212
|
+
} else if (ts.isShorthandPropertyAssignment(property)) {
|
|
213
|
+
const symbol = checker.getShorthandAssignmentValueSymbol(property);
|
|
214
|
+
if (symbol !== undefined) {
|
|
215
|
+
visit(property.name, symbol);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (ts.isIdentifier(target)) {
|
|
222
|
+
const symbol = checker.getSymbolAtLocation(target);
|
|
223
|
+
if (symbol !== undefined) {
|
|
224
|
+
visit(target, symbol);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const walk = (node: ts.Node): void => {
|
|
230
|
+
if (ts.isBinaryExpression(node) && isAssignmentOperator(node.operatorToken.kind)) {
|
|
231
|
+
classify(node.left);
|
|
232
|
+
} else if (
|
|
233
|
+
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
|
|
234
|
+
(node.operator === ts.SyntaxKind.PlusPlusToken ||
|
|
235
|
+
node.operator === ts.SyntaxKind.MinusMinusToken)
|
|
236
|
+
) {
|
|
237
|
+
classify(node.operand);
|
|
238
|
+
} else if (
|
|
239
|
+
(ts.isForInStatement(node) || ts.isForOfStatement(node)) &&
|
|
240
|
+
!ts.isVariableDeclarationList(node.initializer)
|
|
241
|
+
) {
|
|
242
|
+
classify(node.initializer);
|
|
243
|
+
}
|
|
244
|
+
ts.forEachChild(node, walk);
|
|
245
|
+
};
|
|
246
|
+
walk(root);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function collectTopLevel(
|
|
250
|
+
sourceFile: ts.SourceFile,
|
|
251
|
+
checker: ts.TypeChecker,
|
|
252
|
+
): Map<ts.Symbol, TopLevelDecl> {
|
|
253
|
+
const declarations = new Map<ts.Symbol, TopLevelDecl>();
|
|
254
|
+
let order = 0;
|
|
255
|
+
|
|
256
|
+
const add = (name: ts.Node | undefined, statement: ts.Statement, node: ts.Node): void => {
|
|
257
|
+
if (name === undefined || !ts.isIdentifier(name)) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
const symbol = checker.getSymbolAtLocation(name);
|
|
261
|
+
if (symbol === undefined || declarations.has(symbol)) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
declarations.set(symbol, { name: name.text, statement, node, order: order++ });
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const addBinding = (name: ts.BindingName, statement: ts.Statement, node: ts.Node): void => {
|
|
268
|
+
if (ts.isIdentifier(name)) {
|
|
269
|
+
add(name, statement, node);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
for (const element of name.elements) {
|
|
273
|
+
if (ts.isBindingElement(element)) {
|
|
274
|
+
addBinding(element.name, statement, node);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
for (const statement of sourceFile.statements) {
|
|
280
|
+
if (ts.isVariableStatement(statement)) {
|
|
281
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
282
|
+
addBinding(declaration.name, statement, declaration);
|
|
283
|
+
}
|
|
284
|
+
} else if (
|
|
285
|
+
ts.isFunctionDeclaration(statement) ||
|
|
286
|
+
ts.isClassDeclaration(statement) ||
|
|
287
|
+
ts.isEnumDeclaration(statement)
|
|
288
|
+
) {
|
|
289
|
+
add(statement.name, statement, statement);
|
|
290
|
+
} else if (ts.isImportDeclaration(statement)) {
|
|
291
|
+
const clause = statement.importClause;
|
|
292
|
+
if (clause === undefined) {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
add(clause.name, statement, statement);
|
|
296
|
+
const bindings = clause.namedBindings;
|
|
297
|
+
if (bindings === undefined) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (ts.isNamespaceImport(bindings)) {
|
|
301
|
+
add(bindings.name, statement, statement);
|
|
302
|
+
} else {
|
|
303
|
+
for (const element of bindings.elements) {
|
|
304
|
+
add(element.name, statement, statement);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return declarations;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function hasExportModifier(statement: ts.Statement): boolean {
|
|
314
|
+
return (
|
|
315
|
+
ts.canHaveModifiers(statement) &&
|
|
316
|
+
(ts.getModifiers(statement) ?? []).some(
|
|
317
|
+
(modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
|
|
318
|
+
)
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Runtime value meaning, not the `export` keyword: `export type` and
|
|
323
|
+
// `export interface` are erased before emit and never open a companion.
|
|
324
|
+
function isValueSymbol(symbol: ts.Symbol | undefined, checker: ts.TypeChecker): boolean {
|
|
325
|
+
if (symbol === undefined) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const resolved = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol;
|
|
329
|
+
return (resolved.flags & ts.SymbolFlags.Value) !== 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The source's runtime value exports, in source order. A default export is
|
|
334
|
+
* excluded: on a script-kind source it is the lifecycle factory result, which
|
|
335
|
+
* erasure removes and no companion can carry.
|
|
336
|
+
*/
|
|
337
|
+
function collectExports(
|
|
338
|
+
sourceFile: ts.SourceFile,
|
|
339
|
+
checker: ts.TypeChecker,
|
|
340
|
+
topLevel: Map<ts.Symbol, TopLevelDecl>,
|
|
341
|
+
): ExportEntry[] {
|
|
342
|
+
const exported: ExportEntry[] = [];
|
|
343
|
+
const seen = new Set<string>();
|
|
344
|
+
|
|
345
|
+
const take = (symbol: ts.Symbol | undefined, name: string, nameNode: ts.Node): void => {
|
|
346
|
+
const declaration = symbol && topLevel.get(symbol);
|
|
347
|
+
if (symbol === undefined || declaration === undefined) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (!isValueSymbol(symbol, checker)) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// The pair, not the symbol: `export { value as a, value as b }` is two
|
|
354
|
+
// exports of one member.
|
|
355
|
+
const key = `${declaration.order}${name}`;
|
|
356
|
+
if (seen.has(key)) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
seen.add(key);
|
|
360
|
+
exported.push({ symbol, name, nameNode });
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const takeLocal = (symbol: ts.Symbol | undefined, name: ts.Identifier): void => {
|
|
364
|
+
const declaration = symbol && topLevel.get(symbol);
|
|
365
|
+
if (declaration === undefined) {
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
take(symbol, name.text, declaration.node);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
for (const statement of sourceFile.statements) {
|
|
372
|
+
if (ts.isExportDeclaration(statement)) {
|
|
373
|
+
if (statement.moduleSpecifier !== undefined || statement.isTypeOnly) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
const clause = statement.exportClause;
|
|
377
|
+
if (clause === undefined || !ts.isNamedExports(clause)) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
for (const element of clause.elements) {
|
|
381
|
+
if (element.isTypeOnly) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
take(checker.getExportSpecifierLocalTargetSymbol(element), element.name.text, element.name);
|
|
385
|
+
}
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (!hasExportModifier(statement)) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (ts.isVariableStatement(statement)) {
|
|
392
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
393
|
+
forEachDeclaredName(declaration.name, (name) =>
|
|
394
|
+
takeLocal(checker.getSymbolAtLocation(name), name),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
} else if (
|
|
398
|
+
ts.isFunctionDeclaration(statement) ||
|
|
399
|
+
ts.isClassDeclaration(statement) ||
|
|
400
|
+
ts.isEnumDeclaration(statement)
|
|
401
|
+
) {
|
|
402
|
+
if (statement.name !== undefined) {
|
|
403
|
+
takeLocal(checker.getSymbolAtLocation(statement.name), statement.name);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return exported;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function forEachDeclaredName(name: ts.BindingName, visit: (name: ts.Identifier) => void): void {
|
|
412
|
+
if (ts.isIdentifier(name)) {
|
|
413
|
+
visit(name);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
for (const element of name.elements) {
|
|
417
|
+
if (ts.isBindingElement(element)) {
|
|
418
|
+
forEachDeclaredName(element.name, visit);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isLifecycleFactoryCall(expression: ts.Expression, checker: ts.TypeChecker): boolean {
|
|
424
|
+
return ts.isCallExpression(expression) && resolvesToFactoryExport(expression.expression, checker);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isEffectFreeExpression(expression: ts.Expression | undefined): boolean {
|
|
428
|
+
if (expression === undefined) {
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
if (
|
|
432
|
+
ts.isParenthesizedExpression(expression) ||
|
|
433
|
+
ts.isAsExpression(expression) ||
|
|
434
|
+
ts.isTypeAssertionExpression(expression) ||
|
|
435
|
+
ts.isNonNullExpression(expression)
|
|
436
|
+
) {
|
|
437
|
+
return isEffectFreeExpression(expression.expression);
|
|
438
|
+
}
|
|
439
|
+
if (ts.isIdentifier(expression) || ts.isLiteralExpression(expression)) {
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
switch (expression.kind) {
|
|
443
|
+
case ts.SyntaxKind.TrueKeyword:
|
|
444
|
+
case ts.SyntaxKind.FalseKeyword:
|
|
445
|
+
case ts.SyntaxKind.NullKeyword:
|
|
446
|
+
case ts.SyntaxKind.ThisKeyword:
|
|
447
|
+
return true;
|
|
448
|
+
default:
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression)) {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (ts.isPrefixUnaryExpression(expression)) {
|
|
455
|
+
return (
|
|
456
|
+
expression.operator !== ts.SyntaxKind.PlusPlusToken &&
|
|
457
|
+
expression.operator !== ts.SyntaxKind.MinusMinusToken &&
|
|
458
|
+
isEffectFreeExpression(expression.operand)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (ts.isBinaryExpression(expression)) {
|
|
462
|
+
return (
|
|
463
|
+
!isAssignmentOperator(expression.operatorToken.kind) &&
|
|
464
|
+
isEffectFreeExpression(expression.left) &&
|
|
465
|
+
isEffectFreeExpression(expression.right)
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
if (ts.isConditionalExpression(expression)) {
|
|
469
|
+
return (
|
|
470
|
+
isEffectFreeExpression(expression.condition) &&
|
|
471
|
+
isEffectFreeExpression(expression.whenTrue) &&
|
|
472
|
+
isEffectFreeExpression(expression.whenFalse)
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
if (ts.isArrayLiteralExpression(expression)) {
|
|
476
|
+
return expression.elements.every(isEffectFreeExpression);
|
|
477
|
+
}
|
|
478
|
+
if (ts.isObjectLiteralExpression(expression)) {
|
|
479
|
+
return expression.properties.every((property) => {
|
|
480
|
+
if (ts.isPropertyAssignment(property)) {
|
|
481
|
+
return isEffectFreeExpression(property.initializer);
|
|
482
|
+
}
|
|
483
|
+
return ts.isShorthandPropertyAssignment(property) || ts.isMethodDeclaration(property);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Whether a top-level statement can be reordered across the companion's load
|
|
491
|
+
* without changing what the source does. Judged syntactically and
|
|
492
|
+
* conservatively: anything not recognized is assumed effectful.
|
|
493
|
+
*
|
|
494
|
+
* Statements the emit erases are effect-free because they cannot reach the
|
|
495
|
+
* output at all. That covers a factory-only import, elided by
|
|
496
|
+
* `isFactoryOnlyImport`, and the lifecycle factory call itself, which erasure
|
|
497
|
+
* rewrites into hook function declarations — plain assignments that no
|
|
498
|
+
* companion load can observe. Classifying either as effectful would report the
|
|
499
|
+
* ordinary opening of essentially every source this analysis serves.
|
|
500
|
+
*/
|
|
501
|
+
function isEffectFreeStatement(statement: ts.Statement, checker: ts.TypeChecker): boolean {
|
|
502
|
+
if (
|
|
503
|
+
ts.isImportDeclaration(statement) ||
|
|
504
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
505
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
506
|
+
ts.isFunctionDeclaration(statement) ||
|
|
507
|
+
ts.isExportDeclaration(statement)
|
|
508
|
+
) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
if (ts.isVariableStatement(statement)) {
|
|
512
|
+
return statement.declarationList.declarations.every((declaration) =>
|
|
513
|
+
isEffectFreeExpression(declaration.initializer),
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (ts.isExpressionStatement(statement)) {
|
|
517
|
+
return (
|
|
518
|
+
isLifecycleFactoryCall(statement.expression, checker) ||
|
|
519
|
+
isEffectFreeExpression(statement.expression)
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
523
|
+
return (
|
|
524
|
+
isLifecycleFactoryCall(statement.expression, checker) ||
|
|
525
|
+
isEffectFreeExpression(statement.expression)
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function siteOf(node: ts.Node, role: string): ClosureSite {
|
|
532
|
+
const sourceFile = node.getSourceFile();
|
|
533
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
534
|
+
return { role, line: line + 1, column: character + 1 };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function formatSites(sites: readonly ClosureSite[]): string {
|
|
538
|
+
return sites.map((site) => `${site.role} at ${site.line}:${site.column}`).join(", ");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function bySourcePosition(a: ClosureSite, b: ClosureSite): number {
|
|
542
|
+
return a.line === b.line ? a.column - b.column : a.line - b.line;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Which of a script-kind source's top-level declarations would move into a
|
|
547
|
+
* companion module, and the shapes a split cannot preserve.
|
|
548
|
+
*
|
|
549
|
+
* The companion holds the transitive closure of top-level declarations
|
|
550
|
+
* reachable from the source's runtime value exports; everything else stays in
|
|
551
|
+
* the script and runs there once. Two shapes break that split — a binding
|
|
552
|
+
* reassigned on one side and read on the other, which a Lua local cannot carry,
|
|
553
|
+
* and effectful top-level work the split would reorder — and both are reported
|
|
554
|
+
* against the source rather than emitted.
|
|
555
|
+
*/
|
|
556
|
+
export function computeCompanionClosure(
|
|
557
|
+
sourceFile: ts.SourceFile,
|
|
558
|
+
checker: ts.TypeChecker,
|
|
559
|
+
): CompanionClosure {
|
|
560
|
+
const topLevel = collectTopLevel(sourceFile, checker);
|
|
561
|
+
const exportEntries = collectExports(sourceFile, checker, topLevel);
|
|
562
|
+
|
|
563
|
+
// Which statement declares a binding is what the emit moves, so the walk
|
|
564
|
+
// reaches its fixpoint over statements rather than names: moving one makes
|
|
565
|
+
// every binding it declares a member, and those bindings' own references
|
|
566
|
+
// extend the closure again. Without that, `const a = 1, b = p + 1` carries `b`
|
|
567
|
+
// across while leaving `p` behind, and the companion reads an unbound global.
|
|
568
|
+
const siblingsOf = new Map<ts.Statement, ts.Symbol[]>();
|
|
569
|
+
for (const [symbol, declaration] of topLevel) {
|
|
570
|
+
siblingsOf.set(declaration.statement, [
|
|
571
|
+
...(siblingsOf.get(declaration.statement) ?? []),
|
|
572
|
+
symbol,
|
|
573
|
+
]);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const memberSymbols = new Set<ts.Symbol>();
|
|
577
|
+
const importBindingSymbols = new Set<ts.Symbol>();
|
|
578
|
+
const closureStatements = new Set<ts.Statement>();
|
|
579
|
+
const queue: ts.Symbol[] = exportEntries.map((entry) => entry.symbol);
|
|
580
|
+
while (queue.length > 0) {
|
|
581
|
+
const symbol = queue.pop();
|
|
582
|
+
if (symbol === undefined || memberSymbols.has(symbol)) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
memberSymbols.add(symbol);
|
|
586
|
+
const declaration = topLevel.get(symbol);
|
|
587
|
+
if (declaration === undefined) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
// An import is copyable, not movable: TSTL emits its `require` bindings
|
|
591
|
+
// through its own hoisting, so there is no statement here to move.
|
|
592
|
+
if (ts.isImportDeclaration(declaration.statement)) {
|
|
593
|
+
importBindingSymbols.add(symbol);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (!closureStatements.has(declaration.statement)) {
|
|
597
|
+
closureStatements.add(declaration.statement);
|
|
598
|
+
for (const sibling of siblingsOf.get(declaration.statement) ?? []) {
|
|
599
|
+
if (!memberSymbols.has(sibling)) {
|
|
600
|
+
queue.push(sibling);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
forEachReference(declaration.node, checker, (_node, referenced) => {
|
|
605
|
+
if (topLevel.has(referenced) && !memberSymbols.has(referenced)) {
|
|
606
|
+
queue.push(referenced);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// An export declaration is the machinery that opens the companion, so it
|
|
612
|
+
// travels with what it reads: leaving it behind makes the script assign a
|
|
613
|
+
// local it no longer binds, into a table every importer has already loaded.
|
|
614
|
+
const splits = closureStatements.size > 0;
|
|
615
|
+
for (const statement of sourceFile.statements) {
|
|
616
|
+
if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) {
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (statement.moduleSpecifier !== undefined) {
|
|
620
|
+
// A re-export's whole emitted block is export machinery, with no local
|
|
621
|
+
// half to keep, so it moves whenever the source splits at all.
|
|
622
|
+
if (splits) {
|
|
623
|
+
closureStatements.add(statement);
|
|
624
|
+
}
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const clause = statement.exportClause;
|
|
628
|
+
if (clause === undefined || !ts.isNamedExports(clause)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const readsMember = clause.elements.some((element) => {
|
|
632
|
+
if (element.isTypeOnly) {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
const local = checker.getExportSpecifierLocalTargetSymbol(element);
|
|
636
|
+
return local !== undefined && memberSymbols.has(local);
|
|
637
|
+
});
|
|
638
|
+
if (readsMember) {
|
|
639
|
+
closureStatements.add(statement);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Reassignment anywhere in the source, the companion side included: that is
|
|
644
|
+
// what the `increment()` shape turns on, where nothing in the script assigns.
|
|
645
|
+
const mutations = new Map<ts.Symbol, ts.Identifier[]>();
|
|
646
|
+
forEachAssignmentTarget(sourceFile, checker, (node, symbol) => {
|
|
647
|
+
if (!memberSymbols.has(symbol)) {
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
mutations.set(symbol, [...(mutations.get(symbol) ?? []), node]);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// The script is every top-level statement outside the closure. An export
|
|
654
|
+
// declaration is neither: it is the machinery that opens the companion, not
|
|
655
|
+
// code that runs in the script.
|
|
656
|
+
const scriptReferences = new Map<ts.Symbol, ts.Identifier[]>();
|
|
657
|
+
for (const statement of sourceFile.statements) {
|
|
658
|
+
if (closureStatements.has(statement) || ts.isExportDeclaration(statement)) {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
forEachReference(statement, checker, (node, symbol) => {
|
|
662
|
+
if (memberSymbols.has(symbol)) {
|
|
663
|
+
scriptReferences.set(symbol, [...(scriptReferences.get(symbol) ?? []), node]);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const ordered = [...memberSymbols]
|
|
669
|
+
.flatMap((symbol) => {
|
|
670
|
+
const declaration = topLevel.get(symbol);
|
|
671
|
+
return declaration === undefined ? [] : [{ symbol, declaration }];
|
|
672
|
+
})
|
|
673
|
+
.sort((a, b) => a.declaration.order - b.declaration.order);
|
|
674
|
+
|
|
675
|
+
const exportSet = new Set(exportEntries.map((entry) => entry.symbol));
|
|
676
|
+
const violations: ClosureViolation[] = [];
|
|
677
|
+
|
|
678
|
+
for (const { symbol, declaration } of ordered) {
|
|
679
|
+
const mutated = mutations.get(symbol);
|
|
680
|
+
const referenced = scriptReferences.get(symbol);
|
|
681
|
+
if (mutated === undefined || referenced === undefined) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const sites = [
|
|
685
|
+
...mutated.map((node) => siteOf(node, "reassigned")),
|
|
686
|
+
...referenced.map((node) => siteOf(node, "read by the script")),
|
|
687
|
+
]
|
|
688
|
+
.filter(
|
|
689
|
+
(site, index, all) =>
|
|
690
|
+
all.findIndex((other) => other.line === site.line && other.column === site.column) ===
|
|
691
|
+
index,
|
|
692
|
+
)
|
|
693
|
+
.sort(bySourcePosition);
|
|
694
|
+
violations.push({
|
|
695
|
+
kind: "shared-mutable",
|
|
696
|
+
member: declaration.name,
|
|
697
|
+
sites,
|
|
698
|
+
message:
|
|
699
|
+
`"${declaration.name}" is reassigned and is also used outside the companion, ` +
|
|
700
|
+
`so the script's copy would never see the update (${formatSites(sites)}). ` +
|
|
701
|
+
"Move that state into a source with no lifecycle factory.",
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (const entry of exportEntries) {
|
|
706
|
+
if (entry.name !== COMPANION_INTERNAL_FIELD) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const sites = [siteOf(entry.nameNode, "exported")];
|
|
710
|
+
violations.push({
|
|
711
|
+
kind: "reserved-internal-name",
|
|
712
|
+
member: entry.name,
|
|
713
|
+
sites,
|
|
714
|
+
message:
|
|
715
|
+
`"${COMPANION_INTERNAL_FIELD}" is reserved for the companion's own internals ` +
|
|
716
|
+
`and cannot be exported (${formatSites(sites)}). Rename the export.`,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// The companion loads as a unit, so the constraint is pairwise: any effectful
|
|
721
|
+
// script statement with any closure declaration below it is reordered by the
|
|
722
|
+
// split, whatever the first member's position.
|
|
723
|
+
for (const statement of sourceFile.statements) {
|
|
724
|
+
if (closureStatements.has(statement) || isEffectFreeStatement(statement, checker)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const start = statement.getStart(sourceFile);
|
|
728
|
+
const hoisted = ordered.find(
|
|
729
|
+
(entry) => entry.declaration.statement.getStart(sourceFile) > start,
|
|
730
|
+
);
|
|
731
|
+
if (hoisted === undefined) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const sites = [
|
|
735
|
+
siteOf(statement, "runs in the script"),
|
|
736
|
+
siteOf(hoisted.declaration.node, "moves into the companion"),
|
|
737
|
+
];
|
|
738
|
+
violations.push({
|
|
739
|
+
kind: "initialization-order",
|
|
740
|
+
member: hoisted.declaration.name,
|
|
741
|
+
sites,
|
|
742
|
+
message:
|
|
743
|
+
`top-level work precedes "${hoisted.declaration.name}", which the companion would run first, ` +
|
|
744
|
+
`reversing their order (${formatSites(sites)}). ` +
|
|
745
|
+
"Move that work below the companion's declarations, or into a lifecycle hook.",
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return {
|
|
750
|
+
members: ordered.map(({ declaration }) => declaration.name),
|
|
751
|
+
exports: exportEntries.map((entry) => entry.name),
|
|
752
|
+
statements: sourceFile.statements.filter((statement) => closureStatements.has(statement)),
|
|
753
|
+
importBindings: ordered
|
|
754
|
+
.filter(({ symbol }) => importBindingSymbols.has(symbol))
|
|
755
|
+
.map(({ declaration }) => declaration.name),
|
|
756
|
+
internals: ordered
|
|
757
|
+
.filter(({ symbol }) => !exportSet.has(symbol) && scriptReferences.has(symbol))
|
|
758
|
+
.map(({ declaration }) => declaration.name),
|
|
759
|
+
violations,
|
|
760
|
+
};
|
|
761
|
+
}
|