rbxts-transform-luau 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.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # rbxts-transform-luau
2
+
3
+ A [roblox-ts](https://roblox-ts.com) / [rotor](https://github.com/roblox-ts/rotor) TypeScript transformer that makes compiled Luau output idiomatic — cleaning up noise, organizing the preamble, injecting Luau directives, annotating types, and converting JSDoc comments.
4
+
5
+ ## What it does
6
+
7
+ ### Luau directives
8
+
9
+ Injects `--!strict` and/or `--!optimize N` at the top of every file.
10
+
11
+ ```luau
12
+ --!native
13
+ --!optimize 2
14
+ --!strict
15
+ ```
16
+
17
+ ### Preamble organization
18
+
19
+ Sorts the top of every file into labeled sections: directives → services → runtime → imports. Makes the compiled output readable at a glance.
20
+
21
+ ```luau
22
+ --!optimize 2
23
+ --!strict
24
+
25
+ -- Compiled with roblox-ts v3.0.0
26
+
27
+ -- Services
28
+ const _ReplicatedStorage = game:GetService("ReplicatedStorage")
29
+
30
+ -- Runtime
31
+ const TS = require(_ReplicatedStorage:WaitForChild("include"):WaitForChild("RuntimeLib"))
32
+
33
+ -- Imports
34
+ const fns; if false then fns = require(...) else fns = TS.import(...) :: any end
35
+ ```
36
+
37
+ ### `const` promotion
38
+
39
+ Locals and imports that are never reassigned anywhere in the file are promoted to `const`, allowing the Luau native compiler to make stronger assumptions.
40
+
41
+ ```luau
42
+ -- Before
43
+ local N = 100000
44
+ local function compute()
45
+ local scale = 0.5
46
+ local result = scale * N
47
+ local base; if false then base = require(...) else base = TS.import(...) :: any end
48
+
49
+ -- After
50
+ const N = 100000
51
+ local function compute()
52
+ const scale = 0.5
53
+ const result = scale * N
54
+ local base; if false then base = require(...) else base = TS.import(...) :: any end
55
+ ```
56
+
57
+ Only variables initialized inline (`local name = value`) are eligible. Imports use a deferred-assignment workaround (`local name; ... name = ...`) that requires `local` in Luau, so they are left alone. Variables that are mutated anywhere in the file (`i += 1`, reassignment, etc.) are also correctly left as `local`.
58
+
59
+ ### Luau type annotations
60
+
61
+ Uses the TypeScript type checker to inject Luau type annotations into every function signature — covering inferred types that have no explicit annotation in source.
62
+
63
+ ```typescript
64
+ // Input — types inferred by TypeScript, not explicitly written
65
+ export function dot(a: Vector3, b: Vector3) {
66
+ return a.X * b.X + a.Y * b.Y + a.Z * b.Z
67
+ }
68
+ export function lerp(a: number, b: number, t: number) {
69
+ return a + (b - a) * t
70
+ }
71
+ ```
72
+
73
+ ```luau
74
+ -- Output — type checker resolved them; luau-lsp now sees them too
75
+ local function dot(a: Vector3, b: Vector3): number
76
+ local function lerp(a: number, b: number, t: number): number
77
+ ```
78
+
79
+ Supports: primitives (`number`, `string`, `boolean`), Roblox value types (`Vector3`, `CFrame`, `UDim2`, etc.), arrays (`{T}`), and `LuaTuple<[T, U]>` → multi-return `(T, U)`.
80
+
81
+ Set `annotate: false` to disable.
82
+
83
+ ### TS.import type hints
84
+
85
+ Wraps `TS.import` calls with a dead-code `require` branch so luau-lsp can infer the module's return type — zero runtime cost.
86
+
87
+ ```luau
88
+ -- luau-lsp gets full autocomplete and types through the require branch
89
+ const fns; if false then fns = require(_ReplicatedStorage.shared.fns) else fns = TS.import(script, _ReplicatedStorage, "shared", "fns") :: any end
90
+ ```
91
+
92
+ ### JSDoc → Luau doc comments
93
+
94
+ TypeScript JSDoc (`@param`, `@returns`, `@deprecated`) is converted to luau-lsp doc comment format with inferred Luau types. Works with both roblox-ts and rotor.
95
+
96
+ ```typescript
97
+ /**
98
+ * Computes the dot product of two vectors.
99
+ * @param a First vector.
100
+ * @param b Second vector.
101
+ * @returns Scalar result.
102
+ */
103
+ export function dot(a: Vector3, b: Vector3): number { ... }
104
+
105
+ /** @deprecated Use dot() instead. */
106
+ export function oldDot(a: Vector3, b: Vector3): number { ... }
107
+ ```
108
+
109
+ ```luau
110
+ --- Computes the dot product of two vectors.
111
+ --- @param a Vector3 First vector.
112
+ --- @param b Vector3 Second vector.
113
+ --- @return number Scalar result.
114
+ local function dot(a: Vector3, b: Vector3): number
115
+
116
+ --- @deprecated Use dot() instead.
117
+ local function oldDot(a: Vector3, b: Vector3): number
118
+ ```
119
+
120
+ ### Comment cleanup
121
+
122
+ Strips useless block comments (`--[[ ]]`) that compilers emit for non-function declarations where no useful information can be extracted.
123
+
124
+ ## Installation
125
+
126
+ ```bash
127
+ npm install --save-dev rbxts-transform-luau
128
+ ```
129
+
130
+ ## Setup
131
+
132
+ ```json
133
+ {
134
+ "compilerOptions": {
135
+ "plugins": [
136
+ {
137
+ "transform": "rbxts-transform-luau",
138
+ "strict": true,
139
+ "optimize": 2,
140
+ "annotate": true
141
+ }
142
+ ]
143
+ }
144
+ }
145
+ ```
146
+
147
+ ## Options
148
+
149
+ | Option | Type | Default | Description |
150
+ |---|---|---|---|
151
+ | `strict` | `boolean` | `true` | Inject `--!strict` at the top of every file |
152
+ | `optimize` | `false \| 0 \| 1 \| 2` | `false` | Inject `--!optimize N` (`false` disables, `0`/`1`/`2` set the level) |
153
+ | `annotate` | `boolean` | `true` | Inject Luau type annotations using the TypeScript type checker |
154
+ | `verbose` | `boolean` | `false` | Log per-file processing info to the console |
@@ -0,0 +1,6 @@
1
+ export interface PluginConfig {
2
+ strict?: boolean;
3
+ optimize?: false | 0 | 1 | 2;
4
+ annotate?: boolean;
5
+ verbose?: boolean;
6
+ }
package/out/config.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/out/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import ts from "typescript";
2
+ import type { PluginConfig } from "./config";
3
+ export type { PluginConfig };
4
+ export default function (program: ts.Program, config?: PluginConfig): ts.TransformerFactory<ts.SourceFile>;
package/out/index.js ADDED
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.default = default_1;
30
+ const typescript_1 = __importDefault(require("typescript"));
31
+ const path = __importStar(require("path"));
32
+ const format_1 = require("./passes/format");
33
+ const LUAU_TYPE = {
34
+ number: "number",
35
+ string: "string",
36
+ boolean: "boolean",
37
+ void: "()",
38
+ Vector3: "Vector3",
39
+ Vector2: "Vector2",
40
+ Vector2int16: "Vector2int16",
41
+ Vector3int16: "Vector3int16",
42
+ CFrame: "CFrame",
43
+ UDim: "UDim",
44
+ UDim2: "UDim2",
45
+ Color3: "Color3",
46
+ BrickColor: "BrickColor",
47
+ TweenInfo: "TweenInfo",
48
+ NumberRange: "NumberRange",
49
+ NumberSequence: "NumberSequence",
50
+ ColorSequence: "ColorSequence",
51
+ Rect: "Rect",
52
+ Region3: "Region3",
53
+ Ray: "Ray",
54
+ buffer: "buffer",
55
+ Instance: "Instance",
56
+ BasePart: "BasePart",
57
+ Part: "Part",
58
+ Model: "Model",
59
+ Player: "Player",
60
+ Camera: "Camera",
61
+ Workspace: "Workspace",
62
+ RunService: "RunService",
63
+ Players: "Players",
64
+ };
65
+ function mapTypeNode(typeNode) {
66
+ if (typescript_1.default.isTypeReferenceNode(typeNode)) {
67
+ const name = typescript_1.default.isIdentifier(typeNode.typeName) ? typeNode.typeName.text : null;
68
+ if (!name)
69
+ return null;
70
+ if (LUAU_TYPE[name])
71
+ return LUAU_TYPE[name];
72
+ if ((name === "Array" || name === "ReadonlyArray") && typeNode.typeArguments?.length === 1) {
73
+ const inner = mapTypeNode(typeNode.typeArguments[0]);
74
+ return inner ? `{${inner}}` : "{any}";
75
+ }
76
+ if (name === "LuaTuple" && typeNode.typeArguments?.length === 1) {
77
+ const arg = typeNode.typeArguments[0];
78
+ if (typescript_1.default.isTupleTypeNode(arg)) {
79
+ const elements = arg.elements
80
+ .map(e => {
81
+ const el = "type" in e ? e.type : e;
82
+ return mapTypeNode(el);
83
+ })
84
+ .filter((t) => t !== null);
85
+ if (elements.length > 0)
86
+ return `(${elements.join(", ")})`;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+ if (typescript_1.default.isArrayTypeNode(typeNode)) {
92
+ const inner = mapTypeNode(typeNode.elementType);
93
+ return inner ? `{${inner}}` : "{any}";
94
+ }
95
+ const kw = {
96
+ [typescript_1.default.SyntaxKind.NumberKeyword]: "number",
97
+ [typescript_1.default.SyntaxKind.StringKeyword]: "string",
98
+ [typescript_1.default.SyntaxKind.BooleanKeyword]: "boolean",
99
+ [typescript_1.default.SyntaxKind.VoidKeyword]: "()",
100
+ };
101
+ if (typeNode.kind in kw)
102
+ return kw[typeNode.kind];
103
+ return null;
104
+ }
105
+ function luauTypeForParam(checker, node) {
106
+ if (node.type) {
107
+ const mapped = mapTypeNode(node.type);
108
+ if (mapped)
109
+ return mapped;
110
+ }
111
+ const name = checker.typeToString(checker.getTypeAtLocation(node));
112
+ return LUAU_TYPE[name] ?? null;
113
+ }
114
+ function luauTypeForReturn(checker, node) {
115
+ if (node.type) {
116
+ const mapped = mapTypeNode(node.type);
117
+ if (mapped)
118
+ return mapped;
119
+ }
120
+ const sig = checker.getSignatureFromDeclaration(node);
121
+ if (!sig)
122
+ return null;
123
+ const ret = checker.getReturnTypeOfSignature(sig);
124
+ const name = checker.typeToString(ret);
125
+ return LUAU_TYPE[name] ?? null;
126
+ }
127
+ function collectTypes(checker, sourceFile) {
128
+ const types = new Map();
129
+ function visit(node) {
130
+ if (typescript_1.default.isFunctionDeclaration(node) && node.name) {
131
+ const params = node.parameters.map(p => luauTypeForParam(checker, p));
132
+ const ret = luauTypeForReturn(checker, node);
133
+ if (params.some(p => p !== null) || ret !== null) {
134
+ types.set(node.name.text, { params, ret });
135
+ }
136
+ }
137
+ typescript_1.default.forEachChild(node, visit);
138
+ }
139
+ visit(sourceFile);
140
+ return types;
141
+ }
142
+ function outPathForSource(sourceFile, program) {
143
+ const options = program.getCompilerOptions();
144
+ const outDir = options.outDir;
145
+ if (!outDir)
146
+ return null;
147
+ const rootDir = options.rootDir ?? commonRoot(program.getRootFileNames());
148
+ if (!rootDir)
149
+ return null;
150
+ const rel = path.relative(rootDir, sourceFile.fileName);
151
+ if (rel.startsWith(".."))
152
+ return null;
153
+ const dir = path.dirname(rel);
154
+ const base = path.basename(rel).replace(/\.tsx?$/, "");
155
+ const renamedBase = base.replace(/^index(?=$|\.)/, "init");
156
+ return path.join(outDir, dir, `${renamedBase}.luau`);
157
+ }
158
+ function commonRoot(files) {
159
+ if (files.length === 0)
160
+ return undefined;
161
+ const parts = files[0].split(path.sep);
162
+ let root = parts.slice(0, parts.length - 1);
163
+ for (const f of files.slice(1)) {
164
+ const fp = f.split(path.sep);
165
+ let i = 0;
166
+ while (i < root.length && i < fp.length - 1 && root[i] === fp[i])
167
+ i++;
168
+ root = root.slice(0, i);
169
+ }
170
+ return root.join(path.sep) || undefined;
171
+ }
172
+ const pending = new Map();
173
+ let finalizeRegistered = false;
174
+ function flushPending() {
175
+ for (const [, meta] of pending) {
176
+ try {
177
+ (0, format_1.formatFile)(meta.outPath, meta.strict, meta.optimizeLevel, meta.sidecar, meta.annotate ? meta.types : new Map());
178
+ }
179
+ catch {
180
+ // silently skip files that fail — they stay as-is
181
+ }
182
+ }
183
+ pending.clear();
184
+ }
185
+ function registerFinalizer() {
186
+ if (finalizeRegistered)
187
+ return;
188
+ finalizeRegistered = true;
189
+ process.on("exit", flushPending);
190
+ }
191
+ function jsDocText(comment) {
192
+ if (!comment)
193
+ return "";
194
+ if (typeof comment === "string")
195
+ return comment.trim().replace(/^—\s*/, "");
196
+ const raw = comment
197
+ .map(c => ("text" in c ? c.text : ""))
198
+ .join("");
199
+ return raw.trim().replace(/^—\s*/, "");
200
+ }
201
+ function collectJsDoc(ts, sourceFile) {
202
+ const sidecar = new Map();
203
+ function visit(node) {
204
+ if (ts.isFunctionDeclaration(node) && node.name) {
205
+ const jsDocs = node.jsDoc;
206
+ if (jsDocs && jsDocs.length > 0) {
207
+ const doc = jsDocs[jsDocs.length - 1];
208
+ const rawDesc = jsDocText(doc.comment);
209
+ const desc = rawDesc.split("\n").map(l => l.trim()).filter(Boolean);
210
+ const params = new Map();
211
+ let returns = "";
212
+ let deprecated;
213
+ for (const tag of doc.tags ?? []) {
214
+ if (ts.isJSDocParameterTag(tag)) {
215
+ const name = ts.isIdentifier(tag.name) ? tag.name.text : "";
216
+ if (name)
217
+ params.set(name, jsDocText(tag.comment).trim());
218
+ }
219
+ else if (ts.isJSDocReturnTag(tag)) {
220
+ returns = jsDocText(tag.comment).trim();
221
+ }
222
+ else if (ts.isJSDocDeprecatedTag(tag)) {
223
+ deprecated = jsDocText(tag.comment).trim();
224
+ }
225
+ }
226
+ if (desc.length > 0 || params.size > 0 || returns || deprecated !== undefined) {
227
+ sidecar.set(node.name.text, { desc, params, returns, deprecated });
228
+ }
229
+ }
230
+ }
231
+ ts.forEachChild(node, visit);
232
+ }
233
+ visit(sourceFile);
234
+ return sidecar;
235
+ }
236
+ function default_1(program, config = {}) {
237
+ const { strict = true, optimize = false, annotate = true, verbose = false } = config;
238
+ const optimizeLevel = optimize === false ? false : [0, 1, 2].includes(optimize) ? optimize : 2;
239
+ // Watch mode: flush the previous run's pending files before starting this one.
240
+ flushPending();
241
+ registerFinalizer();
242
+ const outDir = program.getCompilerOptions().outDir;
243
+ const checker = annotate ? program.getTypeChecker() : null;
244
+ return (_ctx) => (sourceFile) => {
245
+ const outPath = outPathForSource(sourceFile, program);
246
+ if (outPath) {
247
+ const sidecar = collectJsDoc(typescript_1.default, sourceFile);
248
+ const types = checker ? collectTypes(checker, sourceFile) : new Map();
249
+ pending.set(outPath, { outPath, strict, optimizeLevel, annotate, verbose, sidecar, types });
250
+ if (verbose) {
251
+ const rel = outDir ? path.relative(outDir, outPath) : outPath;
252
+ console.log(`luau: ${rel}`);
253
+ }
254
+ }
255
+ return sourceFile;
256
+ };
257
+ }
@@ -0,0 +1,23 @@
1
+ export type FnTypes = {
2
+ params: Array<string | null>;
3
+ ret: string | null;
4
+ };
5
+ export declare function injectTypeAnnotations(src: string, types: Map<string, FnTypes>): string;
6
+ export declare function stripUselessBlockComments(src: string): string;
7
+ export declare function fixBlockCommentOpeners(src: string): string;
8
+ export declare function organizePreamble(src: string): string;
9
+ export declare function hoistGetService(src: string): string;
10
+ export declare function promoteConstIfUnmutated(src: string, name: string): string;
11
+ export declare function promoteConsts(src: string): string;
12
+ export declare function addSpacing(src: string): string;
13
+ export declare function castTsImports(src: string): string;
14
+ export type FnDoc = {
15
+ desc: string[];
16
+ params: Map<string, string>;
17
+ returns: string;
18
+ deprecated?: string;
19
+ };
20
+ export declare function injectJsDocFromSidecar(src: string, sidecar: Map<string, FnDoc>): string;
21
+ export declare function convertJsDocComments(src: string): string;
22
+ export declare function applyDirectives(src: string, strict: boolean, optimizeLevel: false | 0 | 1 | 2): string;
23
+ export declare function formatFile(luauPath: string, strict: boolean, optimizeLevel: false | 0 | 1 | 2, sidecar?: Map<string, FnDoc>, types?: Map<string, FnTypes>): void;
@@ -0,0 +1,571 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.injectTypeAnnotations = injectTypeAnnotations;
27
+ exports.stripUselessBlockComments = stripUselessBlockComments;
28
+ exports.fixBlockCommentOpeners = fixBlockCommentOpeners;
29
+ exports.organizePreamble = organizePreamble;
30
+ exports.hoistGetService = hoistGetService;
31
+ exports.promoteConstIfUnmutated = promoteConstIfUnmutated;
32
+ exports.promoteConsts = promoteConsts;
33
+ exports.addSpacing = addSpacing;
34
+ exports.castTsImports = castTsImports;
35
+ exports.injectJsDocFromSidecar = injectJsDocFromSidecar;
36
+ exports.convertJsDocComments = convertJsDocComments;
37
+ exports.applyDirectives = applyDirectives;
38
+ exports.formatFile = formatFile;
39
+ const fs = __importStar(require("fs"));
40
+ function byLengthDesc(a, b) {
41
+ return b.length - a.length;
42
+ }
43
+ function escapeRegex(s) {
44
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ }
46
+ function injectTypeAnnotations(src, types) {
47
+ if (types.size === 0)
48
+ return src;
49
+ for (const [fnName, ann] of types) {
50
+ if (ann.params.every(p => p === null) && ann.ret === null)
51
+ continue;
52
+ const re = new RegExp(`(local function ${escapeRegex(fnName)}\\()([^)]*)(\\.\\.\\.)?(\\))(?:\\s*:\\s*[^\\r\\n]+)?`);
53
+ src = src.replace(re, (_m, open, rawParams, vararg, close) => {
54
+ const names = rawParams.split(",").map((s) => s.trim()).filter(Boolean);
55
+ const annotated = names.map((name, i) => {
56
+ const bare = name.split(":")[0].trim();
57
+ const typ = ann.params[i];
58
+ return typ ? `${bare}: ${typ}` : bare;
59
+ });
60
+ if (vararg)
61
+ annotated.push("...");
62
+ return `${open}${annotated.join(", ")}${close}${ann.ret ? `: ${ann.ret}` : ""}`;
63
+ });
64
+ }
65
+ return src;
66
+ }
67
+ function stripUselessBlockComments(src) {
68
+ const NOISE_TAGS = new Set([
69
+ "@param", "@returns", "@return", "@throws", "@deprecated",
70
+ "@private", "@protected", "@public", "@hidden", "@ignore", "@internal",
71
+ ]);
72
+ function isNoiseLine(raw) {
73
+ const t = raw.trim().replace(/^\*+\s*/, "");
74
+ if (t === "")
75
+ return true;
76
+ const tag = t.split(/\s/)[0].toLowerCase();
77
+ return NOISE_TAGS.has(tag);
78
+ }
79
+ const lines = src.split("\n");
80
+ const out = [];
81
+ let i = 0;
82
+ while (i < lines.length) {
83
+ const trimmed = lines[i].trim();
84
+ if (trimmed === "--[[") {
85
+ const openerIdx = i;
86
+ const body = [];
87
+ i++;
88
+ while (i < lines.length && lines[i].trim() !== "]]") {
89
+ body.push(lines[i]);
90
+ i++;
91
+ }
92
+ const closerIdx = i;
93
+ const isAllNoise = body.every(isNoiseLine);
94
+ if (isAllNoise) {
95
+ i++;
96
+ if (lines[i]?.trim() === "")
97
+ i++;
98
+ continue;
99
+ }
100
+ out.push(lines[openerIdx]);
101
+ for (const bl of body)
102
+ out.push(bl);
103
+ if (closerIdx < lines.length)
104
+ out.push(lines[closerIdx]);
105
+ i = closerIdx + 1;
106
+ continue;
107
+ }
108
+ out.push(lines[i]);
109
+ i++;
110
+ }
111
+ return out.join("\n");
112
+ }
113
+ function fixBlockCommentOpeners(src) {
114
+ const lines = src.split("\n");
115
+ const out = [];
116
+ let i = 0;
117
+ while (i < lines.length) {
118
+ const trimmed = lines[i].trim();
119
+ if (trimmed === "--[[") {
120
+ out.push(lines[i]);
121
+ i++;
122
+ while (i < lines.length) {
123
+ const t = lines[i].trim();
124
+ if (t === "" && lines[i + 1]?.trim() === "]]") {
125
+ i++;
126
+ continue;
127
+ }
128
+ out.push(lines[i]);
129
+ if (t === "]]") {
130
+ i++;
131
+ break;
132
+ }
133
+ i++;
134
+ }
135
+ continue;
136
+ }
137
+ if (trimmed.startsWith("*")) {
138
+ let j = i;
139
+ while (j < lines.length && lines[j].trim().startsWith("*"))
140
+ j++;
141
+ const hasTrailingBlank = j < lines.length && lines[j].trim() === "" && lines[j + 1]?.trim() === "]]";
142
+ if (hasTrailingBlank)
143
+ j++;
144
+ const hasCloser = lines[j]?.trim() === "]]";
145
+ if (hasCloser) {
146
+ out.push("--[[");
147
+ const bodyStart = lines[i]?.trim() === "*" ? i + 1 : i;
148
+ const bodyEnd = hasTrailingBlank ? j - 1 : j;
149
+ for (let k = bodyStart; k < bodyEnd; k++)
150
+ out.push(lines[k]);
151
+ out.push(lines[j]);
152
+ i = j + 1;
153
+ continue;
154
+ }
155
+ }
156
+ out.push(lines[i]);
157
+ i++;
158
+ }
159
+ return out.join("\n");
160
+ }
161
+ function organizePreamble(src) {
162
+ const lines = src.split("\n");
163
+ let i = 0;
164
+ const shebang = [];
165
+ const compiledLines = [];
166
+ const services = [];
167
+ const runtime = [];
168
+ const imports = [];
169
+ const bindings = [];
170
+ while (i < lines.length) {
171
+ const line = lines[i];
172
+ const trimmed = line.trim();
173
+ if (trimmed === "") {
174
+ i++;
175
+ continue;
176
+ }
177
+ if (/^--\[\[/.test(trimmed)) {
178
+ break;
179
+ }
180
+ else if (/^--!/.test(line)) {
181
+ shebang.push(line);
182
+ i++;
183
+ }
184
+ else if (/^-- Compiled/.test(line)) {
185
+ compiledLines.push(line);
186
+ i++;
187
+ }
188
+ else if (/^--/.test(line)) {
189
+ i++;
190
+ }
191
+ else if (/^(?:local|const) \w+ = game:GetService\(/.test(line)) {
192
+ const svcVar = line.match(/^(?:local|const) (\w+) = game:GetService\(/)[1];
193
+ const duplicate = services.some(s => s.match(/^(?:local|const) (\w+) = game:GetService\(/)[1] === svcVar);
194
+ if (!duplicate)
195
+ services.push(line);
196
+ i++;
197
+ }
198
+ else if (/^(?:local|const) \w+ = require\(/.test(line)) {
199
+ runtime.push(line);
200
+ i++;
201
+ }
202
+ else if (/^(?:local|const) \w+ = TS\.import\(/.test(line)) {
203
+ imports.push(line);
204
+ i++;
205
+ }
206
+ else if (/^TS\.import\(/.test(line)) {
207
+ imports.push(line);
208
+ i++;
209
+ }
210
+ else if (/^(?:local|const) \w+ = \w+[\.\[]/.test(line) && !/^(?:local|const) function/.test(line)) {
211
+ bindings.push(line);
212
+ i++;
213
+ }
214
+ else {
215
+ break;
216
+ }
217
+ }
218
+ shebang.sort(byLengthDesc);
219
+ services.sort(byLengthDesc);
220
+ imports.sort(byLengthDesc);
221
+ bindings.sort(byLengthDesc);
222
+ const out = [...shebang];
223
+ if (compiledLines.length > 0)
224
+ out.push("", ...compiledLines);
225
+ if (services.length > 0)
226
+ out.push("", "-- Services", ...services);
227
+ if (runtime.length > 0)
228
+ out.push("", "-- Runtime", ...runtime);
229
+ if (imports.length > 0)
230
+ out.push("", "-- Imports", ...imports);
231
+ if (bindings.length > 0)
232
+ out.push("", "-- Bindings", ...bindings);
233
+ if (i < lines.length)
234
+ out.push("", ...lines.slice(i));
235
+ return out.join("\n");
236
+ }
237
+ function hoistGetService(src) {
238
+ const re = /game:GetService\("([^"]+)"\)/g;
239
+ const counts = new Map();
240
+ for (const m of src.matchAll(re)) {
241
+ counts.set(m[1], (counts.get(m[1]) ?? 0) + 1);
242
+ }
243
+ const toHoist = [...counts.entries()]
244
+ .filter(([, n]) => n >= 2)
245
+ .map(([svc]) => svc)
246
+ .filter(svc => !new RegExp(`(?:^|\\n)(?:local|const) _${svc} = game:GetService\\(`).test(src));
247
+ if (toHoist.length === 0)
248
+ return src;
249
+ const decls = toHoist.map(svc => `local _${svc} = game:GetService("${svc}")`).join("\n");
250
+ for (const svc of toHoist) {
251
+ src = src.split(`game:GetService("${svc}")`).join(`_${svc}`);
252
+ }
253
+ const insertAt = src.search(/^(?!--[!\s]|--\s*Compiled)/m);
254
+ if (insertAt === -1)
255
+ return decls + "\n" + src;
256
+ return src.slice(0, insertAt) + decls + "\n" + src.slice(insertAt);
257
+ }
258
+ function promoteConstIfUnmutated(src, name) {
259
+ const lines = src.split("\n");
260
+ const escaped = escapeRegex(name);
261
+ const declRe = new RegExp(`^(\\t*)local (${escaped}) =`);
262
+ const reassignRe = new RegExp(`^\\t*${escaped}\\s*(?:\\+|-|\\*|/{1,2}|%|\\^|\\.\\.)?=(?!=)`);
263
+ for (let i = 0; i < lines.length; i++) {
264
+ const m = lines[i].match(declRe);
265
+ if (!m)
266
+ continue;
267
+ const declIndent = m[1].length;
268
+ let mutated = false;
269
+ for (let j = i + 1; j < lines.length; j++) {
270
+ const line = lines[j];
271
+ const trimmed = line.replace(/^\t*/, "");
272
+ if (trimmed === "")
273
+ continue;
274
+ const indent = line.length - trimmed.length;
275
+ if (indent < declIndent)
276
+ break;
277
+ if (reassignRe.test(line)) {
278
+ mutated = true;
279
+ break;
280
+ }
281
+ }
282
+ if (!mutated)
283
+ lines[i] = lines[i].replace(declRe, `$1const $2 =`);
284
+ }
285
+ return lines.join("\n");
286
+ }
287
+ function promoteConsts(src) {
288
+ const lines = src.split("\n");
289
+ // Only `local name =` (value assigned at declaration) — never `local name;`
290
+ const declRe = /^(\t*)local ([A-Za-z_][A-Za-z0-9_]*) =/;
291
+ const toPromote = [];
292
+ for (let i = 0; i < lines.length; i++) {
293
+ const m = lines[i].match(declRe);
294
+ if (!m)
295
+ continue;
296
+ const name = m[2];
297
+ // Any reassignment to this name on lines AFTER the declaration line
298
+ const mutRe = new RegExp(`(?:^|\\t)${escapeRegex(name)}\\s*(?:[+\\-*/%^]|\\.\\.|/{1,2})?=(?!=)`);
299
+ let mutated = false;
300
+ for (let j = i + 1; j < lines.length; j++) {
301
+ if (mutRe.test(lines[j])) {
302
+ mutated = true;
303
+ break;
304
+ }
305
+ }
306
+ if (!mutated)
307
+ toPromote.push(i);
308
+ }
309
+ if (toPromote.length === 0)
310
+ return src;
311
+ for (const i of toPromote) {
312
+ lines[i] = lines[i].replace(/^(\t*)local /, "$1const ");
313
+ }
314
+ return lines.join("\n");
315
+ }
316
+ function addSpacing(src) {
317
+ const lines = src.split("\n");
318
+ const out = [];
319
+ for (let i = 0; i < lines.length; i++) {
320
+ const line = lines[i];
321
+ const trimmed = line.trim();
322
+ const prevTrimmed = (out.length > 0 ? out[out.length - 1] : "").trim();
323
+ const alreadyBlank = prevTrimmed === "";
324
+ if (!alreadyBlank) {
325
+ if (/^local function /.test(trimmed) && prevTrimmed !== "]]" && !/^---/.test(prevTrimmed)) {
326
+ out.push("");
327
+ }
328
+ else if (/^return\b/.test(trimmed) &&
329
+ !/\b(then|do|repeat)$/.test(prevTrimmed) &&
330
+ !/function\s*\([^)]*\)$/.test(prevTrimmed) &&
331
+ !/^local function /.test(prevTrimmed)) {
332
+ out.push("");
333
+ }
334
+ else if (/^(do\b|while |for |if |repeat\b)/.test(trimmed) &&
335
+ /^(local |const )/.test(prevTrimmed)) {
336
+ out.push("");
337
+ }
338
+ else if (/^local /.test(trimmed) && /^const /.test(prevTrimmed)) {
339
+ out.push("");
340
+ }
341
+ }
342
+ out.push(line);
343
+ if (trimmed === "end") {
344
+ const next = lines[i + 1]?.trim() ?? "";
345
+ if (next !== "" && !/^(end\b|else\b|elseif\b|until\b)/.test(next)) {
346
+ out.push("");
347
+ }
348
+ }
349
+ }
350
+ return out.join("\n");
351
+ }
352
+ function castTsImports(src) {
353
+ // Replace TS.import calls with an if/else where the dead branch does a direct
354
+ // require() so luau-lsp can infer the module type without typeof(require(...))
355
+ // (which is broken per luau-lang#1844 and luau-lsp#1057).
356
+ //
357
+ // Generated pattern:
358
+ // local opt
359
+ // if false then
360
+ // opt = require(game:GetService("ReplicatedStorage").shared.fns)
361
+ // else
362
+ // opt = TS.import(script, _ReplicatedStorage, "shared", "fns") :: any
363
+ // end
364
+ //
365
+ // Type checker unifies both branches; runtime always takes the else path.
366
+ // Match full declaration lines: [const|local] name = TS.import(...)
367
+ const re = /^([ \t]*)(?:(local|const) (\w+) = )?(TS\.import\(script,[ \t]*([^,")\s][^,)]*?)((?:,[ \t]*"[^"]*")*)\))$/gm;
368
+ return src.replace(re, (_, indent, kw, varName, call, base, childrenRaw) => {
369
+ const children = [];
370
+ for (const m of childrenRaw.matchAll(/"([^"]*)"/g))
371
+ children.push(m[1]);
372
+ const path = base.trim() + children.map(c => /^[A-Za-z_][A-Za-z0-9_]*$/.test(c) ? `.${c}` : `["${c}"]`).join("");
373
+ if (!varName) {
374
+ // Bare TS.import call (side-effect only) — leave as-is
375
+ return _;
376
+ }
377
+ return `${indent}local ${varName}; if false then ${varName} = require(${path}) else ${varName} = ${call} :: any end`;
378
+ });
379
+ }
380
+ function injectJsDocFromSidecar(src, sidecar) {
381
+ if (sidecar.size === 0)
382
+ return src;
383
+ const lines = src.split("\n");
384
+ const out = [];
385
+ for (let i = 0; i < lines.length; i++) {
386
+ const funcMatch = lines[i].match(/^(\t*)local function (\w+)\(([^)]*)\)(?::\s*(.+))?$/);
387
+ if (funcMatch) {
388
+ const name = funcMatch[2];
389
+ const doc = sidecar.get(name);
390
+ const prevTrimmed = out.length > 0 ? out[out.length - 1].trim() : "";
391
+ const alreadyHasDoc = /^---/.test(prevTrimmed);
392
+ if (doc && !alreadyHasDoc) {
393
+ const indent = funcMatch[1];
394
+ const paramTypes = new Map();
395
+ if (funcMatch[3].trim()) {
396
+ for (const part of funcMatch[3].split(",")) {
397
+ const pm = part.trim().match(/^(\w+)(\??):\s*(.+)$/);
398
+ if (pm)
399
+ paramTypes.set(pm[1], pm[2] ? `${pm[3].trim()}?` : pm[3].trim());
400
+ }
401
+ }
402
+ const rawRet = funcMatch[4]?.trim() ?? "";
403
+ const retType = rawRet.startsWith("(") && rawRet.endsWith(")")
404
+ ? rawRet.slice(1, -1).split(",")[0]?.trim() ?? ""
405
+ : rawRet;
406
+ if (doc.deprecated !== undefined)
407
+ out.push(`${indent}--- @deprecated${doc.deprecated ? ` ${doc.deprecated}` : ""}`);
408
+ for (const desc of doc.desc)
409
+ out.push(`${indent}--- ${desc}`);
410
+ for (const [paramName, paramDesc] of doc.params) {
411
+ const type = paramTypes.get(paramName);
412
+ out.push(`${indent}--- @param ${paramName}${type ? ` ${type}` : ""}${paramDesc ? ` ${paramDesc}` : ""}`);
413
+ }
414
+ if (doc.returns) {
415
+ out.push(`${indent}--- @return${retType ? ` ${retType}` : ""} ${doc.returns}`);
416
+ }
417
+ }
418
+ }
419
+ out.push(lines[i]);
420
+ }
421
+ return out.join("\n");
422
+ }
423
+ function convertJsDocComments(src) {
424
+ const lines = src.split("\n");
425
+ const out = [];
426
+ let i = 0;
427
+ while (i < lines.length) {
428
+ const trimmed = lines[i].trim();
429
+ if (trimmed === "--[[") {
430
+ const blockStart = i;
431
+ const rawBody = [];
432
+ i++;
433
+ while (i < lines.length && lines[i].trim() !== "]]") {
434
+ rawBody.push(lines[i]);
435
+ i++;
436
+ }
437
+ const closerLine = i < lines.length ? lines[i] : "]]";
438
+ i++; // skip ]]
439
+ // Skip blank lines between block and next statement
440
+ let j = i;
441
+ while (j < lines.length && lines[j].trim() === "")
442
+ j++;
443
+ const nextLine = j < lines.length ? lines[j] : "";
444
+ const funcMatch = nextLine.match(/^(\t*)local function (\w+)\(([^)]*)\)(?::\s*(.+))?$/);
445
+ if (!funcMatch) {
446
+ out.push(lines[blockStart]);
447
+ for (const bl of rawBody)
448
+ out.push(bl);
449
+ out.push(closerLine);
450
+ continue;
451
+ }
452
+ // Strip * prefixes from JSDoc lines (rotor emits "\t * text" format)
453
+ const cleanBody = rawBody
454
+ .map(l => l.trim().replace(/^\*+\s*/, "").trim())
455
+ .filter(l => l !== "");
456
+ const descLines = [];
457
+ const paramTags = [];
458
+ let returnDesc = "";
459
+ let deprecatedMsg;
460
+ for (const line of cleanBody) {
461
+ const lc = line.toLowerCase();
462
+ if (lc.startsWith("@param")) {
463
+ const m = line.match(/@param\s+(\w+)(?:\s+\{[^}]*\})?\s*(.*)/i);
464
+ if (m)
465
+ paramTags.push({ name: m[1].trim(), desc: m[2].trim() });
466
+ }
467
+ else if (lc.startsWith("@returns") || lc.startsWith("@return")) {
468
+ const m = line.match(/@returns?\s*(.*)/i);
469
+ if (m)
470
+ returnDesc = m[1].trim();
471
+ }
472
+ else if (lc.startsWith("@deprecated")) {
473
+ const m = line.match(/@deprecated\s*(.*)/i);
474
+ deprecatedMsg = m?.[1].trim() ?? "";
475
+ }
476
+ else if (!lc.startsWith("@")) {
477
+ descLines.push(line);
478
+ }
479
+ }
480
+ if (descLines.length === 0 && paramTags.length === 0 && !returnDesc && deprecatedMsg === undefined) {
481
+ out.push(lines[blockStart]);
482
+ for (const bl of rawBody)
483
+ out.push(bl);
484
+ out.push(closerLine);
485
+ continue;
486
+ }
487
+ // Extract param types from Luau function signature
488
+ const paramTypes = new Map();
489
+ const rawParams = funcMatch[3];
490
+ if (rawParams.trim()) {
491
+ for (const part of rawParams.split(",")) {
492
+ const pm = part.trim().match(/^(\w+)(\??):\s*(.+)$/);
493
+ if (pm)
494
+ paramTypes.set(pm[1], pm[2] ? `${pm[3].trim()}?` : pm[3].trim());
495
+ }
496
+ }
497
+ // Extract return types from Luau function signature
498
+ const rawRet = funcMatch[4]?.trim() ?? "";
499
+ let retTypes = [];
500
+ if (rawRet) {
501
+ retTypes = rawRet.startsWith("(") && rawRet.endsWith(")")
502
+ ? rawRet.slice(1, -1).split(",").map(t => t.trim()).filter(Boolean)
503
+ : [rawRet];
504
+ }
505
+ const indent = funcMatch[1];
506
+ if (deprecatedMsg !== undefined)
507
+ out.push(`${indent}--- @deprecated${deprecatedMsg ? ` ${deprecatedMsg}` : ""}`);
508
+ for (const desc of descLines) {
509
+ out.push(`${indent}--- ${desc}`);
510
+ }
511
+ for (const { name, desc } of paramTags) {
512
+ const type = paramTypes.get(name);
513
+ out.push(`${indent}--- @param ${name}${type ? ` ${type}` : ""}${desc ? ` ${desc}` : ""}`);
514
+ }
515
+ if (returnDesc) {
516
+ const retType = retTypes[0] ?? "";
517
+ out.push(`${indent}--- @return${retType ? ` ${retType}` : ""} ${returnDesc}`);
518
+ }
519
+ i = j; // skip blank lines, let function line emit normally
520
+ continue;
521
+ }
522
+ out.push(lines[i]);
523
+ i++;
524
+ }
525
+ return out.join("\n");
526
+ }
527
+ function applyDirectives(src, strict, optimizeLevel) {
528
+ if (strict && !src.includes("--!strict")) {
529
+ src = "--!strict\n" + src;
530
+ }
531
+ if (optimizeLevel !== false && !src.includes("--!optimize")) {
532
+ src = `--!optimize ${optimizeLevel}\n` + src;
533
+ }
534
+ return src;
535
+ }
536
+ const writingFiles = new Set();
537
+ function formatFile(luauPath, strict, optimizeLevel, sidecar = new Map(), types = new Map()) {
538
+ if (writingFiles.has(luauPath))
539
+ return;
540
+ if (!fs.existsSync(luauPath))
541
+ return;
542
+ let src = fs.readFileSync(luauPath, "utf8");
543
+ let changed = false;
544
+ const apply = (fn) => {
545
+ const next = fn(src);
546
+ if (next !== src) {
547
+ src = next;
548
+ changed = true;
549
+ }
550
+ };
551
+ apply(s => applyDirectives(s, strict, optimizeLevel));
552
+ apply(hoistGetService);
553
+ apply(fixBlockCommentOpeners);
554
+ apply(organizePreamble);
555
+ apply(s => injectTypeAnnotations(s, types));
556
+ apply(castTsImports);
557
+ apply(promoteConsts);
558
+ apply(convertJsDocComments);
559
+ apply(s => injectJsDocFromSidecar(s, sidecar));
560
+ apply(stripUselessBlockComments);
561
+ apply(addSpacing);
562
+ if (changed) {
563
+ writingFiles.add(luauPath);
564
+ try {
565
+ fs.writeFileSync(luauPath, src, "utf8");
566
+ }
567
+ finally {
568
+ setTimeout(() => writingFiles.delete(luauPath), 50).unref();
569
+ }
570
+ }
571
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "rbxts-transform-luau",
3
+ "version": "1.0.0",
4
+ "description": "roblox-ts transformer: idiomatic Luau output — preamble organization, comment cleanup, const promotion, and directive injection",
5
+ "main": "out/index.js",
6
+ "types": "out/index.d.ts",
7
+ "files": [
8
+ "out",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc"
13
+ },
14
+ "keywords": [
15
+ "roblox-ts",
16
+ "rbxts",
17
+ "transformer",
18
+ "luau",
19
+ "formatting",
20
+ "output"
21
+ ],
22
+ "devDependencies": {
23
+ "@types/node": "^26.0.0",
24
+ "roblox-ts": "^3.0.0",
25
+ "typescript": "5.5.3"
26
+ }
27
+ }