@defold-typescript/types 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.
Files changed (58) hide show
  1. package/api-targets.json +84 -0
  2. package/generated/b2d.d.ts +13 -0
  3. package/generated/buffer.d.ts +25 -0
  4. package/generated/builtin-messages.d.ts +42 -0
  5. package/generated/camera.d.ts +7 -0
  6. package/generated/collectionfactory.d.ts +17 -0
  7. package/generated/collectionproxy.d.ts +14 -0
  8. package/generated/crash.d.ts +31 -0
  9. package/generated/factory.d.ts +17 -0
  10. package/generated/go.d.ts +96 -0
  11. package/generated/graphics.d.ts +115 -0
  12. package/generated/gui.d.ts +245 -0
  13. package/generated/http.d.ts +8 -0
  14. package/generated/iac.d.ts +8 -0
  15. package/generated/iap.d.ts +16 -0
  16. package/generated/image.d.ts +16 -0
  17. package/generated/json.d.ts +11 -0
  18. package/generated/kinds/gui-script.d.ts +39 -0
  19. package/generated/kinds/render-script.d.ts +39 -0
  20. package/generated/kinds/script.d.ts +38 -0
  21. package/generated/label.d.ts +23 -0
  22. package/generated/liveupdate.d.ts +23 -0
  23. package/generated/model.d.ts +23 -0
  24. package/generated/msg.d.ts +12 -0
  25. package/generated/particlefx.d.ts +22 -0
  26. package/generated/physics.d.ts +47 -0
  27. package/generated/profiler.d.ts +28 -0
  28. package/generated/push.d.ts +15 -0
  29. package/generated/render.d.ts +55 -0
  30. package/generated/resource.d.ts +33 -0
  31. package/generated/socket.d.ts +25 -0
  32. package/generated/sound.d.ts +28 -0
  33. package/generated/sprite.d.ts +23 -0
  34. package/generated/sys.d.ts +37 -0
  35. package/generated/tilemap.d.ts +24 -0
  36. package/generated/timer.d.ts +12 -0
  37. package/generated/vmath.d.ts +63 -0
  38. package/generated/webview.d.ts +15 -0
  39. package/generated/window.d.ts +28 -0
  40. package/generated/zlib.d.ts +9 -0
  41. package/index.d.ts +63 -0
  42. package/package.json +46 -0
  43. package/scripts/doc-source.ts +100 -0
  44. package/scripts/fidelity-audit.ts +311 -0
  45. package/scripts/fidelity-baseline.json +282 -0
  46. package/scripts/materialize-version.ts +51 -0
  47. package/scripts/regen.ts +294 -0
  48. package/scripts/sync-api-docs.ts +375 -0
  49. package/src/api-doc.ts +168 -0
  50. package/src/core-types.ts +121 -0
  51. package/src/emit-dts.ts +754 -0
  52. package/src/emit-messages.ts +148 -0
  53. package/src/go-overloads.d.ts +35 -0
  54. package/src/index.ts +24 -0
  55. package/src/lifecycle.ts +81 -0
  56. package/src/msg-overloads.d.ts +21 -0
  57. package/src/publish-dts.ts +33 -0
  58. package/src/script-api.ts +95 -0
@@ -0,0 +1,754 @@
1
+ import type {
2
+ ApiConstant,
3
+ ApiFunction,
4
+ ApiModule,
5
+ ApiParameter,
6
+ ApiProperty,
7
+ ApiVariable,
8
+ } from "./api-doc";
9
+ import { DEFOLD_TYPE_MAP } from "./core-types";
10
+
11
+ export interface EmitOptions {
12
+ mapType?: (defoldType: string) => string;
13
+ // Constant FQNs from *other* modules, so a foreign token like
14
+ // `graphics.BUFFER_TYPE_COLOR0_BIT` used as a param type inside `render`
15
+ // brands to the same FQN-keyed type its owning module's `const` emits,
16
+ // instead of widening to `unknown`.
17
+ knownConstantFqns?: ReadonlySet<string>;
18
+ }
19
+
20
+ export const TS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
21
+ const INDENT = " ";
22
+
23
+ // TS reserved words that pass api-doc's identifier check but cannot be emitted
24
+ // directly inside `declare namespace { function/const <name> }` — `function
25
+ // delete(...)` / `const null:` are hard syntax errors. They are recovered as an
26
+ // internal `_<name>` declaration re-exported under the reserved name via
27
+ // `export { _<name> as <name> }`, the only form that is both TS-legal and keeps
28
+ // the engine's real call name. `regen` imports this set and lets these members
29
+ // flow through to the emitter instead of dropping them.
30
+ export const TS_RESERVED_NAMES = new Set([
31
+ "delete",
32
+ "new",
33
+ "class",
34
+ "function",
35
+ "return",
36
+ "if",
37
+ "else",
38
+ "for",
39
+ "while",
40
+ "do",
41
+ "switch",
42
+ "case",
43
+ "break",
44
+ "continue",
45
+ "var",
46
+ "let",
47
+ "const",
48
+ "try",
49
+ "catch",
50
+ "finally",
51
+ "throw",
52
+ "typeof",
53
+ "instanceof",
54
+ "in",
55
+ "void",
56
+ "yield",
57
+ "await",
58
+ "null",
59
+ "true",
60
+ "false",
61
+ "super",
62
+ "this",
63
+ "import",
64
+ "export",
65
+ "default",
66
+ "with",
67
+ "debugger",
68
+ "extends",
69
+ ]);
70
+
71
+ // Element names whose `table` slot is a genuinely-arbitrary lua table by design
72
+ // — the serialization/JSON passthrough functions. Their emitted
73
+ // `Record<string | number, unknown>` is the faithful "any lua table" type, not a
74
+ // `recordTables` fidelity loss, so the audit consults this set to avoid counting
75
+ // them. A new ref-doc function with an opaque table must be added here
76
+ // deliberately; until then it surfaces under `recordTables` as a visible signal.
77
+ export const ARBITRARY_TABLE_SLOTS = new Set([
78
+ "json.encode",
79
+ "json.decode",
80
+ "sys.save",
81
+ "sys.load",
82
+ "sys.serialize",
83
+ "sys.deserialize",
84
+ ]);
85
+
86
+ // Element names whose `table` slot is a prose-only `a table mapping X to Y`
87
+ // shape the field-list parser cannot read, but whose key/value a human curated
88
+ // from the doc. Emitted as `LuaMap<K, V>` because the key is a branded `Hash`,
89
+ // illegal in a TS index signature (`string | number | symbol` only); `LuaMap`
90
+ // is the TSTL non-string-key Lua-table idiom and resolves the same way the
91
+ // already-emitted `LuaMultiReturn` does. The audit consults this map too, so the
92
+ // gate and the emitted surface stay coupled; a slot not curated here still
93
+ // surfaces under `recordTables` until a human adds it.
94
+ export const MAPPING_TABLE_SLOTS: ReadonlyMap<string, { key: string; value: string }> = new Map([
95
+ ["gui.clone_tree", { key: "hash", value: "node" }],
96
+ ["gui.get_tree", { key: "hash", value: "node" }],
97
+ ["gui.get_layouts", { key: "hash", value: "vector3" }],
98
+ ]);
99
+
100
+ // Element names whose `table` slot is a prose-only `array/list/table of <T>` shape
101
+ // the field-list parser cannot read, but whose element type a human curated from
102
+ // the doc. The value is a single element token (`T[]`) or a token list when the
103
+ // element is itself a union — `go.delete`'s "table of id's" is the id union
104
+ // `string | hash | url`, emitted `(string | Hash | Url)[]`. Emitted as a plain
105
+ // array, never `LuaMap`: an array element carries no illegal-index-key problem so
106
+ // no TSTL wiring is needed. The audit consults this map too, so the gate and the
107
+ // emitted surface stay coupled; a slot not curated here still surfaces under
108
+ // `recordTables` until a human adds it. Each function here has exactly one `table`
109
+ // slot, so the element name keys it unambiguously.
110
+ export const HOMOGENEOUS_ARRAY_SLOTS: ReadonlyMap<string, string | readonly string[]> = new Map<
111
+ string,
112
+ string | readonly string[]
113
+ >([
114
+ ["buffer.set_metadata", "number"],
115
+ ["buffer.get_metadata", "number"],
116
+ ["vmath.vector", "number"],
117
+ ["sound.get_groups", "hash"],
118
+ ["iap.list", "string"],
119
+ ["go.delete", ["string", "hash", "url"]],
120
+ ]);
121
+
122
+ /**
123
+ * Recover a Defold `function(...)` callback-signature token into a TypeScript
124
+ * function type. The token carries parameter names but no inner types, so each
125
+ * param is typed `unknown`; arity and names are preserved so the callback stays
126
+ * self-documenting on hover. Return is `void` — Defold callbacks are
127
+ * side-effecting and the engine ignores any returned value. A param that is not
128
+ * a valid TS identifier (e.g. the nested `function(function())`) is named
129
+ * positionally `argN`. Returns `null` for any non-`function(...)` token, which
130
+ * is the scope boundary both the emitter and the audit key off.
131
+ *
132
+ * Lives here rather than in `core-types.ts` because that module is fed verbatim
133
+ * to typescript-to-lua as ambient source, and TSTL rejects regex literals.
134
+ */
135
+ export function recoverCallbackSignature(token: string): string | null {
136
+ const match = /^function\((.*)\)$/.exec(token);
137
+ if (match === null) return null;
138
+ const body = match[1] ?? "";
139
+ const raw = body.trim() === "" ? [] : body.split(",");
140
+ const named = raw.map((param, index) => {
141
+ const trimmed = param.trim();
142
+ return TS_IDENTIFIER.test(trimmed) ? trimmed : `arg${index}`;
143
+ });
144
+ return `(${named.map((n) => `${n}: unknown`).join(", ")}) => void`;
145
+ }
146
+
147
+ // The trailing `([^<]*)` captures the plain prose immediately after the type
148
+ // span up to the next tag. The "is this a list?" signal ("a list of …") lives in
149
+ // that prose, never in the field name or type token.
150
+ const TABLE_FIELD =
151
+ /<dt>\s*<code>([^<]+)<\/code>\s*<\/dt>\s*<dd>\s*<span class="type">([^<]+)<\/span>([^<]*)/g;
152
+ const LIST_PROSE = /\ba list of\b/i;
153
+ // The slot-level array signal ("an array of …" / "a list of …") lives in the
154
+ // prose preceding the field list, never in a field's own `<dd>` (that is the
155
+ // per-field `LIST_PROSE` path). `isSlotLevelList` therefore tests only the
156
+ // substring before the first `<dl>`/`<ul>`/`<li>`, so a field-internal list
157
+ // marker can never wrap the whole slot.
158
+ export const SLOT_LEVEL_LIST_PROSE = /\b(an?\s+array of|a\s+list of)\b/i;
159
+ function isSlotLevelList(doc: string): boolean {
160
+ const prefix = doc.split(/<dl>|<ul>|<li>/i)[0] ?? "";
161
+ return SLOT_LEVEL_LIST_PROSE.test(prefix);
162
+ }
163
+ const FLATTENED_TABLE = /<li>\s*<dl>/;
164
+ // A number-list slot's element type is read from the brace form a "a list of …"
165
+ // `<dd>` ends with: `in the form {px0, py0, ..., pxn, pyn}` / `{i0, i1, ..., in}`.
166
+ // Every comma-separated token must be a numeric placeholder — an optional letter
167
+ // axis prefix then a digit (`px0`, `i2`, `0`) or the symbolic nth-index `pxn`/`un`
168
+ // — or an ellipsis; at least one must carry a digit. A brace body with quotes,
169
+ // nested braces, or plain word identifiers is not a number list and stays `Record`.
170
+ const NUMBER_LIST_BRACE = /\bin the form (?:of\s+)?\{([^}]*)\}/i;
171
+ const NUMBER_LIST_TOKEN = /^(?:[a-z]*\d+|[a-z]+n|\.\.\.|…)$/i;
172
+ function isNumberListForm(prose: string): boolean {
173
+ const brace = NUMBER_LIST_BRACE.exec(prose);
174
+ if (brace === null) return false;
175
+ const body = brace[1] ?? "";
176
+ if (/["<{]/.test(body)) return false;
177
+ const tokens = body
178
+ .split(",")
179
+ .map((t) => t.trim())
180
+ .filter((t) => t.length > 0);
181
+ if (tokens.length === 0) return false;
182
+ let numeric = 0;
183
+ for (const token of tokens) {
184
+ if (!NUMBER_LIST_TOKEN.test(token)) return false;
185
+ if (/\d/.test(token)) numeric += 1;
186
+ }
187
+ return numeric > 0;
188
+ }
189
+
190
+ // The `<ul>` typed-field shape lists a field as `<span class="type">T</span>
191
+ // <code>name</code>` — type first, name second, the reverse of the `<dl>` form.
192
+ const UL_TABLE_FIELD = /<li>\s*<span class="type">([^<]+)<\/span>\s*<code>([^<]+)<\/code>/g;
193
+ const DASH_TABLE_FIELD =
194
+ /^\s*-\s*(?:<code>([^<]+)<\/code>|([A-Za-z_$][A-Za-z0-9_$]*))\s*<span class="type">([^<]+)<\/span>/gm;
195
+ // The code-dash shape names the field first inside a `<code>` and puts the dash
196
+ // between the name and its type span: `<code>NAME</code> - <span
197
+ // class="type">T</span>` (`sys.open_url`'s `target`). Untyped value lists in the
198
+ // same doc (`<code>_self</code> - prose`) carry no type span and never match.
199
+ const CODE_DASH_TABLE_FIELD = /<code>([^<]+)<\/code>\s*-\s*<span class="type">([^<]+)<\/span>/g;
200
+ // A cross-reference table doc carries no inline fields, only a pointer to a
201
+ // sibling element: `See <a href="…#<element.name>">…`. The capture is the
202
+ // fragment after `#` — the referenced element's full name. Non-global: a single
203
+ // match suffices and keeps `.exec` stateless.
204
+ const CROSS_REF_TABLE = /See\s+<a href="[^"]*#([^"]+)">/;
205
+ // A doc enumerating its own *typed* fields inline (`<dt>` or typed `<li>` items)
206
+ // owns its field list; a `See` anchor beside it is supplementary detail, not the
207
+ // slot's source of truth. The pure-pointer cross-reference branch fires only for
208
+ // a doc with no inline list of its own. When the inline list is an *untyped
209
+ // name-only* `<ul>` (`<li>NAME</li>`, no `<span class="type">`) the names alone
210
+ // recover nothing, so the supplementary branch adopts the referenced sibling's
211
+ // fields filtered to those names (`resource.get_atlas` lists
212
+ // `texture`/`geometries`/`animations` then points at `resource.set_atlas`).
213
+ const OWN_FIELD_LIST_MARKUP = /<dt>|<li>/;
214
+ // Plain `<li>NAME</li>` items — a bare identifier with no `<span class="type">`,
215
+ // `<code>`, or nested markup. Used to read the field *names* a supplementary
216
+ // cross-reference slot enumerates; the sibling supplies the types. Does not match
217
+ // the typed `<li>` forms `parseUlFields` handles, the flattened `<li><dl>` form,
218
+ // or multi-word prose `<li>` items.
219
+ const UL_NAME_FIELD = /<li>\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*<\/li>/g;
220
+
221
+ /**
222
+ * Recover a `table`-typed slot's field structure from its doc HTML. Three
223
+ * regular field shapes are recognised: the `<dl>` definition list
224
+ * (`<dt><code>NAME</code></dt><dd><span class="type">T</span>…`), the `<ul>`
225
+ * typed list (`<li><span class="type">T</span> <code>NAME</code>…`,
226
+ * type-before-name), and dash-list option fields (`- NAME <span
227
+ * class="type">T</span>` or `- <code>NAME</code> <span class="type">T</span>`,
228
+ * name-before-type), and the code-dash form (`<code>NAME</code> - <span
229
+ * class="type">T</span>`, name-in-code then dash then type). They all use the
230
+ * same `<span class="type">` form `parseProperty` reads from a PROPERTY brief.
231
+ * Returns the ordered `{ name, types }[]` (types split on `|`) when at least one
232
+ * field matches, `null` otherwise — the boundary both the emitter and the
233
+ * fidelity audit key off so the gate and the emitted surface cannot drift. The
234
+ * `<dl>` form takes precedence; the `<ul>`, dash-list, and code-dash fallbacks
235
+ * fire only when the earlier forms yield nothing.
236
+ *
237
+ * Lives here beside `recoverCallbackSignature` rather than in `core-types.ts`
238
+ * because that module is fed verbatim to typescript-to-lua, which rejects regex
239
+ * literals.
240
+ */
241
+ export interface TableField {
242
+ name: string;
243
+ types: string[];
244
+ fields?: TableField[];
245
+ isList?: boolean;
246
+ numberList?: boolean;
247
+ }
248
+
249
+ function parseUlFields(doc: string): TableField[] {
250
+ const fields: TableField[] = [];
251
+ for (const match of doc.matchAll(UL_TABLE_FIELD)) {
252
+ const name = (match[2] ?? "").trim();
253
+ const types = splitTypeTokens(match[1] ?? "");
254
+ if (name.length > 0) fields.push({ name, types });
255
+ }
256
+ return fields;
257
+ }
258
+
259
+ function parseUlNames(doc: string): string[] {
260
+ const names: string[] = [];
261
+ for (const match of doc.matchAll(UL_NAME_FIELD)) {
262
+ const name = (match[1] ?? "").trim();
263
+ if (name.length > 0) names.push(name);
264
+ }
265
+ return names;
266
+ }
267
+
268
+ function parseDashListFields(doc: string): TableField[] {
269
+ const fields: TableField[] = [];
270
+ for (const match of doc.matchAll(DASH_TABLE_FIELD)) {
271
+ const name = (match[1] ?? match[2] ?? "").trim();
272
+ const types = splitTypeTokens(match[3] ?? "");
273
+ if (name.length > 0) fields.push({ name, types });
274
+ }
275
+ return fields;
276
+ }
277
+
278
+ function parseCodeDashFields(doc: string): TableField[] {
279
+ const fields: TableField[] = [];
280
+ for (const match of doc.matchAll(CODE_DASH_TABLE_FIELD)) {
281
+ const name = (match[1] ?? "").trim();
282
+ const types = splitTypeTokens(match[2] ?? "");
283
+ if (name.length > 0) fields.push({ name, types });
284
+ }
285
+ return fields;
286
+ }
287
+
288
+ function splitTypeTokens(raw: string): string[] {
289
+ return raw
290
+ .split("|")
291
+ .map((t) => t.trim())
292
+ .filter((t) => t.length > 0);
293
+ }
294
+
295
+ // Resolver from a referenced element's name to that element's first
296
+ // `table`-typed param-or-return doc. Threaded into `parseTableFields` so a
297
+ // cross-reference slot can adopt the referenced sibling's already-recovered
298
+ // fields. The resolver is module-scoped, so a cross-module anchor never
299
+ // resolves (left for a later slice).
300
+ export type TableDocResolver = (elementName: string) => string | undefined;
301
+
302
+ interface TableDocSource {
303
+ name: string;
304
+ slots: readonly { types: readonly string[]; doc: string }[];
305
+ }
306
+
307
+ export function buildTableDocResolver(sources: readonly TableDocSource[]): TableDocResolver {
308
+ const docByName = new Map<string, string>();
309
+ for (const source of sources) {
310
+ if (docByName.has(source.name)) continue;
311
+ const tableSlot = source.slots.find((slot) => slot.types.includes("table"));
312
+ if (tableSlot !== undefined) docByName.set(source.name, tableSlot.doc);
313
+ }
314
+ return (elementName) => docByName.get(elementName);
315
+ }
316
+
317
+ export function parseTableFields(doc: string, resolver?: TableDocResolver): TableField[] | null {
318
+ const fields: TableField[] = [];
319
+ for (const match of doc.matchAll(TABLE_FIELD)) {
320
+ const name = (match[1] ?? "").trim();
321
+ const types = splitTypeTokens(match[2] ?? "");
322
+ if (name.length === 0) continue;
323
+ const field: TableField = { name, types };
324
+ const prose = match[3] ?? "";
325
+ if (types.includes("table") && LIST_PROSE.test(prose)) {
326
+ field.isList = true;
327
+ if (isNumberListForm(prose)) field.numberList = true;
328
+ }
329
+ fields.push(field);
330
+ }
331
+ if (FLATTENED_TABLE.test(doc) && fields.some((field) => isTableField(field))) {
332
+ return groupFlattenedTableFields(fields);
333
+ }
334
+ // Nested recovery, scoped to the one unambiguous shape: a `<dl>` declaring a
335
+ // single `table`-typed field whose keys sit in an immediately-following
336
+ // top-level `<ul>` typed-field list (`window.get_safe_area`). Attach the
337
+ // `<ul>` fields as that field's nested shape so the slot recovers as a nested
338
+ // object instead of a `Record`.
339
+ if (fields.length === 1) {
340
+ const only = fields[0];
341
+ if (only && only.types.length === 1 && only.types[0] === "table") {
342
+ const nested = parseUlFields(doc);
343
+ if (nested.length > 0) {
344
+ only.fields = nested;
345
+ return [only];
346
+ }
347
+ }
348
+ }
349
+ if (fields.length === 0) {
350
+ for (const field of parseUlFields(doc)) fields.push(field);
351
+ }
352
+ if (fields.length === 0) {
353
+ for (const field of parseDashListFields(doc)) fields.push(field);
354
+ }
355
+ if (fields.length === 0) {
356
+ for (const field of parseCodeDashFields(doc)) fields.push(field);
357
+ }
358
+ if (fields.length > 0) return fields;
359
+ // The direct parsers recovered nothing. If a resolver is present and the doc
360
+ // is a cross-reference pointer, adopt the referenced element's fields. Recurse
361
+ // without the resolver — the depth-1 / cycle guard: a referenced doc that is
362
+ // itself only another cross-ref anchor recovers nothing.
363
+ if (resolver !== undefined && !OWN_FIELD_LIST_MARKUP.test(doc)) {
364
+ const ref = CROSS_REF_TABLE.exec(doc);
365
+ const target = ref?.[1];
366
+ if (target !== undefined) {
367
+ const resolved = resolver(target);
368
+ if (resolved !== undefined) return parseTableFields(resolved);
369
+ }
370
+ }
371
+ // Supplementary cross-reference: an untyped name-only `<ul>` (recovered nothing
372
+ // above) beside a `See <sibling>` pointer. Adopt the referenced sibling's
373
+ // recovered fields, filtered to the names the own `<ul>` enumerates — the
374
+ // filter is load-bearing: it excludes sibling fields this slot does not list,
375
+ // so the recovery cannot raise the table-granularity loss count. One hop: the
376
+ // sibling is resolved without the resolver (depth-1 / cycle guard).
377
+ if (resolver !== undefined && OWN_FIELD_LIST_MARKUP.test(doc)) {
378
+ const names = parseUlNames(doc);
379
+ const ref = CROSS_REF_TABLE.exec(doc);
380
+ const target = ref?.[1];
381
+ if (names.length > 0 && target !== undefined) {
382
+ const resolved = resolver(target);
383
+ if (resolved !== undefined) {
384
+ const resolvedFields = parseTableFields(resolved);
385
+ if (resolvedFields !== null) {
386
+ const wanted = new Set(names);
387
+ const filtered = resolvedFields.filter((field) => wanted.has(field.name));
388
+ if (filtered.length > 0) return filtered;
389
+ }
390
+ }
391
+ }
392
+ }
393
+ return null;
394
+ }
395
+
396
+ function isTableField(field: TableField): boolean {
397
+ return field.types.length === 1 && field.types[0] === "table";
398
+ }
399
+
400
+ function groupFlattenedTableFields(fields: readonly TableField[]): TableField[] {
401
+ const grouped: TableField[] = [];
402
+ let open: TableField | undefined;
403
+ for (const field of fields) {
404
+ if (isTableField(field)) {
405
+ grouped.push(field);
406
+ open = field;
407
+ continue;
408
+ }
409
+ if (open === undefined) {
410
+ grouped.push(field);
411
+ continue;
412
+ }
413
+ open.fields ??= [];
414
+ open.fields.push(field);
415
+ }
416
+ return grouped;
417
+ }
418
+
419
+ export function emitDeclarations(module: ApiModule, options?: EmitOptions): string {
420
+ const prefix = `${module.namespace}.`;
421
+
422
+ const constantFqns = new Set(module.constants.map((c) => c.name));
423
+ const knownConstantFqns = options?.knownConstantFqns;
424
+ const baseMapType = options?.mapType ?? defaultMapType;
425
+ const mapType = (token: string): string =>
426
+ constantFqns.has(token) || knownConstantFqns?.has(token)
427
+ ? brandType(token)
428
+ : baseMapType(token);
429
+
430
+ const constants = module.constants
431
+ .map((c) => prepareConstant(c, prefix))
432
+ .filter((entry): entry is PreparedConstant => entry !== null)
433
+ .sort((a, b) => a.name.localeCompare(b.name));
434
+
435
+ const variables = module.variables
436
+ .map((v) => prepareVariable(v, prefix))
437
+ .filter((entry): entry is PreparedVariable => entry !== null)
438
+ .sort((a, b) => a.name.localeCompare(b.name));
439
+
440
+ const functions = module.functions
441
+ .map((fn) => prepareFunction(fn, prefix))
442
+ .filter((entry): entry is PreparedFunction => entry !== null)
443
+ .sort((a, b) =>
444
+ a.name === b.name
445
+ ? a.original.parameters.length - b.original.parameters.length
446
+ : a.name.localeCompare(b.name),
447
+ );
448
+
449
+ const resolver = buildTableDocResolver(
450
+ module.functions.map((fn) => ({
451
+ name: fn.name,
452
+ slots: [...fn.parameters, ...fn.returnValues],
453
+ })),
454
+ );
455
+
456
+ const typedefs = module.typedefs
457
+ .filter((t) => TS_IDENTIFIER.test(t.name))
458
+ .sort((a, b) => a.name.localeCompare(b.name));
459
+
460
+ // A re-export alias (`export { _x as x }`) switches the ambient namespace out
461
+ // of its implicit-export mode, so once any alias is present every sibling
462
+ // declaration must carry an explicit `export` keyword to stay visible. The
463
+ // keyword is otherwise omitted to keep every alias-free module byte-identical.
464
+ const hasAliases =
465
+ variables.some((v) => TS_RESERVED_NAMES.has(v.name)) ||
466
+ functions.some((fn) => TS_RESERVED_NAMES.has(fn.name));
467
+ const decl = hasAliases ? "export " : "";
468
+
469
+ const lines: string[] = [];
470
+ lines.push(`declare namespace ${module.namespace} {`);
471
+
472
+ for (const t of typedefs) {
473
+ lines.push(`${INDENT}${decl}type ${t.name} = Opaque<"${t.name}">;`);
474
+ }
475
+ for (const c of constants) {
476
+ lines.push(`${INDENT}${decl}const ${c.name}: ${brandType(c.fqn)};`);
477
+ }
478
+ const aliases: { internal: string; public: string }[] = [];
479
+ for (const v of variables) {
480
+ // A reserved-name member's `_`-prefixed declaration stays un-exported so it
481
+ // is local-only (not reachable as `ns._x`); the alias below re-exports it
482
+ // under the public reserved name.
483
+ const reserved = TS_RESERVED_NAMES.has(v.name);
484
+ const emitName = aliasName(v.name, aliases);
485
+ const line = emitVariable(v, emitName, mapType);
486
+ if (line !== null) lines.push(`${INDENT}${reserved ? "" : decl}${line}`);
487
+ }
488
+ for (const fn of functions) {
489
+ const reserved = TS_RESERVED_NAMES.has(fn.name);
490
+ const emitName = aliasName(fn.name, aliases);
491
+ const line = emitFunction(fn, emitName, mapType, resolver);
492
+ lines.push(`${INDENT}${reserved ? "" : decl}${line}`);
493
+ }
494
+
495
+ for (const alias of [...aliases].sort((a, b) => a.public.localeCompare(b.public))) {
496
+ lines.push(`${INDENT}export { ${alias.internal} as ${alias.public} };`);
497
+ }
498
+
499
+ if (module.properties.length > 0) {
500
+ const members = [...module.properties].sort((a, b) => a.name.localeCompare(b.name));
501
+ lines.push(`${INDENT}${decl}interface properties {`);
502
+ for (const p of members) {
503
+ lines.push(`${INDENT}${INDENT}${emitPropertyMember(p, mapType)}`);
504
+ }
505
+ lines.push(`${INDENT}}`);
506
+ }
507
+
508
+ lines.push("}");
509
+ return `${lines.join("\n")}\n`;
510
+ }
511
+
512
+ interface PreparedConstant {
513
+ name: string;
514
+ fqn: string;
515
+ }
516
+
517
+ interface PreparedFunction {
518
+ name: string;
519
+ original: ApiFunction;
520
+ }
521
+
522
+ interface PreparedVariable {
523
+ name: string;
524
+ original: ApiVariable;
525
+ }
526
+
527
+ function prepareFunction(fn: ApiFunction, prefix: string): PreparedFunction | null {
528
+ const stripped = stripPrefix(fn.name, prefix);
529
+ if (!TS_IDENTIFIER.test(stripped)) return null;
530
+ return { name: stripped, original: fn };
531
+ }
532
+
533
+ function prepareConstant(c: ApiConstant, prefix: string): PreparedConstant | null {
534
+ const stripped = stripPrefix(c.name, prefix);
535
+ if (!TS_IDENTIFIER.test(stripped)) return null;
536
+ return { name: stripped, fqn: c.name };
537
+ }
538
+
539
+ function brandType(fqn: string): string {
540
+ return `number & { readonly __brand: "${fqn}" }`;
541
+ }
542
+
543
+ function prepareVariable(v: ApiVariable, prefix: string): PreparedVariable | null {
544
+ const stripped = stripPrefix(v.name, prefix);
545
+ if (!TS_IDENTIFIER.test(stripped)) return null;
546
+ return { name: stripped, original: v };
547
+ }
548
+
549
+ function stripPrefix(name: string, prefix: string): string {
550
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
551
+ }
552
+
553
+ // When a prepared member's name is a TS reserved word it cannot be emitted
554
+ // directly, so it is declared under an `_`-prefixed internal name and the
555
+ // `{ internal, public }` pair is recorded for a trailing `export { _x as x }`
556
+ // alias. Non-reserved names pass through unchanged.
557
+ function aliasName(name: string, aliases: { internal: string; public: string }[]): string {
558
+ if (!TS_RESERVED_NAMES.has(name)) return name;
559
+ const internal = `_${name}`;
560
+ aliases.push({ internal, public: name });
561
+ return internal;
562
+ }
563
+
564
+ function emitVariable(
565
+ prepared: PreparedVariable,
566
+ name: string,
567
+ mapType: (t: string) => string,
568
+ ): string {
569
+ const ts =
570
+ prepared.original.types.length > 0
571
+ ? unionFromTokens(prepared.original.types, mapType)
572
+ : "unknown";
573
+ return `const ${name}: ${ts};`;
574
+ }
575
+
576
+ function emitFunction(
577
+ prepared: PreparedFunction,
578
+ name: string,
579
+ mapType: (t: string) => string,
580
+ resolver: TableDocResolver,
581
+ ): string {
582
+ const original = prepared.original.parameters;
583
+ const elementName = prepared.original.name;
584
+ const cutoff = trailingOptionalCutoff(original);
585
+ const params = original
586
+ .map((p, i) => emitParameter(p, i, i >= cutoff, mapType, resolver, elementName))
587
+ .join(", ");
588
+ const ret = emitReturn(prepared.original.returnValues, mapType, resolver, elementName);
589
+ return `function ${name}(${params}): ${ret.type};${ret.trailing}`;
590
+ }
591
+
592
+ function isDocOptional(p: ApiParameter): boolean {
593
+ return p.isOptional || p.types.includes("nil");
594
+ }
595
+
596
+ function trailingOptionalCutoff(params: readonly ApiParameter[]): number {
597
+ let cutoff = params.length;
598
+ for (let i = params.length - 1; i >= 0; i -= 1) {
599
+ const p = params[i];
600
+ if (p && isDocOptional(p)) cutoff = i;
601
+ else break;
602
+ }
603
+ return cutoff;
604
+ }
605
+
606
+ function emitParameter(
607
+ p: ApiParameter,
608
+ index: number,
609
+ optional: boolean,
610
+ mapType: (t: string) => string,
611
+ resolver: TableDocResolver,
612
+ elementName: string,
613
+ ): string {
614
+ const name = TS_IDENTIFIER.test(p.name) ? p.name : `arg${index}`;
615
+ const concrete = p.types.filter((t) => t !== "nil");
616
+ const ts =
617
+ concrete.length > 0
618
+ ? mapSlotUnion(concrete, p.doc, mapType, true, resolver, elementName)
619
+ : "unknown";
620
+ return `${name}${optional ? "?" : ""}: ${ts}`;
621
+ }
622
+
623
+ function emitReturn(
624
+ returnValues: ApiParameter[],
625
+ mapType: (t: string) => string,
626
+ resolver: TableDocResolver,
627
+ elementName: string,
628
+ ): { type: string; trailing: string } {
629
+ if (returnValues.length === 0) return { type: "void", trailing: "" };
630
+ if (returnValues.length > 1) {
631
+ // Defold multi-returns are positional and always present; each slot maps
632
+ // straight through (unknown when the doc lists no type) into a tuple that
633
+ // typescript-to-lua erases to `local a, b = fn()`.
634
+ const slots = returnValues.map((rv) =>
635
+ rv.types.length > 0
636
+ ? mapSlotUnion(rv.types, rv.doc, mapType, false, resolver, elementName)
637
+ : "unknown",
638
+ );
639
+ return { type: `LuaMultiReturn<[${slots.join(", ")}]>`, trailing: "" };
640
+ }
641
+ const first = returnValues[0];
642
+ if (!first) return { type: "void", trailing: "" };
643
+ const ts =
644
+ first.types.length > 0
645
+ ? mapSlotUnion(first.types, first.doc, mapType, false, resolver, elementName)
646
+ : "unknown";
647
+ return { type: ts, trailing: "" };
648
+ }
649
+
650
+ function emitPropertyMember(p: ApiProperty, mapType: (t: string) => string): string {
651
+ const key = TS_IDENTIFIER.test(p.name) ? p.name : JSON.stringify(p.name);
652
+ const ts = p.types.length > 0 ? unionFromTokens(p.types, mapType) : "unknown";
653
+ return `${key}: ${ts};`;
654
+ }
655
+
656
+ // Like `unionFromTokens`, but a `table` token whose slot doc carries a parseable
657
+ // `<dl>` field list emits an inline object type instead of the opaque `Record`
658
+ // fallback. Other tokens in the union map unchanged. `optionalFields` marks the
659
+ // recovered fields `?` for input params — a `<dl>` does not encode per-field
660
+ // optionality and an input option-bag's fields are individually omittable (the
661
+ // old `Record` accepted partial/empty objects), so a parameter's fields are
662
+ // optional while a return's fields (engine-populated, all present) stay required.
663
+ function mapSlotUnion(
664
+ types: readonly string[],
665
+ doc: string,
666
+ mapType: (t: string) => string,
667
+ optionalFields: boolean,
668
+ resolver: TableDocResolver,
669
+ elementName: string,
670
+ ): string {
671
+ const mapped: string[] = [];
672
+ const seen = new Set<string>();
673
+ for (const token of types) {
674
+ let ts: string;
675
+ if (token === "table") {
676
+ const mapping = MAPPING_TABLE_SLOTS.get(elementName);
677
+ const element = HOMOGENEOUS_ARRAY_SLOTS.get(elementName);
678
+ if (mapping !== undefined) {
679
+ ts = `LuaMap<${mapType(mapping.key)}, ${mapType(mapping.value)}>`;
680
+ } else if (element !== undefined) {
681
+ const tokens = typeof element === "string" ? [element] : element;
682
+ ts =
683
+ tokens.length > 1
684
+ ? `(${unionFromTokens(tokens, mapType)})[]`
685
+ : `${mapType(tokens[0] as string)}[]`;
686
+ } else {
687
+ const fields = parseTableFields(doc, resolver);
688
+ if (fields !== null) {
689
+ const object = inlineTableType(fields, mapType, optionalFields);
690
+ ts = isSlotLevelList(doc) ? `${object}[]` : object;
691
+ } else {
692
+ ts = mapType(token);
693
+ }
694
+ }
695
+ } else {
696
+ ts = mapType(token);
697
+ }
698
+ if (seen.has(ts)) continue;
699
+ seen.add(ts);
700
+ mapped.push(ts);
701
+ }
702
+ return mapped.join(" | ");
703
+ }
704
+
705
+ export function inlineTableType(
706
+ fields: readonly TableField[],
707
+ mapType: (t: string) => string,
708
+ optionalFields: boolean,
709
+ ): string {
710
+ const members = fields.map((field) => {
711
+ const key = TS_IDENTIFIER.test(field.name) ? field.name : JSON.stringify(field.name);
712
+ // A field carrying recovered nested fields (the mixed `<dl>`+`<ul>` shape)
713
+ // emits a one-level-nested inline object; every other field token maps
714
+ // through the same machinery as a top-level slot. Deeper nesting is not
715
+ // recovered — a nested `table` field with no nested fields stays `Record`.
716
+ // A field whose doc reads "a list of …" emits an array of that recovered
717
+ // element shape, but only when members were recovered — a bare `Record` with
718
+ // no machine-readable element type is left unwrapped (no stray `[]`). A
719
+ // number-list field carries no member shape but a machine-readable numeric
720
+ // element type ("in the form {px0, …}"), so it emits `number[]`.
721
+ const ts =
722
+ field.fields !== undefined
723
+ ? `${inlineTableType(field.fields, mapType, optionalFields)}${field.isList ? "[]" : ""}`
724
+ : field.numberList === true
725
+ ? "number[]"
726
+ : field.types.length > 0
727
+ ? unionFromTokens(field.types, mapType)
728
+ : "unknown";
729
+ return `${key}${optionalFields ? "?" : ""}: ${ts}`;
730
+ });
731
+ return `{ ${members.join("; ")} }`;
732
+ }
733
+
734
+ function unionFromTokens(tokens: readonly string[], mapType: (t: string) => string): string {
735
+ const mapped: string[] = [];
736
+ const seen = new Set<string>();
737
+ for (const token of tokens) {
738
+ const ts = mapType(token);
739
+ if (seen.has(ts)) continue;
740
+ seen.add(ts);
741
+ mapped.push(ts);
742
+ }
743
+ return mapped.join(" | ");
744
+ }
745
+
746
+ function defaultMapType(token: string): string {
747
+ if (Object.hasOwn(DEFOLD_TYPE_MAP, token)) {
748
+ const mapped = DEFOLD_TYPE_MAP[token];
749
+ if (typeof mapped === "string") return mapped;
750
+ }
751
+ const callback = recoverCallbackSignature(token);
752
+ if (callback !== null) return callback;
753
+ return "unknown";
754
+ }