@gwigz/slua-tstl-plugin 1.1.0 → 1.3.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/README.md +54 -1
- package/dist/define.d.ts +19 -0
- package/dist/define.js +83 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +129 -143
- package/dist/lua-ast-walk.d.ts +10 -0
- package/dist/lua-ast-walk.js +422 -0
- package/dist/lua-transforms.d.ts +41 -0
- package/dist/lua-transforms.js +302 -0
- package/dist/optimize.d.ts +2 -0
- package/dist/optimize.js +1 -0
- package/dist/shake-plugin.d.ts +11 -0
- package/dist/shake-plugin.js +64 -0
- package/dist/shake-strip.d.ts +8 -0
- package/dist/shake-strip.js +88 -0
- package/dist/shake.d.ts +13 -0
- package/dist/shake.js +74 -0
- package/package.json +12 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import * as lua from "typescript-to-lua";
|
|
3
|
+
import { walkBlocks, walkIdentifiers, containsIdentifier } from "./lua-ast-walk.js";
|
|
4
|
+
const JSDOC_TAG_RE = /@(?:index(?:Arg|Return)|define)\b/;
|
|
5
|
+
/**
|
|
6
|
+
* Strip internal JSDoc tags from Lua comments (@indexArg, @indexReturn, @define).
|
|
7
|
+
* These are only consumed by the plugin at transpile time.
|
|
8
|
+
*
|
|
9
|
+
* TSTL generates JSDoc as flat string arrays in leadingComments:
|
|
10
|
+
* ["-", " @define FLAG", " continuation line", " @indexArg 0"]
|
|
11
|
+
* Each string becomes a `-- <text>` line. When we find a tag line, we also
|
|
12
|
+
* remove its continuation lines (those starting with ` ` but not ` @`).
|
|
13
|
+
*/
|
|
14
|
+
export function stripInternalJSDocTags(file) {
|
|
15
|
+
let changed = false;
|
|
16
|
+
walkBlocks(file, (statements) => {
|
|
17
|
+
for (const stmt of statements) {
|
|
18
|
+
if (!stmt.leadingComments || stmt.leadingComments.length === 0)
|
|
19
|
+
continue;
|
|
20
|
+
const filtered = [];
|
|
21
|
+
let skip = false;
|
|
22
|
+
for (const comment of stmt.leadingComments) {
|
|
23
|
+
if (typeof comment === "string") {
|
|
24
|
+
if (JSDOC_TAG_RE.test(comment)) {
|
|
25
|
+
// Tag line, skip it and subsequent continuation lines
|
|
26
|
+
skip = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (skip) {
|
|
30
|
+
// Continuation line: starts with space but not with ` @tag`
|
|
31
|
+
if (comment.startsWith(" ") && !comment.match(/^ @\w/)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
// Not a continuation, stop skipping
|
|
35
|
+
skip = false;
|
|
36
|
+
}
|
|
37
|
+
filtered.push(comment);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Block comment (string[]), strip if any line contains the tag
|
|
41
|
+
skip = false;
|
|
42
|
+
if (comment.some((line) => JSDOC_TAG_RE.test(line))) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
filtered.push(comment);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// If only the JSDoc opener "-" remains, remove it too
|
|
49
|
+
if (filtered.length === 1 && filtered[0] === "-") {
|
|
50
|
+
filtered.length = 0;
|
|
51
|
+
}
|
|
52
|
+
if (filtered.length !== stmt.leadingComments.length) {
|
|
53
|
+
stmt.leadingComments = filtered.length > 0 ? filtered : undefined;
|
|
54
|
+
changed = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
return changed;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Strip empty module boilerplate from files without explicit exports.
|
|
62
|
+
* `moduleDetection: "force"` causes TSTL to wrap every file as a module;
|
|
63
|
+
* standalone SLua scripts don't need the ____exports wrapper.
|
|
64
|
+
*/
|
|
65
|
+
export function stripEmptyModuleBoilerplate(file, sourceFiles) {
|
|
66
|
+
const stmts = file.statements;
|
|
67
|
+
if (stmts.length < 2)
|
|
68
|
+
return false;
|
|
69
|
+
// Find `local ____exports = {}` at top level
|
|
70
|
+
const declIdx = stmts.findIndex((s) => lua.isVariableDeclarationStatement(s) &&
|
|
71
|
+
s.left.length === 1 &&
|
|
72
|
+
s.left[0].text === "____exports" &&
|
|
73
|
+
s.right &&
|
|
74
|
+
s.right.length === 1 &&
|
|
75
|
+
lua.isTableExpression(s.right[0]) &&
|
|
76
|
+
s.right[0].fields.length === 0);
|
|
77
|
+
if (declIdx === -1)
|
|
78
|
+
return false;
|
|
79
|
+
// Find `return ____exports` at end
|
|
80
|
+
const last = stmts[stmts.length - 1];
|
|
81
|
+
if (!lua.isReturnStatement(last) ||
|
|
82
|
+
last.expressions.length !== 1 ||
|
|
83
|
+
!lua.isIdentifier(last.expressions[0]) ||
|
|
84
|
+
last.expressions[0].text !== "____exports") {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// Check TS source files for explicit exports
|
|
88
|
+
const hasExplicitExports = sourceFiles?.some((sf) => sf.statements.some((s) => ts.isExportDeclaration(s) ||
|
|
89
|
+
ts.isExportAssignment(s) ||
|
|
90
|
+
(ts.canHaveModifiers(s) &&
|
|
91
|
+
ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword))));
|
|
92
|
+
if (hasExplicitExports)
|
|
93
|
+
return false;
|
|
94
|
+
// Remove both: the decl and the return
|
|
95
|
+
stmts.splice(stmts.length - 1, 1); // remove return first (higher index)
|
|
96
|
+
stmts.splice(declIdx, 1); // then remove decl
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Collapse default-parameter nil-checks into `x = x or <literal>`.
|
|
101
|
+
* Matches: if x == nil then x = <literal> end
|
|
102
|
+
* Safe for strings and numbers (both truthy in Lua); the TS source only
|
|
103
|
+
* generates these for string/number defaults.
|
|
104
|
+
*/
|
|
105
|
+
export function collapseDefaultParamNilChecks(file) {
|
|
106
|
+
let changed = false;
|
|
107
|
+
walkBlocks(file, (statements) => {
|
|
108
|
+
for (let i = 0; i < statements.length; i++) {
|
|
109
|
+
const stmt = statements[i];
|
|
110
|
+
if (!lua.isIfStatement(stmt))
|
|
111
|
+
continue;
|
|
112
|
+
if (stmt.elseBlock)
|
|
113
|
+
continue;
|
|
114
|
+
// condition: x == nil
|
|
115
|
+
if (!lua.isBinaryExpression(stmt.condition))
|
|
116
|
+
continue;
|
|
117
|
+
if (stmt.condition.operator !== lua.SyntaxKind.EqualityOperator)
|
|
118
|
+
continue;
|
|
119
|
+
if (!lua.isIdentifier(stmt.condition.left))
|
|
120
|
+
continue;
|
|
121
|
+
if (!lua.isNilLiteral(stmt.condition.right))
|
|
122
|
+
continue;
|
|
123
|
+
const paramName = stmt.condition.left;
|
|
124
|
+
// ifBlock has exactly 1 statement: x = <literal>
|
|
125
|
+
if (stmt.ifBlock.statements.length !== 1)
|
|
126
|
+
continue;
|
|
127
|
+
const inner = stmt.ifBlock.statements[0];
|
|
128
|
+
if (!lua.isAssignmentStatement(inner))
|
|
129
|
+
continue;
|
|
130
|
+
if (inner.left.length !== 1 || inner.right.length !== 1)
|
|
131
|
+
continue;
|
|
132
|
+
const assignTarget = inner.left[0];
|
|
133
|
+
if (!lua.isIdentifier(assignTarget))
|
|
134
|
+
continue;
|
|
135
|
+
if (assignTarget.text !== paramName.text)
|
|
136
|
+
continue;
|
|
137
|
+
const literal = inner.right[0];
|
|
138
|
+
if (!lua.isStringLiteral(literal) && !lua.isNumericLiteral(literal)) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Replace with: x = x or <literal>
|
|
142
|
+
const orExpr = lua.createBinaryExpression(lua.cloneIdentifier(paramName), literal, lua.SyntaxKind.OrOperator);
|
|
143
|
+
const assignment = lua.createAssignmentStatement(lua.cloneIdentifier(paramName), orExpr);
|
|
144
|
+
// Preserve leading comments from the if statement
|
|
145
|
+
if (stmt.leadingComments) {
|
|
146
|
+
assignment.leadingComments = stmt.leadingComments;
|
|
147
|
+
}
|
|
148
|
+
statements[i] = assignment;
|
|
149
|
+
changed = true;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
return changed;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Shorten TSTL destructuring temp names: ____*_result_* -> _rN.
|
|
156
|
+
* Two-pass: collect unique names in order, then rename all identifiers.
|
|
157
|
+
*/
|
|
158
|
+
export function shortenTempNames(file) {
|
|
159
|
+
const seen = new Map();
|
|
160
|
+
let counter = 0;
|
|
161
|
+
const tempRe = /^____\w+_result_\d+$/;
|
|
162
|
+
// Pass 1: collect unique temp names in order of first occurrence
|
|
163
|
+
walkIdentifiers(file, (id) => {
|
|
164
|
+
if (tempRe.test(id.text) && !seen.has(id.text)) {
|
|
165
|
+
seen.set(id.text, `_r${counter++}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if (seen.size === 0)
|
|
169
|
+
return false;
|
|
170
|
+
// Pass 2: rename all matching identifiers
|
|
171
|
+
walkIdentifiers(file, (id) => {
|
|
172
|
+
const short = seen.get(id.text);
|
|
173
|
+
if (short)
|
|
174
|
+
id.text = short;
|
|
175
|
+
});
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Collapse consecutive field accesses from the same shortened temp into
|
|
180
|
+
* multi-assignment: `local a = _r0.x; local b = _r0.y` -> `local a, b = _r0.x, _r0.y`.
|
|
181
|
+
* Only collapses when the base matches `_r\d+` (shortened temps).
|
|
182
|
+
*/
|
|
183
|
+
export function collapseFieldAccesses(file) {
|
|
184
|
+
let changed = false;
|
|
185
|
+
const tempBaseRe = /^_r\d+$/;
|
|
186
|
+
walkBlocks(file, (statements) => {
|
|
187
|
+
let i = 0;
|
|
188
|
+
while (i < statements.length) {
|
|
189
|
+
const stmt = statements[i];
|
|
190
|
+
// Match: local <name> = <temp>.<field>
|
|
191
|
+
if (!lua.isVariableDeclarationStatement(stmt) ||
|
|
192
|
+
stmt.left.length !== 1 ||
|
|
193
|
+
!stmt.right ||
|
|
194
|
+
stmt.right.length !== 1 ||
|
|
195
|
+
!lua.isTableIndexExpression(stmt.right[0]) ||
|
|
196
|
+
!lua.isIdentifier(stmt.right[0].table) ||
|
|
197
|
+
!tempBaseRe.test(stmt.right[0].table.text)) {
|
|
198
|
+
i++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const baseName = stmt.right[0].table.text;
|
|
202
|
+
// Look ahead for consecutive field accesses on the same base
|
|
203
|
+
const left = [...stmt.left];
|
|
204
|
+
const right = [...stmt.right];
|
|
205
|
+
let end = i + 1;
|
|
206
|
+
while (end < statements.length) {
|
|
207
|
+
const next = statements[end];
|
|
208
|
+
if (lua.isVariableDeclarationStatement(next) &&
|
|
209
|
+
next.left.length === 1 &&
|
|
210
|
+
next.right &&
|
|
211
|
+
next.right.length === 1 &&
|
|
212
|
+
lua.isTableIndexExpression(next.right[0]) &&
|
|
213
|
+
lua.isIdentifier(next.right[0].table) &&
|
|
214
|
+
next.right[0].table.text === baseName) {
|
|
215
|
+
left.push(...next.left);
|
|
216
|
+
right.push(...next.right);
|
|
217
|
+
end++;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (end - i > 1) {
|
|
224
|
+
// Create merged multi-assignment
|
|
225
|
+
const merged = lua.createVariableDeclarationStatement(left, right);
|
|
226
|
+
if (stmt.leadingComments) {
|
|
227
|
+
merged.leadingComments = stmt.leadingComments;
|
|
228
|
+
}
|
|
229
|
+
statements.splice(i, end - i, merged);
|
|
230
|
+
changed = true;
|
|
231
|
+
}
|
|
232
|
+
i++;
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
return changed;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Merge forward-declared `local x` with its first `x = value` assignment.
|
|
239
|
+
* Only inlines when there are no references to x between declaration and assignment.
|
|
240
|
+
*/
|
|
241
|
+
export function inlineForwardDeclarations(file) {
|
|
242
|
+
let changed = false;
|
|
243
|
+
walkBlocks(file, (statements) => {
|
|
244
|
+
for (let i = 0; i < statements.length; i++) {
|
|
245
|
+
const stmt = statements[i];
|
|
246
|
+
// Match forward declaration: `local var1, var2, ...` with no initializer
|
|
247
|
+
if (!lua.isVariableDeclarationStatement(stmt) || (stmt.right && stmt.right.length > 0)) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const vars = stmt.left;
|
|
251
|
+
const inlined = new Set();
|
|
252
|
+
for (const varId of vars) {
|
|
253
|
+
const varName = varId.text;
|
|
254
|
+
// Scan forward for assignment to same identifier
|
|
255
|
+
for (let j = i + 1; j < statements.length; j++) {
|
|
256
|
+
const candidate = statements[j];
|
|
257
|
+
if (lua.isAssignmentStatement(candidate) &&
|
|
258
|
+
candidate.left.length === 1 &&
|
|
259
|
+
lua.isIdentifier(candidate.left[0]) &&
|
|
260
|
+
candidate.left[0].text === varName &&
|
|
261
|
+
candidate.right.length === 1) {
|
|
262
|
+
// Don't inline if RHS references the variable (self-referencing)
|
|
263
|
+
if (containsIdentifier(candidate.right[0], varName))
|
|
264
|
+
break;
|
|
265
|
+
// Don't inline multi-variable assignments like `a, b = fn()`
|
|
266
|
+
// (already handled by the length check above)
|
|
267
|
+
// Replace assignment with variable declaration
|
|
268
|
+
const newDecl = lua.createVariableDeclarationStatement(lua.cloneIdentifier(candidate.left[0]), candidate.right[0]);
|
|
269
|
+
if (candidate.leadingComments) {
|
|
270
|
+
newDecl.leadingComments = candidate.leadingComments;
|
|
271
|
+
}
|
|
272
|
+
statements[j] = newDecl;
|
|
273
|
+
inlined.add(varName);
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
// Reference to varName prevents inlining
|
|
277
|
+
if (containsIdentifier(candidate, varName))
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (inlined.size === 0)
|
|
282
|
+
continue;
|
|
283
|
+
changed = true;
|
|
284
|
+
const remaining = vars.filter((v) => !inlined.has(v.text));
|
|
285
|
+
if (remaining.length === 0) {
|
|
286
|
+
// Remove the forward declaration entirely
|
|
287
|
+
statements.splice(i, 1);
|
|
288
|
+
i--; // re-check this index
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
// Keep remaining vars in the forward declaration
|
|
292
|
+
statements[i] = lua.createVariableDeclarationStatement(remaining);
|
|
293
|
+
if (stmt.leadingComments) {
|
|
294
|
+
;
|
|
295
|
+
statements[i].leadingComments =
|
|
296
|
+
stmt.leadingComments;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
return changed;
|
|
302
|
+
}
|
package/dist/optimize.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface OptimizeFlags {
|
|
|
15
15
|
inlineLocals?: boolean;
|
|
16
16
|
/** Strip `tostring()` from number-typed template literal interpolations. */
|
|
17
17
|
numericConcat?: boolean;
|
|
18
|
+
/** Collapse `if x == nil then x = <literal> end` to `x = x or <literal>`. */
|
|
19
|
+
defaultParams?: boolean;
|
|
18
20
|
}
|
|
19
21
|
export declare const ALL_OPTIMIZE: Required<OptimizeFlags>;
|
|
20
22
|
/**
|
package/dist/optimize.js
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Plugin } from "rollup";
|
|
2
|
+
interface ResolverOptions {
|
|
3
|
+
paths: Record<string, string[]>;
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Rollup plugin that resolves tsconfig `paths` to .ts source files
|
|
8
|
+
* and strips types so rollup can parse them for tree-shaking analysis.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createTsconfigResolverPlugin(options: ResolverOptions): Plugin;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
const STRIP_TYPES_OPTIONS = {
|
|
4
|
+
target: ts.ScriptTarget.ESNext,
|
|
5
|
+
module: ts.ModuleKind.ESNext,
|
|
6
|
+
verbatimModuleSyntax: false,
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Rollup plugin that resolves tsconfig `paths` to .ts source files
|
|
10
|
+
* and strips types so rollup can parse them for tree-shaking analysis.
|
|
11
|
+
*/
|
|
12
|
+
export function createTsconfigResolverPlugin(options) {
|
|
13
|
+
const { paths, baseUrl } = options;
|
|
14
|
+
const matchers = Object.entries(paths)
|
|
15
|
+
.filter(([pattern]) => pattern.includes("*"))
|
|
16
|
+
.map(([pattern, substitutions]) => {
|
|
17
|
+
const starIndex = pattern.indexOf("*");
|
|
18
|
+
return {
|
|
19
|
+
prefix: pattern.substring(0, starIndex),
|
|
20
|
+
suffix: pattern.substring(starIndex + 1),
|
|
21
|
+
substitutions,
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
name: "tsconfig-resolver",
|
|
26
|
+
resolveId(source, importer) {
|
|
27
|
+
for (const { prefix, suffix, substitutions } of matchers) {
|
|
28
|
+
if (source.startsWith(prefix) && source.endsWith(suffix)) {
|
|
29
|
+
const wildcard = source.slice(prefix.length, source.length - suffix.length || undefined);
|
|
30
|
+
for (const sub of substitutions) {
|
|
31
|
+
const resolved = sub.replace("*", wildcard);
|
|
32
|
+
const full = join(baseUrl, resolved);
|
|
33
|
+
if (ts.sys.fileExists(full)) {
|
|
34
|
+
return full;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (importer &&
|
|
40
|
+
importer.endsWith(".ts") &&
|
|
41
|
+
(source.startsWith("./") || source.startsWith("../"))) {
|
|
42
|
+
const dir = dirname(importer);
|
|
43
|
+
const withExt = join(dir, `${source}.ts`);
|
|
44
|
+
if (ts.sys.fileExists(withExt))
|
|
45
|
+
return withExt;
|
|
46
|
+
const asIndex = join(dir, source, "index.ts");
|
|
47
|
+
if (ts.sys.fileExists(asIndex))
|
|
48
|
+
return asIndex;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
},
|
|
52
|
+
load(id) {
|
|
53
|
+
if (!id.endsWith(".ts"))
|
|
54
|
+
return null;
|
|
55
|
+
const source = ts.sys.readFile(id);
|
|
56
|
+
if (source === undefined)
|
|
57
|
+
return null;
|
|
58
|
+
const result = ts.transpileModule(source, {
|
|
59
|
+
compilerOptions: STRIP_TYPES_OPTIONS,
|
|
60
|
+
});
|
|
61
|
+
return result.outputText;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remove exported declarations (and their unreferenced internal dependencies)
|
|
3
|
+
* from TypeScript source, keeping only the declarations whose names are in
|
|
4
|
+
* `survivingExports` plus anything they transitively reference.
|
|
5
|
+
*
|
|
6
|
+
* Returns the modified TypeScript source string.
|
|
7
|
+
*/
|
|
8
|
+
export declare function stripDeadExports(source: string, survivingExports: Set<string>): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Remove exported declarations (and their unreferenced internal dependencies)
|
|
4
|
+
* from TypeScript source, keeping only the declarations whose names are in
|
|
5
|
+
* `survivingExports` plus anything they transitively reference.
|
|
6
|
+
*
|
|
7
|
+
* Returns the modified TypeScript source string.
|
|
8
|
+
*/
|
|
9
|
+
export function stripDeadExports(source, survivingExports) {
|
|
10
|
+
const sourceFile = ts.createSourceFile("module.ts", source, ts.ScriptTarget.Latest, true);
|
|
11
|
+
// 1. Map declaration names to their nodes
|
|
12
|
+
const declarations = new Map();
|
|
13
|
+
const stmtNames = new Map();
|
|
14
|
+
for (const stmt of sourceFile.statements) {
|
|
15
|
+
const names = getDeclaredNames(stmt);
|
|
16
|
+
stmtNames.set(stmt, names);
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
declarations.set(name, stmt);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// 2. Build reference graph: which declarations reference which others
|
|
22
|
+
const references = new Map();
|
|
23
|
+
for (const [name, node] of declarations) {
|
|
24
|
+
const refs = new Set();
|
|
25
|
+
collectReferences(node, declarations, refs);
|
|
26
|
+
refs.delete(name); // Remove self-reference
|
|
27
|
+
references.set(name, refs);
|
|
28
|
+
}
|
|
29
|
+
// 3. Walk from surviving exports to find all reachable declarations
|
|
30
|
+
const reachable = new Set();
|
|
31
|
+
function markReachable(name) {
|
|
32
|
+
if (reachable.has(name))
|
|
33
|
+
return;
|
|
34
|
+
reachable.add(name);
|
|
35
|
+
const refs = references.get(name);
|
|
36
|
+
if (refs) {
|
|
37
|
+
for (const ref of refs)
|
|
38
|
+
markReachable(ref);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const name of survivingExports) {
|
|
42
|
+
if (declarations.has(name))
|
|
43
|
+
markReachable(name);
|
|
44
|
+
}
|
|
45
|
+
// 4. Transform: remove unreachable declarations
|
|
46
|
+
const transformer = () => {
|
|
47
|
+
return (sf) => {
|
|
48
|
+
const filtered = sf.statements.filter((stmt) => {
|
|
49
|
+
const names = stmtNames.get(stmt) ?? [];
|
|
50
|
+
if (names.length === 0)
|
|
51
|
+
return true;
|
|
52
|
+
return names.some((n) => reachable.has(n));
|
|
53
|
+
});
|
|
54
|
+
return ts.factory.updateSourceFile(sf, filtered);
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const result = ts.transform(sourceFile, [transformer]);
|
|
58
|
+
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
|
|
59
|
+
const printed = printer.printFile(result.transformed[0]);
|
|
60
|
+
result.dispose();
|
|
61
|
+
return printed;
|
|
62
|
+
}
|
|
63
|
+
function getDeclaredNames(node) {
|
|
64
|
+
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
65
|
+
return [node.name.text];
|
|
66
|
+
}
|
|
67
|
+
if (ts.isVariableStatement(node)) {
|
|
68
|
+
return node.declarationList.declarations
|
|
69
|
+
.filter((d) => ts.isIdentifier(d.name))
|
|
70
|
+
.map((d) => d.name.text);
|
|
71
|
+
}
|
|
72
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
73
|
+
return [node.name.text];
|
|
74
|
+
}
|
|
75
|
+
// Re-exports: export { spawn } or export { spawn } from "./internal/spawn"
|
|
76
|
+
if (ts.isExportDeclaration(node) && node.exportClause && ts.isNamedExports(node.exportClause)) {
|
|
77
|
+
return node.exportClause.elements.map((e) => e.name.text);
|
|
78
|
+
}
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
function collectReferences(node, declarations, refs) {
|
|
82
|
+
if (ts.isIdentifier(node)) {
|
|
83
|
+
if (declarations.has(node.text)) {
|
|
84
|
+
refs.add(node.text);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ts.forEachChild(node, (child) => collectReferences(child, declarations, refs));
|
|
88
|
+
}
|
package/dist/shake.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ShakeOptions {
|
|
2
|
+
/** Entry file paths (same files you'd pass as luaBundleEntry). */
|
|
3
|
+
entry: string[];
|
|
4
|
+
/** Path to tsconfig.json (used to resolve module paths). */
|
|
5
|
+
tsconfig: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ShakeResult {
|
|
8
|
+
/** All files to pass to TSTL (entry files + module files). */
|
|
9
|
+
files: string[];
|
|
10
|
+
/** Map of module file path to Set of surviving export names. */
|
|
11
|
+
survivingExports: Map<string, Set<string>>;
|
|
12
|
+
}
|
|
13
|
+
export declare function shakeModules(options: ShakeOptions): Promise<ShakeResult>;
|
package/dist/shake.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { createTsconfigResolverPlugin } from "./shake-plugin.js";
|
|
4
|
+
function readTsconfigPaths(tsconfigPath) {
|
|
5
|
+
const absolute = resolve(tsconfigPath);
|
|
6
|
+
const configFile = ts.readConfigFile(absolute, ts.sys.readFile);
|
|
7
|
+
if (configFile.error) {
|
|
8
|
+
const msg = ts.flattenDiagnosticMessageText(configFile.error.messageText, "\n");
|
|
9
|
+
throw new Error(`shakeModules: failed to read tsconfig at ${absolute}: ${msg}`);
|
|
10
|
+
}
|
|
11
|
+
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(absolute));
|
|
12
|
+
if (parsed.errors.length > 0) {
|
|
13
|
+
const msg = parsed.errors
|
|
14
|
+
.map((e) => ts.flattenDiagnosticMessageText(e.messageText, "\n"))
|
|
15
|
+
.join("\n");
|
|
16
|
+
throw new Error(`shakeModules: tsconfig errors in ${absolute}: ${msg}`);
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
paths: parsed.options.paths ?? {},
|
|
20
|
+
baseUrl: parsed.options.baseUrl ?? dirname(absolute),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export async function shakeModules(options) {
|
|
24
|
+
let rollup;
|
|
25
|
+
try {
|
|
26
|
+
rollup = await import("rollup");
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error("shakeModules requires rollup as a peer dependency. Install it with: bun add -d rollup");
|
|
30
|
+
}
|
|
31
|
+
const { paths, baseUrl } = readTsconfigPaths(options.tsconfig);
|
|
32
|
+
const resolverPlugin = createTsconfigResolverPlugin({ paths, baseUrl });
|
|
33
|
+
const resolvedModuleFiles = new Set();
|
|
34
|
+
const entryFiles = options.entry.map((e) => resolve(e));
|
|
35
|
+
const wrappedPlugin = {
|
|
36
|
+
name: "tsconfig-resolver-wrapper",
|
|
37
|
+
resolveId(source, importer) {
|
|
38
|
+
const result = resolverPlugin.resolveId.call(this, source, importer);
|
|
39
|
+
if (typeof result === "string") {
|
|
40
|
+
resolvedModuleFiles.add(result);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
if (!importer)
|
|
44
|
+
return null;
|
|
45
|
+
return { id: source, external: true };
|
|
46
|
+
},
|
|
47
|
+
load(id) {
|
|
48
|
+
return resolverPlugin.load.call(this, id);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const bundle = await rollup.rollup({
|
|
52
|
+
input: entryFiles,
|
|
53
|
+
plugins: [wrappedPlugin],
|
|
54
|
+
treeshake: true,
|
|
55
|
+
});
|
|
56
|
+
const { output } = await bundle.generate({
|
|
57
|
+
format: "esm",
|
|
58
|
+
preserveModules: true,
|
|
59
|
+
});
|
|
60
|
+
await bundle.close();
|
|
61
|
+
const survivingExports = new Map();
|
|
62
|
+
for (const chunk of output) {
|
|
63
|
+
if (chunk.type !== "chunk" || !chunk.facadeModuleId)
|
|
64
|
+
continue;
|
|
65
|
+
if (resolvedModuleFiles.has(chunk.facadeModuleId)) {
|
|
66
|
+
survivingExports.set(chunk.facadeModuleId, new Set(chunk.exports));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const fileSet = new Set(entryFiles);
|
|
70
|
+
for (const moduleFile of resolvedModuleFiles) {
|
|
71
|
+
fileSet.add(moduleFile);
|
|
72
|
+
}
|
|
73
|
+
return { files: [...fileSet], survivingExports };
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gwigz/slua-tstl-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "TypeScriptToLua plugin for targeting Second Life's SLua runtime",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
".": {
|
|
19
19
|
"types": "./dist/index.d.ts",
|
|
20
20
|
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./shake": {
|
|
23
|
+
"types": "./dist/shake.d.ts",
|
|
24
|
+
"default": "./dist/shake.js"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"publishConfig": {
|
|
@@ -28,10 +32,16 @@
|
|
|
28
32
|
"test": "bun test"
|
|
29
33
|
},
|
|
30
34
|
"dependencies": {
|
|
35
|
+
"@gwigz/slua-types": "workspace:^",
|
|
31
36
|
"typescript-to-lua": "^1.33.0"
|
|
32
37
|
},
|
|
33
38
|
"peerDependencies": {
|
|
34
|
-
"
|
|
39
|
+
"rollup": "^4.0.0",
|
|
35
40
|
"typescript": "~5.9.3"
|
|
41
|
+
},
|
|
42
|
+
"peerDependenciesMeta": {
|
|
43
|
+
"rollup": {
|
|
44
|
+
"optional": true
|
|
45
|
+
}
|
|
36
46
|
}
|
|
37
47
|
}
|