@defold-typescript/library-types 0.20.8 → 0.21.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/api-doc/decore.json +1883 -0
- package/api-doc/druid.json +11818 -0
- package/generated/decore.d.ts +228 -0
- package/generated/druid.d.ts +2067 -0
- package/luals-targets.json +28 -0
- package/package.json +6 -3
- package/scripts/__snapshots__/emit-library-dts.test.ts.snap +22 -0
- package/scripts/__snapshots__/parse-luals.test.ts.snap +127 -1006
- package/scripts/emit-library-dts.ts +252 -0
- package/scripts/lower-api-doc.ts +115 -0
- package/scripts/luals-fidelity.ts +31 -10
- package/scripts/map-luals-types.ts +65 -0
- package/scripts/parse-luals.ts +60 -11
- package/scripts/sync-luals-types.ts +70 -7
|
@@ -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,
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
+
}
|
package/scripts/parse-luals.ts
CHANGED
|
@@ -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
|
|
80
|
-
*
|
|
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)
|
|
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: {
|
|
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
|
-
{
|
|
191
|
-
|
|
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
|
-
{
|
|
194
|
-
|
|
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
|
+
}
|