@tscomfy/codegen 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -2
- package/dist/doc.d.ts +78 -0
- package/dist/doc.d.ts.map +1 -0
- package/dist/doc.js +335 -0
- package/dist/doc.js.map +1 -0
- package/dist/emit-schema-module.d.ts +11 -0
- package/dist/emit-schema-module.d.ts.map +1 -0
- package/dist/emit-schema-module.js +39 -0
- package/dist/emit-schema-module.js.map +1 -0
- package/dist/emit-types.d.ts +15 -0
- package/dist/emit-types.d.ts.map +1 -0
- package/dist/emit-types.js +718 -0
- package/dist/emit-types.js.map +1 -0
- package/dist/export-name.d.ts +16 -0
- package/dist/export-name.d.ts.map +1 -0
- package/dist/export-name.js +43 -0
- package/dist/export-name.js.map +1 -0
- package/dist/expressions.d.ts +51 -0
- package/dist/expressions.d.ts.map +1 -0
- package/dist/expressions.js +147 -0
- package/dist/expressions.js.map +1 -0
- package/dist/generate.d.ts +35 -0
- package/dist/generate.d.ts.map +1 -0
- package/dist/generate.js +30 -0
- package/dist/generate.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/names.d.ts +36 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +81 -0
- package/dist/names.js.map +1 -0
- package/dist/rules.d.ts +23 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +82 -0
- package/dist/rules.js.map +1 -0
- package/dist/transpile.d.ts +40 -0
- package/dist/transpile.d.ts.map +1 -0
- package/dist/transpile.js +531 -0
- package/dist/transpile.js.map +1 -0
- package/dist/typing.d.ts +18 -0
- package/dist/typing.d.ts.map +1 -0
- package/dist/typing.js +387 -0
- package/dist/typing.js.map +1 -0
- package/dist/values.d.ts +15 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +22 -0
- package/dist/values.js.map +1 -0
- package/package.json +46 -4
- package/src/doc.ts +390 -0
- package/src/emit-schema-module.ts +47 -0
- package/src/emit-types.ts +880 -0
- package/src/export-name.ts +51 -0
- package/src/expressions.ts +201 -0
- package/src/generate.ts +59 -0
- package/src/index.ts +22 -0
- package/src/names.ts +89 -0
- package/src/rules.ts +86 -0
- package/src/transpile.ts +639 -0
- package/src/typing.ts +456 -0
- package/src/values.ts +28 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
// Emits `nodes.ts`: the `Nodes` interface and every type it refers to (docs/plan.md §6).
|
|
2
|
+
//
|
|
3
|
+
// Layout rules keep every type expression a union of short atoms, so the printed code matches
|
|
4
|
+
// oxfmt: combo option lists, socket-type unions, dynamic-combo options, Autogrow name sets,
|
|
5
|
+
// DynamicGroup rows and multi-output results all get named declarations.
|
|
6
|
+
//
|
|
7
|
+
// Soundness (plan §1): every link type is a subset of what `accepts()` allows, and every value
|
|
8
|
+
// type a subset of what the backend's validation accepts. Where the two can't be matched
|
|
9
|
+
// exactly, the types are the stricter side.
|
|
10
|
+
import { createDiagnostic, namedOutputs, sendsInputValue, stringify, } from "@tscomfy/core";
|
|
11
|
+
import { assignment, group, hardline, ifBreak, indent, join, line, print, PRINT_WIDTH, softline, } from "./doc.js";
|
|
12
|
+
import { commentLines, commentText, headerLines, NameRegistry, pascal, propertyKey, quote, } from "./names.js";
|
|
13
|
+
import { forcedRowField, isObjectMember, isRequired } from "./rules.js";
|
|
14
|
+
/**
|
|
15
|
+
* Emits the types-only `nodes.ts` for a schema: one function type per node on the `Nodes`
|
|
16
|
+
* interface, with input interfaces and shared aliases. `header` lines become `//` comments at the
|
|
17
|
+
* top. Pure and deterministic.
|
|
18
|
+
*/
|
|
19
|
+
export function emitTypes(schema, header = []) {
|
|
20
|
+
return new TypesEmitter(schema).emit(header);
|
|
21
|
+
}
|
|
22
|
+
/** Names the generated file imports from @tscomfy/core. */
|
|
23
|
+
const CORE_TYPES = [
|
|
24
|
+
"Bounded",
|
|
25
|
+
"In",
|
|
26
|
+
"JsonValue",
|
|
27
|
+
"Match",
|
|
28
|
+
"NodeHandle",
|
|
29
|
+
"NodeOptions",
|
|
30
|
+
"OptionList",
|
|
31
|
+
"Out",
|
|
32
|
+
];
|
|
33
|
+
/** Names generated declarations must not take: imports, globals the output uses, `Nodes`. */
|
|
34
|
+
const RESERVED = [...CORE_TYPES, "NoInfer", "Nodes"];
|
|
35
|
+
const NO_TEMPLATES = new Set();
|
|
36
|
+
class TypesEmitter {
|
|
37
|
+
#schema;
|
|
38
|
+
/** Shared aliases and type parameters. Node-derived names contain `$`, so never clash. */
|
|
39
|
+
#names = new NameRegistry(RESERVED);
|
|
40
|
+
#bases = new NameRegistry();
|
|
41
|
+
/** Every type parameter name in use; shared aliases avoid them, since parameters shadow. */
|
|
42
|
+
#parameterNames = new Set();
|
|
43
|
+
#comboAliases = new Map();
|
|
44
|
+
#socketAliases = new Map();
|
|
45
|
+
#lengthAliases = new Map();
|
|
46
|
+
#shared = [];
|
|
47
|
+
#imports = new Set();
|
|
48
|
+
#diagnostics = [];
|
|
49
|
+
constructor(schema) {
|
|
50
|
+
this.#schema = schema;
|
|
51
|
+
}
|
|
52
|
+
emit(header) {
|
|
53
|
+
const members = [];
|
|
54
|
+
const declarations = [];
|
|
55
|
+
for (const node of Object.values(this.#schema.nodes)) {
|
|
56
|
+
const scope = this.#scope(node);
|
|
57
|
+
members.push(this.#nodeMember(scope));
|
|
58
|
+
declarations.push(...scope.declarations);
|
|
59
|
+
}
|
|
60
|
+
const nodes = [
|
|
61
|
+
"/** Every node of the schema, by class type. Use through a typed schema's builder. */",
|
|
62
|
+
hardline,
|
|
63
|
+
interfaceDoc("Nodes", [], [], members),
|
|
64
|
+
];
|
|
65
|
+
const shared = this.#shared
|
|
66
|
+
.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
67
|
+
.map((declaration) => declaration.doc);
|
|
68
|
+
const imports = CORE_TYPES.filter((name) => this.#imports.has(name));
|
|
69
|
+
const body = [
|
|
70
|
+
...(imports.length === 0
|
|
71
|
+
? []
|
|
72
|
+
: [
|
|
73
|
+
group([
|
|
74
|
+
"import type {",
|
|
75
|
+
indent([line, join([",", line], imports)]),
|
|
76
|
+
ifBreak(","),
|
|
77
|
+
line,
|
|
78
|
+
'} from "@tscomfy/core";',
|
|
79
|
+
]),
|
|
80
|
+
]),
|
|
81
|
+
nodes,
|
|
82
|
+
...declarations,
|
|
83
|
+
...shared,
|
|
84
|
+
];
|
|
85
|
+
const doc = [
|
|
86
|
+
...headerLines(header).flatMap((text) => [text, hardline]),
|
|
87
|
+
header.length > 0 ? hardline : "",
|
|
88
|
+
join([hardline, hardline], body),
|
|
89
|
+
hardline,
|
|
90
|
+
];
|
|
91
|
+
return { contents: print(doc, PRINT_WIDTH), diagnostics: this.#diagnostics };
|
|
92
|
+
}
|
|
93
|
+
#scope(node) {
|
|
94
|
+
const templates = new Map();
|
|
95
|
+
const parameters = new NameRegistry();
|
|
96
|
+
const collect = (input) => {
|
|
97
|
+
switch (input.kind) {
|
|
98
|
+
case "matchType":
|
|
99
|
+
if (!templates.has(input.templateId)) {
|
|
100
|
+
// Type parameters shadow top-level names, so they must not equal any of them.
|
|
101
|
+
let parameter = parameters.claim(`T${pascal(input.templateId, "Template")}`);
|
|
102
|
+
while (this.#names.has(parameter))
|
|
103
|
+
parameter = parameters.claim(parameter);
|
|
104
|
+
this.#parameterNames.add(parameter);
|
|
105
|
+
const constraint = this.#allowed(input.allowed, input.templateId);
|
|
106
|
+
templates.set(input.templateId, {
|
|
107
|
+
id: input.templateId,
|
|
108
|
+
parameter,
|
|
109
|
+
constraint,
|
|
110
|
+
fallback: constraint === "string" ? '"*"' : constraint,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
case "dynamicCombo":
|
|
115
|
+
for (const option of input.options)
|
|
116
|
+
option.inputs.forEach(collect);
|
|
117
|
+
break;
|
|
118
|
+
case "autogrow":
|
|
119
|
+
collect(input.template);
|
|
120
|
+
break;
|
|
121
|
+
case "dynamicGroup":
|
|
122
|
+
input.fields.forEach(collect);
|
|
123
|
+
break;
|
|
124
|
+
case "socket":
|
|
125
|
+
case "int":
|
|
126
|
+
case "float":
|
|
127
|
+
case "string":
|
|
128
|
+
case "boolean":
|
|
129
|
+
case "combo":
|
|
130
|
+
case "value":
|
|
131
|
+
case "unknown":
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
node.inputs.forEach(collect);
|
|
136
|
+
const base = this.#bases.claim(pascal(node.classType, "Node"));
|
|
137
|
+
return {
|
|
138
|
+
node,
|
|
139
|
+
base,
|
|
140
|
+
templates,
|
|
141
|
+
inferred: new Set(),
|
|
142
|
+
declarations: [],
|
|
143
|
+
names: new NameRegistry([`${base}$In`, `${base}$Out`, `${base}$Outputs`]),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/** The node's member of `Nodes`, after emitting its declarations into the scope. */
|
|
147
|
+
#nodeMember(scope) {
|
|
148
|
+
const { node, base } = scope;
|
|
149
|
+
this.#imports.add("NodeOptions");
|
|
150
|
+
const inputs = this.#inputsInterface(scope, `${base}$In`, base, node.inputs, [node.classType]);
|
|
151
|
+
const result = this.#result(scope);
|
|
152
|
+
const all = new Set([...inputs.templates, ...result.templates]);
|
|
153
|
+
const parameters = [...scope.templates.values()]
|
|
154
|
+
.filter((template) => all.has(template.id))
|
|
155
|
+
.map((template) => `${template.parameter} extends ${template.constraint} = ${template.fallback}`);
|
|
156
|
+
return [
|
|
157
|
+
docComment(nodeDocLines(node)),
|
|
158
|
+
group([
|
|
159
|
+
"readonly ",
|
|
160
|
+
propertyKey(node.classType),
|
|
161
|
+
": ",
|
|
162
|
+
typeParameters(parameters),
|
|
163
|
+
"(",
|
|
164
|
+
indent([
|
|
165
|
+
softline,
|
|
166
|
+
inputs.optional ? "inputs?: " : "inputs: ",
|
|
167
|
+
inputs.reference,
|
|
168
|
+
",",
|
|
169
|
+
line,
|
|
170
|
+
"options?: NodeOptions",
|
|
171
|
+
]),
|
|
172
|
+
ifBreak(","),
|
|
173
|
+
softline,
|
|
174
|
+
") => ",
|
|
175
|
+
result.reference,
|
|
176
|
+
";",
|
|
177
|
+
]),
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
/** The node's return type: a handle, one output, or a tuple with named members. */
|
|
181
|
+
#result(scope) {
|
|
182
|
+
const { outputs } = scope.node;
|
|
183
|
+
const built = outputs.map((output) => this.#outputAtom(scope, output));
|
|
184
|
+
const templates = new Set(built.flatMap((output) => [...output.templates]));
|
|
185
|
+
const [only] = built;
|
|
186
|
+
if (only === undefined) {
|
|
187
|
+
this.#imports.add("NodeHandle");
|
|
188
|
+
return { reference: "NodeHandle", templates };
|
|
189
|
+
}
|
|
190
|
+
if (built.length === 1)
|
|
191
|
+
return { reference: only.atom, templates };
|
|
192
|
+
this.#imports.add("NodeHandle");
|
|
193
|
+
const args = this.#arguments(scope, templates);
|
|
194
|
+
const parameters = this.#parameters(scope, templates);
|
|
195
|
+
const tupleName = `${scope.base}$Outputs`;
|
|
196
|
+
scope.declarations.push([
|
|
197
|
+
inlineComment(`The outputs of \`${scope.node.classType}\`, by slot.`),
|
|
198
|
+
hardline,
|
|
199
|
+
assignment(["export type ", tupleName, typeParameters(parameters)], [tuple(built.map((output) => output.atom)), ";"]),
|
|
200
|
+
]);
|
|
201
|
+
const named = [...namedOutputs(outputs)].map(([outputName, slot]) => [
|
|
202
|
+
"readonly ",
|
|
203
|
+
propertyKey(outputName),
|
|
204
|
+
": ",
|
|
205
|
+
built[slot]?.atom ?? "never",
|
|
206
|
+
";",
|
|
207
|
+
]);
|
|
208
|
+
const name = `${scope.base}$Out`;
|
|
209
|
+
scope.declarations.push([
|
|
210
|
+
inlineComment(`What \`${scope.node.classType}\` returns: its outputs by slot, and by name where unique.`),
|
|
211
|
+
hardline,
|
|
212
|
+
interfaceDoc(name, parameters, [reference(tupleName, args), "NodeHandle"], named),
|
|
213
|
+
]);
|
|
214
|
+
return { reference: reference(name, args), templates };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* What an output is typed as (§6.2). Whatever isn't a plain named type gets an atom that only
|
|
218
|
+
* connects where `accepts()` agrees for every input: option lists their own name, and outputs
|
|
219
|
+
* whose comma list contains `*` without being exactly `*` the stricter types.
|
|
220
|
+
*/
|
|
221
|
+
#outputAtom(scope, output) {
|
|
222
|
+
this.#imports.add("Out");
|
|
223
|
+
if (output.matchTemplate !== undefined) {
|
|
224
|
+
const template = scope.templates.get(output.matchTemplate);
|
|
225
|
+
if (template !== undefined) {
|
|
226
|
+
return { atom: `Out<${template.parameter}>`, templates: new Set([template.id]) };
|
|
227
|
+
}
|
|
228
|
+
return { atom: 'Out<"*">', templates: NO_TEMPLATES };
|
|
229
|
+
}
|
|
230
|
+
const type = output.type;
|
|
231
|
+
switch (type.kind) {
|
|
232
|
+
case "combo":
|
|
233
|
+
this.#imports.add("OptionList");
|
|
234
|
+
return { atom: "Out<OptionList>", templates: NO_TEMPLATES };
|
|
235
|
+
case "any":
|
|
236
|
+
return { atom: 'Out<"*">', templates: NO_TEMPLATES };
|
|
237
|
+
case "named":
|
|
238
|
+
// A padded `*` passes into string-typed inputs only, so it fits only `*` inputs here.
|
|
239
|
+
return {
|
|
240
|
+
atom: type.name === "*" ? "Out<string>" : `Out<${quote(type.name)}>`,
|
|
241
|
+
templates: NO_TEMPLATES,
|
|
242
|
+
};
|
|
243
|
+
case "union": {
|
|
244
|
+
// `*` in a list passes into string-typed inputs only; typed as the other members.
|
|
245
|
+
const names = type.names.filter((name) => name !== "*");
|
|
246
|
+
return { atom: `Out<${this.#socketNames(names, output.name)}>`, templates: NO_TEMPLATES };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* An interface of inputs, emitted into the scope. Declarations the inputs need are named
|
|
252
|
+
* `<prefix>$<Input>`. `optional` is true when every input may be left out, so the object
|
|
253
|
+
* itself may be too.
|
|
254
|
+
*/
|
|
255
|
+
#inputsInterface(scope, name, prefix, inputs, path, sendsSomething = false) {
|
|
256
|
+
const members = [];
|
|
257
|
+
const templates = new Set();
|
|
258
|
+
let optional = true;
|
|
259
|
+
const forced = sendsSomething ? forcedRowField(inputs) : undefined;
|
|
260
|
+
for (const input of inputs) {
|
|
261
|
+
// Inputs the frontend never sends a value for (display-only widgets) take none (§7.4).
|
|
262
|
+
if (!sendsInputValue(input))
|
|
263
|
+
continue;
|
|
264
|
+
const built = this.#inputType(scope, input, `${prefix}$${pascal(input.name, "Input")}`, [
|
|
265
|
+
...path,
|
|
266
|
+
input.name,
|
|
267
|
+
]);
|
|
268
|
+
for (const template of built.templates)
|
|
269
|
+
templates.add(template);
|
|
270
|
+
const required = isRequired(input) || input === forced;
|
|
271
|
+
if (required)
|
|
272
|
+
optional = false;
|
|
273
|
+
members.push([
|
|
274
|
+
docComment(inputDocLines(input)),
|
|
275
|
+
"readonly ",
|
|
276
|
+
propertyKey(input.name),
|
|
277
|
+
required ? ":" : "?:",
|
|
278
|
+
unionDoc(built.atoms),
|
|
279
|
+
";",
|
|
280
|
+
]);
|
|
281
|
+
}
|
|
282
|
+
if (members.length === 0) {
|
|
283
|
+
// No inputs: reject every key rather than accept anything, as `{}` would.
|
|
284
|
+
members.push("readonly [key: string]: never;");
|
|
285
|
+
}
|
|
286
|
+
const parameters = this.#parameters(scope, templates);
|
|
287
|
+
scope.declarations.push([
|
|
288
|
+
inlineComment(`Inputs of \`${path.join(" › ")}\`.`),
|
|
289
|
+
hardline,
|
|
290
|
+
interfaceDoc(name, parameters, [], members),
|
|
291
|
+
]);
|
|
292
|
+
return {
|
|
293
|
+
reference: reference(name, this.#arguments(scope, templates)),
|
|
294
|
+
optional,
|
|
295
|
+
templates,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The type of one input's value. `name` is the candidate name for declarations it needs,
|
|
300
|
+
* claimed only when one is emitted.
|
|
301
|
+
*/
|
|
302
|
+
#inputType(scope, input, name, path) {
|
|
303
|
+
switch (input.kind) {
|
|
304
|
+
case "socket":
|
|
305
|
+
return { atoms: this.#link(input.type, input.name), templates: NO_TEMPLATES };
|
|
306
|
+
case "int":
|
|
307
|
+
return {
|
|
308
|
+
atoms: [
|
|
309
|
+
"number",
|
|
310
|
+
"bigint",
|
|
311
|
+
...this.#widgetLink(input.type, input.socketless, input.name),
|
|
312
|
+
],
|
|
313
|
+
templates: NO_TEMPLATES,
|
|
314
|
+
};
|
|
315
|
+
case "float":
|
|
316
|
+
return {
|
|
317
|
+
atoms: ["number", ...this.#widgetLink(input.type, input.socketless, input.name)],
|
|
318
|
+
templates: NO_TEMPLATES,
|
|
319
|
+
};
|
|
320
|
+
case "string":
|
|
321
|
+
return {
|
|
322
|
+
atoms: ["string", ...this.#widgetLink(input.type, input.socketless, input.name)],
|
|
323
|
+
templates: NO_TEMPLATES,
|
|
324
|
+
};
|
|
325
|
+
case "boolean":
|
|
326
|
+
return {
|
|
327
|
+
atoms: ["boolean", ...this.#widgetLink(input.type, input.socketless, input.name)],
|
|
328
|
+
templates: NO_TEMPLATES,
|
|
329
|
+
};
|
|
330
|
+
case "combo":
|
|
331
|
+
return { atoms: this.#combo(input, path), templates: NO_TEMPLATES };
|
|
332
|
+
case "value":
|
|
333
|
+
this.#imports.add("JsonValue");
|
|
334
|
+
return {
|
|
335
|
+
atoms: ["JsonValue", ...this.#widgetLink(input.type, input.socketless, input.name)],
|
|
336
|
+
templates: NO_TEMPLATES,
|
|
337
|
+
};
|
|
338
|
+
case "autogrow":
|
|
339
|
+
return this.#autogrow(scope, input, name, path);
|
|
340
|
+
case "dynamicCombo":
|
|
341
|
+
return this.#dynamicCombo(scope, input, name, path);
|
|
342
|
+
case "dynamicGroup": {
|
|
343
|
+
const rows = scope.names.claim(name);
|
|
344
|
+
const row = this.#inputsInterface(scope, scope.names.claim(`${rows}$Row`), rows, input.fields, path, true);
|
|
345
|
+
return {
|
|
346
|
+
atoms: [this.#repeated(row.reference, row.reference, input.min, input.max)],
|
|
347
|
+
templates: row.templates,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
case "matchType": {
|
|
351
|
+
const template = scope.templates.get(input.templateId);
|
|
352
|
+
this.#imports.add("Out");
|
|
353
|
+
if (template === undefined)
|
|
354
|
+
return { atoms: ["Out<string>"], templates: NO_TEMPLATES };
|
|
355
|
+
return { atoms: [this.#matchAtom(scope, template)], templates: new Set([template.id]) };
|
|
356
|
+
}
|
|
357
|
+
case "unknown":
|
|
358
|
+
this.#diagnostics.push(createDiagnostic("W_UNTYPED_INPUT", path, `The normalizer couldn't read input ${input.name}; its value is typed as unknown.`));
|
|
359
|
+
return { atoms: ["unknown"], templates: NO_TEMPLATES };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* A MatchType position: the first one of a template infers it (`Out<T | "*">`), the others
|
|
364
|
+
* must agree with it (§6.3).
|
|
365
|
+
*/
|
|
366
|
+
#matchAtom(scope, template) {
|
|
367
|
+
if (!scope.inferred.has(template.id)) {
|
|
368
|
+
scope.inferred.add(template.id);
|
|
369
|
+
return `Out<${template.parameter} | "*">`;
|
|
370
|
+
}
|
|
371
|
+
this.#imports.add("Match");
|
|
372
|
+
return `Out<Match<NoInfer<${template.parameter}>>>`;
|
|
373
|
+
}
|
|
374
|
+
#combo(input, path) {
|
|
375
|
+
const open = input.upload || input.remote;
|
|
376
|
+
let element;
|
|
377
|
+
if (input.options.length === 0) {
|
|
378
|
+
if (!input.remote) {
|
|
379
|
+
this.#diagnostics.push(createDiagnostic("W_EMPTY_COMBO", path, `Combo ${input.name} lists no options; its values are typed as string.`));
|
|
380
|
+
}
|
|
381
|
+
element = "string";
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
element = this.#comboAlias(input.options, input.name);
|
|
385
|
+
}
|
|
386
|
+
const widened = open && element !== "string";
|
|
387
|
+
const values = input.multiselect
|
|
388
|
+
? [widened ? `readonly (${element} | (string & {}))[]` : `readonly ${element}[]`]
|
|
389
|
+
: widened
|
|
390
|
+
? [element, "(string & {})"]
|
|
391
|
+
: [element];
|
|
392
|
+
if (input.socketless)
|
|
393
|
+
return values;
|
|
394
|
+
// A legacy combo's links must match its whole option list; only `*` is sure to (§5.2).
|
|
395
|
+
if (input.form === "legacy") {
|
|
396
|
+
this.#imports.add("Out");
|
|
397
|
+
return [...values, 'Out<"*">'];
|
|
398
|
+
}
|
|
399
|
+
return [...values, ...this.#link(input.type, input.name)];
|
|
400
|
+
}
|
|
401
|
+
#autogrow(scope, input, name, path) {
|
|
402
|
+
const { template } = input;
|
|
403
|
+
// A MatchType template: the first slot infers it and the others must agree, so a list of
|
|
404
|
+
// mixed types can't infer a union (§6.3).
|
|
405
|
+
let first;
|
|
406
|
+
let rest;
|
|
407
|
+
let templates;
|
|
408
|
+
const matched = template.kind === "matchType" ? scope.templates.get(template.templateId) : undefined;
|
|
409
|
+
if (matched !== undefined) {
|
|
410
|
+
this.#imports.add("Out");
|
|
411
|
+
first = this.#matchAtom(scope, matched);
|
|
412
|
+
rest = this.#matchAtom(scope, matched);
|
|
413
|
+
templates = new Set([matched.id]);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
const slot = this.#inputType(scope, template, `${name}$Slot`, path);
|
|
417
|
+
first =
|
|
418
|
+
slot.atoms.length === 1 ? (slot.atoms[0] ?? "never") : ["(", join(" | ", slot.atoms), ")"];
|
|
419
|
+
rest = first;
|
|
420
|
+
templates = slot.templates;
|
|
421
|
+
}
|
|
422
|
+
// The backend has exactly `slots.length` slots; those before `min` are required when the
|
|
423
|
+
// template is, whatever section the Autogrow itself is in.
|
|
424
|
+
const required = template.required ? Math.min(input.min, input.slots.length) : 0;
|
|
425
|
+
if (input.form === "prefix") {
|
|
426
|
+
return { atoms: [this.#repeated(first, rest, required, input.slots.length)], templates };
|
|
427
|
+
}
|
|
428
|
+
const members = input.slots.map((slotName, index) => [
|
|
429
|
+
"readonly ",
|
|
430
|
+
propertyKey(slotName),
|
|
431
|
+
index < required || isObjectMember(slotName) ? ": " : "?: ",
|
|
432
|
+
index === 0 ? first : rest,
|
|
433
|
+
";",
|
|
434
|
+
]);
|
|
435
|
+
const slots = scope.names.claim(name);
|
|
436
|
+
const parameters = this.#parameters(scope, templates);
|
|
437
|
+
scope.declarations.push([
|
|
438
|
+
inlineComment(`Slots of \`${path.join(" › ")}\`.`),
|
|
439
|
+
hardline,
|
|
440
|
+
interfaceDoc(slots, parameters, [], members),
|
|
441
|
+
]);
|
|
442
|
+
return { atoms: [reference(slots, this.#arguments(scope, templates))], templates };
|
|
443
|
+
}
|
|
444
|
+
#dynamicCombo(scope, input, name, path) {
|
|
445
|
+
const choices = scope.names.claim(name);
|
|
446
|
+
const members = [];
|
|
447
|
+
const templates = new Set();
|
|
448
|
+
for (const option of input.options) {
|
|
449
|
+
if (option.inputs.length === 0) {
|
|
450
|
+
members.push(quote(option.key));
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
const optionName = scope.names.claim(`${choices}$${pascal(option.key, "Option")}`);
|
|
454
|
+
const inputs = this.#inputsInterface(scope, scope.names.claim(`${optionName}$Inputs`), optionName, option.inputs, [...path, option.key]);
|
|
455
|
+
for (const template of inputs.templates)
|
|
456
|
+
templates.add(template);
|
|
457
|
+
const parameters = this.#parameters(scope, inputs.templates);
|
|
458
|
+
scope.declarations.push([
|
|
459
|
+
inlineComment(`Option ${quote(option.key)} of \`${path.join(" › ")}\`.`),
|
|
460
|
+
hardline,
|
|
461
|
+
interfaceDoc(optionName, parameters, [], [
|
|
462
|
+
["readonly option: ", quote(option.key), ";"],
|
|
463
|
+
[inputs.optional ? "readonly inputs?: " : "readonly inputs: ", inputs.reference, ";"],
|
|
464
|
+
]),
|
|
465
|
+
]);
|
|
466
|
+
const optionReference = reference(optionName, this.#arguments(scope, inputs.templates));
|
|
467
|
+
// An option whose inputs may all be left out can also be given by its key alone.
|
|
468
|
+
members.push(...(inputs.optional ? [quote(option.key), optionReference] : [optionReference]));
|
|
469
|
+
}
|
|
470
|
+
const parameters = this.#parameters(scope, templates);
|
|
471
|
+
scope.declarations.push(aliasDoc(choices, parameters, members.length > 0 ? members : ["never"], `The choices of \`${path.join(" › ")}\`.`));
|
|
472
|
+
return { atoms: [reference(choices, this.#arguments(scope, templates))], templates };
|
|
473
|
+
}
|
|
474
|
+
/** The link part of a widget, unless it has no socket. */
|
|
475
|
+
#widgetLink(type, socketless, context) {
|
|
476
|
+
return socketless ? [] : this.#link(type, context);
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* What a socket of `type` accepts (§6.2, §5.2): outputs of the type or `*`. Exactly `COMBO`
|
|
480
|
+
* also takes option lists, and exactly `*` takes everything. A `*` inside a comma list passes
|
|
481
|
+
* strings only, so such a union is typed by its other members: stricter, and never letting an
|
|
482
|
+
* option list in.
|
|
483
|
+
*/
|
|
484
|
+
#link(type, context) {
|
|
485
|
+
switch (type.kind) {
|
|
486
|
+
case "any":
|
|
487
|
+
this.#imports.add("Out");
|
|
488
|
+
return ["Out<string>"];
|
|
489
|
+
case "named":
|
|
490
|
+
this.#imports.add("In");
|
|
491
|
+
if (type.name !== "COMBO")
|
|
492
|
+
return [`In<${quote(type.name)}>`];
|
|
493
|
+
this.#imports.add("Out");
|
|
494
|
+
this.#imports.add("OptionList");
|
|
495
|
+
return ['In<"COMBO">', "Out<OptionList>"];
|
|
496
|
+
case "union": {
|
|
497
|
+
this.#imports.add("In");
|
|
498
|
+
const names = type.names.filter((name) => name !== "*");
|
|
499
|
+
return [`In<${this.#socketNames(names, context)}>`];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* A list of `minimum` to `maximum` elements (§6.2): the backend reads no more slots or rows
|
|
505
|
+
* than exist, so neither may the types. `first` types the first element and `rest` the others,
|
|
506
|
+
* so a MatchType slot list infers from its first only. The length is bounded with a shared
|
|
507
|
+
* `Length<min>To<max>` alias rather than a long tuple, which costs far more to type-check.
|
|
508
|
+
*/
|
|
509
|
+
#repeated(first, rest, minimum, maximum) {
|
|
510
|
+
const elements = minimum === 0
|
|
511
|
+
? first === rest
|
|
512
|
+
? []
|
|
513
|
+
: [[first, "?"]]
|
|
514
|
+
: [first, ...Array.from({ length: minimum - 1 }, () => rest)];
|
|
515
|
+
const list = elements.length === 0 ? ["readonly ", rest, "[]"] : tuple([...elements, ["...", rest, "[]"]]);
|
|
516
|
+
this.#imports.add("Bounded");
|
|
517
|
+
return reference("Bounded", [list, this.#lengths(minimum, maximum)]);
|
|
518
|
+
}
|
|
519
|
+
/** A shared alias for the lengths `minimum` to `maximum`. */
|
|
520
|
+
#lengths(minimum, maximum) {
|
|
521
|
+
const key = `${String(minimum)}-${String(maximum)}`;
|
|
522
|
+
const existing = this.#lengthAliases.get(key);
|
|
523
|
+
if (existing !== undefined)
|
|
524
|
+
return existing;
|
|
525
|
+
const name = this.#claimShared(`Length${String(minimum)}To${String(maximum)}`);
|
|
526
|
+
this.#lengthAliases.set(key, name);
|
|
527
|
+
const lengths = Array.from({ length: maximum - minimum + 1 }, (_, index) => String(minimum + index));
|
|
528
|
+
this.#shared.push({ name, doc: aliasDoc(name, [], lengths, "Allowed list lengths.") });
|
|
529
|
+
return name;
|
|
530
|
+
}
|
|
531
|
+
/** A MatchType template's constraint. */
|
|
532
|
+
#allowed(type, templateId) {
|
|
533
|
+
switch (type.kind) {
|
|
534
|
+
case "any":
|
|
535
|
+
return "string";
|
|
536
|
+
case "named":
|
|
537
|
+
return type.name === "*" ? "string" : quote(type.name);
|
|
538
|
+
case "union":
|
|
539
|
+
return type.names.includes("*") ? "string" : this.#socketNames(type.names, templateId);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** Socket type names as a type argument: one literal, or a shared alias for a union. */
|
|
543
|
+
#socketNames(names, context) {
|
|
544
|
+
const [first] = names;
|
|
545
|
+
if (first === undefined)
|
|
546
|
+
return "never";
|
|
547
|
+
if (names.length === 1)
|
|
548
|
+
return quote(first);
|
|
549
|
+
const key = names.join(",");
|
|
550
|
+
const existing = this.#socketAliases.get(key);
|
|
551
|
+
if (existing !== undefined)
|
|
552
|
+
return existing;
|
|
553
|
+
const name = this.#claimShared(`${pascal(context, "Socket")}Types`);
|
|
554
|
+
this.#socketAliases.set(key, name);
|
|
555
|
+
this.#shared.push({ name, doc: aliasDoc(name, [], names.map(quote), "Socket types.") });
|
|
556
|
+
return name;
|
|
557
|
+
}
|
|
558
|
+
/** A shared alias for a combo's option list; identical lists share one. */
|
|
559
|
+
#comboAlias(options, context) {
|
|
560
|
+
const key = stringify(options);
|
|
561
|
+
const existing = this.#comboAliases.get(key);
|
|
562
|
+
if (existing !== undefined)
|
|
563
|
+
return existing;
|
|
564
|
+
const name = this.#claimShared(pascal(context, "Combo"));
|
|
565
|
+
this.#comboAliases.set(key, name);
|
|
566
|
+
const literals = [...new Set(options.map(literal))];
|
|
567
|
+
this.#shared.push({ name, doc: aliasDoc(name, [], literals, "Combo options.") });
|
|
568
|
+
return name;
|
|
569
|
+
}
|
|
570
|
+
/** A name for a shared alias, clear of every type parameter. */
|
|
571
|
+
#claimShared(base) {
|
|
572
|
+
let name = this.#names.claim(base);
|
|
573
|
+
while (this.#parameterNames.has(name))
|
|
574
|
+
name = this.#names.claim(base);
|
|
575
|
+
return name;
|
|
576
|
+
}
|
|
577
|
+
/** `TA extends …` for the templates a declaration uses, in node order. */
|
|
578
|
+
#parameters(scope, used) {
|
|
579
|
+
return [...scope.templates.values()]
|
|
580
|
+
.filter((template) => used.has(template.id))
|
|
581
|
+
.map((template) => `${template.parameter} extends ${template.constraint}`);
|
|
582
|
+
}
|
|
583
|
+
/** The type arguments that reference a declaration using those templates. */
|
|
584
|
+
#arguments(scope, used) {
|
|
585
|
+
return [...scope.templates.values()]
|
|
586
|
+
.filter((template) => used.has(template.id))
|
|
587
|
+
.map((template) => template.parameter);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
function literal(option) {
|
|
591
|
+
return typeof option === "string" ? quote(option) : String(option);
|
|
592
|
+
}
|
|
593
|
+
/** `Name` or `Name<A, B>`, breaking the arguments the way oxfmt does when they don't fit. */
|
|
594
|
+
function reference(name, args) {
|
|
595
|
+
if (args.length === 0)
|
|
596
|
+
return name;
|
|
597
|
+
return group([name, "<", indent([softline, join([",", line], args)]), softline, ">"]);
|
|
598
|
+
}
|
|
599
|
+
function tuple(elements) {
|
|
600
|
+
return group([
|
|
601
|
+
"readonly [",
|
|
602
|
+
indent([softline, join([",", line], elements)]),
|
|
603
|
+
ifBreak(","),
|
|
604
|
+
softline,
|
|
605
|
+
"]",
|
|
606
|
+
]);
|
|
607
|
+
}
|
|
608
|
+
/** A union after a `:`: flat, or one `| atom` per line. */
|
|
609
|
+
function unionDoc(atoms) {
|
|
610
|
+
if (atoms.length === 1)
|
|
611
|
+
return [" ", atoms[0] ?? "never"];
|
|
612
|
+
return group(indent([line, ifBreak("| "), join([line, "| "], atoms)]));
|
|
613
|
+
}
|
|
614
|
+
function typeParameters(parameters) {
|
|
615
|
+
if (parameters.length === 0)
|
|
616
|
+
return "";
|
|
617
|
+
return group([
|
|
618
|
+
"<",
|
|
619
|
+
indent([softline, join([",", line], parameters)]),
|
|
620
|
+
ifBreak(","),
|
|
621
|
+
softline,
|
|
622
|
+
">",
|
|
623
|
+
]);
|
|
624
|
+
}
|
|
625
|
+
function aliasDoc(name, parameters, atoms, comment) {
|
|
626
|
+
const left = ["export type ", name, typeParameters(parameters)];
|
|
627
|
+
// One member is an assignment; a union breaks into `| member` lines.
|
|
628
|
+
const declaration = atoms.length === 1
|
|
629
|
+
? assignment(left, [atoms[0] ?? "never", ";"])
|
|
630
|
+
: group([left, " =", unionDoc(atoms), ";"]);
|
|
631
|
+
return [inlineComment(comment), hardline, declaration];
|
|
632
|
+
}
|
|
633
|
+
function interfaceDoc(name, parameters, parents, members) {
|
|
634
|
+
const heading = group([
|
|
635
|
+
"export interface ",
|
|
636
|
+
name,
|
|
637
|
+
typeParameters(parameters),
|
|
638
|
+
parents.length > 0
|
|
639
|
+
? group(indent([line, "extends", group(indent([line, join([",", line], parents)]))]))
|
|
640
|
+
: "",
|
|
641
|
+
" {",
|
|
642
|
+
]);
|
|
643
|
+
if (members.length === 0)
|
|
644
|
+
return [heading, "}"];
|
|
645
|
+
return [heading, indent([hardline, join(hardline, members)]), hardline, "}"];
|
|
646
|
+
}
|
|
647
|
+
/** A one-line doc comment holding server text. */
|
|
648
|
+
function inlineComment(text) {
|
|
649
|
+
return `/** ${commentText(text)} */`;
|
|
650
|
+
}
|
|
651
|
+
/** A JSDoc comment from text lines; nothing when there are none. */
|
|
652
|
+
function docComment(lines) {
|
|
653
|
+
const text = lines.flatMap(commentLines);
|
|
654
|
+
if (text.length === 0)
|
|
655
|
+
return "";
|
|
656
|
+
if (text.length === 1)
|
|
657
|
+
return [`/** ${text[0] ?? ""} */`, hardline];
|
|
658
|
+
return [
|
|
659
|
+
"/**",
|
|
660
|
+
...text.flatMap((each) => [hardline, each === "" ? " *" : ` * ${each}`]),
|
|
661
|
+
hardline,
|
|
662
|
+
" */",
|
|
663
|
+
hardline,
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
function nodeDocLines(node) {
|
|
667
|
+
const lines = [
|
|
668
|
+
node.category === "" ? node.displayName : `${node.displayName} · ${node.category}`,
|
|
669
|
+
];
|
|
670
|
+
if (node.description !== undefined)
|
|
671
|
+
lines.push("", node.description);
|
|
672
|
+
const lists = node.outputs.filter((output) => output.isList).map((output) => output.name);
|
|
673
|
+
if (lists.length > 0) {
|
|
674
|
+
lines.push("", `List outputs (run what they feed once per item): ${lists.join(", ")}.`);
|
|
675
|
+
}
|
|
676
|
+
if (node.experimental)
|
|
677
|
+
lines.push("", "Experimental.");
|
|
678
|
+
if (node.deprecated)
|
|
679
|
+
lines.push("", "@deprecated");
|
|
680
|
+
return lines;
|
|
681
|
+
}
|
|
682
|
+
function inputDocLines(input) {
|
|
683
|
+
const lines = input.tooltip === undefined ? [] : [input.tooltip];
|
|
684
|
+
const facts = [];
|
|
685
|
+
if ("default" in input) {
|
|
686
|
+
facts.push(`Default: \`${stringify(input.default)}\`.`);
|
|
687
|
+
}
|
|
688
|
+
if (input.kind === "int" || input.kind === "float") {
|
|
689
|
+
if (input.min !== undefined && input.max !== undefined) {
|
|
690
|
+
facts.push(`Range: \`${numberText(input.min)}\` to \`${numberText(input.max)}\`.`);
|
|
691
|
+
}
|
|
692
|
+
else if (input.min !== undefined) {
|
|
693
|
+
facts.push(`Minimum: \`${numberText(input.min)}\`.`);
|
|
694
|
+
}
|
|
695
|
+
else if (input.max !== undefined) {
|
|
696
|
+
facts.push(`Maximum: \`${numberText(input.max)}\`.`);
|
|
697
|
+
}
|
|
698
|
+
if (input.step !== undefined)
|
|
699
|
+
facts.push(`Step: \`${String(input.step)}\`.`);
|
|
700
|
+
}
|
|
701
|
+
if (input.name === "__proto__") {
|
|
702
|
+
// An object literal's `__proto__: x` sets the prototype instead of a property.
|
|
703
|
+
facts.push('Pass it as `["__proto__"]: value`; a plain `__proto__:` key sets the prototype.');
|
|
704
|
+
}
|
|
705
|
+
if (facts.length > 0) {
|
|
706
|
+
if (lines.length > 0)
|
|
707
|
+
lines.push("");
|
|
708
|
+
lines.push(facts.join(" "));
|
|
709
|
+
}
|
|
710
|
+
return lines;
|
|
711
|
+
}
|
|
712
|
+
/** A number as written in docs: integers beyond 2^53 in full, not JavaScript's short form. */
|
|
713
|
+
function numberText(value) {
|
|
714
|
+
return typeof value === "number" && Number.isInteger(value) && !Number.isSafeInteger(value)
|
|
715
|
+
? BigInt(value).toString()
|
|
716
|
+
: String(value);
|
|
717
|
+
}
|
|
718
|
+
//# sourceMappingURL=emit-types.js.map
|