@gwigz/slua-tstl-plugin 0.1.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,195 @@
1
+ # `@gwigz/slua-tstl-plugin`
2
+
3
+ [TypeScriptToLua](https://typescripttolua.github.io) plugin to provide better DX with SLua types.
4
+
5
+ ## What it does
6
+
7
+ - Translates TypeScript patterns to native Luau/LSL equivalents (see below)
8
+ - Handles adjusting `Vector`, `Quaternion`, and `UUID` casing
9
+ - Validates `luaTarget` is set to `Luau`
10
+ - Warns if `luaLibImport` is not `none` or `inline` (for now)
11
+
12
+ ## Transforms
13
+
14
+ The plugin replaces TSTL lualib helpers with native Luau stdlib and LSL function calls for better performance and smaller output.
15
+
16
+ ### JSON
17
+
18
+ | TypeScript | Lua output |
19
+ | --------------------- | -------------------- |
20
+ | `JSON.stringify(val)` | `lljson.encode(val)` |
21
+ | `JSON.parse(str)` | `lljson.decode(str)` |
22
+
23
+ For SL-typed JSON (preserving vector/quaternion/uuid), use `lljson.slencode`/`lljson.sldecode` directly.
24
+
25
+ ### Base64
26
+
27
+ | TypeScript | Lua output |
28
+ | ----------- | ---------------------- |
29
+ | `btoa(str)` | `llbase64.encode(str)` |
30
+ | `atob(str)` | `llbase64.decode(str)` |
31
+
32
+ ### String methods
33
+
34
+ String methods are translated to LSL `ll.*` functions or Luau `string.*` stdlib calls:
35
+
36
+ | TypeScript | Lua output |
37
+ | ---------------------- | -------------------------------------- |
38
+ | `str.toUpperCase()` | `ll.ToUpper(str)` |
39
+ | `str.toLowerCase()` | `ll.ToLower(str)` |
40
+ | `str.trim()` | `ll.StringTrim(str, STRING_TRIM)` |
41
+ | `str.trimStart()` | `ll.StringTrim(str, STRING_TRIM_HEAD)` |
42
+ | `str.trimEnd()` | `ll.StringTrim(str, STRING_TRIM_TAIL)` |
43
+ | `str.indexOf(x)` | `ll.SubStringIndex(str, x)` |
44
+ | `str.includes(x)` | `string.find(str, x, 1, true) ~= nil` |
45
+ | `str.startsWith(x)` | `string.find(str, x, 1, true) == 1` |
46
+ | `str.split(sep)` | `string.split(str, sep)` |
47
+ | `str.repeat(n)` | `string.rep(str, n)` |
48
+ | `str.substring(start)` | `string.sub(str, start + 1)` |
49
+ | `str.substring(s, e)` | `string.sub(str, s + 1, e)` |
50
+
51
+ > [!NOTE]
52
+ > `str.indexOf(x, fromIndex)` and `str.startsWith(x, position)` with a second argument fall through to TSTL's default handling. Similarly, `str.split()` with no separator is not transformed.
53
+
54
+ ### Array methods
55
+
56
+ | TypeScript | Lua output |
57
+ | ------------------- | --------------------------------- |
58
+ | `arr.includes(val)` | `table.find(arr, val) ~= nil` |
59
+ | `arr.indexOf(val)` | `(table.find(arr, val) or 0) - 1` |
60
+
61
+ > [!NOTE]
62
+ > `arr.indexOf(val, fromIndex)` with a second argument falls through to TSTL's default handling.
63
+
64
+ ### Bitwise operators
65
+
66
+ TypeScript bitwise operators are automatically translated to `bit32` library calls, since SLua does not support native Lua bitwise operators.
67
+
68
+ | TypeScript | Lua output |
69
+ | ---------- | --------------------- |
70
+ | `a & b` | `bit32.band(a, b)` |
71
+ | `a \| b` | `bit32.bor(a, b)` |
72
+ | `a ^ b` | `bit32.bxor(a, b)` |
73
+ | `a << b` | `bit32.lshift(a, b)` |
74
+ | `a >> b` | `bit32.arshift(a, b)` |
75
+ | `a >>> b` | `bit32.rshift(a, b)` |
76
+ | `~a` | `bit32.bnot(a)` |
77
+
78
+ Compound assignments (`&=`, `|=`, `^=`, `<<=`, `>>=`, `>>>=`) are also supported and desugar to the same `bit32` calls.
79
+
80
+ #### `btest` optimization
81
+
82
+ Comparisons of a bitwise AND against zero are automatically optimized to `bit32.btest`:
83
+
84
+ | TypeScript | Lua output |
85
+ | --------------- | ----------------------- |
86
+ | `(a & b) !== 0` | `bit32.btest(a, b)` |
87
+ | `(a & b) === 0` | `not bit32.btest(a, b)` |
88
+
89
+ This works with `!=`, `==`, and with the zero on either side (`0 !== (a & b)`).
90
+
91
+ ### Floor division
92
+
93
+ `Math.floor(a / b)` is translated to the native Luau floor division operator `//`:
94
+
95
+ | TypeScript | Lua output |
96
+ | ------------------- | ---------- |
97
+ | `Math.floor(a / b)` | `a // b` |
98
+
99
+ This only applies when the argument is directly a `/` expression. `Math.floor(x)` with a non-division argument is left as-is.
100
+
101
+ > [!WARNING]
102
+ > JavaScript integer truncation idioms `~~x` and `x | 0` do **not** map cleanly to Luau. `~~x` emits `bit32.bnot(bit32.bnot(x))` and `x | 0` emits `bit32.bor(x, 0)`, neither of which preserves correct semantics for negative numbers (the `bit32` library operates on unsigned 32-bit integers). Use `math.floor(x)` for floor truncation instead.
103
+
104
+ ## Keeping output small
105
+
106
+ Some TypeScript patterns pull in large TSTL runtime helpers. Here are recommendations for keeping output lean:
107
+
108
+ ### Avoid `delete` on objects
109
+
110
+ The `delete` operator pulls in `__TS__Delete`, which depends on the entire Error class hierarchy (`Error`, `TypeError`, `RangeError`, etc.), `__TS__Class`, `__TS__ClassExtends`, `__TS__New`, and `__TS__ObjectGetOwnPropertyDescriptors`, roughly **150 lines** of runtime code.
111
+
112
+ Instead, type your records to allow `undefined` and assign `undefined` (which compiles to `nil`):
113
+
114
+ ```typescript
115
+ // Bad
116
+ const cache: Record<string, Data> = {}
117
+ delete cache[key]
118
+
119
+ // Good, compiles to `cache[key] = nil`
120
+ const cache: Record<string, Data | undefined> = {}
121
+ cache[key] = undefined
122
+ ```
123
+
124
+ To clear an entire record, use `let` and reassign instead of iterating with `delete`:
125
+
126
+ ```typescript
127
+ // Bad
128
+ for (const key of Object.keys(cache)) {
129
+ delete cache[key]
130
+ }
131
+
132
+ // Good
133
+ let cache: Record<string, Data | undefined> = {}
134
+ // ...
135
+ cache = {}
136
+ ```
137
+
138
+ ### Avoid `Array.splice()`
139
+
140
+ `splice()` pulls in `__TS__ArraySplice` and `__TS__CountVarargs`, roughly **75 lines**. Rebuild the array instead:
141
+
142
+ ```typescript
143
+ // Bad
144
+ for (let i = items.length - 1; i >= 0; i--) {
145
+ if (shouldRemove(items[i])) {
146
+ items.splice(i, 1)
147
+ }
148
+ }
149
+
150
+ // Good, compiles to simple table operations
151
+ let items: Item[] = []
152
+ const remaining: Item[] = []
153
+
154
+ for (const item of items) {
155
+ if (!shouldRemove(item)) {
156
+ remaining.push(item)
157
+ }
158
+ }
159
+
160
+ items = remaining
161
+ ```
162
+
163
+ ### Prefer `for...in` over `Object.entries()`
164
+
165
+ `Object.entries()` pulls in `__TS__ObjectEntries`. Use `Object.keys()` with indexing, or `for...in` which compiles directly to `for key in pairs(obj)`:
166
+
167
+ ```typescript
168
+ // Pulls in __TS__ObjectEntries
169
+ for (const [key, value] of Object.entries(obj)) { ... }
170
+
171
+ // Compiles to `for key in pairs(obj)`, no helpers
172
+ for (const key in obj) {
173
+ const value = obj[key]
174
+ }
175
+ ```
176
+
177
+ ### Avoid `Map` and `Set`
178
+
179
+ TSTL's `Map` and `Set` polyfills add **~400 lines** of runtime. Use plain `Record<string, T>` and arrays instead:
180
+
181
+ ```typescript
182
+ // Bad, ~400 lines of runtime
183
+ const lookup = new Map<string, UUID>()
184
+ const seen = new Set<string>()
185
+
186
+ // Good, plain Lua tables
187
+ const lookup: Record<string, UUID | undefined> = {}
188
+ const seen: Record<string, boolean> = {}
189
+ ```
190
+
191
+ ## Build
192
+
193
+ ```bash
194
+ bun run build
195
+ ```
@@ -0,0 +1 @@
1
+ export declare function transpile(code: string): string;
@@ -0,0 +1,16 @@
1
+ import * as tstl from "typescript-to-lua";
2
+ import plugin from "../index";
3
+ export function transpile(code) {
4
+ const result = tstl.transpileVirtualProject({ "main.ts": code }, {
5
+ luaTarget: tstl.LuaTarget.Luau,
6
+ noImplicitSelf: true,
7
+ noHeader: true,
8
+ luaLibImport: tstl.LuaLibImportKind.None,
9
+ luaPlugins: [{ plugin: plugin }],
10
+ });
11
+ if (result.diagnostics.length > 0) {
12
+ const messages = result.diagnostics.map((d) => typeof d.messageText === "string" ? d.messageText : d.messageText.messageText);
13
+ throw new Error(`Transpilation failed:\n${messages.join("\n")}`);
14
+ }
15
+ return result.transpiledFiles.find((f) => f.outPath === "main.lua")?.lua ?? "";
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ import * as ts from "typescript";
5
+ import * as tstl from "typescript-to-lua";
6
+ import plugin from "../index";
7
+ import { transpile as transpileSimple } from "./helpers";
8
+ const TYPES_PATH = resolve(import.meta.dir, "../../../../packages/types/index.d.ts");
9
+ const LANG_EXT_PATH = resolve(import.meta.dir, "../../../../node_modules/@typescript-to-lua/language-extensions/index.d.ts");
10
+ const sluaTypes = readFileSync(TYPES_PATH, "utf-8");
11
+ const langExt = readFileSync(LANG_EXT_PATH, "utf-8");
12
+ function transpile(code) {
13
+ const result = tstl.transpileVirtualProject({
14
+ "main.ts": code,
15
+ "language-extensions.d.ts": langExt,
16
+ "slua.d.ts": sluaTypes,
17
+ }, {
18
+ luaTarget: tstl.LuaTarget.Luau,
19
+ noImplicitSelf: true,
20
+ noHeader: true,
21
+ luaLibImport: tstl.LuaLibImportKind.Inline,
22
+ noImplicitGlobalVariables: true,
23
+ noLib: true,
24
+ strict: true,
25
+ luaPlugins: [{ plugin: plugin }],
26
+ });
27
+ return result.transpiledFiles.find((f) => f.outPath === "main.lua")?.lua ?? "";
28
+ }
29
+ describe("ts-slua plugin", () => {
30
+ it("beforeTransform errors on non-Luau target", () => {
31
+ const diagnostics = plugin.beforeTransform({}, { luaTarget: tstl.LuaTarget.Lua53 }, {});
32
+ expect(diagnostics).toHaveLength(1);
33
+ expect(diagnostics[0].messageText).toContain("Luau");
34
+ });
35
+ it("beforeTransform passes with correct config", () => {
36
+ const diagnostics = plugin.beforeTransform({}, {
37
+ luaTarget: tstl.LuaTarget.Luau,
38
+ luaLibImport: tstl.LuaLibImportKind.None,
39
+ }, {});
40
+ expect(diagnostics).toHaveLength(0);
41
+ });
42
+ it("beforeTransform warns on non-none luaLibImport", () => {
43
+ const diagnostics = plugin.beforeTransform({}, {
44
+ luaTarget: tstl.LuaTarget.Luau,
45
+ luaLibImport: tstl.LuaLibImportKind.Require,
46
+ }, {});
47
+ expect(diagnostics).toHaveLength(1);
48
+ expect(diagnostics[0].category).toBe(ts.DiagnosticCategory.Warning);
49
+ });
50
+ });
51
+ describe("transpilation output", () => {
52
+ it("does not inject self into LLEvents callbacks", () => {
53
+ const lua = transpile(`
54
+ LLEvents.on("touch_start", function(events) {
55
+ ll.Say(0, "touched")
56
+ })
57
+ `);
58
+ expect(lua).toContain("LLEvents:on");
59
+ expect(lua).not.toMatch(/function\(self/);
60
+ });
61
+ it("does not inject self into LLTimers callbacks", () => {
62
+ const lua = transpile(`
63
+ LLTimers.every(10, function(scheduled, interval) {
64
+ ll.Say(0, "tick")
65
+ })
66
+ `);
67
+ expect(lua).toContain("LLTimers:every");
68
+ expect(lua).not.toMatch(/function\(self/);
69
+ });
70
+ });
71
+ describe("floor division", () => {
72
+ it("translates Math.floor(a / b) to floor division operator", () => {
73
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = Math.floor(a / b)");
74
+ expect(lua).toContain("a // b");
75
+ expect(lua).not.toContain("math.floor");
76
+ });
77
+ it("handles literal operands", () => {
78
+ const lua = transpileSimple("const x = Math.floor(10 / 3)");
79
+ expect(lua).toContain("10 // 3");
80
+ });
81
+ it("does not transform Math.floor with non-division argument", () => {
82
+ const lua = transpileSimple("declare const a: number;\nconst x = Math.floor(a)");
83
+ expect(lua).not.toContain("//");
84
+ expect(lua).toContain("math.floor(a)");
85
+ });
86
+ it("does not transform Math.floor with complex non-division expression", () => {
87
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = Math.floor(a + b)");
88
+ expect(lua).not.toContain("//");
89
+ expect(lua).toContain("math.floor");
90
+ });
91
+ });
92
+ describe("bitwise operators", () => {
93
+ it("translates & to bit32.band", () => {
94
+ const lua = transpileSimple("const x = (1 as number) & (2 as number)");
95
+ expect(lua).toContain("bit32.band(1, 2)");
96
+ });
97
+ it("translates | to bit32.bor", () => {
98
+ const lua = transpileSimple("const x = (1 as number) | (2 as number)");
99
+ expect(lua).toContain("bit32.bor(1, 2)");
100
+ });
101
+ it("translates ^ to bit32.bxor", () => {
102
+ const lua = transpileSimple("const x = (1 as number) ^ (2 as number)");
103
+ expect(lua).toContain("bit32.bxor(1, 2)");
104
+ });
105
+ it("translates << to bit32.lshift", () => {
106
+ const lua = transpileSimple("const x = (1 as number) << (2 as number)");
107
+ expect(lua).toContain("bit32.lshift(1, 2)");
108
+ });
109
+ it("translates >> to bit32.arshift", () => {
110
+ const lua = transpileSimple("const x = (1 as number) >> (2 as number)");
111
+ expect(lua).toContain("bit32.arshift(1, 2)");
112
+ });
113
+ it("translates >>> to bit32.rshift", () => {
114
+ const lua = transpileSimple("const x = (1 as number) >>> (2 as number)");
115
+ expect(lua).toContain("bit32.rshift(1, 2)");
116
+ });
117
+ it("translates ~ to bit32.bnot", () => {
118
+ const lua = transpileSimple("const x = ~(1 as number)");
119
+ expect(lua).toContain("bit32.bnot(1)");
120
+ });
121
+ describe("btest optimization", () => {
122
+ it("translates (a & b) !== 0 to bit32.btest", () => {
123
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = (a & b) !== 0");
124
+ expect(lua).toContain("bit32.btest(a, b)");
125
+ expect(lua).not.toContain("bit32.band");
126
+ });
127
+ it("translates (a & b) != 0 to bit32.btest", () => {
128
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = (a & b) != 0");
129
+ expect(lua).toContain("bit32.btest(a, b)");
130
+ });
131
+ it("translates (a & b) === 0 to not bit32.btest", () => {
132
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = (a & b) === 0");
133
+ expect(lua).toContain("not bit32.btest(a, b)");
134
+ });
135
+ it("translates (a & b) == 0 to not bit32.btest", () => {
136
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = (a & b) == 0");
137
+ expect(lua).toContain("not bit32.btest(a, b)");
138
+ });
139
+ it("translates 0 !== (a & b) to bit32.btest (flipped)", () => {
140
+ const lua = transpileSimple("declare const a: number, b: number;\nconst x = 0 !== (a & b)");
141
+ expect(lua).toContain("bit32.btest(a, b)");
142
+ });
143
+ });
144
+ it("handles compound expressions: (a & b) | c", () => {
145
+ const lua = transpileSimple("declare const a: number, b: number, c: number;\nconst x = (a & b) | c");
146
+ expect(lua).toContain("bit32.bor(");
147
+ expect(lua).toContain("bit32.band(a, b)");
148
+ expect(lua).toMatch(/bit32\.bor\(\s*bit32\.band\(a, b\)/);
149
+ });
150
+ describe("compound bitwise assignments", () => {
151
+ it("translates &= to bit32.band", () => {
152
+ const lua = transpileSimple("declare let a: number; a &= 3");
153
+ expect(lua).toContain("bit32.band(a, 3)");
154
+ });
155
+ it("translates |= to bit32.bor", () => {
156
+ const lua = transpileSimple("declare let a: number; a |= 3");
157
+ expect(lua).toContain("bit32.bor(a, 3)");
158
+ });
159
+ it("translates ^= to bit32.bxor", () => {
160
+ const lua = transpileSimple("declare let a: number; a ^= 3");
161
+ expect(lua).toContain("bit32.bxor(a, 3)");
162
+ });
163
+ it("translates <<= to bit32.lshift", () => {
164
+ const lua = transpileSimple("declare let a: number; a <<= 3");
165
+ expect(lua).toContain("bit32.lshift(a, 3)");
166
+ });
167
+ it("translates >>= to bit32.arshift", () => {
168
+ const lua = transpileSimple("declare let a: number; a >>= 3");
169
+ expect(lua).toContain("bit32.arshift(a, 3)");
170
+ });
171
+ it("translates >>>= to bit32.rshift", () => {
172
+ const lua = transpileSimple("declare let a: number; a >>>= 3");
173
+ expect(lua).toContain("bit32.rshift(a, 3)");
174
+ });
175
+ it("handles property access LHS", () => {
176
+ const lua = transpileSimple("declare let obj: {prop: number}; obj.prop &= 3");
177
+ expect(lua).toContain("bit32.band(");
178
+ expect(lua).not.toMatch(/\s&\s/);
179
+ });
180
+ });
181
+ it("does not affect non-bitwise binary operators", () => {
182
+ const lua = transpileSimple("const x = 1 + 2");
183
+ expect(lua).toContain("1 + 2");
184
+ expect(lua).not.toContain("bit32");
185
+ });
186
+ it("does not affect non-bitwise unary operators", () => {
187
+ const lua = transpileSimple("const x = -(1 as number)");
188
+ expect(lua).toContain("-1");
189
+ expect(lua).not.toContain("bit32");
190
+ });
191
+ });
192
+ describe("JSON transforms", () => {
193
+ it("translates JSON.stringify to lljson.encode", () => {
194
+ const lua = transpileSimple("declare const obj: any;\nconst s = JSON.stringify(obj)");
195
+ expect(lua).toContain("lljson.encode(obj)");
196
+ expect(lua).not.toContain("JSON");
197
+ });
198
+ it("translates JSON.parse to lljson.decode", () => {
199
+ const lua = transpileSimple('declare const s: string;\nconst obj = JSON.parse(s)');
200
+ expect(lua).toContain("lljson.decode(s)");
201
+ expect(lua).not.toContain("JSON");
202
+ });
203
+ });
204
+ describe("base64 transforms", () => {
205
+ it("translates btoa to llbase64.encode", () => {
206
+ const lua = transpileSimple("declare const s: string;\nconst b = btoa(s)");
207
+ expect(lua).toContain("llbase64.encode(s)");
208
+ expect(lua).not.toContain("btoa");
209
+ });
210
+ it("translates atob to llbase64.decode", () => {
211
+ const lua = transpileSimple("declare const b: string;\nconst s = atob(b)");
212
+ expect(lua).toContain("llbase64.decode(b)");
213
+ expect(lua).not.toContain("atob");
214
+ });
215
+ });
216
+ describe("string ll.* transforms", () => {
217
+ it("translates toUpperCase to ll.ToUpper", () => {
218
+ const lua = transpileSimple('const s = "hello".toUpperCase()');
219
+ expect(lua).toContain("ll.ToUpper");
220
+ });
221
+ it("translates toLowerCase to ll.ToLower", () => {
222
+ const lua = transpileSimple('const s = "HELLO".toLowerCase()');
223
+ expect(lua).toContain("ll.ToLower");
224
+ });
225
+ it("translates trim to ll.StringTrim with STRING_TRIM", () => {
226
+ const lua = transpileSimple('declare const s: string;\nconst x = s.trim()');
227
+ expect(lua).toContain("ll.StringTrim");
228
+ expect(lua).toContain("STRING_TRIM");
229
+ });
230
+ it("translates trimStart to ll.StringTrim with STRING_TRIM_HEAD", () => {
231
+ const lua = transpileSimple('interface String { trimStart(): string; }\ndeclare const s: string;\nconst x = s.trimStart()');
232
+ expect(lua).toContain("ll.StringTrim");
233
+ expect(lua).toContain("STRING_TRIM_HEAD");
234
+ });
235
+ it("translates trimEnd to ll.StringTrim with STRING_TRIM_TAIL", () => {
236
+ const lua = transpileSimple('interface String { trimEnd(): string; }\ndeclare const s: string;\nconst x = s.trimEnd()');
237
+ expect(lua).toContain("ll.StringTrim");
238
+ expect(lua).toContain("STRING_TRIM_TAIL");
239
+ });
240
+ it("translates indexOf (1-arg) to ll.SubStringIndex", () => {
241
+ const lua = transpileSimple('declare const s: string;\nconst i = s.indexOf("x")');
242
+ expect(lua).toContain("ll.SubStringIndex");
243
+ });
244
+ it("translates indexOf with literal fromIndex to string.find with constant folding", () => {
245
+ const lua = transpileSimple('declare const s: string;\nconst i = s.indexOf("x", 5)');
246
+ expect(lua).not.toContain("ll.SubStringIndex");
247
+ expect(lua).toContain('string.find(s, "x", 6, true)');
248
+ expect(lua).toContain("(string.find(");
249
+ expect(lua).toContain("or 0) - 1");
250
+ });
251
+ it("translates indexOf with expression fromIndex to string.find with + 1", () => {
252
+ const lua = transpileSimple('declare const s: string;\ndeclare const n: number;\nconst i = s.indexOf("x", n)');
253
+ expect(lua).toContain('string.find(s, "x", n + 1, true)');
254
+ expect(lua).toContain("or 0) - 1");
255
+ });
256
+ });
257
+ describe("string Luau stdlib transforms", () => {
258
+ it("translates includes to string.find", () => {
259
+ const lua = transpileSimple('interface String { includes(searchString: string): boolean }\ndeclare const s: string;\nconst b = s.includes("x")');
260
+ expect(lua).toContain("string.find(s,");
261
+ expect(lua).toContain("true");
262
+ expect(lua).toContain("~= nil");
263
+ });
264
+ it("translates split(sep) to string.split", () => {
265
+ const lua = transpileSimple('interface String { split(separator: string): string[] }\ndeclare const s: string;\nconst a = s.split(",")');
266
+ expect(lua).toContain("string.split(s,");
267
+ });
268
+ it("does not transform split() with no separator", () => {
269
+ const lua = transpileSimple("interface String { split(separator?: string): string[] }\ndeclare const s: string;\nconst a = s.split()");
270
+ expect(lua).not.toContain("string.split");
271
+ });
272
+ it("translates repeat to string.rep", () => {
273
+ const lua = transpileSimple("interface String { repeat(count: number): string }\ndeclare const s: string;\nconst r = s.repeat(3)");
274
+ expect(lua).toContain("string.rep(s, 3)");
275
+ });
276
+ it("translates startsWith to string.find == 1", () => {
277
+ const lua = transpileSimple('interface String { startsWith(searchString: string): boolean }\ndeclare const s: string;\nconst b = s.startsWith("pre")');
278
+ expect(lua).toContain("string.find(s,");
279
+ expect(lua).toContain("true");
280
+ expect(lua).toContain("== 1");
281
+ });
282
+ it("does not transform startsWith with position argument", () => {
283
+ const lua = transpileSimple('interface String { startsWith(searchString: string, position?: number): boolean }\ndeclare const s: string;\nconst b = s.startsWith("pre", 3)');
284
+ expect(lua).not.toContain("string.find");
285
+ });
286
+ it("translates substring(start) to string.sub with constant folding", () => {
287
+ const lua = transpileSimple("declare const s: string;\nconst x = s.substring(5)");
288
+ expect(lua).toContain("string.sub(s, 6)");
289
+ });
290
+ it("translates substring(start, end) to string.sub with constant folding", () => {
291
+ const lua = transpileSimple("declare const s: string;\nconst x = s.substring(1, 5)");
292
+ expect(lua).toContain("string.sub(s, 2, 5)");
293
+ });
294
+ it("translates substring with expression arg to start + 1", () => {
295
+ const lua = transpileSimple("declare const s: string;\ndeclare const i: number;\nconst x = s.substring(i)");
296
+ expect(lua).toContain("string.sub(s, i + 1)");
297
+ });
298
+ });
299
+ describe("array transforms", () => {
300
+ it("translates includes to table.find ~= nil", () => {
301
+ const lua = transpileSimple("interface Array<T> { includes(searchElement: T): boolean; }\ndeclare const arr: number[];\nconst b = arr.includes(3)");
302
+ expect(lua).toContain("table.find(arr, 3)");
303
+ expect(lua).toContain("~= nil");
304
+ });
305
+ it("translates indexOf (1-arg) to (table.find or 0) - 1", () => {
306
+ const lua = transpileSimple("interface Array<T> { indexOf(searchElement: T, fromIndex?: number): number; }\ndeclare const arr: number[];\nconst i = arr.indexOf(3)");
307
+ expect(lua).toContain("table.find(arr, 3)");
308
+ expect(lua).toContain("- 1");
309
+ });
310
+ it("does not transform indexOf with fromIndex argument", () => {
311
+ const lua = transpileSimple("interface Array<T> { indexOf(searchElement: T, fromIndex?: number): number; }\ndeclare const arr: number[];\nconst i = arr.indexOf(3, 1)");
312
+ expect(lua).not.toContain("table.find");
313
+ });
314
+ });
@@ -0,0 +1,3 @@
1
+ import * as tstl from "typescript-to-lua";
2
+ declare const plugin: tstl.Plugin;
3
+ export default plugin;
package/dist/index.js ADDED
@@ -0,0 +1,468 @@
1
+ import * as ts from "typescript";
2
+ import * as tstl from "typescript-to-lua";
3
+ /**
4
+ * PascalCase class names that map to lowercase Lua globals.
5
+ * `new Vector(...)` is handled by @customConstructor, but static access
6
+ * like `Vector.zero` emits `Vector.zero` in Lua -- which doesn't exist.
7
+ * The PropertyAccessExpression visitor rewrites the Lua identifier to lowercase.
8
+ */
9
+ const PASCAL_TO_LOWER = {
10
+ Vector: "vector",
11
+ Quaternion: "quaternion",
12
+ UUID: "uuid",
13
+ };
14
+ /**
15
+ * TSTL treats "bit32" as a Lua keyword and renames it to "____bit32" in output.
16
+ * This is incorrect for Luau where bit32 is a valid global library.
17
+ * The visitor rewrites the mangled name back; the diagnostic is suppressed
18
+ * separately in consumers (e.g. the playground transpiler worker).
19
+ */
20
+ const TSTL_KEYWORD_FIXUPS = {
21
+ ____bit32: "bit32",
22
+ };
23
+ /**
24
+ * Creates a `bit32.<fn>(...args)` Lua call expression.
25
+ * The optional `node` attaches TypeScript source-map information; when
26
+ * patching already-lowered Lua AST nodes (e.g. from compound-assignment
27
+ * desugaring) there is no originating TS node, so it may be omitted.
28
+ */
29
+ function createBit32Call(fn, args, node) {
30
+ return tstl.createCallExpression(tstl.createTableIndexExpression(tstl.createIdentifier("bit32"), tstl.createStringLiteral(fn)), args, node);
31
+ }
32
+ const BINARY_BITWISE_OPS = {
33
+ [ts.SyntaxKind.AmpersandToken]: "band",
34
+ [ts.SyntaxKind.BarToken]: "bor",
35
+ [ts.SyntaxKind.CaretToken]: "bxor",
36
+ [ts.SyntaxKind.LessThanLessThanToken]: "lshift",
37
+ [ts.SyntaxKind.GreaterThanGreaterThanToken]: "arshift",
38
+ [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: "rshift",
39
+ };
40
+ /**
41
+ * Compound bitwise assignment tokens (`&=`, `|=`, etc.) map to the same
42
+ * `bit32.*` functions as their non-compound counterparts. We handle
43
+ * these at the TypeScript AST level rather than patching the Lua AST,
44
+ * because TSTL's desugaring loses the distinction between `>>=`
45
+ * (arshift) and `>>>=` (rshift) -- both lower to the same Lua operator.
46
+ */
47
+ const COMPOUND_BITWISE_OPS = {
48
+ [ts.SyntaxKind.AmpersandEqualsToken]: "band",
49
+ [ts.SyntaxKind.BarEqualsToken]: "bor",
50
+ [ts.SyntaxKind.CaretEqualsToken]: "bxor",
51
+ [ts.SyntaxKind.LessThanLessThanEqualsToken]: "lshift",
52
+ [ts.SyntaxKind.GreaterThanGreaterThanEqualsToken]: "arshift",
53
+ [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken]: "rshift",
54
+ };
55
+ /**
56
+ * Returns true when `node` is `Math.floor(<single-arg>)`.
57
+ */
58
+ function isMathFloor(node) {
59
+ return (node.arguments.length === 1 &&
60
+ ts.isPropertyAccessExpression(node.expression) &&
61
+ ts.isIdentifier(node.expression.expression) &&
62
+ node.expression.expression.text === "Math" &&
63
+ node.expression.name.text === "floor");
64
+ }
65
+ const EQUALITY_OPS = new Set([
66
+ ts.SyntaxKind.EqualsEqualsToken,
67
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
68
+ ts.SyntaxKind.ExclamationEqualsToken,
69
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
70
+ ]);
71
+ function isZeroLiteral(node) {
72
+ return ts.isNumericLiteral(node) && node.text === "0";
73
+ }
74
+ function isNegatedEquality(op) {
75
+ return (op === ts.SyntaxKind.ExclamationEqualsToken || op === ts.SyntaxKind.ExclamationEqualsEqualsToken);
76
+ }
77
+ /**
78
+ * Detect `(a & b) !== 0`, `0 === (a & b)`, etc. and return the `&` expression
79
+ * plus whether to negate the btest result.
80
+ */
81
+ function extractBtestPattern(node) {
82
+ const op = node.operatorToken.kind;
83
+ if (!EQUALITY_OPS.has(op))
84
+ return null;
85
+ let bandExpr;
86
+ if (isZeroLiteral(node.right)) {
87
+ bandExpr = ts.isParenthesizedExpression(node.left) ? node.left.expression : node.left;
88
+ }
89
+ else if (isZeroLiteral(node.left)) {
90
+ bandExpr = ts.isParenthesizedExpression(node.right) ? node.right.expression : node.right;
91
+ }
92
+ else {
93
+ return null;
94
+ }
95
+ if (!ts.isBinaryExpression(bandExpr) ||
96
+ bandExpr.operatorToken.kind !== ts.SyntaxKind.AmpersandToken) {
97
+ return null;
98
+ }
99
+ // `== 0` / `=== 0` mean "no bits in common", so we negate btest (negate = true).
100
+ // `!= 0` / `!== 0` mean "some bits in common", which is what btest returns
101
+ // directly, so no negation is needed. The `!isNegatedEquality` double-negative
102
+ // captures this: negated equality (!=) -> false -> don't negate btest.
103
+ return { band: bandExpr, negate: !isNegatedEquality(op) };
104
+ }
105
+ /**
106
+ * Type-checking helpers for catalog transforms.
107
+ */
108
+ function isStringType(expr, checker) {
109
+ const type = checker.getTypeAtLocation(expr);
110
+ return !!(type.flags & ts.TypeFlags.StringLike);
111
+ }
112
+ function isArrayType(expr, checker) {
113
+ const type = checker.getTypeAtLocation(expr);
114
+ return checker.isArrayLikeType(type);
115
+ }
116
+ /**
117
+ * Checks whether `node` is `obj.method(args)` where `obj` matches the
118
+ * given type predicate and the method name matches.
119
+ */
120
+ function isMethodCall(node, checker, typeGuard, method, argCount) {
121
+ if (!ts.isPropertyAccessExpression(node.expression))
122
+ return false;
123
+ if (node.expression.name.text !== method)
124
+ return false;
125
+ if (argCount !== undefined && node.arguments.length !== argCount)
126
+ return false;
127
+ return typeGuard(node.expression.expression, checker);
128
+ }
129
+ /**
130
+ * Checks whether `node` is `Namespace.method(args)` using syntactic
131
+ * identifier matching (no TypeChecker needed).
132
+ */
133
+ function isNamespaceCall(node, namespace, method) {
134
+ if (!ts.isPropertyAccessExpression(node.expression))
135
+ return false;
136
+ if (node.expression.name.text !== method)
137
+ return false;
138
+ return (ts.isIdentifier(node.expression.expression) && node.expression.expression.text === namespace);
139
+ }
140
+ /**
141
+ * Checks whether `node` is a call to a global function by name.
142
+ */
143
+ function isGlobalCall(node, name) {
144
+ return ts.isIdentifier(node.expression) && node.expression.text === name;
145
+ }
146
+ /**
147
+ * Creates a `ns.fn(...args)` Lua call expression.
148
+ */
149
+ function createNamespacedCall(ns, fn, args, node) {
150
+ return tstl.createCallExpression(tstl.createTableIndexExpression(tstl.createIdentifier(ns), tstl.createStringLiteral(fn)), args, node);
151
+ }
152
+ const CALL_TRANSFORMS = [
153
+ // JSON.stringify(val) -> lljson.encode(val)
154
+ {
155
+ match: (node) => isNamespaceCall(node, "JSON", "stringify"),
156
+ emit: (node, context) => {
157
+ const args = node.arguments.map((a) => context.transformExpression(a));
158
+ return createNamespacedCall("lljson", "encode", args, node);
159
+ },
160
+ },
161
+ // JSON.parse(str) -> lljson.decode(str)
162
+ {
163
+ match: (node) => isNamespaceCall(node, "JSON", "parse"),
164
+ emit: (node, context) => {
165
+ const args = node.arguments.map((a) => context.transformExpression(a));
166
+ return createNamespacedCall("lljson", "decode", args, node);
167
+ },
168
+ },
169
+ // btoa(str) -> llbase64.encode(str)
170
+ {
171
+ match: (node) => isGlobalCall(node, "btoa") && node.arguments.length === 1,
172
+ emit: (node, context) => {
173
+ const args = node.arguments.map((a) => context.transformExpression(a));
174
+ return createNamespacedCall("llbase64", "encode", args, node);
175
+ },
176
+ },
177
+ // atob(str) -> llbase64.decode(str)
178
+ {
179
+ match: (node) => isGlobalCall(node, "atob") && node.arguments.length === 1,
180
+ emit: (node, context) => {
181
+ const args = node.arguments.map((a) => context.transformExpression(a));
182
+ return createNamespacedCall("llbase64", "decode", args, node);
183
+ },
184
+ },
185
+ // str.toUpperCase() -> ll.ToUpper(str)
186
+ {
187
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "toUpperCase", 0),
188
+ emit: (node, context) => {
189
+ const str = context.transformExpression(node.expression.expression);
190
+ return createNamespacedCall("ll", "ToUpper", [str], node);
191
+ },
192
+ },
193
+ // str.toLowerCase() -> ll.ToLower(str)
194
+ {
195
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "toLowerCase", 0),
196
+ emit: (node, context) => {
197
+ const str = context.transformExpression(node.expression.expression);
198
+ return createNamespacedCall("ll", "ToLower", [str], node);
199
+ },
200
+ },
201
+ // str.trim() -> ll.StringTrim(str, STRING_TRIM)
202
+ {
203
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "trim", 0),
204
+ emit: (node, context) => {
205
+ const str = context.transformExpression(node.expression.expression);
206
+ return createNamespacedCall("ll", "StringTrim", [str, tstl.createIdentifier("STRING_TRIM")], node);
207
+ },
208
+ },
209
+ // str.trimStart() -> ll.StringTrim(str, STRING_TRIM_HEAD)
210
+ {
211
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "trimStart", 0),
212
+ emit: (node, context) => {
213
+ const str = context.transformExpression(node.expression.expression);
214
+ return createNamespacedCall("ll", "StringTrim", [str, tstl.createIdentifier("STRING_TRIM_HEAD")], node);
215
+ },
216
+ },
217
+ // str.trimEnd() -> ll.StringTrim(str, STRING_TRIM_TAIL)
218
+ {
219
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "trimEnd", 0),
220
+ emit: (node, context) => {
221
+ const str = context.transformExpression(node.expression.expression);
222
+ return createNamespacedCall("ll", "StringTrim", [str, tstl.createIdentifier("STRING_TRIM_TAIL")], node);
223
+ },
224
+ },
225
+ // str.indexOf(x) -> ll.SubStringIndex(str, x) (1-arg only)
226
+ {
227
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "indexOf", 1),
228
+ emit: (node, context) => {
229
+ const str = context.transformExpression(node.expression.expression);
230
+ const search = context.transformExpression(node.arguments[0]);
231
+ return createNamespacedCall("ll", "SubStringIndex", [str, search], node);
232
+ },
233
+ },
234
+ // str.indexOf(x, fromIndex) -> (string.find(str, x, fromIndex + 1, true) or 0) - 1
235
+ {
236
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "indexOf", 2),
237
+ emit: (node, context) => {
238
+ const str = context.transformExpression(node.expression.expression);
239
+ const search = context.transformExpression(node.arguments[0]);
240
+ const fromArg = node.arguments[1];
241
+ const init = ts.isNumericLiteral(fromArg)
242
+ ? tstl.createNumericLiteral(Number(fromArg.text) + 1)
243
+ : tstl.createBinaryExpression(context.transformExpression(fromArg), tstl.createNumericLiteral(1), tstl.SyntaxKind.AdditionOperator, node);
244
+ const findCall = createNamespacedCall("string", "find", [str, search, init, tstl.createBooleanLiteral(true)], node);
245
+ const findOrZero = tstl.createBinaryExpression(findCall, tstl.createNumericLiteral(0), tstl.SyntaxKind.OrOperator, node);
246
+ return tstl.createBinaryExpression(tstl.createParenthesizedExpression(findOrZero), tstl.createNumericLiteral(1), tstl.SyntaxKind.SubtractionOperator, node);
247
+ },
248
+ },
249
+ // str.includes(x) -> string.find(str, x, 1, true) ~= nil
250
+ {
251
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "includes", 1),
252
+ emit: (node, context) => {
253
+ const str = context.transformExpression(node.expression.expression);
254
+ const search = context.transformExpression(node.arguments[0]);
255
+ const findCall = createNamespacedCall("string", "find", [str, search, tstl.createNumericLiteral(1), tstl.createBooleanLiteral(true)], node);
256
+ return tstl.createBinaryExpression(findCall, tstl.createNilLiteral(), tstl.SyntaxKind.InequalityOperator, node);
257
+ },
258
+ },
259
+ // str.split(sep) -> string.split(str, sep) (1-arg only)
260
+ {
261
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "split", 1),
262
+ emit: (node, context) => {
263
+ const str = context.transformExpression(node.expression.expression);
264
+ const sep = context.transformExpression(node.arguments[0]);
265
+ return createNamespacedCall("string", "split", [str, sep], node);
266
+ },
267
+ },
268
+ // str.repeat(n) -> string.rep(str, n)
269
+ {
270
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "repeat", 1),
271
+ emit: (node, context) => {
272
+ const str = context.transformExpression(node.expression.expression);
273
+ const n = context.transformExpression(node.arguments[0]);
274
+ return createNamespacedCall("string", "rep", [str, n], node);
275
+ },
276
+ },
277
+ // str.startsWith(search) -> string.find(str, search, 1, true) == 1 (1-arg only)
278
+ {
279
+ match: (node, checker) => isMethodCall(node, checker, isStringType, "startsWith", 1),
280
+ emit: (node, context) => {
281
+ const str = context.transformExpression(node.expression.expression);
282
+ const search = context.transformExpression(node.arguments[0]);
283
+ const findCall = createNamespacedCall("string", "find", [str, search, tstl.createNumericLiteral(1), tstl.createBooleanLiteral(true)], node);
284
+ return tstl.createBinaryExpression(findCall, tstl.createNumericLiteral(1), tstl.SyntaxKind.EqualityOperator, node);
285
+ },
286
+ },
287
+ // str.substring(start) -> string.sub(str, start + 1)
288
+ // str.substring(start, end) -> string.sub(str, start + 1, end)
289
+ {
290
+ match: (node, checker) => {
291
+ if (!isMethodCall(node, checker, isStringType, "substring")) {
292
+ return false;
293
+ }
294
+ return node.arguments.length === 1 || node.arguments.length === 2;
295
+ },
296
+ emit: (node, context) => {
297
+ const str = context.transformExpression(node.expression.expression);
298
+ const startArg = node.arguments[0];
299
+ const start = ts.isNumericLiteral(startArg)
300
+ ? tstl.createNumericLiteral(Number(startArg.text) + 1)
301
+ : tstl.createBinaryExpression(context.transformExpression(startArg), tstl.createNumericLiteral(1), tstl.SyntaxKind.AdditionOperator, node);
302
+ const args = [str, start];
303
+ if (node.arguments.length === 2) {
304
+ args.push(context.transformExpression(node.arguments[1]));
305
+ }
306
+ return createNamespacedCall("string", "sub", args, node);
307
+ },
308
+ },
309
+ // arr.includes(val) -> table.find(arr, val) ~= nil
310
+ {
311
+ match: (node, checker) => isMethodCall(node, checker, isArrayType, "includes", 1),
312
+ emit: (node, context) => {
313
+ const arr = context.transformExpression(node.expression.expression);
314
+ const val = context.transformExpression(node.arguments[0]);
315
+ const findCall = createNamespacedCall("table", "find", [arr, val], node);
316
+ return tstl.createBinaryExpression(findCall, tstl.createNilLiteral(), tstl.SyntaxKind.InequalityOperator, node);
317
+ },
318
+ },
319
+ // arr.indexOf(val) -> (table.find(arr, val) or 0) - 1 (1-arg only)
320
+ {
321
+ match: (node, checker) => isMethodCall(node, checker, isArrayType, "indexOf", 1),
322
+ emit: (node, context) => {
323
+ const arr = context.transformExpression(node.expression.expression);
324
+ const val = context.transformExpression(node.arguments[0]);
325
+ const findCall = createNamespacedCall("table", "find", [arr, val], node);
326
+ const findOrZero = tstl.createBinaryExpression(findCall, tstl.createNumericLiteral(0), tstl.SyntaxKind.OrOperator, node);
327
+ return tstl.createBinaryExpression(tstl.createParenthesizedExpression(findOrZero), tstl.createNumericLiteral(1), tstl.SyntaxKind.SubtractionOperator, node);
328
+ },
329
+ },
330
+ ];
331
+ const plugin = {
332
+ visitors: {
333
+ [ts.SyntaxKind.PropertyAccessExpression]: (node, context) => {
334
+ const result = context.superTransformExpression(node);
335
+ // Rewrite identifiers in the Lua AST (PascalCase -> lowercase, TSTL keyword fixups).
336
+ if (tstl.isTableIndexExpression(result) && tstl.isIdentifier(result.table)) {
337
+ const replacement = PASCAL_TO_LOWER[result.table.text] ?? TSTL_KEYWORD_FIXUPS[result.table.text];
338
+ if (replacement) {
339
+ result.table.text = replacement;
340
+ }
341
+ }
342
+ return result;
343
+ },
344
+ [ts.SyntaxKind.BinaryExpression]: (node, context) => {
345
+ // Check for btest pattern: (a & b) !== 0, 0 === (a & b), etc.
346
+ const btest = extractBtestPattern(node);
347
+ if (btest) {
348
+ const left = context.transformExpression(btest.band.left);
349
+ const right = context.transformExpression(btest.band.right);
350
+ const call = createBit32Call("btest", [left, right], node);
351
+ return btest.negate
352
+ ? tstl.createUnaryExpression(call, tstl.SyntaxKind.NotOperator, node)
353
+ : call;
354
+ }
355
+ const op = node.operatorToken.kind;
356
+ const fn = BINARY_BITWISE_OPS[op];
357
+ if (fn) {
358
+ const left = context.transformExpression(node.left);
359
+ const right = context.transformExpression(node.right);
360
+ return createBit32Call(fn, [left, right], node);
361
+ }
362
+ // Compound bitwise assignments (`&=`, `|=`, `^=`, `<<=`, `>>=`, `>>>=`).
363
+ // Manually desugar to `lhs = bit32.<fn>(lhs, rhs)` so we preserve the
364
+ // correct function (especially arshift vs rshift). This path is only
365
+ // reached when a compound assignment is used as an *expression*; the
366
+ // statement case is handled by the ExpressionStatement visitor below.
367
+ const compoundFn = COMPOUND_BITWISE_OPS[op];
368
+ if (compoundFn) {
369
+ const left = context.transformExpression(node.left);
370
+ const right = context.transformExpression(node.right);
371
+ const call = createBit32Call(compoundFn, [left, right], node);
372
+ context.addPrecedingStatements(tstl.createAssignmentStatement(left, call, node));
373
+ return left;
374
+ }
375
+ return context.superTransformExpression(node);
376
+ },
377
+ // Compound bitwise assignments used as statements (`a &= 3;`) are handled
378
+ // by TSTL's ExpressionStatement -> transformBinaryExpressionStatement path,
379
+ // which never calls the BinaryExpression visitor. We intercept here and
380
+ // manually desugar to `lhs = bit32.<fn>(lhs, rhs)` to preserve the correct
381
+ // function name (especially arshift vs rshift which TSTL conflates).
382
+ [ts.SyntaxKind.ExpressionStatement]: (node, context) => {
383
+ if (ts.isBinaryExpression(node.expression)) {
384
+ const compoundFn = COMPOUND_BITWISE_OPS[node.expression.operatorToken.kind];
385
+ if (compoundFn) {
386
+ const left = context.transformExpression(node.expression.left);
387
+ const right = context.transformExpression(node.expression.right);
388
+ const call = createBit32Call(compoundFn, [left, right], node);
389
+ return [tstl.createAssignmentStatement(left, call, node)];
390
+ }
391
+ }
392
+ return context.superTransformStatements(node);
393
+ },
394
+ [ts.SyntaxKind.CallExpression]: (node, context) => {
395
+ // Catalog-driven transforms
396
+ for (const transform of CALL_TRANSFORMS) {
397
+ if (transform.match(node, context.checker)) {
398
+ return transform.emit(node, context);
399
+ }
400
+ }
401
+ // `Math.floor(a / b)` -> `a // b` (native Luau floor division operator)
402
+ if (isMathFloor(node)) {
403
+ const arg = node.arguments[0];
404
+ if (ts.isBinaryExpression(arg) && arg.operatorToken.kind === ts.SyntaxKind.SlashToken) {
405
+ const left = context.transformExpression(arg.left);
406
+ const right = context.transformExpression(arg.right);
407
+ return tstl.createBinaryExpression(left, right, tstl.SyntaxKind.FloorDivisionOperator, node);
408
+ }
409
+ }
410
+ return context.superTransformExpression(node);
411
+ },
412
+ [ts.SyntaxKind.PrefixUnaryExpression]: (node, context) => {
413
+ if (node.operator === ts.SyntaxKind.TildeToken) {
414
+ const operand = context.transformExpression(node.operand);
415
+ return createBit32Call("bnot", [operand], node);
416
+ }
417
+ return context.superTransformExpression(node);
418
+ },
419
+ },
420
+ beforeTransform(_program, options) {
421
+ const diagnostics = [];
422
+ if (options.luaTarget !== tstl.LuaTarget.Luau) {
423
+ diagnostics.push({
424
+ file: undefined,
425
+ start: undefined,
426
+ length: undefined,
427
+ messageText: '@gwigz/slua-tstl-plugin requires luaTarget to be "Luau", set "luaTarget": "Luau" in tsconfig.json',
428
+ category: ts.DiagnosticCategory.Error,
429
+ code: 90000,
430
+ source: "@gwigz/slua-tstl-plugin",
431
+ });
432
+ }
433
+ if (options.luaLibImport !== undefined &&
434
+ ![tstl.LuaLibImportKind.None, tstl.LuaLibImportKind.Inline].includes(options.luaLibImport)) {
435
+ diagnostics.push({
436
+ file: undefined,
437
+ start: undefined,
438
+ length: undefined,
439
+ messageText: '@gwigz/slua-tstl-plugin requires luaLibImport to be "none" or "inline"',
440
+ category: ts.DiagnosticCategory.Warning,
441
+ code: 90002,
442
+ source: "@gwigz/slua-tstl-plugin",
443
+ });
444
+ }
445
+ return diagnostics;
446
+ },
447
+ beforeEmit(program, _options, _emitHost, result) {
448
+ // Strip empty module boilerplate from files without explicit exports.
449
+ // `moduleDetection: "force"` causes TSTL to wrap every file as a module;
450
+ // standalone SLua scripts don't need the ____exports wrapper.
451
+ for (const file of result) {
452
+ if (!file.code.includes("local ____exports = {}\n"))
453
+ continue;
454
+ if (!file.code.trimEnd().endsWith("return ____exports"))
455
+ continue;
456
+ const hasExplicitExports = file.sourceFiles?.some((sf) => sf.statements.some((s) => ts.isExportDeclaration(s) ||
457
+ ts.isExportAssignment(s) ||
458
+ (ts.canHaveModifiers(s) &&
459
+ ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword))));
460
+ if (!hasExplicitExports) {
461
+ file.code = file.code
462
+ .replace(/local ____exports = \{\}\n/, "")
463
+ .replace(/\nreturn ____exports\n?$/, "\n");
464
+ }
465
+ }
466
+ },
467
+ };
468
+ export default plugin;
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@gwigz/slua-tstl-plugin",
3
+ "version": "0.1.0",
4
+ "description": "TypeScriptToLua plugin for targeting Second Life's SLua runtime",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/gwigz/slua.git",
9
+ "directory": "packages/tstl-plugin"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "main": "dist/index.js",
15
+ "types": "dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "test": "bun test"
25
+ },
26
+ "dependencies": {
27
+ "typescript-to-lua": "^1.33.0"
28
+ },
29
+ "peerDependencies": {
30
+ "typescript": "~5.7.0"
31
+ }
32
+ }