@gwigz/slua-tstl-plugin 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/dist/index.js +208 -0
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -5,6 +5,8 @@
5
5
  ## What it does
6
6
 
7
7
  - Translates TypeScript patterns to native Luau/LSL equivalents (see below)
8
+ - Automatically adjusts `ll.*` index arguments and return values from 0-based to 1-based
9
+ - Optimizes self-reassignment array concat/spread to in-place `table.extend`
8
10
  - Handles adjusting `Vector`, `Quaternion`, and `UUID` casing
9
11
  - Validates `luaTarget` is set to `Luau`
10
12
 
@@ -46,6 +48,7 @@ String methods are translated to LSL `ll.*` functions or Luau `string.*` stdlib
46
48
  | `str.repeat(n)` | `string.rep(str, n)` |
47
49
  | `str.substring(start)` | `string.sub(str, start + 1)` |
48
50
  | `str.substring(s, e)` | `string.sub(str, s + 1, e)` |
51
+ | `str.replaceAll(a, b)` | `ll.ReplaceSubString(str, a, b, 0)` |
49
52
 
50
53
  > [!NOTE]
51
54
  > `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.
@@ -87,6 +90,37 @@ Comparisons of a bitwise AND against zero are automatically optimized to `bit32.
87
90
 
88
91
  This works with `!=`, `==`, and with the zero on either side (`0 !== (a & b)`).
89
92
 
93
+ ### `ll.*` index adjustment
94
+
95
+ SLua's `ll.*` functions use 1-based indexing (Lua convention), but TypeScript uses 0-based. The plugin automatically adjusts index arguments and return values based on `@indexArg` and `@indexReturn` JSDoc tags in the type definitions:
96
+
97
+ | TypeScript | Lua output |
98
+ | -------------------------------- | ------------------------------------------------------------ |
99
+ | `ll.GetSubString("hello", 0, 2)` | `ll.GetSubString("hello", 1, 3)` |
100
+ | `ll.GetSubString("hello", i, j)` | `ll.GetSubString("hello", i + 1, j + 1)` |
101
+ | `ll.ListFindList(a, b)` | `____tmp = ll.ListFindList(a, b); ____tmp and (____tmp - 1)` |
102
+
103
+ - **`@indexArg`** parameters get `+ 1` (constant-folded for literals)
104
+ - **`@indexReturn`** wraps the result in a nil-safe `____tmp and (____tmp - 1)` expression
105
+ - Functions without these tags (e.g. `ll.Say`) are left unchanged
106
+
107
+ ### Array concat self-assignment
108
+
109
+ When an array is reassigned to itself with additional elements appended, the plugin emits `table.extend` (SLua's in-place append) instead of TSTL's `__TS__ArrayConcat` which allocates a new table:
110
+
111
+ | TypeScript | Lua output |
112
+ | ---------------------------- | --------------------------------------- |
113
+ | `arr = arr.concat(b)` | `table.extend(arr, b)` |
114
+ | `arr = arr.concat(b, c)` | `table.extend(table.extend(arr, b), c)` |
115
+ | `arr = [...arr, ...b]` | `table.extend(arr, b)` |
116
+ | `arr = [...arr, ...b, ...c]` | `table.extend(table.extend(arr, b), c)` |
117
+
118
+ This optimization only applies when:
119
+
120
+ - The expression is a statement (not `const result = arr.concat(b)`)
121
+ - The LHS is a simple identifier matching the receiver/first spread
122
+ - All concat arguments / spread expressions are array-typed
123
+
90
124
  ### Floor division
91
125
 
92
126
  `Math.floor(a / b)` is translated to the native Luau floor division operator `//`:
package/dist/index.js CHANGED
@@ -113,6 +113,17 @@ function isArrayType(expr, checker) {
113
113
  const type = checker.getTypeAtLocation(expr);
114
114
  return checker.isArrayLikeType(type);
115
115
  }
116
+ function isDetectedEventType(expr, checker) {
117
+ const type = checker.getTypeAtLocation(expr);
118
+ return type.symbol?.name === "DetectedEvent";
119
+ }
120
+ /**
121
+ * Returns true when `node` is `detectedEvent.index` — a property access
122
+ * on a `DetectedEvent` reading the `.index` field.
123
+ */
124
+ function isDetectedEventIndex(node, checker) {
125
+ return node.name.text === "index" && isDetectedEventType(node.expression, checker);
126
+ }
116
127
  /**
117
128
  * Checks whether `node` is `obj.method(args)` where `obj` matches the
118
129
  * given type predicate and the method name matches.
@@ -149,6 +160,175 @@ function isGlobalCall(node, name) {
149
160
  function createNamespacedCall(ns, fn, args, node) {
150
161
  return tstl.createCallExpression(tstl.createTableIndexExpression(tstl.createIdentifier(ns), tstl.createStringLiteral(fn)), args, node);
151
162
  }
163
+ // ---------------------------------------------------------------------------
164
+ // Self-reassignment array concat -> table.extend optimization
165
+ // ---------------------------------------------------------------------------
166
+ /**
167
+ * Detects `arr = arr.concat(b, c, ...)` where LHS is a simple identifier,
168
+ * the receiver matches LHS, and all concat arguments are array-typed.
169
+ */
170
+ function extractConcatSelfAssignment(expr, checker) {
171
+ if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
172
+ return null;
173
+ if (!ts.isIdentifier(expr.left))
174
+ return null;
175
+ if (!ts.isCallExpression(expr.right))
176
+ return null;
177
+ if (!ts.isPropertyAccessExpression(expr.right.expression))
178
+ return null;
179
+ if (expr.right.expression.name.text !== "concat")
180
+ return null;
181
+ if (!ts.isIdentifier(expr.right.expression.expression))
182
+ return null;
183
+ if (expr.right.expression.expression.text !== expr.left.text)
184
+ return null;
185
+ if (expr.right.arguments.length === 0)
186
+ return null;
187
+ for (const arg of expr.right.arguments) {
188
+ if (!isArrayType(arg, checker))
189
+ return null;
190
+ }
191
+ return { name: expr.left, args: expr.right.arguments };
192
+ }
193
+ /**
194
+ * Detects `arr = [...arr, ...b, ...c]` where LHS is a simple identifier,
195
+ * all elements are spreads, the first spread matches LHS, and all tail
196
+ * spread expressions are array-typed.
197
+ */
198
+ function extractSpreadSelfAssignment(expr, checker) {
199
+ if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken)
200
+ return null;
201
+ if (!ts.isIdentifier(expr.left))
202
+ return null;
203
+ if (!ts.isArrayLiteralExpression(expr.right))
204
+ return null;
205
+ const elements = expr.right.elements;
206
+ if (elements.length < 2)
207
+ return null;
208
+ for (const el of elements) {
209
+ if (!ts.isSpreadElement(el))
210
+ return null;
211
+ }
212
+ const first = elements[0];
213
+ if (!ts.isIdentifier(first.expression))
214
+ return null;
215
+ if (first.expression.text !== expr.left.text)
216
+ return null;
217
+ const tailArgs = [];
218
+ for (let i = 1; i < elements.length; i++) {
219
+ const spread = elements[i];
220
+ if (!isArrayType(spread.expression, checker))
221
+ return null;
222
+ tailArgs.push(spread.expression);
223
+ }
224
+ return { name: expr.left, args: tailArgs };
225
+ }
226
+ /**
227
+ * Builds nested `table.extend` calls:
228
+ * - Single arg: `table.extend(arr, b)`
229
+ * - Multiple: `table.extend(table.extend(arr, b), c)`
230
+ */
231
+ function emitChainedExtend(target, args, node) {
232
+ let result = createNamespacedCall("table", "extend", [target, args[0]], node);
233
+ for (let i = 1; i < args.length; i++) {
234
+ result = createNamespacedCall("table", "extend", [result, args[i]], node);
235
+ }
236
+ return result;
237
+ }
238
+ /**
239
+ * For an `ll.Foo(...)` call, inspect the resolved signature's JSDoc tags
240
+ * to determine which arguments have `@indexArg` semantics and whether
241
+ * the return value has `@indexReturn` semantics.
242
+ */
243
+ function getLLIndexSemantics(node, checker) {
244
+ // Must be ll.Something(...)
245
+ if (!ts.isPropertyAccessExpression(node.expression))
246
+ return null;
247
+ if (!ts.isIdentifier(node.expression.expression))
248
+ return null;
249
+ if (node.expression.expression.text !== "ll")
250
+ return null;
251
+ const sig = checker.getResolvedSignature(node);
252
+ const decl = sig?.declaration;
253
+ if (!decl || !ts.isFunctionDeclaration(decl))
254
+ return null;
255
+ const tags = ts.getJSDocTags(decl);
256
+ const indexArgs = new Set();
257
+ let indexReturn = false;
258
+ for (const tag of tags) {
259
+ const tagName = tag.tagName.text;
260
+ if (tagName === "indexArg") {
261
+ const text = typeof tag.comment === "string"
262
+ ? tag.comment
263
+ : Array.isArray(tag.comment)
264
+ ? tag.comment.map((c) => c.text).join("")
265
+ : undefined;
266
+ if (text) {
267
+ indexArgs.add(text.trim());
268
+ }
269
+ }
270
+ else if (tagName === "indexReturn") {
271
+ indexReturn = true;
272
+ }
273
+ }
274
+ if (indexArgs.size === 0 && !indexReturn)
275
+ return null;
276
+ return { indexArgs, indexReturn };
277
+ }
278
+ /**
279
+ * Adjusts a 0-based index argument to 1-based for Lua.
280
+ * Constant-folds numeric literals; otherwise emits `expr + 1`.
281
+ */
282
+ function adjustIndexArg(arg, context) {
283
+ if (ts.isNumericLiteral(arg)) {
284
+ return tstl.createNumericLiteral(Number(arg.text) + 1);
285
+ }
286
+ // DetectedEvent.index is already 1-based at runtime. The PropertyAccess
287
+ // visitor emits `- 1` to make it 0-based for TS, and @indexArg adds `+ 1`.
288
+ // These cancel out, so emit the raw property access directly.
289
+ if (ts.isPropertyAccessExpression(arg) && isDetectedEventIndex(arg, context.checker)) {
290
+ const obj = context.transformExpression(arg.expression);
291
+ return tstl.createTableIndexExpression(obj, tstl.createStringLiteral("index"));
292
+ }
293
+ return tstl.createBinaryExpression(context.transformExpression(arg), tstl.createNumericLiteral(1), tstl.SyntaxKind.AdditionOperator, arg);
294
+ }
295
+ /**
296
+ * Emit an `ll.Foo(...)` call with automatic index adjustments:
297
+ * - `@indexArg` parameters get `+1`
298
+ * - `@indexReturn` wraps the result in a nil-safe `__tmp and (__tmp - 1)`
299
+ */
300
+ function emitLLIndexCall(node, context, semantics) {
301
+ const expr = node.expression;
302
+ const fnName = expr.name.text;
303
+ // Resolve parameter names from the declaration
304
+ const sig = context.checker.getResolvedSignature(node);
305
+ const params = sig?.declaration && ts.isFunctionDeclaration(sig.declaration)
306
+ ? sig.declaration.parameters
307
+ : undefined;
308
+ const args = node.arguments.map((arg, i) => {
309
+ const paramName = params?.[i]?.name;
310
+ const name = paramName && ts.isIdentifier(paramName) ? paramName.text : undefined;
311
+ if (name && semantics.indexArgs.has(name)) {
312
+ return adjustIndexArg(arg, context);
313
+ }
314
+ return context.transformExpression(arg);
315
+ });
316
+ const call = createNamespacedCall("ll", fnName, args, node);
317
+ if (!semantics.indexReturn) {
318
+ return call;
319
+ }
320
+ // Check if return type is number-like (skip for list/string returns like llFindNotecardTextSync)
321
+ const retType = context.checker.getTypeAtLocation(node);
322
+ const isNumberReturn = !!(retType.flags & ts.TypeFlags.NumberLike) ||
323
+ (retType.isUnion() && retType.types.some((t) => !!(t.flags & ts.TypeFlags.NumberLike)));
324
+ if (!isNumberReturn) {
325
+ return call;
326
+ }
327
+ // Nil-safe return adjustment: `local __tmp = ll.Fn(...); __tmp and (__tmp - 1)`
328
+ const tempId = tstl.createIdentifier("____tmp");
329
+ context.addPrecedingStatements(tstl.createVariableDeclarationStatement(tempId, call, node));
330
+ return tstl.createBinaryExpression(tstl.cloneIdentifier(tempId), tstl.createParenthesizedExpression(tstl.createBinaryExpression(tstl.cloneIdentifier(tempId), tstl.createNumericLiteral(1), tstl.SyntaxKind.SubtractionOperator)), tstl.SyntaxKind.AndOperator, node);
331
+ }
152
332
  const CALL_TRANSFORMS = [
153
333
  // JSON.stringify(val) -> lljson.encode(val)
154
334
  {
@@ -351,6 +531,12 @@ const plugin = {
351
531
  result.table.text = replacement;
352
532
  }
353
533
  }
534
+ // DetectedEvent.index is 1-based at runtime in SLua; emit `obj.index - 1`
535
+ // so TypeScript sees a 0-based value. This composes correctly with
536
+ // @indexArg functions (which add +1, cancelling out the -1).
537
+ if (isDetectedEventIndex(node, context.checker)) {
538
+ return tstl.createBinaryExpression(result, tstl.createNumericLiteral(1), tstl.SyntaxKind.SubtractionOperator, node);
539
+ }
354
540
  return result;
355
541
  },
356
542
  [ts.SyntaxKind.BinaryExpression]: (node, context) => {
@@ -400,6 +586,15 @@ const plugin = {
400
586
  const call = createBit32Call(compoundFn, [left, right], node);
401
587
  return [tstl.createAssignmentStatement(left, call, node)];
402
588
  }
589
+ // Self-reassignment concat/spread -> table.extend (in-place, no assignment needed)
590
+ const concatMatch = extractConcatSelfAssignment(node.expression, context.checker) ??
591
+ extractSpreadSelfAssignment(node.expression, context.checker);
592
+ if (concatMatch) {
593
+ const target = context.transformExpression(concatMatch.name);
594
+ const args = concatMatch.args.map((a) => context.transformExpression(a));
595
+ const call = emitChainedExtend(target, args, node);
596
+ return [tstl.createExpressionStatement(call, node)];
597
+ }
403
598
  }
404
599
  return context.superTransformStatements(node);
405
600
  },
@@ -410,6 +605,11 @@ const plugin = {
410
605
  return transform.emit(node, context);
411
606
  }
412
607
  }
608
+ // ll.* index-semantics: automatic 0->1 index adjustment
609
+ const indexSemantics = getLLIndexSemantics(node, context.checker);
610
+ if (indexSemantics) {
611
+ return emitLLIndexCall(node, context, indexSemantics);
612
+ }
413
613
  // `Math.floor(a / b)` -> `a // b` (native Luau floor division operator)
414
614
  if (isMathFloor(node)) {
415
615
  const arg = node.arguments[0];
@@ -445,6 +645,14 @@ const plugin = {
445
645
  return diagnostics;
446
646
  },
447
647
  beforeEmit(program, _options, _emitHost, result) {
648
+ // Strip internal @indexArg / @indexReturn JSDoc tags from Lua comments.
649
+ // These are only consumed by the plugin at transpile time; they should
650
+ // not leak into the output as Lua comments.
651
+ for (const file of result) {
652
+ file.code = file.code
653
+ .replace(/^--\s*@index(?:Arg|Return)\b.*\n/gm, "")
654
+ .replace(/^(---.*)\n(?:-- *\n)+(?=local |ll\.)/gm, "$1\n");
655
+ }
448
656
  // Strip empty module boilerplate from files without explicit exports.
449
657
  // `moduleDetection: "force"` causes TSTL to wrap every file as a module;
450
658
  // standalone SLua scripts don't need the ____exports wrapper.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gwigz/slua-tstl-plugin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "TypeScriptToLua plugin for targeting Second Life's SLua runtime",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,6 @@
28
28
  "typescript-to-lua": "^1.33.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "typescript": "~5.7.0"
31
+ "typescript": "~5.9.3"
32
32
  }
33
33
  }