@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,660 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import {
|
|
3
|
+
createAssignmentStatement,
|
|
4
|
+
createCallExpression,
|
|
5
|
+
createFile,
|
|
6
|
+
createIdentifier,
|
|
7
|
+
createReturnStatement,
|
|
8
|
+
createStringLiteral,
|
|
9
|
+
createTableExpression,
|
|
10
|
+
createTableFieldExpression,
|
|
11
|
+
createTableIndexExpression,
|
|
12
|
+
createVariableDeclarationStatement,
|
|
13
|
+
isBlock,
|
|
14
|
+
isCallExpression,
|
|
15
|
+
isDoStatement,
|
|
16
|
+
isForInStatement,
|
|
17
|
+
isForStatement,
|
|
18
|
+
isFunctionDefinition,
|
|
19
|
+
isFunctionExpression,
|
|
20
|
+
isIdentifier,
|
|
21
|
+
isMethodCallExpression,
|
|
22
|
+
isRepeatStatement,
|
|
23
|
+
isReturnStatement,
|
|
24
|
+
isStringLiteral,
|
|
25
|
+
isTableExpression,
|
|
26
|
+
isVariableDeclarationStatement,
|
|
27
|
+
LuaPrinter,
|
|
28
|
+
type Node,
|
|
29
|
+
type Plugin,
|
|
30
|
+
type ProcessedFile,
|
|
31
|
+
type Statement,
|
|
32
|
+
type TransformationContext,
|
|
33
|
+
} from "typescript-to-lua";
|
|
34
|
+
import { COMPANION_INTERNAL_FIELD, computeCompanionClosure } from "./companion-closure";
|
|
35
|
+
import { resolvesToFactoryExport } from "./lifecycle-erasure";
|
|
36
|
+
import { requirePathForRel } from "./output-paths";
|
|
37
|
+
|
|
38
|
+
/** The table TSTL opens every module chunk with and returns from it. */
|
|
39
|
+
const EXPORTS_NAME = "____exports";
|
|
40
|
+
|
|
41
|
+
function exportsIdentifier() {
|
|
42
|
+
return createIdentifier(EXPORTS_NAME);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether the source owns Defold lifecycle hooks, which is what makes its output
|
|
47
|
+
* a component resource rather than a requirable module. Only the three runtime
|
|
48
|
+
* kinds split: an editor script's chunk returns its own hooks table instead of
|
|
49
|
+
* `____exports`, so the halves a split would produce are not the halves below.
|
|
50
|
+
*/
|
|
51
|
+
function isRuntimeScriptSource(sourceFile: ts.SourceFile, checker: ts.TypeChecker): boolean {
|
|
52
|
+
return sourceFile.statements.some((statement) => {
|
|
53
|
+
const expression = ts.isExpressionStatement(statement)
|
|
54
|
+
? statement.expression
|
|
55
|
+
: ts.isExportAssignment(statement) && !statement.isExportEquals
|
|
56
|
+
? statement.expression
|
|
57
|
+
: undefined;
|
|
58
|
+
return (
|
|
59
|
+
expression !== undefined &&
|
|
60
|
+
ts.isCallExpression(expression) &&
|
|
61
|
+
resolvesToFactoryExport(expression.expression, checker)
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface CompanionPlan {
|
|
67
|
+
/** Full start of each top-level statement the companion takes over. */
|
|
68
|
+
readonly closureStarts: ReadonlySet<number>;
|
|
69
|
+
/** Members the script reaches by bare name, which the companion must expose. */
|
|
70
|
+
readonly internals: readonly string[];
|
|
71
|
+
/** Import bindings the companion needs its own `require` of. */
|
|
72
|
+
readonly importBindings: readonly string[];
|
|
73
|
+
/** The module path the script's `require` names. */
|
|
74
|
+
readonly requirePath: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function planFor(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CompanionPlan | undefined {
|
|
78
|
+
if (sourceFile.isDeclarationFile || !isRuntimeScriptSource(sourceFile, checker)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const closure = computeCompanionClosure(sourceFile, checker);
|
|
82
|
+
// A source the previous slice rejects is reported by the build, not split: the
|
|
83
|
+
// shapes it names are exactly the ones a companion would silently break.
|
|
84
|
+
if (closure.statements.length === 0 || closure.violations.length > 0) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
closureStarts: new Set(closure.statements.map((statement) => statement.pos)),
|
|
89
|
+
internals: closure.internals,
|
|
90
|
+
importBindings: closure.importBindings,
|
|
91
|
+
requirePath: requirePathForRel(sourceFile.fileName),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isLuaNode(value: unknown): value is Node {
|
|
96
|
+
return typeof value === "object" && value !== null && typeof (value as Node).kind === "number";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function forEachLuaNode(root: Node, visit: (node: Node) => void): void {
|
|
100
|
+
visit(root);
|
|
101
|
+
for (const value of Object.values(root)) {
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
for (const element of value) {
|
|
104
|
+
if (isLuaNode(element)) {
|
|
105
|
+
forEachLuaNode(element, visit);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} else if (isLuaNode(value)) {
|
|
109
|
+
forEachLuaNode(value, visit);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function declaredNames(statement: Statement): readonly string[] {
|
|
115
|
+
return isVariableDeclarationStatement(statement) ? statement.left.map((name) => name.text) : [];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The names these statements read but do not bind, under Lua's own scoping.
|
|
120
|
+
*
|
|
121
|
+
* A flat identifier walk cannot answer this: a parameter, a nested `local` and a
|
|
122
|
+
* loop variable are all spelled as plain identifiers, so every name introduced
|
|
123
|
+
* inside a moved function would read as a free reference to whatever the script
|
|
124
|
+
* happens to bind under the same spelling.
|
|
125
|
+
*
|
|
126
|
+
* The statements' own top level is one scope, pre-seeded with everything it
|
|
127
|
+
* declares before the walk starts. That makes it order-insensitive, unlike the
|
|
128
|
+
* nested scopes, which is what keeps TSTL's hoisted `local a, b` forward
|
|
129
|
+
* references reading as bound.
|
|
130
|
+
*/
|
|
131
|
+
function freeNames(statements: readonly Statement[]): Set<string> {
|
|
132
|
+
const free = new Set<string>();
|
|
133
|
+
const scopes: Set<string>[] = [new Set()];
|
|
134
|
+
|
|
135
|
+
function declare(name: string): void {
|
|
136
|
+
scopes[scopes.length - 1]?.add(name);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isBound(name: string): boolean {
|
|
140
|
+
return scopes.some((scope) => scope.has(name));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function inScope(run: () => void): void {
|
|
144
|
+
scopes.push(new Set());
|
|
145
|
+
run();
|
|
146
|
+
scopes.pop();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function visitEach(nodes: readonly Node[] | undefined): void {
|
|
150
|
+
for (const node of nodes ?? []) {
|
|
151
|
+
visit(node);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function visit(node: Node | undefined): void {
|
|
156
|
+
if (node === undefined) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (isIdentifier(node)) {
|
|
160
|
+
if (!isBound(node.text)) {
|
|
161
|
+
free.add(node.text);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// `right` before `left`, so `local x = x` reads the outer `x` as Lua does.
|
|
166
|
+
if (isVariableDeclarationStatement(node)) {
|
|
167
|
+
// Except the one shape the printer emits as `local function name(...)`
|
|
168
|
+
// "to allow recursion" — there the name is bound inside its own body.
|
|
169
|
+
// `FunctionDefinition` narrows only `right`, so mirror the printer, which
|
|
170
|
+
// binds `left[0]` and prints no other name in this shape.
|
|
171
|
+
if (isFunctionDefinition(node)) {
|
|
172
|
+
const [name] = node.left;
|
|
173
|
+
if (name !== undefined) {
|
|
174
|
+
declare(name.text);
|
|
175
|
+
}
|
|
176
|
+
visit(node.right[0]);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
visitEach(node.right);
|
|
180
|
+
for (const name of node.left) {
|
|
181
|
+
declare(name.text);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (isFunctionExpression(node)) {
|
|
186
|
+
inScope(() => {
|
|
187
|
+
for (const param of node.params ?? []) {
|
|
188
|
+
declare(param.text);
|
|
189
|
+
}
|
|
190
|
+
visit(node.body);
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (isForStatement(node)) {
|
|
195
|
+
visit(node.controlVariableInitializer);
|
|
196
|
+
visit(node.limitExpression);
|
|
197
|
+
visit(node.stepExpression);
|
|
198
|
+
inScope(() => {
|
|
199
|
+
declare(node.controlVariable.text);
|
|
200
|
+
visit(node.body);
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (isForInStatement(node)) {
|
|
205
|
+
visitEach(node.expressions);
|
|
206
|
+
inScope(() => {
|
|
207
|
+
for (const name of node.names) {
|
|
208
|
+
declare(name.text);
|
|
209
|
+
}
|
|
210
|
+
visit(node.body);
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// `repeat … until` is the one loop whose condition sees the body's locals.
|
|
215
|
+
if (isRepeatStatement(node)) {
|
|
216
|
+
inScope(() => {
|
|
217
|
+
visitEach(node.body.statements);
|
|
218
|
+
visit(node.condition);
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (isBlock(node) || isDoStatement(node)) {
|
|
223
|
+
inScope(() => visitEach(node.statements));
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// A method name is not a variable read; a table index spelled as an
|
|
227
|
+
// identifier is.
|
|
228
|
+
if (isMethodCallExpression(node)) {
|
|
229
|
+
visit(node.prefixExpression);
|
|
230
|
+
visitEach(node.params);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const value of Object.values(node)) {
|
|
234
|
+
if (Array.isArray(value)) {
|
|
235
|
+
for (const element of value) {
|
|
236
|
+
if (isLuaNode(element)) {
|
|
237
|
+
visit(element);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} else if (isLuaNode(value)) {
|
|
241
|
+
visit(value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const statement of statements) {
|
|
247
|
+
for (const name of declaredNames(statement)) {
|
|
248
|
+
declare(name);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const statement of statements) {
|
|
252
|
+
visit(statement);
|
|
253
|
+
}
|
|
254
|
+
return free;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Where each module specifier the source imports from lands as a Lua require.
|
|
259
|
+
*
|
|
260
|
+
* TSTL rewrites its own requires by string substitution over the printed code,
|
|
261
|
+
* in a pass that runs *after* `afterPrint` — so the script's copy is rewritten
|
|
262
|
+
* and the companion's, printed here, never is. Resolving through the checker and
|
|
263
|
+
* `requirePathForRel` reaches the same spelling by the same segment math the
|
|
264
|
+
* build's resolution check reads. A specifier that resolves to no emitted source
|
|
265
|
+
* — a declaration file, an unresolved module — is left as the source wrote it.
|
|
266
|
+
*/
|
|
267
|
+
function resolvedRequirePaths(
|
|
268
|
+
sourceFile: ts.SourceFile,
|
|
269
|
+
checker: ts.TypeChecker,
|
|
270
|
+
): Map<string, string> {
|
|
271
|
+
const paths = new Map<string, string>();
|
|
272
|
+
for (const statement of sourceFile.statements) {
|
|
273
|
+
const specifier =
|
|
274
|
+
ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement)
|
|
275
|
+
? statement.moduleSpecifier
|
|
276
|
+
: undefined;
|
|
277
|
+
if (specifier === undefined || !ts.isStringLiteral(specifier)) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
const target = checker
|
|
281
|
+
.getSymbolAtLocation(specifier)
|
|
282
|
+
?.declarations?.find((declaration): declaration is ts.SourceFile =>
|
|
283
|
+
ts.isSourceFile(declaration),
|
|
284
|
+
);
|
|
285
|
+
if (target === undefined || target.isDeclarationFile) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
paths.set(specifier.text, requirePathForRel(target.fileName));
|
|
289
|
+
}
|
|
290
|
+
return paths;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Rewrites every `require` a moved statement carries, at whatever depth — a
|
|
295
|
+
* re-export's is nested in the `do … end` block TSTL emits for it.
|
|
296
|
+
*
|
|
297
|
+
* In place, which is safe for exactly these statements: `split` sends an owned
|
|
298
|
+
* statement to the companion alone, so no printer ever sees the node again.
|
|
299
|
+
*/
|
|
300
|
+
function resolveMovedRequires(
|
|
301
|
+
statements: readonly Statement[],
|
|
302
|
+
paths: ReadonlyMap<string, string>,
|
|
303
|
+
) {
|
|
304
|
+
for (const statement of statements) {
|
|
305
|
+
forEachLuaNode(statement, (node) => {
|
|
306
|
+
if (!isCallExpression(node) || !isIdentifier(node.expression)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (node.expression.text !== "require" || node.params.length !== 1) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const [argument] = node.params;
|
|
313
|
+
if (argument === undefined || !isStringLiteral(argument)) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const resolved = paths.get(argument.value);
|
|
317
|
+
if (resolved !== undefined) {
|
|
318
|
+
argument.value = resolved;
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Rebuilt rather than mutated in place: the script keeps the very same node, and
|
|
325
|
+
// its require is still spelled the way TSTL's later pass expects to find it.
|
|
326
|
+
function withResolvedRequire(statement: Statement, paths: ReadonlyMap<string, string>): Statement {
|
|
327
|
+
if (!isVariableDeclarationStatement(statement) || statement.left.length !== 1) {
|
|
328
|
+
return statement;
|
|
329
|
+
}
|
|
330
|
+
const name = statement.left[0];
|
|
331
|
+
const [value] = statement.right ?? [];
|
|
332
|
+
if (name === undefined || value === undefined || !isCallExpression(value)) {
|
|
333
|
+
return statement;
|
|
334
|
+
}
|
|
335
|
+
if (!isIdentifier(value.expression) || value.expression.text !== "require") {
|
|
336
|
+
return statement;
|
|
337
|
+
}
|
|
338
|
+
const [argument] = value.params;
|
|
339
|
+
if (argument === undefined || !isStringLiteral(argument)) {
|
|
340
|
+
return statement;
|
|
341
|
+
}
|
|
342
|
+
const resolved = paths.get(argument.value);
|
|
343
|
+
if (resolved === undefined) {
|
|
344
|
+
return statement;
|
|
345
|
+
}
|
|
346
|
+
return createVariableDeclarationStatement(
|
|
347
|
+
createIdentifier(name.text),
|
|
348
|
+
createCallExpression(createIdentifier("require"), [createStringLiteral(resolved)]),
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The script statements binding names the companion reads but does not bind —
|
|
354
|
+
* TSTL's hoisted `require` prelude — to copy, not move, into the companion.
|
|
355
|
+
*
|
|
356
|
+
* Copying is the only option and also the right one: an import's Lua comes from
|
|
357
|
+
* TSTL's own hoisting rather than from the visitor, so there is no statement to
|
|
358
|
+
* move, and Lua's module cache makes the second `require` the same table, so a
|
|
359
|
+
* mutable import keeps its identity across the split. The walk is transitive
|
|
360
|
+
* because `local x = ____lib.x` is useless without `local ____lib = require(…)`.
|
|
361
|
+
*/
|
|
362
|
+
function copiedPrelude(halves: SplitHalves, importBindings: readonly string[]): Statement[] {
|
|
363
|
+
const needed = new Set(importBindings);
|
|
364
|
+
// `importBindings` is what the companion's import needs actually come from.
|
|
365
|
+
// This second seed is a net over the scope model: today it contributes only
|
|
366
|
+
// names no script declaration binds, so it copies nothing, and a future gap
|
|
367
|
+
// in `freeNames` surfaces as an over-copy rather than an unbound global.
|
|
368
|
+
for (const name of freeNames(halves.companion)) {
|
|
369
|
+
needed.add(name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const alreadyCarried = new Set(halves.companion);
|
|
373
|
+
const candidates = halves.script.filter(
|
|
374
|
+
(statement) => isVariableDeclarationStatement(statement) && !alreadyCarried.has(statement),
|
|
375
|
+
);
|
|
376
|
+
const copied = new Set<Statement>();
|
|
377
|
+
let grew = true;
|
|
378
|
+
while (grew) {
|
|
379
|
+
grew = false;
|
|
380
|
+
for (const statement of candidates) {
|
|
381
|
+
if (copied.has(statement) || !declaredNames(statement).some((name) => needed.has(name))) {
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
copied.add(statement);
|
|
385
|
+
// The statement's own free names, so copying `local warm = ____boot.warm`
|
|
386
|
+
// pulls `____boot` behind it and not `warm`.
|
|
387
|
+
for (const name of freeNames([statement])) {
|
|
388
|
+
needed.add(name);
|
|
389
|
+
}
|
|
390
|
+
grew = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return halves.script.filter((statement) => copied.has(statement));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function namesIdentifier(statement: Statement, names: ReadonlySet<string>): boolean {
|
|
397
|
+
let found = false;
|
|
398
|
+
forEachLuaNode(statement, (node) => {
|
|
399
|
+
if (isIdentifier(node) && names.has(node.text)) {
|
|
400
|
+
found = true;
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
return found;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isExportsTableInit(statement: Statement): boolean {
|
|
407
|
+
if (!isVariableDeclarationStatement(statement) || statement.left.length !== 1) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
const [single] = statement.right ?? [];
|
|
411
|
+
return (
|
|
412
|
+
statement.left[0]?.text === EXPORTS_NAME && single !== undefined && isTableExpression(single)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isExportsReturn(statement: Statement): boolean {
|
|
417
|
+
if (!isReturnStatement(statement) || statement.expressions.length !== 1) {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
const [single] = statement.expressions;
|
|
421
|
+
return single !== undefined && isIdentifier(single) && single.text === EXPORTS_NAME;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A bare `local a, b` with no initializer is the declaration TSTL's hoisting
|
|
425
|
+
// prepends for forward-referenced names. It belongs to neither half, so both
|
|
426
|
+
// halves take a copy: without it the half that assigns one of those names would
|
|
427
|
+
// write a global instead.
|
|
428
|
+
function isHoistedNameDeclaration(statement: Statement): boolean {
|
|
429
|
+
return isVariableDeclarationStatement(statement) && statement.right === undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function internalsField(name: string) {
|
|
433
|
+
return createTableIndexExpression(
|
|
434
|
+
createTableIndexExpression(exportsIdentifier(), createStringLiteral(COMPANION_INTERNAL_FIELD)),
|
|
435
|
+
createStringLiteral(name),
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
interface SplitHalves {
|
|
440
|
+
readonly script: Statement[];
|
|
441
|
+
readonly companion: Statement[];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Where the companion's `require` goes in the script: after the leading run of
|
|
446
|
+
* plain declarations, which is where TSTL's hoisting has already put every other
|
|
447
|
+
* module's `require`, so a runtime import written above the closure still loads
|
|
448
|
+
* first. The run stops at the first statement that reads the companion, and at
|
|
449
|
+
* the first non-declaration — a lifecycle hook closes over the internals it
|
|
450
|
+
* reads, so its declaration must precede the hook's.
|
|
451
|
+
*/
|
|
452
|
+
function requireInsertionPoint(script: readonly Statement[], reads: ReadonlySet<string>): number {
|
|
453
|
+
let index = 0;
|
|
454
|
+
while (index < script.length) {
|
|
455
|
+
const statement = script[index];
|
|
456
|
+
if (statement === undefined || !isVariableDeclarationStatement(statement)) {
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
if (namesIdentifier(statement, reads)) {
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
index++;
|
|
463
|
+
}
|
|
464
|
+
return index;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function split(statements: readonly Statement[], owned: ReadonlySet<Statement>): SplitHalves {
|
|
468
|
+
const script: Statement[] = [];
|
|
469
|
+
const companion: Statement[] = [];
|
|
470
|
+
for (const statement of statements) {
|
|
471
|
+
if (isExportsTableInit(statement) || isExportsReturn(statement)) {
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (owned.has(statement)) {
|
|
475
|
+
companion.push(statement);
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
if (isHoistedNameDeclaration(statement)) {
|
|
479
|
+
companion.push(statement);
|
|
480
|
+
}
|
|
481
|
+
script.push(statement);
|
|
482
|
+
}
|
|
483
|
+
return { script, companion };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface CompanionEmitter {
|
|
487
|
+
readonly plugin: Plugin;
|
|
488
|
+
/**
|
|
489
|
+
* Companion Lua by source file name, for the sources that produced one. Read
|
|
490
|
+
* after a transpile; a source absent from it emits a single chunk.
|
|
491
|
+
*/
|
|
492
|
+
readonly companions: ReadonlyMap<string, string>;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Splits a script-kind source that exports runtime values into two chunks: a
|
|
497
|
+
* companion module holding the dependency closure of those exports, and the
|
|
498
|
+
* script, which requires it rather than redeclaring it. One definition then
|
|
499
|
+
* serves the script's own hooks and every importer, which is what makes a
|
|
500
|
+
* mutable export the same table on both sides.
|
|
501
|
+
*
|
|
502
|
+
* A factory rather than a shared constant: the captured chunks belong to one
|
|
503
|
+
* transpile, and a module-level plugin would leak them between the editor
|
|
504
|
+
* session's rebuilds.
|
|
505
|
+
*/
|
|
506
|
+
export function createCompanionEmitPlugin(): CompanionEmitter {
|
|
507
|
+
const plans = new Map<string, CompanionPlan | undefined>();
|
|
508
|
+
const owned = new Map<string, Set<Statement>>();
|
|
509
|
+
const companions = new Map<string, string>();
|
|
510
|
+
|
|
511
|
+
function planOf(sourceFile: ts.SourceFile, checker: ts.TypeChecker): CompanionPlan | undefined {
|
|
512
|
+
const key = sourceFile.fileName;
|
|
513
|
+
if (!plans.has(key)) {
|
|
514
|
+
plans.set(key, planFor(sourceFile, checker));
|
|
515
|
+
}
|
|
516
|
+
return plans.get(key);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function capture(node: ts.Statement, context: TransformationContext): Statement[] {
|
|
520
|
+
const statements = context.superTransformStatements(node);
|
|
521
|
+
const sourceFile = context.sourceFile;
|
|
522
|
+
const plan = planOf(sourceFile, context.checker);
|
|
523
|
+
if (plan === undefined || !plan.closureStarts.has(node.pos)) {
|
|
524
|
+
return statements;
|
|
525
|
+
}
|
|
526
|
+
let bucket = owned.get(sourceFile.fileName);
|
|
527
|
+
if (bucket === undefined) {
|
|
528
|
+
bucket = new Set();
|
|
529
|
+
owned.set(sourceFile.fileName, bucket);
|
|
530
|
+
}
|
|
531
|
+
for (const statement of statements) {
|
|
532
|
+
bucket.add(statement);
|
|
533
|
+
}
|
|
534
|
+
return statements;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const visitors: Plugin["visitors"] = {
|
|
538
|
+
[ts.SyntaxKind.VariableStatement]: capture,
|
|
539
|
+
[ts.SyntaxKind.FunctionDeclaration]: capture,
|
|
540
|
+
[ts.SyntaxKind.ClassDeclaration]: capture,
|
|
541
|
+
[ts.SyntaxKind.EnumDeclaration]: capture,
|
|
542
|
+
// Not ImportDeclaration: `superTransformStatements` returns nothing for one,
|
|
543
|
+
// because TSTL emits its `require` bindings through its own hoisting. The
|
|
544
|
+
// companion takes a copy of that prelude instead — see `copiedPrelude`.
|
|
545
|
+
[ts.SyntaxKind.ExportDeclaration]: capture,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
let configuredNoResolvePaths: readonly string[] = [];
|
|
549
|
+
|
|
550
|
+
const plugin: Plugin = {
|
|
551
|
+
visitors,
|
|
552
|
+
beforeTransform(_program, options) {
|
|
553
|
+
configuredNoResolvePaths = options.noResolvePaths ?? [];
|
|
554
|
+
},
|
|
555
|
+
afterPrint(program, options, emitHost, result) {
|
|
556
|
+
const emitted: string[] = [];
|
|
557
|
+
for (const file of result) {
|
|
558
|
+
const sourceFile = file.sourceFiles?.[0];
|
|
559
|
+
if (sourceFile === undefined || file.luaAst === undefined) {
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const plan = plans.get(sourceFile.fileName);
|
|
563
|
+
const bucket = owned.get(sourceFile.fileName);
|
|
564
|
+
if (plan === undefined || bucket === undefined || bucket.size === 0) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
emitCompanion(program, emitHost, file, sourceFile, plan, bucket, companions);
|
|
568
|
+
emitted.push(plan.requirePath);
|
|
569
|
+
}
|
|
570
|
+
// The companion is written by the CLI, not by TSTL's emit plan, so TSTL's
|
|
571
|
+
// resolver would report the script's own require as unresolvable and
|
|
572
|
+
// rewrite it to a fallback path. Exempting exactly the paths just emitted
|
|
573
|
+
// leaves the require spelled the way `requirePathForRel` spells it, which
|
|
574
|
+
// is the spelling the build's resolution check reads.
|
|
575
|
+
if (emitted.length > 0) {
|
|
576
|
+
options.noResolvePaths = [...configuredNoResolvePaths, ...emitted];
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
return { plugin, companions };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function emitCompanion(
|
|
585
|
+
program: ts.Program,
|
|
586
|
+
emitHost: Parameters<NonNullable<Plugin["afterPrint"]>>[2],
|
|
587
|
+
file: ProcessedFile,
|
|
588
|
+
sourceFile: ts.SourceFile,
|
|
589
|
+
plan: CompanionPlan,
|
|
590
|
+
owned: ReadonlySet<Statement>,
|
|
591
|
+
companions: Map<string, string>,
|
|
592
|
+
): void {
|
|
593
|
+
const luaAst = file.luaAst;
|
|
594
|
+
if (luaAst === undefined) {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const halves = split(luaAst.statements, owned);
|
|
598
|
+
const requirePaths = resolvedRequirePaths(sourceFile, program.getTypeChecker());
|
|
599
|
+
// A require that moved is no longer in the script for TSTL's later pass to
|
|
600
|
+
// find, so the companion is the only place left to spell it correctly.
|
|
601
|
+
resolveMovedRequires(halves.companion, requirePaths);
|
|
602
|
+
|
|
603
|
+
const closureTable = createTableExpression(
|
|
604
|
+
plan.internals.map((name) =>
|
|
605
|
+
createTableFieldExpression(createIdentifier(name), createStringLiteral(name)),
|
|
606
|
+
),
|
|
607
|
+
);
|
|
608
|
+
const companionStatements: Statement[] = [
|
|
609
|
+
createVariableDeclarationStatement(exportsIdentifier(), createTableExpression()),
|
|
610
|
+
...copiedPrelude(halves, plan.importBindings).map((statement) =>
|
|
611
|
+
withResolvedRequire(statement, requirePaths),
|
|
612
|
+
),
|
|
613
|
+
...halves.companion,
|
|
614
|
+
...(plan.internals.length > 0
|
|
615
|
+
? [
|
|
616
|
+
createAssignmentStatement(
|
|
617
|
+
createTableIndexExpression(
|
|
618
|
+
exportsIdentifier(),
|
|
619
|
+
createStringLiteral(COMPANION_INTERNAL_FIELD),
|
|
620
|
+
),
|
|
621
|
+
closureTable,
|
|
622
|
+
),
|
|
623
|
+
]
|
|
624
|
+
: []),
|
|
625
|
+
createReturnStatement([exportsIdentifier()]),
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
const reads = new Set([EXPORTS_NAME, ...plan.internals]);
|
|
629
|
+
const insertAt = requireInsertionPoint(halves.script, reads);
|
|
630
|
+
const scriptStatements: Statement[] = [
|
|
631
|
+
...halves.script.slice(0, insertAt),
|
|
632
|
+
createVariableDeclarationStatement(
|
|
633
|
+
exportsIdentifier(),
|
|
634
|
+
createCallExpression(createIdentifier("require"), [createStringLiteral(plan.requirePath)]),
|
|
635
|
+
),
|
|
636
|
+
...plan.internals.map((name) =>
|
|
637
|
+
createVariableDeclarationStatement(createIdentifier(name), internalsField(name)),
|
|
638
|
+
),
|
|
639
|
+
...halves.script.slice(insertAt),
|
|
640
|
+
createReturnStatement([exportsIdentifier()]),
|
|
641
|
+
];
|
|
642
|
+
|
|
643
|
+
// Both halves carry the whole feature set rather than a per-half estimate: an
|
|
644
|
+
// underestimate leaves a half calling a nil global, and the cost of an
|
|
645
|
+
// overestimate is an unused local.
|
|
646
|
+
const features = new Set(luaAst.luaLibFeatures);
|
|
647
|
+
const printer = new LuaPrinter(emitHost, program, sourceFile.fileName);
|
|
648
|
+
companions.set(
|
|
649
|
+
sourceFile.fileName,
|
|
650
|
+
printer.print(createFile(companionStatements, new Set(features), luaAst.trivia, sourceFile))
|
|
651
|
+
.code,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
const scriptFile = createFile(scriptStatements, features, luaAst.trivia, sourceFile);
|
|
655
|
+
const printed = new LuaPrinter(emitHost, program, sourceFile.fileName).print(scriptFile);
|
|
656
|
+
file.luaAst = scriptFile;
|
|
657
|
+
file.code = printed.code;
|
|
658
|
+
file.sourceMap = printed.sourceMap;
|
|
659
|
+
file.sourceMapNode = printed.sourceMapNode;
|
|
660
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { scanEmittedRequires } from "./lua-require-scan";
|
|
2
|
+
import { TIMERS_REQUIRE_NAME } from "./timers-runtime";
|
|
3
|
+
|
|
4
|
+
export const LUALIB_REQUIRE_NAME = "lualib_bundle";
|
|
5
|
+
|
|
6
|
+
// Written unconditionally by both build paths and backed by no TypeScript
|
|
7
|
+
// source, so a require of either can never be unresolvable.
|
|
8
|
+
const GENERATED_RUNTIME_REQUIRES: ReadonlySet<string> = new Set([
|
|
9
|
+
LUALIB_REQUIRE_NAME,
|
|
10
|
+
TIMERS_REQUIRE_NAME,
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The distinct string-literal module paths an emitted Lua chunk requires, in
|
|
15
|
+
* source order. The two generated runtimes are skipped, as is any require whose
|
|
16
|
+
* argument is not a string literal — neither can name a source the build owns.
|
|
17
|
+
*/
|
|
18
|
+
export function findEmittedRequires(lua: string): string[] {
|
|
19
|
+
const found: string[] = [];
|
|
20
|
+
const seen = new Set<string>();
|
|
21
|
+
|
|
22
|
+
for (const { path } of scanEmittedRequires(lua)) {
|
|
23
|
+
if (GENERATED_RUNTIME_REQUIRES.has(path) || seen.has(path)) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
seen.add(path);
|
|
27
|
+
found.push(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return found;
|
|
31
|
+
}
|