rbxts-transform-boost 0.1.0 → 1.0.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.
@@ -2,7 +2,6 @@ import type ts from "typescript";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
 
5
- // Luau type names for TypeScript type strings rotor knows about
6
5
  const LUAU_TYPE: Record<string, string> = {
7
6
  number: "number",
8
7
  string: "string",
@@ -24,7 +23,6 @@ const LUAU_TYPE: Record<string, string> = {
24
23
  Region3: "Region3",
25
24
  Ray: "Ray",
26
25
  buffer: "buffer",
27
- // Roblox service types
28
26
  Instance: "Instance",
29
27
  BasePart: "BasePart",
30
28
  Part: "Part",
@@ -34,37 +32,26 @@ const LUAU_TYPE: Record<string, string> = {
34
32
  Workspace: "Workspace",
35
33
  RunService: "RunService",
36
34
  Players: "Players",
37
- // Luau numeric arrays — kept as {number} in Luau
38
35
  };
39
36
 
40
37
  type FnAnnotation = {
41
- params: Array<string | null>; // null = unknown/skip
38
+ params: Array<string | null>;
39
+ ret: string | null;
42
40
  };
43
41
 
44
- // Global sidecar: outLuauPath → list of function annotations for that file
45
- const sidecar = new Map<string, Map<string, FnAnnotation>>();
46
- let hooked = false;
42
+ type FileSidecar = {
43
+ fns: Map<string, FnAnnotation>;
44
+ consts: Set<string>;
45
+ };
47
46
 
48
- function luauTypeForTsType(
49
- ts: typeof import("typescript"),
50
- checker: ts.TypeChecker,
51
- node: ts.ParameterDeclaration,
52
- ): string | null {
53
- if (node.type) {
54
- const mapped = mapTypeNode(ts, node.type);
55
- if (mapped) return mapped;
56
- }
57
- const type = checker.getTypeAtLocation(node);
58
- const name = checker.typeToString(type);
59
- return LUAU_TYPE[name] ?? null;
60
- }
47
+ const sidecar = new Map<string, FileSidecar>();
48
+ let hooked = false;
61
49
 
62
50
  function mapTypeNode(ts: typeof import("typescript"), typeNode: ts.TypeNode): string | null {
63
51
  if (ts.isTypeReferenceNode(typeNode)) {
64
52
  const name = ts.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : null;
65
53
  if (!name) return null;
66
54
  if (LUAU_TYPE[name]) return LUAU_TYPE[name];
67
- // Array<T> → {T}
68
55
  if ((name === "Array" || name === "ReadonlyArray") && typeNode.typeArguments?.length === 1) {
69
56
  const inner = mapTypeNode(ts, typeNode.typeArguments[0]);
70
57
  return inner ? `{${inner}}` : "{any}";
@@ -84,25 +71,44 @@ function mapTypeNode(ts: typeof import("typescript"), typeNode: ts.TypeNode): st
84
71
  return null;
85
72
  }
86
73
 
87
- function outPathForSource(
88
- sourceFile: ts.SourceFile,
89
- program: ts.Program,
74
+ function luauTypeForParam(
75
+ ts: typeof import("typescript"),
76
+ checker: ts.TypeChecker,
77
+ node: ts.ParameterDeclaration,
90
78
  ): string | null {
79
+ if (node.type) {
80
+ const mapped = mapTypeNode(ts, node.type);
81
+ if (mapped) return mapped;
82
+ }
83
+ const name = checker.typeToString(checker.getTypeAtLocation(node));
84
+ return LUAU_TYPE[name] ?? null;
85
+ }
86
+
87
+ function luauTypeForReturn(
88
+ ts: typeof import("typescript"),
89
+ checker: ts.TypeChecker,
90
+ node: ts.FunctionDeclaration,
91
+ ): string | null {
92
+ if (node.type) {
93
+ const mapped = mapTypeNode(ts, node.type);
94
+ if (mapped) return mapped;
95
+ }
96
+ const sig = checker.getSignatureFromDeclaration(node);
97
+ if (!sig) return null;
98
+ const ret = checker.getReturnTypeOfSignature(sig);
99
+ const name = checker.typeToString(ret);
100
+ return LUAU_TYPE[name] ?? null;
101
+ }
102
+
103
+ function outPathForSource(sourceFile: ts.SourceFile, program: ts.Program): string | null {
91
104
  const options = program.getCompilerOptions();
92
105
  const outDir = options.outDir;
93
106
  if (!outDir) return null;
94
-
95
- // Compute rootDir: explicit option or the common root of all source files
96
- const rootDir = options.rootDir
97
- ?? commonRoot(program.getRootFileNames());
107
+ const rootDir = options.rootDir ?? commonRoot(program.getRootFileNames());
98
108
  if (!rootDir) return null;
99
-
100
109
  const rel = path.relative(rootDir, sourceFile.fileName);
101
110
  if (rel.startsWith("..")) return null;
102
-
103
- // Change .ts / .tsx extension to .luau
104
- const luauRel = rel.replace(/\.tsx?$/, ".luau");
105
- return path.join(outDir, luauRel);
111
+ return path.join(outDir, rel.replace(/\.tsx?$/, ".luau"));
106
112
  }
107
113
 
108
114
  function commonRoot(files: readonly string[]): string | undefined {
@@ -124,15 +130,25 @@ function collectAnnotations(
124
130
  sourceFile: ts.SourceFile,
125
131
  outPath: string,
126
132
  ): void {
127
- const fileMap = sidecar.get(outPath) ?? new Map<string, FnAnnotation>();
128
- sidecar.set(outPath, fileMap);
133
+ const entry = sidecar.get(outPath) ?? { fns: new Map<string, FnAnnotation>(), consts: new Set<string>() };
134
+ sidecar.set(outPath, entry);
129
135
 
130
136
  function visit(node: ts.Node): void {
131
137
  if (ts.isFunctionDeclaration(node) && node.name) {
132
- const fnName = node.name.text;
133
- const params = node.parameters.map(p => luauTypeForTsType(ts, checker, p));
134
- if (params.some(p => p !== null)) {
135
- fileMap.set(fnName, { params });
138
+ const params = node.parameters.map(p => luauTypeForParam(ts, checker, p));
139
+ const ret = luauTypeForReturn(ts, checker, node);
140
+ if (params.some(p => p !== null) || ret !== null) {
141
+ entry.fns.set(node.name.text, { params, ret });
142
+ }
143
+ }
144
+ if (ts.isVariableStatement(node)) {
145
+ const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
146
+ if (isConst) {
147
+ for (const decl of node.declarationList.declarations) {
148
+ if (ts.isIdentifier(decl.name)) {
149
+ entry.consts.add(decl.name.text);
150
+ }
151
+ }
136
152
  }
137
153
  }
138
154
  ts.forEachChild(node, visit);
@@ -140,33 +156,215 @@ function collectAnnotations(
140
156
  visit(sourceFile);
141
157
  }
142
158
 
143
- function injectAnnotations(luauPath: string, fileMap: Map<string, FnAnnotation>): void {
159
+ function byLengthDesc(a: string, b: string): number {
160
+ return b.length - a.length;
161
+ }
162
+
163
+ type ImportGroup = { label: string; lines: string[] };
164
+
165
+ function organizePreamble(src: string): string {
166
+ const lines = src.split("\n");
167
+ let i = 0;
168
+
169
+ // Collect --! directives separately from other leading comments
170
+ const shebang: string[] = [];
171
+ const header: string[] = [];
172
+ while (i < lines.length && lines[i].startsWith("--")) {
173
+ if (lines[i].startsWith("--!")) {
174
+ shebang.push(lines[i++]);
175
+ } else {
176
+ header.push(lines[i++]);
177
+ }
178
+ }
179
+ shebang.sort(byLengthDesc);
180
+
181
+ const services: string[] = [];
182
+ const runtime: string[] = [];
183
+ const importGroups: ImportGroup[] = [];
184
+ const bindings: string[] = [];
185
+
186
+ let pendingLabel: string | null = null;
187
+ let currentImports: string[] = [];
188
+
189
+ function flushImports(): void {
190
+ if (currentImports.length > 0) {
191
+ importGroups.push({ label: pendingLabel ?? "-- Imports", lines: [...currentImports] });
192
+ currentImports = [];
193
+ }
194
+ pendingLabel = null;
195
+ }
196
+
197
+ while (i < lines.length) {
198
+ const line = lines[i];
199
+
200
+ if (line.trim() === "") {
201
+ // Blank line = end of current import group
202
+ flushImports();
203
+ i++;
204
+ continue;
205
+ }
206
+
207
+ if (/^--!/.test(line)) {
208
+ // Rotor can emit --!native after preamble locals — hoist it up
209
+ shebang.push(line); shebang.sort(byLengthDesc); i++;
210
+ } else if (/^--/.test(line)) {
211
+ // User comment becomes the label for the next import group
212
+ flushImports();
213
+ pendingLabel = line; i++;
214
+ } else if (/^local \w+ = game:GetService\(/.test(line)) {
215
+ flushImports();
216
+ services.push(line); i++;
217
+ } else if (/^local \w+ = require\(/.test(line)) {
218
+ flushImports();
219
+ runtime.push(line); i++;
220
+ } else if (/^local \w+ = TS\.import\(/.test(line)) {
221
+ currentImports.push(line); i++;
222
+ } else if (/^local \w+ = \w+[\.\[]/.test(line) && !/^local function/.test(line)) {
223
+ flushImports();
224
+ bindings.push(line); i++;
225
+ } else {
226
+ break;
227
+ }
228
+ }
229
+ flushImports();
230
+
231
+ services.sort(byLengthDesc);
232
+ bindings.sort(byLengthDesc);
233
+
234
+ const out: string[] = [...shebang];
235
+ if (header.length > 0) out.push("", ...header);
236
+ if (runtime.length > 0) out.push("", "-- Runtime", ...runtime);
237
+ if (services.length > 0) out.push("", "-- Services", ...services);
238
+ for (const group of importGroups) {
239
+ group.lines.sort(byLengthDesc);
240
+ out.push("", group.label, ...group.lines);
241
+ }
242
+ if (bindings.length > 0) out.push("", "-- Bindings", ...bindings);
243
+ if (i < lines.length) out.push("", ...lines.slice(i));
244
+
245
+ return out.join("\n");
246
+ }
247
+
248
+ function hoistGetService(src: string): string {
249
+ // Count occurrences of each game:GetService("X") call
250
+ const re = /game:GetService\("([^"]+)"\)/g;
251
+ const counts = new Map<string, number>();
252
+ for (const m of src.matchAll(re)) {
253
+ counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
254
+ }
255
+
256
+ const toHoist = [...counts.entries()].filter(([, n]) => n >= 2).map(([svc]) => svc);
257
+ if (toHoist.length === 0) return src;
258
+
259
+ // Build locals and replace
260
+ const decls = toHoist
261
+ .map(svc => `local _${svc} = game:GetService("${svc}")`)
262
+ .join("\n");
263
+
264
+ for (const svc of toHoist) {
265
+ src = src.split(`game:GetService("${svc}")`).join(`_${svc}`);
266
+ }
267
+
268
+ // Insert after any leading --! directives and the rotor header comment
269
+ const insertAt = src.search(/^(?!--[!\s]|--\s*Compiled)/m);
270
+ if (insertAt === -1) return decls + "\n" + src;
271
+ return src.slice(0, insertAt) + decls + "\n" + src.slice(insertAt);
272
+ }
273
+
274
+ function addSpacing(src: string): string {
275
+ const lines = src.split("\n");
276
+ const out: string[] = [];
277
+
278
+ for (let i = 0; i < lines.length; i++) {
279
+ const line = lines[i];
280
+ const trimmed = line.trim();
281
+ const prevOut = out.length > 0 ? out[out.length - 1] : "";
282
+ const prevTrimmed = prevOut.trim();
283
+ const alreadyBlank = prevTrimmed === "";
284
+
285
+ if (!alreadyBlank) {
286
+ // Blank before top-level local function
287
+ if (/^local function /.test(trimmed)) {
288
+ out.push("");
289
+ }
290
+ // Blank before return when it's not the first statement in its block
291
+ else if (
292
+ /^return\b/.test(trimmed) &&
293
+ !/\b(then|do|repeat)$/.test(prevTrimmed) &&
294
+ !/function\s*\([^)]*\)$/.test(prevTrimmed) &&
295
+ !/^local function /.test(prevTrimmed)
296
+ ) {
297
+ out.push("");
298
+ }
299
+ // Blank before a block starter (do/while/for/if/repeat) when prev is local/const
300
+ else if (/^(do\b|while |for |if |repeat\b)/.test(trimmed) && /^(local |const )/.test(prevTrimmed)) {
301
+ out.push("");
302
+ }
303
+ // Blank on const → local transition
304
+ else if (/^local /.test(trimmed) && /^const /.test(prevTrimmed)) {
305
+ out.push("");
306
+ }
307
+ }
308
+
309
+ out.push(line);
310
+
311
+ // Blank after `end` when next non-blank line is not end/else/elseif/until
312
+ if (trimmed === "end") {
313
+ const next = lines[i + 1]?.trim() ?? "";
314
+ if (next !== "" && !/^(end\b|else\b|elseif\b|until\b)/.test(next)) {
315
+ out.push("");
316
+ }
317
+ }
318
+ }
319
+
320
+ return out.join("\n");
321
+ }
322
+
323
+ function injectAnnotations(luauPath: string, entry: FileSidecar): void {
144
324
  if (!fs.existsSync(luauPath)) return;
145
325
  let src = fs.readFileSync(luauPath, "utf8");
146
326
  let changed = false;
147
327
 
148
- for (const [fnName, ann] of fileMap) {
149
- if (ann.params.every(p => p === null)) continue;
328
+ // Inject param + return type annotations
329
+ for (const [fnName, ann] of entry.fns) {
330
+ if (ann.params.every(p => p === null) && ann.ret === null) continue;
150
331
 
151
- // Match: local function fnName(a, b, c)
152
- // Captures the param list so we can replace individual names
153
332
  const re = new RegExp(
154
- `(local function ${escapeRegex(fnName)}\\()([^)]*)(\\.\\.\\.\\))?\\)`,
333
+ `(local function ${escapeRegex(fnName)}\\()([^)]*)(\\.\\.\\.)?(\\))`,
155
334
  );
156
- src = src.replace(re, (_match: string, open: string, rawParams: string, vararg: string | undefined) => {
335
+ src = src.replace(re, (_m, open: string, rawParams: string, vararg: string | undefined, close: string) => {
157
336
  const names = rawParams.split(",").map((s: string) => s.trim()).filter(Boolean);
158
337
  const annotated = names.map((name: string, i: number) => {
159
- // strip any existing annotation
160
338
  const bare = name.split(":")[0].trim();
161
339
  const typ = ann.params[i];
162
340
  return typ ? `${bare}: ${typ}` : bare;
163
341
  });
164
342
  if (vararg) annotated.push("...");
343
+ const retSuffix = ann.ret ? `: ${ann.ret}` : "";
165
344
  changed = true;
166
- return `${open}${annotated.join(", ")})`;
345
+ return `${open}${annotated.join(", ")}${close}${retSuffix}`;
167
346
  });
168
347
  }
169
348
 
349
+ // Replace local → const for TypeScript const declarations
350
+ for (const name of entry.consts) {
351
+ const re = new RegExp(`^(\\t*)local (${escapeRegex(name)}) =`, "m");
352
+ const next = src.replace(re, `$1const $2 =`);
353
+ if (next !== src) { src = next; changed = true; }
354
+ }
355
+
356
+ // Hoist any repeated game:GetService() calls injected by the compiler
357
+ const hoisted = hoistGetService(src);
358
+ if (hoisted !== src) { src = hoisted; changed = true; }
359
+
360
+ // Organize preamble into labeled sections
361
+ const organized = organizePreamble(src);
362
+ if (organized !== src) { src = organized; changed = true; }
363
+
364
+ // Add blank lines between top-level blocks for readability
365
+ const spaced = addSpacing(src);
366
+ if (spaced !== src) { src = spaced; changed = true; }
367
+
170
368
  if (changed) fs.writeFileSync(luauPath, src, "utf8");
171
369
  }
172
370
 
@@ -182,12 +380,10 @@ function installWatcher(outDir: string): void {
182
380
  if (!filename || !filename.endsWith(".luau")) return;
183
381
  const full = path.join(outDir, filename);
184
382
  if (seen.has(full)) return;
185
- const fileMap = sidecar.get(full);
186
- if (!fileMap) return;
383
+ const entry = sidecar.get(full);
384
+ if (!entry) return;
187
385
  seen.add(full);
188
- if (fileMap.size > 0) {
189
- try { injectAnnotations(full, fileMap); } catch { /* ignore */ }
190
- }
386
+ try { injectAnnotations(full, entry); } catch { /* ignore */ }
191
387
  });
192
388
  watcher.unref();
193
389
  }
@@ -199,9 +395,6 @@ export function annotatePass(
199
395
  ): void {
200
396
  const outPath = outPathForSource(sourceFile, program);
201
397
  if (!outPath) return;
202
-
203
- const checker = program.getTypeChecker();
204
- collectAnnotations(ts, checker, sourceFile, outPath);
205
- const outDir = program.getCompilerOptions().outDir!;
206
- installWatcher(outDir);
398
+ collectAnnotations(ts, program.getTypeChecker(), sourceFile, outPath);
399
+ installWatcher(program.getCompilerOptions().outDir!);
207
400
  }
@@ -1,19 +1,34 @@
1
1
  import type ts from "typescript";
2
- import { hasOptimizeDirective } from "../util";
2
+ import { hasOptimizeDirective, hasStrictDirective } from "../util";
3
3
 
4
4
  export function nativePass(
5
5
  ts: typeof import("typescript"),
6
6
  ctx: ts.TransformationContext,
7
7
  sourceFile: ts.SourceFile,
8
+ optimize: boolean,
9
+ strict: boolean,
8
10
  ): ts.SourceFile {
9
- if (hasOptimizeDirective(sourceFile)) return sourceFile;
10
-
11
11
  const factory = ctx.factory;
12
- const optimize = ts.addSyntheticLeadingComment(
13
- factory.createNotEmittedStatement(sourceFile),
14
- ts.SyntaxKind.SingleLineCommentTrivia,
15
- "!optimize 2",
16
- true,
17
- );
18
- return factory.updateSourceFile(sourceFile, [optimize, ...Array.from(sourceFile.statements)]);
12
+ const prepend: ts.Statement[] = [];
13
+
14
+ if (strict && !hasStrictDirective(sourceFile)) {
15
+ prepend.push(ts.addSyntheticLeadingComment(
16
+ factory.createNotEmittedStatement(sourceFile),
17
+ ts.SyntaxKind.SingleLineCommentTrivia,
18
+ "!strict",
19
+ true,
20
+ ));
21
+ }
22
+
23
+ if (optimize && !hasOptimizeDirective(sourceFile)) {
24
+ prepend.push(ts.addSyntheticLeadingComment(
25
+ factory.createNotEmittedStatement(sourceFile),
26
+ ts.SyntaxKind.SingleLineCommentTrivia,
27
+ "!optimize 2",
28
+ true,
29
+ ));
30
+ }
31
+
32
+ if (prepend.length === 0) return sourceFile;
33
+ return factory.updateSourceFile(sourceFile, [...prepend, ...Array.from(sourceFile.statements)]);
19
34
  }
package/src/util.ts CHANGED
@@ -4,6 +4,10 @@ export function hasOptimizeDirective(sourceFile: ts.SourceFile): boolean {
4
4
  return /^--!optimize\b/m.test(sourceFile.text) || /^\/\/!optimize\b/m.test(sourceFile.text);
5
5
  }
6
6
 
7
+ export function hasStrictDirective(sourceFile: ts.SourceFile): boolean {
8
+ return /^--!strict\b/m.test(sourceFile.text) || /^\/\/!strict\b/m.test(sourceFile.text);
9
+ }
10
+
7
11
  export function chainKey(ts: typeof import("typescript"), node: ts.Expression): string | undefined {
8
12
  if (ts.isIdentifier(node)) return node.text;
9
13
  if (ts.isPropertyAccessExpression(node)) {
@@ -1,14 +0,0 @@
1
- {
2
- "name": "rbxts-transform-perf-test",
3
- "private": true,
4
- "scripts": {
5
- "compile": "lumen src -o out/src -i out/include -p default.project.json",
6
- "watch": "lumen src -o out/src -i out/include -p default.project.json --watch"
7
- },
8
- "devDependencies": {
9
- "@rbxts/compiler-types": "^3.0.0-types.0",
10
- "@rbxts/types": "^1.0.908",
11
- "typescript": "=5.5.3",
12
- "rbxts-transform-boost": "file:../"
13
- }
14
- }
@@ -1,29 +0,0 @@
1
- #!/usr/bin/env node
2
- // Replaces "-- !fn-native\n" markers (emitted by rbxts-transform-perf's native pass)
3
- // with bare "@native\n" Luau attributes in compiled output files.
4
- // Run after `rotor build` to make per-function @native actually take effect.
5
-
6
- const fs = require("fs");
7
- const path = require("path");
8
-
9
- const MARKER = "--!fn-native\n";
10
- const ATTRIBUTE = "@native\n";
11
-
12
- function processDir(dir) {
13
- if (!fs.existsSync(dir)) return;
14
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
15
- const full = path.join(dir, entry.name);
16
- if (entry.isDirectory()) {
17
- processDir(full);
18
- } else if (entry.name.endsWith(".luau") || entry.name.endsWith(".lua")) {
19
- const original = fs.readFileSync(full, "utf8");
20
- if (!original.includes(MARKER)) continue;
21
- const patched = original.replaceAll(MARKER, ATTRIBUTE);
22
- fs.writeFileSync(full, patched, "utf8");
23
- console.log(`@native patched: ${full}`);
24
- }
25
- }
26
- }
27
-
28
- const outDir = process.argv[2] ?? path.join(__dirname, "..", "test", "out");
29
- processDir(outDir);