@defold-typescript/library-types 0.20.8 → 0.21.1

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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Renders a `LibraryModel` (from `parse-luals.ts`) into a committed `.d.ts`: a
3
+ * single `declare module '<moduleId>' { ... }` block of plain `interface`s, `type`
4
+ * aliases, and `export function`s, mapping every member type through the shipped
5
+ * `mapLualsType`. Pure and deterministic — output depends only on the model and the
6
+ * options — so the `--emit` CLI arm and its golden round-trip test agree
7
+ * byte-for-byte.
8
+ *
9
+ * A sibling to `@defold-typescript/types`' `emit-dts.ts`, not an extension of it:
10
+ * that emitter is `ApiModule`-shaped and Defold-specific, whereas `LibraryModel` is
11
+ * a different OOP shape. Only `renderDocComment`, `TS_RESERVED_NAMES`, and
12
+ * `TS_IDENTIFIER` are reused from the types surface.
13
+ *
14
+ * Renders interface/method generic parameters and `extends` clauses: each generic
15
+ * `name` is scoped into a child `MapContext` (an identity rename) so a bare `T`
16
+ * resolves to `T` instead of lowering to `unknown`, constraints and `extends`
17
+ * targets map through the existing rename map, and an `extends` clause is emitted
18
+ * only for parents that resolve to a declared interface.
19
+ */
20
+
21
+ import {
22
+ luaMultiReturn,
23
+ renderDocComment,
24
+ TS_IDENTIFIER,
25
+ TS_RESERVED_NAMES,
26
+ varargElementType,
27
+ } from "@defold-typescript/types";
28
+ import {
29
+ type MapContext,
30
+ mapLualsType,
31
+ matchSelfHookField,
32
+ scopeGenerics,
33
+ } from "./map-luals-types";
34
+ import type {
35
+ LibraryAlias,
36
+ LibraryGeneric,
37
+ LibraryInterface,
38
+ LibraryMethod,
39
+ LibraryModel,
40
+ LibraryParam,
41
+ } from "./parse-luals";
42
+
43
+ export interface EmitLibraryOptions {
44
+ moduleId: string;
45
+ typeRenames?: Record<string, string>;
46
+ }
47
+
48
+ const INDENT = "\t";
49
+
50
+ /** A model type name (dotted like `druid.button`) reduced to a legal TS identifier. */
51
+ export function sanitizeTypeName(name: string): string {
52
+ const cleaned = name.replace(/[^A-Za-z0-9_$]/g, "_");
53
+ return /^[A-Za-z_$]/.test(cleaned) ? cleaned : `_${cleaned}`;
54
+ }
55
+
56
+ /**
57
+ * An interface/type member key: bare when it is a plain identifier that is not a
58
+ * reserved word, otherwise a quoted string literal so a name like `default` or
59
+ * `new` stays an ordinary member instead of becoming a syntax error (or, for
60
+ * `new`, a construct signature).
61
+ */
62
+ function memberKey(name: string): string {
63
+ return TS_IDENTIFIER.test(name) && !TS_RESERVED_NAMES.has(name) ? name : JSON.stringify(name);
64
+ }
65
+
66
+ /** A parameter name coerced to a legal, non-reserved binding form. */
67
+ function safeParamName(name: string, index: number): string {
68
+ if (!TS_IDENTIFIER.test(name)) return `arg${index}`;
69
+ return TS_RESERVED_NAMES.has(name) ? `${name}_` : name;
70
+ }
71
+
72
+ export function mapTypes(types: readonly string[], ctx: MapContext): string {
73
+ if (types.length === 0) return "unknown";
74
+ return types.map((token) => mapLualsType(token, ctx).ts).join(" | ");
75
+ }
76
+
77
+ /** A generic parameter list `<A extends C, B>`, or `""` when there are none. */
78
+ export function renderGenericParams(generics: readonly LibraryGeneric[], ctx: MapContext): string {
79
+ if (generics.length === 0) return "";
80
+ const params = generics.map((generic) => {
81
+ if (generic.constraint === undefined || generic.constraint === "") return generic.name;
82
+ const mapped = mapTypes([generic.constraint], ctx);
83
+ // An undeclared constraint lowers to `unknown`; drop it (mirror `renderExtends`'s
84
+ // declared-only filter) rather than emit a vacuous `<T extends unknown>`.
85
+ return mapped === "unknown" ? generic.name : `${generic.name} extends ${mapped}`;
86
+ });
87
+ return `<${params.join(", ")}>`;
88
+ }
89
+
90
+ /** The TS return type for a self-hook field lowered to an optional method. */
91
+ function renderHookReturn(returnTokens: readonly string[], ctx: MapContext): string {
92
+ if (returnTokens.length === 0) return "void";
93
+ if (returnTokens.length === 1) return mapTypes([returnTokens[0] as string], ctx);
94
+ return luaMultiReturn(returnTokens.map((token) => mapTypes([token], ctx)));
95
+ }
96
+
97
+ /**
98
+ * An ` extends X, Y` clause built from `iface.extends` split on commas, keeping only
99
+ * parents that name a declared interface (so no `extends unknown` is ever emitted),
100
+ * mapped through the rename map; `""` when none survive.
101
+ */
102
+ function renderExtends(
103
+ iface: LibraryInterface,
104
+ ctx: MapContext,
105
+ interfaceNames: ReadonlySet<string>,
106
+ ): string {
107
+ if (!iface.extends) return "";
108
+ const parents = iface.extends
109
+ .split(",")
110
+ .map((name) => name.trim())
111
+ .filter((name) => interfaceNames.has(name))
112
+ .map((name) => mapTypes([name], ctx));
113
+ return parents.length > 0 ? ` extends ${parents.join(", ")}` : "";
114
+ }
115
+
116
+ function renderParams(params: readonly LibraryParam[], ctx: MapContext): string {
117
+ return params
118
+ .map((param, index) => {
119
+ const mapped = mapTypes(param.types, ctx);
120
+ if (param.isVararg) {
121
+ return `...args: ${varargElementType(mapped)}`;
122
+ }
123
+ const optional = param.isOptional ? "?" : "";
124
+ return `${safeParamName(param.name, index)}${optional}: ${mapped}`;
125
+ })
126
+ .join(", ");
127
+ }
128
+
129
+ function renderReturn(returns: readonly LibraryParam[], ctx: MapContext): string {
130
+ if (returns.length === 0) return "void";
131
+ if (returns.length === 1) return mapTypes((returns[0] as LibraryParam).types, ctx);
132
+ return luaMultiReturn(returns.map((ret) => mapTypes(ret.types, ctx)));
133
+ }
134
+
135
+ function pushDoc(lines: string[], summary: string, indent: string): void {
136
+ for (const line of renderDocComment({ summary })) lines.push(`${indent}${line}`);
137
+ }
138
+
139
+ function renderAlias(alias: LibraryAlias, ctx: MapContext): string[] {
140
+ const lines: string[] = [];
141
+ pushDoc(lines, alias.doc, INDENT);
142
+ lines.push(`${INDENT}type ${sanitizeTypeName(alias.name)} = ${mapTypes(alias.types, ctx)};`);
143
+ return lines;
144
+ }
145
+
146
+ function renderInterface(
147
+ iface: LibraryInterface,
148
+ ctx: MapContext,
149
+ interfaceNames: ReadonlySet<string>,
150
+ ): string[] {
151
+ const lines: string[] = [];
152
+ pushDoc(lines, iface.brief, INDENT);
153
+ const ifaceCtx = scopeGenerics(ctx, iface.generics);
154
+ const params = renderGenericParams(iface.generics, ifaceCtx);
155
+ const extendsClause = renderExtends(iface, ifaceCtx, interfaceNames);
156
+ lines.push(`${INDENT}interface ${sanitizeTypeName(iface.name)}${params}${extendsClause} {`);
157
+ const body = INDENT + INDENT;
158
+ for (const field of iface.fields) {
159
+ // A base's self-receiving lifecycle hook is emitted as a permissive optional
160
+ // method (`name?(...args: any[]): ret`) so a concrete subinterface's refined
161
+ // override stays assignable under `extends`; strict function-field variance
162
+ // would reject it.
163
+ const hookReturns = matchSelfHookField(field.types, iface.name);
164
+ if (hookReturns !== null) {
165
+ pushDoc(lines, field.doc, body);
166
+ lines.push(
167
+ `${body}${memberKey(field.name)}?(...args: any[]): ${renderHookReturn(hookReturns, ifaceCtx)};`,
168
+ );
169
+ continue;
170
+ }
171
+ const optional = field.isOptional ? "?" : "";
172
+ lines.push(`${body}${memberKey(field.name)}${optional}: ${mapTypes(field.types, ifaceCtx)};`);
173
+ }
174
+ for (const method of iface.methods) {
175
+ pushDoc(lines, method.brief, body);
176
+ const methodCtx = scopeGenerics(ifaceCtx, method.generics);
177
+ const methodParams = renderGenericParams(method.generics, methodCtx);
178
+ lines.push(
179
+ `${body}${memberKey(method.name)}${methodParams}(${renderParams(
180
+ method.params,
181
+ methodCtx,
182
+ )}): ${renderReturn(method.returns, methodCtx)};`,
183
+ );
184
+ }
185
+ lines.push(`${INDENT}}`);
186
+ return lines;
187
+ }
188
+
189
+ function renderModuleFunction(fn: LibraryMethod, ctx: MapContext): string[] {
190
+ const lines: string[] = [];
191
+ pushDoc(lines, fn.brief, INDENT);
192
+ const fnCtx = scopeGenerics(ctx, fn.generics);
193
+ const genericParams = renderGenericParams(fn.generics, fnCtx);
194
+ const params = renderParams(fn.params, fnCtx);
195
+ const signature = `${genericParams}(${params ? `this: void, ${params}` : "this: void"}): ${renderReturn(
196
+ fn.returns,
197
+ fnCtx,
198
+ )}`;
199
+ const isReserved = TS_RESERVED_NAMES.has(fn.name) || !TS_IDENTIFIER.test(fn.name);
200
+ if (isReserved) {
201
+ // A reserved call name (`new`, `delete`) is illegal as a `function` identifier,
202
+ // so it is declared under an internal alias and re-exported under its real name.
203
+ const internal = `${sanitizeTypeName(fn.name)}_`;
204
+ lines.push(`${INDENT}export function ${internal}${signature};`);
205
+ lines.push(`${INDENT}export { ${internal} as ${fn.name} };`);
206
+ } else {
207
+ lines.push(`${INDENT}export function ${fn.name}${signature};`);
208
+ }
209
+ return lines;
210
+ }
211
+
212
+ /**
213
+ * The `MapContext` a library model maps every type token through: each interface
214
+ * and alias name is a known reference plus a sanitized rename (dotted `druid.button`
215
+ * -> `druid_button`), layered over the caller's `typeRenames`. Shared by the emitter
216
+ * (declaration text) and the api-doc lowering so both resolve model references
217
+ * identically — the invariant that keeps `api-doc/<ns>.json` byte-equivalent to
218
+ * `generated/<ns>.d.ts`.
219
+ */
220
+ export function buildModelContext(
221
+ model: LibraryModel,
222
+ typeRenames?: Record<string, string>,
223
+ ): MapContext {
224
+ const declaredNames = [
225
+ ...model.interfaces.map((iface) => iface.name),
226
+ ...model.aliases.map((alias) => alias.name),
227
+ ];
228
+ const nameRenames: Record<string, string> = {};
229
+ for (const name of declaredNames) nameRenames[name] = sanitizeTypeName(name);
230
+ return {
231
+ knownNames: new Set(declaredNames),
232
+ typeRenames: { ...(typeRenames ?? {}), ...nameRenames },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Emit the `.d.ts` text for one library model. References to the model's own
238
+ * interfaces and aliases resolve to their sanitized declaration names via a rename
239
+ * map layered over the caller's `typeRenames`; unresolved references lower to
240
+ * `unknown` inside `mapLualsType` exactly as the fidelity report records them.
241
+ */
242
+ export function emitLibraryDeclarations(model: LibraryModel, opts: EmitLibraryOptions): string {
243
+ const ctx = buildModelContext(model, opts.typeRenames);
244
+
245
+ const interfaceNames = new Set(model.interfaces.map((iface) => iface.name));
246
+ const out: string[] = ["/** @noResolution */", `declare module '${opts.moduleId}' {`];
247
+ for (const alias of model.aliases) out.push(...renderAlias(alias, ctx));
248
+ for (const iface of model.interfaces) out.push(...renderInterface(iface, ctx, interfaceNames));
249
+ for (const fn of model.moduleFunctions) out.push(...renderModuleFunction(fn, ctx));
250
+ out.push("}");
251
+ return `${out.join("\n")}\n`;
252
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Lower a LuaLS `LibraryModel` (`parse-luals.ts`) to the `{ info, elements }`
3
+ * ref-doc JSON shape that `@defold-typescript/types`' `parseDefoldApiDoc`
4
+ * accepts, so the docs-site renders a LuaLS-sourced library through the exact
5
+ * `/api` path a ts-defold `.d.ts` takes. Mirrors `extract-api-doc.ts`: pure and
6
+ * node-free (model in, object out) so it is unit-testable and reused by the
7
+ * `--api-doc` orchestrator arm.
8
+ *
9
+ * We lower from the model rather than re-running `extractApiDoc` on the emitted
10
+ * `generated/<ns>.d.ts`: a druid-style library's surface is almost entirely
11
+ * `interface` declarations, and `extractApiDoc` only emits interfaces reachable
12
+ * from an emitted function/variable or an `export =`, so the emitted `.d.ts`
13
+ * yields near-empty elements. The model carries the interfaces explicitly.
14
+ */
15
+
16
+ import {
17
+ buildModelContext,
18
+ mapTypes,
19
+ renderGenericParams,
20
+ sanitizeTypeName,
21
+ } from "./emit-library-dts";
22
+ import { type MapContext, scopeGenerics } from "./map-luals-types";
23
+ import type { LibraryField, LibraryMethod, LibraryModel, LibraryParam } from "./parse-luals";
24
+
25
+ // A field with an explicit non-public visibility is internal surface; keep only
26
+ // fields with no visibility or an explicit `public`, mirroring how LuaLS hides
27
+ // `private`/`protected`/`package` members from a class's public shape.
28
+ function isPublicField(field: LibraryField): boolean {
29
+ return field.visibility === undefined || field.visibility === "public";
30
+ }
31
+
32
+ // Each type token is mapped independently (one mapped TS string per token) so the
33
+ // ref-doc `types` array stays token-per-slot the way engine ref-docs are shaped.
34
+ function mapTokens(tokens: readonly string[], ctx: MapContext): string[] {
35
+ return tokens.map((token) => mapTypes([token], ctx));
36
+ }
37
+
38
+ function parameterElement(param: LibraryParam, ctx: MapContext): Record<string, unknown> {
39
+ // A vararg's element type stays a plain mapped token here; the renderer arrayifies
40
+ // it (`...args: T[]`) from the `is_vararg` flag, keeping the JSON structurally honest.
41
+ return {
42
+ name: param.isVararg ? "...args" : param.name,
43
+ doc: param.doc,
44
+ types: mapTokens(param.types, ctx),
45
+ is_optional: param.isOptional ? "True" : "False",
46
+ is_vararg: param.isVararg ? "True" : "False",
47
+ };
48
+ }
49
+
50
+ function returnElement(ret: LibraryParam, ctx: MapContext): Record<string, unknown> {
51
+ return { name: "", doc: ret.doc, types: mapTokens(ret.types, ctx) };
52
+ }
53
+
54
+ function functionElement(method: LibraryMethod, ctx: MapContext): Record<string, unknown> {
55
+ const fnCtx = scopeGenerics(ctx, method.generics);
56
+ const generics = renderGenericParams(method.generics, fnCtx);
57
+ return {
58
+ type: "FUNCTION",
59
+ name: method.name,
60
+ brief: method.brief,
61
+ description: method.brief,
62
+ ...(generics !== "" ? { generics } : {}),
63
+ parameters: method.params.map((param) => parameterElement(param, fnCtx)),
64
+ returnvalues: method.returns.map((ret) => returnElement(ret, fnCtx)),
65
+ };
66
+ }
67
+
68
+ function propertyElement(field: LibraryField, ctx: MapContext): Record<string, unknown> {
69
+ return {
70
+ name: field.name,
71
+ brief: field.doc,
72
+ description: field.doc,
73
+ types: mapTokens(field.types, ctx),
74
+ };
75
+ }
76
+
77
+ export function lowerLibraryModel(
78
+ model: LibraryModel,
79
+ { namespace, typeRenames }: { namespace: string; typeRenames?: Record<string, string> },
80
+ ): unknown {
81
+ const ctx = buildModelContext(model, typeRenames);
82
+ const elements: Record<string, unknown>[] = [];
83
+
84
+ for (const fn of model.moduleFunctions) {
85
+ elements.push(functionElement(fn, ctx));
86
+ }
87
+
88
+ for (const iface of model.interfaces) {
89
+ const ifaceCtx = scopeGenerics(ctx, iface.generics);
90
+ const functions = iface.methods.map((method) => functionElement(method, ifaceCtx));
91
+ const properties = iface.fields
92
+ .filter(isPublicField)
93
+ .map((field) => propertyElement(field, ifaceCtx));
94
+ elements.push({
95
+ type: "TYPEDEF",
96
+ name: sanitizeTypeName(iface.name),
97
+ ...(functions.length > 0 ? { functions } : {}),
98
+ ...(properties.length > 0 ? { properties } : {}),
99
+ });
100
+ }
101
+
102
+ for (const alias of model.aliases) {
103
+ elements.push({ type: "TYPEDEF", name: sanitizeTypeName(alias.name) });
104
+ }
105
+
106
+ // The module's own `@class` (named for the namespace, e.g. `@class druid`)
107
+ // carries the library's summary; use it as the page description so a
108
+ // LuaLS-sourced library reads with an intro like every other `/api` page,
109
+ // rather than opening on a bare provenance block. `brief` is its first line.
110
+ const moduleClass = model.interfaces.find((iface) => iface.name === namespace);
111
+ const description = moduleClass?.brief ?? "";
112
+ const brief = description.split("\n")[0] ?? "";
113
+
114
+ return { info: { namespace, brief, description }, elements };
115
+ }
@@ -10,8 +10,8 @@
10
10
  * sorted-unique token list so the gap is visible instead of silent.
11
11
  */
12
12
 
13
- import { type MapContext, mapLualsType } from "./map-luals-types";
14
- import type { LibraryModel } from "./parse-luals";
13
+ import { type MapContext, mapLualsType, scopeGenerics } from "./map-luals-types";
14
+ import type { LibraryGeneric, LibraryModel } from "./parse-luals";
15
15
 
16
16
  export interface FidelityReport {
17
17
  namespace: string;
@@ -51,10 +51,10 @@ export function buildFidelityReport(
51
51
  let undocumentedMembers = 0;
52
52
  const unknownTokens = new Set<string>();
53
53
 
54
- const mapTokens = (tokens: string[]): void => {
54
+ const mapTokens = (tokens: string[], mapCtx: MapContext): void => {
55
55
  for (const token of tokens) {
56
56
  totalTypeTokens++;
57
- const { unknowns } = mapLualsType(token, ctx);
57
+ const { unknowns } = mapLualsType(token, mapCtx);
58
58
  unknownFallbacks += unknowns.length;
59
59
  for (const u of unknowns) unknownTokens.add(u);
60
60
  }
@@ -62,28 +62,49 @@ export function buildFidelityReport(
62
62
 
63
63
  const undocumented = (doc: string): boolean => doc.trim() === "";
64
64
 
65
+ const mapConstraints = (generics: readonly LibraryGeneric[], mapCtx: MapContext): void => {
66
+ for (const generic of generics) {
67
+ if (generic.constraint) mapTokens([generic.constraint], mapCtx);
68
+ }
69
+ };
70
+
65
71
  for (const iface of model.interfaces) {
72
+ const ifaceCtx = scopeGenerics(ctx, iface.generics);
73
+ if (iface.extends) {
74
+ mapTokens(
75
+ iface.extends
76
+ .split(",")
77
+ .map((parent) => parent.trim())
78
+ .filter((parent) => parent !== ""),
79
+ ifaceCtx,
80
+ );
81
+ }
82
+ mapConstraints(iface.generics, ifaceCtx);
66
83
  for (const field of iface.fields) {
67
84
  totalMembers++;
68
85
  if (undocumented(field.doc)) undocumentedMembers++;
69
- mapTokens(field.types);
86
+ mapTokens(field.types, ifaceCtx);
70
87
  }
71
88
  for (const method of iface.methods) {
72
89
  totalMembers++;
73
90
  if (undocumented(method.brief)) undocumentedMembers++;
74
- for (const param of method.params) mapTokens(param.types);
75
- for (const ret of method.returns) mapTokens(ret.types);
91
+ const methodCtx = scopeGenerics(ifaceCtx, method.generics);
92
+ mapConstraints(method.generics, methodCtx);
93
+ for (const param of method.params) mapTokens(param.types, methodCtx);
94
+ for (const ret of method.returns) mapTokens(ret.types, methodCtx);
76
95
  }
77
96
  }
78
97
 
79
98
  for (const fn of model.moduleFunctions) {
80
99
  totalMembers++;
81
100
  if (undocumented(fn.brief)) undocumentedMembers++;
82
- for (const param of fn.params) mapTokens(param.types);
83
- for (const ret of fn.returns) mapTokens(ret.types);
101
+ const fnCtx = scopeGenerics(ctx, fn.generics);
102
+ mapConstraints(fn.generics, fnCtx);
103
+ for (const param of fn.params) mapTokens(param.types, fnCtx);
104
+ for (const ret of fn.returns) mapTokens(ret.types, fnCtx);
84
105
  }
85
106
 
86
- for (const alias of model.aliases) mapTokens(alias.types);
107
+ for (const alias of model.aliases) mapTokens(alias.types, ctx);
87
108
 
88
109
  const coverage =
89
110
  totalTypeTokens === 0
@@ -25,6 +25,24 @@ export interface MapResult {
25
25
  unknowns: string[];
26
26
  }
27
27
 
28
+ /**
29
+ * A child `MapContext` with each generic parameter `name` added as an identity
30
+ * rename and a known name, so a bare `T` maps to `T` instead of lowering to
31
+ * `unknown`. Returns the same ctx when there are no generics. Shared by the emitter
32
+ * (declaration text) and the fidelity report (coverage) so both scope generics
33
+ * identically.
34
+ */
35
+ export function scopeGenerics(ctx: MapContext, generics: readonly { name: string }[]): MapContext {
36
+ if (generics.length === 0) return ctx;
37
+ const knownNames = new Set(ctx.knownNames);
38
+ const typeRenames = { ...ctx.typeRenames };
39
+ for (const generic of generics) {
40
+ knownNames.add(generic.name);
41
+ typeRenames[generic.name] = generic.name;
42
+ }
43
+ return { knownNames, typeRenames };
44
+ }
45
+
28
46
  const SCALARS: Readonly<Record<string, string>> = {
29
47
  integer: "number",
30
48
  number: "number",
@@ -256,3 +274,50 @@ export function mapLualsType(token: string, ctx: MapContext): MapResult {
256
274
  const ts = mapToken(token, ctx, unknowns);
257
275
  return { ts, unknowns };
258
276
  }
277
+
278
+ /**
279
+ * When `types` is exactly one `fun(self: <selfTypeName>, ...)` token — optionally
280
+ * unioned with `nil` — whose first parameter is `self` typed as the enclosing
281
+ * interface's own model name, return the function's raw return tokens (an empty
282
+ * array for a `void`/no-return hook). Returns `null` for every other shape: a data
283
+ * field, a non-`fun` type, an untyped `self`, or a `self` typed as a *different*
284
+ * interface. Reuses the same bracket-aware split/match as the mapper so nested
285
+ * commas and colons inside a param type never mis-split.
286
+ *
287
+ * The `fun` is isolated through its params `)` before the return is read, so a
288
+ * return union or nullable return (which also sits at bracket depth 0, after the
289
+ * `)`) survives instead of being mis-split as an outer `|nil` — the same
290
+ * ordering rule as the mapper's `fun(...)` return-union handling.
291
+ */
292
+ export function matchSelfHookField(
293
+ types: readonly string[],
294
+ selfTypeName: string,
295
+ ): string[] | null {
296
+ if (types.length !== 1) return null;
297
+ const raw = (types[0] as string).trim();
298
+ const members = splitTopLevel(raw, "|").map((member) => member.trim());
299
+ const funIndex = members.findIndex((member) => /^fun\s*\(/.test(member));
300
+ if (funIndex === -1) return null;
301
+ // Anything unioned before the fun may only be a bare outer nullable.
302
+ if (members.slice(0, funIndex).some((member) => member !== "nil")) return null;
303
+ // Rejoin from the fun rightward so a return union or nullable stays whole.
304
+ const fun = members.slice(funIndex).join("|");
305
+ const open = fun.indexOf("(");
306
+ const close = matchBracket(fun, open);
307
+ if (close === -1) return null;
308
+ const paramsStr = fun.slice(open + 1, close).trim();
309
+ const params = paramsStr === "" ? [] : splitTopLevel(paramsStr, ",");
310
+ const first = (params[0]?.trim() ?? "").length > 0 ? splitTopLevel(params[0] as string, ":") : [];
311
+ if (first.length < 2) return null;
312
+ if ((first[0] as string).trim() !== "self") return null;
313
+ if (first.slice(1).join(":").trim() !== selfTypeName) return null;
314
+ const afterClose = fun.slice(close + 1).trim();
315
+ if (afterClose === "") return [];
316
+ if (afterClose.startsWith(":")) {
317
+ const retStr = afterClose.slice(1).trim();
318
+ return retStr === "" ? [] : splitTopLevel(retStr, ",").map((token) => token.trim());
319
+ }
320
+ // Anything else after the params `)` must be an outer `| nil` (whole hook optional).
321
+ const outer = splitTopLevel(afterClose, "|").map((member) => member.trim());
322
+ return outer.every((member) => member === "" || member === "nil") ? [] : null;
323
+ }
@@ -76,17 +76,27 @@ const emptyPending = (): Pending => ({ doc: [], params: [], returns: [], generic
76
76
  /**
77
77
  * Read a single raw type token from the head of `rest`, honoring bracket depth so
78
78
  * an inner space (`table<string, any>`, `fun(a, b): c`) does not end the token. The
79
- * token ends at the first top-level whitespace. Returns the token and the trailing
80
- * remainder (the human description). Never rewrites the token toward TS.
79
+ * token ends at the first top-level whitespace, except that a space right after a
80
+ * top-level `:` or `,` continues the token so a spaced `fun(text_id: string):
81
+ * string` return arrow and a multi-return `fun(): number, string` separator are kept
82
+ * whole rather than truncated at the `):`/`,`. Returns the token and the trailing
83
+ * remainder (the human description). Never rewrites the token toward TS. The only
84
+ * inputs carrying a top-level `:`/`,` are `fun(...)` type expressions, so plain
85
+ * types, unions, and descriptions are unaffected.
81
86
  */
82
87
  function readTypeToken(rest: string): { type: string; rest: string } {
83
88
  let depth = 0;
89
+ let lastNonSpace = "";
84
90
  let i = 0;
85
91
  for (; i < rest.length; i++) {
86
92
  const c = rest[i];
87
93
  if (c === "<" || c === "(" || c === "[" || c === "{") depth++;
88
94
  else if (c === ">" || c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1);
89
- else if ((c === " " || c === "\t") && depth === 0) break;
95
+ else if ((c === " " || c === "\t") && depth === 0) {
96
+ if (lastNonSpace !== ":" && lastNonSpace !== ",") break;
97
+ continue;
98
+ }
99
+ if (c !== undefined && c !== " " && c !== "\t") lastNonSpace = c;
90
100
  }
91
101
  return { type: rest.slice(0, i), rest: rest.slice(i).trim() };
92
102
  }
@@ -183,15 +193,34 @@ interface FunctionDecl {
183
193
  kind: "method" | "module";
184
194
  receiver?: string;
185
195
  name: string;
196
+ // A dotted module form (`function T.name`, `T.name = function`) is public module
197
+ // surface; a bare/`local` form (`function name`, `local function name`) is not.
198
+ qualified: boolean;
186
199
  }
187
200
 
188
- const FUNCTION_FORMS: { re: RegExp; kind: "method" | "module"; recv?: number; name: number }[] = [
201
+ const FUNCTION_FORMS: {
202
+ re: RegExp;
203
+ kind: "method" | "module";
204
+ recv?: number;
205
+ name: number;
206
+ qualified?: boolean;
207
+ }[] = [
189
208
  { re: /^function\s+([A-Za-z_][\w.]*):([A-Za-z_]\w*)\s*\(/, kind: "method", recv: 1, name: 2 },
190
- { re: /^function\s+([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*\(/, kind: "module", name: 2 },
191
- { re: /^(?:local\s+)?function\s+([A-Za-z_]\w*)\s*\(/, kind: "module", name: 1 },
209
+ {
210
+ re: /^function\s+([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*\(/,
211
+ kind: "module",
212
+ name: 2,
213
+ qualified: true,
214
+ },
215
+ { re: /^(?:local\s+)?function\s+([A-Za-z_]\w*)\s*\(/, kind: "module", name: 1, qualified: false },
192
216
  { re: /^([A-Za-z_][\w.]*):([A-Za-z_]\w*)\s*=\s*function\b/, kind: "method", recv: 1, name: 2 },
193
- { re: /^([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*=\s*function\b/, kind: "module", name: 2 },
194
- { re: /^([A-Za-z_]\w*)\s*=\s*function\b/, kind: "module", name: 1 },
217
+ {
218
+ re: /^([A-Za-z_][\w.]*)\.([A-Za-z_]\w*)\s*=\s*function\b/,
219
+ kind: "module",
220
+ name: 2,
221
+ qualified: true,
222
+ },
223
+ { re: /^([A-Za-z_]\w*)\s*=\s*function\b/, kind: "module", name: 1, qualified: false },
195
224
  ];
196
225
 
197
226
  function parseFunctionDecl(line: string): FunctionDecl | null {
@@ -201,9 +230,9 @@ function parseFunctionDecl(line: string): FunctionDecl | null {
201
230
  const name = m[form.name] ?? "";
202
231
  if (form.kind === "method") {
203
232
  const receiver = form.recv ? m[form.recv] : undefined;
204
- return { kind: "method", name, ...(receiver ? { receiver } : {}) };
233
+ return { kind: "method", name, qualified: true, ...(receiver ? { receiver } : {}) };
205
234
  }
206
- return { kind: "module", name };
235
+ return { kind: "module", name, qualified: form.qualified ?? false };
207
236
  }
208
237
  return null;
209
238
  }
@@ -216,6 +245,10 @@ const LOCAL_ASSIGN = /^local\s+([A-Za-z_]\w*)\s*=/;
216
245
  * indented lines — in-body closures, `---@cast`/`---@type` narrowing — are opaque,
217
246
  * so they neither create declarations nor pollute the pending block. Output order
218
247
  * follows source order, making the result stable across repeated runs.
248
+ *
249
+ * Only dotted module forms (`function T.name`, `T.name = function`) count as module
250
+ * surface; a bare or `local function` is a private helper and is skipped. Limitation:
251
+ * a bare function later re-exported via `M.x = helper` is not recovered as surface.
219
252
  */
220
253
  export function parseLualsSource(source: string): LibraryModel {
221
254
  const interfaces: LibraryInterface[] = [];
@@ -318,7 +351,7 @@ export function parseLualsSource(source: string): LibraryModel {
318
351
  if (decl.kind === "method") {
319
352
  const target = decl.receiver ? (receiverBinding.get(decl.receiver) ?? decl.receiver) : "";
320
353
  ensureInterface(target).methods.push(methodFromPending(decl.name));
321
- } else {
354
+ } else if (decl.qualified) {
322
355
  moduleFunctions.push(methodFromPending(decl.name));
323
356
  }
324
357
  pending = emptyPending();
@@ -379,5 +412,21 @@ export function mergeLibraryModels(models: LibraryModel[]): LibraryModel {
379
412
  moduleFunctions.push(...model.moduleFunctions);
380
413
  }
381
414
 
415
+ // A class split across fixtures (e.g. druid's curated + runtime `druid.logger`
416
+ // blocks) concatenates both field sets above, so the same field name can appear
417
+ // twice with conflicting signatures — an invalid declaration masked by
418
+ // `skipLibCheck`. Collapse to the first occurrence; methods stay untouched so
419
+ // overloaded module functions keep every signature.
420
+ for (const iface of interfaces) iface.fields = dedupeByName(iface.fields);
421
+
382
422
  return { interfaces, aliases, moduleFunctions };
383
423
  }
424
+
425
+ function dedupeByName<T extends { name: string }>(items: T[]): T[] {
426
+ const seen = new Set<string>();
427
+ return items.filter((item) => {
428
+ if (seen.has(item.name)) return false;
429
+ seen.add(item.name);
430
+ return true;
431
+ });
432
+ }