@styx-api/core 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/dist/index.cjs +7947 -0
- package/dist/index.d.cts +1143 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +1143 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +7877 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +55 -0
- package/src/backend/backend.ts +95 -0
- package/src/backend/boutiques/boutiques.ts +1049 -0
- package/src/backend/boutiques/index.ts +1 -0
- package/src/backend/code-builder.ts +49 -0
- package/src/backend/collect-field-info.ts +50 -0
- package/src/backend/collect-named-types.ts +103 -0
- package/src/backend/collect-output-fields.ts +222 -0
- package/src/backend/find-doc.ts +38 -0
- package/src/backend/find-struct-node.ts +66 -0
- package/src/backend/index.ts +39 -0
- package/src/backend/python/arg-builder.ts +454 -0
- package/src/backend/python/emit.ts +638 -0
- package/src/backend/python/index.ts +9 -0
- package/src/backend/python/outputs-emit.ts +430 -0
- package/src/backend/python/packaging.ts +173 -0
- package/src/backend/python/python.ts +558 -0
- package/src/backend/python/snippet.ts +84 -0
- package/src/backend/python/typemap.ts +131 -0
- package/src/backend/python/types.ts +8 -0
- package/src/backend/python/validate-emit.ts +356 -0
- package/src/backend/resolve-field-binding.ts +41 -0
- package/src/backend/resolve-output-tokens.ts +80 -0
- package/src/backend/schema/index.ts +2 -0
- package/src/backend/schema/jsonschema.ts +303 -0
- package/src/backend/scope.ts +50 -0
- package/src/backend/sig-entries.ts +97 -0
- package/src/backend/snippet-core.ts +185 -0
- package/src/backend/string-case.ts +30 -0
- package/src/backend/styxdefs-compat.ts +21 -0
- package/src/backend/type-keys.ts +52 -0
- package/src/backend/typescript/arg-builder.ts +420 -0
- package/src/backend/typescript/emit.ts +450 -0
- package/src/backend/typescript/index.ts +10 -0
- package/src/backend/typescript/outputs-emit.ts +389 -0
- package/src/backend/typescript/packaging.ts +130 -0
- package/src/backend/typescript/snippet.ts +60 -0
- package/src/backend/typescript/typemap.ts +47 -0
- package/src/backend/typescript/types.ts +8 -0
- package/src/backend/typescript/typescript.ts +507 -0
- package/src/backend/typescript/validate-emit.ts +341 -0
- package/src/backend/union-variants.ts +42 -0
- package/src/backend/validate-walk.ts +111 -0
- package/src/bindings/binding.ts +77 -0
- package/src/bindings/format.ts +176 -0
- package/src/bindings/index.ts +16 -0
- package/src/bindings/output-gate.ts +50 -0
- package/src/bindings/resolved-output.ts +56 -0
- package/src/bindings/types.ts +16 -0
- package/src/frontend/argdump/index.ts +1 -0
- package/src/frontend/argdump/parser.ts +914 -0
- package/src/frontend/boutiques/destruct-template.ts +50 -0
- package/src/frontend/boutiques/index.ts +1 -0
- package/src/frontend/boutiques/parser.ts +676 -0
- package/src/frontend/boutiques/split-command.ts +69 -0
- package/src/frontend/detect-format.ts +42 -0
- package/src/frontend/frontend.ts +31 -0
- package/src/frontend/index.ts +9 -0
- package/src/frontend/workbench/index.ts +1 -0
- package/src/frontend/workbench/parser.ts +351 -0
- package/src/index.ts +41 -0
- package/src/ir/builders.ts +69 -0
- package/src/ir/format.ts +157 -0
- package/src/ir/index.ts +32 -0
- package/src/ir/meta.ts +91 -0
- package/src/ir/node.ts +95 -0
- package/src/ir/passes/canonicalize.ts +108 -0
- package/src/ir/passes/flatten.ts +73 -0
- package/src/ir/passes/index.ts +7 -0
- package/src/ir/passes/pass.ts +86 -0
- package/src/ir/passes/pipeline.ts +21 -0
- package/src/ir/passes/remove-empty.ts +76 -0
- package/src/ir/passes/simplify.ts +179 -0
- package/src/ir/types.ts +15 -0
- package/src/manifest/context.ts +36 -0
- package/src/manifest/index.ts +3 -0
- package/src/manifest/types.ts +15 -0
- package/src/solver/assign-access.ts +218 -0
- package/src/solver/index.ts +4 -0
- package/src/solver/resolve-outputs.ts +233 -0
- package/src/solver/solver.ts +319 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Binding,
|
|
3
|
+
BindingId,
|
|
4
|
+
BoundType,
|
|
5
|
+
OutputDiagnostic,
|
|
6
|
+
OutputScope,
|
|
7
|
+
OutputValidationResult,
|
|
8
|
+
ResolvedOutput,
|
|
9
|
+
ResolvedToken,
|
|
10
|
+
SolveResult,
|
|
11
|
+
} from "../bindings/index.js";
|
|
12
|
+
import type { Expr, Output } from "../ir/index.js";
|
|
13
|
+
import { effectiveOutputName } from "../ir/index.js";
|
|
14
|
+
|
|
15
|
+
export interface OutputResolution {
|
|
16
|
+
scopes: OutputScope[];
|
|
17
|
+
diagnostics: OutputValidationResult;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Per-binding map keyed by `Binding.name`. Frontends attach outputs to the
|
|
22
|
+
* declaring struct, so refs name fields visible in that scope (the resolver
|
|
23
|
+
* accepts a single global name index because optimization can move bindings
|
|
24
|
+
* around but never duplicates names within a scope).
|
|
25
|
+
*/
|
|
26
|
+
interface NameIndex {
|
|
27
|
+
byName: Map<string, Binding>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether a binding resolves to a value that can be interpolated into an output
|
|
32
|
+
* path token. Scalars/bool/count/literals (and optional/list wrappers of them)
|
|
33
|
+
* carry a concrete value; structs and complex unions do not.
|
|
34
|
+
*
|
|
35
|
+
* This drives the name-tie preference below: an unnamed multi-field struct
|
|
36
|
+
* inherits its first field's name (`findDeepName`), so a struct and its scalar
|
|
37
|
+
* field can share a name. An output ref always means the value, so the
|
|
38
|
+
* interpolable binding must win regardless of which sits shallower.
|
|
39
|
+
*/
|
|
40
|
+
function isInterpolable(type: BoundType): boolean {
|
|
41
|
+
switch (type.kind) {
|
|
42
|
+
case "optional":
|
|
43
|
+
return isInterpolable(type.inner);
|
|
44
|
+
case "list":
|
|
45
|
+
return isInterpolable(type.item);
|
|
46
|
+
case "struct":
|
|
47
|
+
return false;
|
|
48
|
+
case "union":
|
|
49
|
+
// Pure-enum unions are interpolable (the value is a literal); a union with
|
|
50
|
+
// any struct variant is not.
|
|
51
|
+
return type.variants.every((v) => isInterpolable(v.type));
|
|
52
|
+
default:
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `includeRoot` controls whether the binding on `root` itself (depth 0) is
|
|
59
|
+
* indexed. The global index excludes it (a ref must never resolve to the root
|
|
60
|
+
* struct, which shares its name with a deep field via `findDeepName`). A
|
|
61
|
+
* scope-local index includes it: a collapsed single-field union arm boxes its
|
|
62
|
+
* lone field's binding onto the scope node itself (e.g. ants DenoiseImage's
|
|
63
|
+
* `correctedOutputFileName` arm), and that binding - with the field's access
|
|
64
|
+
* path and the arm's variant gate - is exactly what the arm's output ref needs.
|
|
65
|
+
*/
|
|
66
|
+
function indexBindingsByName(
|
|
67
|
+
root: Expr,
|
|
68
|
+
resolve: (n: Expr) => Binding | undefined,
|
|
69
|
+
includeRoot = false,
|
|
70
|
+
): NameIndex {
|
|
71
|
+
const byNameDepth = new Map<string, { binding: Binding; depth: number }>();
|
|
72
|
+
function walk(node: Expr, depth: number): void {
|
|
73
|
+
const binding = resolve(node);
|
|
74
|
+
if (binding && (depth > 0 || includeRoot)) {
|
|
75
|
+
const existing = byNameDepth.get(binding.name);
|
|
76
|
+
if (!existing || preferCandidate({ binding, depth }, existing)) {
|
|
77
|
+
byNameDepth.set(binding.name, { binding, depth });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
switch (node.kind) {
|
|
81
|
+
case "sequence":
|
|
82
|
+
for (const child of node.attrs.nodes) walk(child, depth + 1);
|
|
83
|
+
break;
|
|
84
|
+
case "optional":
|
|
85
|
+
case "repeat":
|
|
86
|
+
walk(node.attrs.node, depth + 1);
|
|
87
|
+
break;
|
|
88
|
+
case "alternative":
|
|
89
|
+
for (const alt of node.attrs.alts) walk(alt, depth + 1);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
walk(root, 0);
|
|
94
|
+
const byName = new Map<string, Binding>();
|
|
95
|
+
for (const [name, { binding }] of byNameDepth) byName.set(name, binding);
|
|
96
|
+
return { byName };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decide whether `cand` should replace `existing` for a shared name. An
|
|
101
|
+
* interpolable binding always beats a structured one (the struct/scalar name
|
|
102
|
+
* collision); otherwise the shallowest binding wins, as before.
|
|
103
|
+
*/
|
|
104
|
+
function preferCandidate(
|
|
105
|
+
cand: { binding: Binding; depth: number },
|
|
106
|
+
existing: { binding: Binding; depth: number },
|
|
107
|
+
): boolean {
|
|
108
|
+
const candLeaf = isInterpolable(cand.binding.type);
|
|
109
|
+
const exLeaf = isInterpolable(existing.binding.type);
|
|
110
|
+
if (candLeaf !== exLeaf) return candLeaf;
|
|
111
|
+
return cand.depth < existing.depth;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Walk the IR collecting `(node, outputs)` pairs in tree order. Outputs attach
|
|
116
|
+
* to struct/sequence nodes by frontend convention.
|
|
117
|
+
*/
|
|
118
|
+
function collectScopes(root: Expr): { node: Expr; outputs: Output[] }[] {
|
|
119
|
+
const out: { node: Expr; outputs: Output[] }[] = [];
|
|
120
|
+
function walk(node: Expr): void {
|
|
121
|
+
if (node.meta?.outputs?.length) out.push({ node, outputs: node.meta.outputs });
|
|
122
|
+
switch (node.kind) {
|
|
123
|
+
case "sequence":
|
|
124
|
+
for (const child of node.attrs.nodes) walk(child);
|
|
125
|
+
break;
|
|
126
|
+
case "optional":
|
|
127
|
+
case "repeat":
|
|
128
|
+
walk(node.attrs.node);
|
|
129
|
+
break;
|
|
130
|
+
case "alternative":
|
|
131
|
+
for (const alt of node.attrs.alts) walk(alt);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
walk(root);
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveOne(
|
|
140
|
+
output: Output,
|
|
141
|
+
index: number,
|
|
142
|
+
localNames: NameIndex,
|
|
143
|
+
globalNames: NameIndex,
|
|
144
|
+
): { resolved: ResolvedOutput; errors: OutputDiagnostic[] } {
|
|
145
|
+
const errors: OutputDiagnostic[] = [];
|
|
146
|
+
const name = effectiveOutputName(output, index);
|
|
147
|
+
const tokens: ResolvedToken[] = [];
|
|
148
|
+
for (const token of output.tokens) {
|
|
149
|
+
if (token.kind === "literal") {
|
|
150
|
+
tokens.push({ kind: "literal", value: token.value });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Prefer a binding declared within the output's own scope subtree. Union
|
|
154
|
+
// arms duplicate field names across scopes (each arm is its own scope), so
|
|
155
|
+
// the global index alone would resolve an arm's output ref to a sibling
|
|
156
|
+
// arm's binding, mixing contradictory variant gates into one output. Fall
|
|
157
|
+
// back to the global index for refs to bindings outside the local subtree.
|
|
158
|
+
const binding =
|
|
159
|
+
localNames.byName.get(token.target.name) ?? globalNames.byName.get(token.target.name);
|
|
160
|
+
if (!binding) {
|
|
161
|
+
errors.push({
|
|
162
|
+
output: name,
|
|
163
|
+
level: "error",
|
|
164
|
+
message: `token ref '${token.target.name}' has no binding with that name (frontend produced an inconsistent output spec)`,
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
tokens.push({
|
|
169
|
+
kind: "ref",
|
|
170
|
+
binding: binding.id,
|
|
171
|
+
...(token.stripExtensions && { stripExtensions: token.stripExtensions }),
|
|
172
|
+
...(token.fallback !== undefined && { fallback: token.fallback }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const resolved: ResolvedOutput = {
|
|
176
|
+
name,
|
|
177
|
+
...(output.doc && { doc: output.doc }),
|
|
178
|
+
tokens,
|
|
179
|
+
...(output.mediaTypes && { mediaTypes: output.mediaTypes }),
|
|
180
|
+
};
|
|
181
|
+
return { resolved, errors };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Translate each `NodeMeta.outputs` entry against the binding registry,
|
|
186
|
+
* grouped by the declaring struct binding (the "scope"). The solver forces a
|
|
187
|
+
* binding on every output-carrying sequence, so an output without a scope
|
|
188
|
+
* binding indicates a frontend bug (outputs attached to a non-sequence
|
|
189
|
+
* node) - it is reported as a diagnostic and dropped.
|
|
190
|
+
*/
|
|
191
|
+
export function resolveOutputs(root: Expr, solved: SolveResult): OutputResolution {
|
|
192
|
+
const names = indexBindingsByName(root, solved.resolve);
|
|
193
|
+
const collected = collectScopes(root);
|
|
194
|
+
|
|
195
|
+
const byScope = new Map<BindingId, OutputScope>();
|
|
196
|
+
const errors: OutputDiagnostic[] = [];
|
|
197
|
+
const warnings: OutputDiagnostic[] = [];
|
|
198
|
+
|
|
199
|
+
let outputIndex = 0;
|
|
200
|
+
for (const { node, outputs } of collected) {
|
|
201
|
+
const scopeBinding = solved.resolve(node);
|
|
202
|
+
if (!scopeBinding) {
|
|
203
|
+
for (const output of outputs) {
|
|
204
|
+
const name = effectiveOutputName(output, outputIndex++);
|
|
205
|
+
errors.push({
|
|
206
|
+
output: name,
|
|
207
|
+
level: "error",
|
|
208
|
+
message: `output '${name}' is attached to a node without a binding (frontends should attach outputs to struct sequences)`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
let bucket = byScope.get(scopeBinding.id);
|
|
214
|
+
if (!bucket) {
|
|
215
|
+
bucket = { scope: scopeBinding.id, outputs: [] };
|
|
216
|
+
byScope.set(scopeBinding.id, bucket);
|
|
217
|
+
}
|
|
218
|
+
// Index only the bindings within this scope's subtree (including the scope
|
|
219
|
+
// node's own binding), so an output ref resolves to the field declared in
|
|
220
|
+
// the same scope (union arm) before falling back to the global index.
|
|
221
|
+
const localNames = indexBindingsByName(node, solved.resolve, true);
|
|
222
|
+
for (const output of outputs) {
|
|
223
|
+
const { resolved, errors: outErrors } = resolveOne(output, outputIndex++, localNames, names);
|
|
224
|
+
errors.push(...outErrors);
|
|
225
|
+
bucket.outputs.push(resolved);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
scopes: Array.from(byScope.values()),
|
|
231
|
+
diagnostics: { errors, warnings },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { Binding, BindingId, BoundType, GateAtom, SolveResult } from "../bindings/index.js";
|
|
2
|
+
import { createRegistry } from "../bindings/index.js";
|
|
3
|
+
import type { Expr, Literal } from "../ir/index.js";
|
|
4
|
+
import { assignAccessPaths } from "./assign-access.js";
|
|
5
|
+
|
|
6
|
+
export interface SolveOptions {
|
|
7
|
+
namingStrategy?: NamingStrategy;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NamingStrategy {
|
|
11
|
+
getName: (node: Expr, path: string[]) => string;
|
|
12
|
+
generateId: () => BindingId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Shared helper for deep name search
|
|
16
|
+
function findDeepName(node: Expr): string | undefined {
|
|
17
|
+
if (node.meta?.name) return node.meta.name;
|
|
18
|
+
|
|
19
|
+
if (node.kind === "optional" || node.kind === "repeat") {
|
|
20
|
+
return findDeepName(node.attrs.node);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (node.kind === "sequence") {
|
|
24
|
+
return node.attrs.nodes
|
|
25
|
+
.filter((n) => n.kind !== "literal")
|
|
26
|
+
.map(findDeepName)
|
|
27
|
+
.find(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultNamingStrategy(): NamingStrategy {
|
|
34
|
+
let counter = 0;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
getName: (node, path) => findDeepName(node) ?? path[path.length - 1] ?? `param_${counter++}`,
|
|
38
|
+
generateId: () => `binding_${counter++}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Helper to check if alternative should collapse to bool
|
|
43
|
+
function isBooleanLiteralPair(variants: Array<{ type: BoundType }>): boolean {
|
|
44
|
+
if (variants.length !== 2 || !variants.every((v) => v.type.kind === "literal")) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const [a, b] = variants.map((v) => (v.type.kind === "literal" ? v.type.value : null));
|
|
48
|
+
return (
|
|
49
|
+
(a === 0 && b === 1) ||
|
|
50
|
+
(a === 1 && b === 0) ||
|
|
51
|
+
(a === "0" && b === "1") ||
|
|
52
|
+
(a === "1" && b === "0") ||
|
|
53
|
+
(a === "false" && b === "true") ||
|
|
54
|
+
(a === "true" && b === "false")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Helper to create literal bound type from IR literal
|
|
59
|
+
function literalFromNode(node: Literal): BoundType {
|
|
60
|
+
const str = node.attrs.str;
|
|
61
|
+
const num = Number(str);
|
|
62
|
+
const isCleanInt = Number.isInteger(num) && !Number.isNaN(num) && String(num) === str;
|
|
63
|
+
return { kind: "literal", value: isCleanInt ? num : str };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Name to give the single wrapped field when a non-struct variant gets boxed
|
|
68
|
+
* into a discriminated struct. For a sequence arm the wrapped value is the
|
|
69
|
+
* inner parameter (`seq(lit("convert"), path{src})` -> `src`), so look past the
|
|
70
|
+
* arm's own (variant) name; otherwise use the value's own deep name.
|
|
71
|
+
*/
|
|
72
|
+
function innerParamName(armNode: Expr): string {
|
|
73
|
+
if (armNode.kind === "sequence") {
|
|
74
|
+
const inner = armNode.attrs.nodes
|
|
75
|
+
.filter((n) => n.kind !== "literal")
|
|
76
|
+
.map(findDeepName)
|
|
77
|
+
.find(Boolean);
|
|
78
|
+
if (inner) return inner;
|
|
79
|
+
}
|
|
80
|
+
return findDeepName(armNode) ?? "value";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Discriminated form of a variant's type: literal variants discriminate by
|
|
85
|
+
* value (returned unchanged); struct variants get an `@type` field prepended;
|
|
86
|
+
* anything else is boxed into `{ "@type": <name>, <field>: <type> }`. Pure -
|
|
87
|
+
* never mutates `type`.
|
|
88
|
+
*/
|
|
89
|
+
function taggedVariantType(name: string, type: BoundType, armNode: Expr): BoundType {
|
|
90
|
+
if (type.kind === "literal") return type;
|
|
91
|
+
const tag: BoundType = { kind: "literal", value: name };
|
|
92
|
+
if (type.kind === "struct") return { kind: "struct", fields: { "@type": tag, ...type.fields } };
|
|
93
|
+
return { kind: "struct", fields: { "@type": tag, [innerParamName(armNode)]: type } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function solve(expr: Expr, options?: SolveOptions): SolveResult {
|
|
97
|
+
const strategy = options?.namingStrategy ?? defaultNamingStrategy();
|
|
98
|
+
const registry = createRegistry();
|
|
99
|
+
const nodeToBinding = new WeakMap<Expr, Binding>();
|
|
100
|
+
|
|
101
|
+
// Wrapper bindings (optional/repeat/alternative) need an id BEFORE recursing
|
|
102
|
+
// into children so the child's `gate` can reference it. Pre-allocate the id
|
|
103
|
+
// here, then materialize the binding with its computed type after the
|
|
104
|
+
// recursion settles.
|
|
105
|
+
function preallocate(): BindingId {
|
|
106
|
+
return strategy.generateId();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function registerBinding(
|
|
110
|
+
id: BindingId,
|
|
111
|
+
node: Expr,
|
|
112
|
+
name: string,
|
|
113
|
+
type: BoundType,
|
|
114
|
+
gate: GateAtom[],
|
|
115
|
+
): Binding {
|
|
116
|
+
// `access` is filled by `assignAccessPaths` once all types have settled
|
|
117
|
+
// (sequence collapse-vs-struct and union arm retyping are only known after
|
|
118
|
+
// the full recursion). Start empty so the field is always present.
|
|
119
|
+
const binding: Binding = { id, node, name, type, gate, access: [] };
|
|
120
|
+
registry.set(id, binding);
|
|
121
|
+
nodeToBinding.set(node, binding);
|
|
122
|
+
return binding;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function createBinding(node: Expr, name: string, type: BoundType, gate: GateAtom[]): Binding {
|
|
126
|
+
return registerBinding(strategy.generateId(), node, name, type, gate);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function solveNode(node: Expr, path: string[], gate: GateAtom[]): BoundType | null {
|
|
130
|
+
const name = strategy.getName(node, path);
|
|
131
|
+
|
|
132
|
+
switch (node.kind) {
|
|
133
|
+
case "literal":
|
|
134
|
+
return null;
|
|
135
|
+
|
|
136
|
+
case "optional": {
|
|
137
|
+
const id = preallocate();
|
|
138
|
+
const childGate = [...gate, { kind: "present" as const, binding: id }];
|
|
139
|
+
const inner = solveNode(node.attrs.node, [...path, name], childGate);
|
|
140
|
+
const type: BoundType = inner === null ? { kind: "bool" } : { kind: "optional", inner };
|
|
141
|
+
registerBinding(id, node, name, type, gate);
|
|
142
|
+
return type;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case "repeat": {
|
|
146
|
+
const id = preallocate();
|
|
147
|
+
// We don't yet know if the inner collapses to a count (no inner
|
|
148
|
+
// binding -> repeat-of-literal) or to a list. Optimistically tag the
|
|
149
|
+
// child gate as `iter`; if the inner is null we replace the wrapper
|
|
150
|
+
// type with `count` and the iter atom never reaches a real binding
|
|
151
|
+
// (no inner binding consumes the gate).
|
|
152
|
+
const childGate = [...gate, { kind: "iter" as const, binding: id }];
|
|
153
|
+
const inner = solveNode(node.attrs.node, [...path, name], childGate);
|
|
154
|
+
const type: BoundType = inner === null ? { kind: "count" } : { kind: "list", item: inner };
|
|
155
|
+
registerBinding(id, node, name, type, gate);
|
|
156
|
+
return type;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "sequence": {
|
|
160
|
+
const fields: Record<string, BoundType> = {};
|
|
161
|
+
// Track the single binding-bearing child so the collapse check below can
|
|
162
|
+
// inspect its node kind (only meaningful when exactly one field exists).
|
|
163
|
+
let soleFieldChild: Expr | undefined;
|
|
164
|
+
for (const child of node.attrs.nodes) {
|
|
165
|
+
const childName = strategy.getName(child, path);
|
|
166
|
+
const childType = solveNode(child, [...path, childName], gate);
|
|
167
|
+
if (childType !== null) {
|
|
168
|
+
fields[childName] = childType;
|
|
169
|
+
soleFieldChild = child;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// A sequence that carries `meta.outputs` must always produce a binding,
|
|
173
|
+
// even when it would otherwise collapse - that binding is the scope key
|
|
174
|
+
// for the outputs declared on it. Empty- and single-field collapses
|
|
175
|
+
// would otherwise leave the scope unbound and the outputs orphaned.
|
|
176
|
+
const hasOutputs = node.meta?.outputs && node.meta.outputs.length > 0;
|
|
177
|
+
if (Object.keys(fields).length === 0) {
|
|
178
|
+
if (hasOutputs) {
|
|
179
|
+
const type: BoundType = { kind: "struct", fields: {} };
|
|
180
|
+
createBinding(node, name, type, gate);
|
|
181
|
+
return type;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
// Collapse a single-field sequence to that field - e.g. `-x <val>`
|
|
186
|
+
// (`seq(lit("-x"), str)`) becomes just the value, so the flag and its
|
|
187
|
+
// one value read as a single optional/required parameter.
|
|
188
|
+
//
|
|
189
|
+
// EXCEPTION: when the field comes from an `optional` child, the
|
|
190
|
+
// sequence's own literal (e.g. `-whole-file`) can be present while the
|
|
191
|
+
// optional sub-field (e.g. `-demean`) is absent. Collapsing would
|
|
192
|
+
// conflate those two independent optional states and drop the sub-field
|
|
193
|
+
// (it would have no struct to live on). Keep such a sequence a struct so
|
|
194
|
+
// the sub-field stays addressable. A `repeat`/scalar/alternative child
|
|
195
|
+
// is tied 1:1 to the flag's presence, so collapsing those stays correct.
|
|
196
|
+
if (
|
|
197
|
+
Object.keys(fields).length === 1 &&
|
|
198
|
+
!hasOutputs &&
|
|
199
|
+
soleFieldChild?.kind !== "optional"
|
|
200
|
+
) {
|
|
201
|
+
return Object.values(fields)[0]!;
|
|
202
|
+
}
|
|
203
|
+
const type: BoundType = { kind: "struct", fields };
|
|
204
|
+
createBinding(node, name, type, gate);
|
|
205
|
+
return type;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case "alternative": {
|
|
209
|
+
const id = preallocate();
|
|
210
|
+
// Resolve each arm's variant name first so child gates can carry it.
|
|
211
|
+
// `variantTag` (the sub-command id) is preferred over `name`: a
|
|
212
|
+
// single-field sub-command collapses onto its inner field, whose `name`
|
|
213
|
+
// wins, so `name` alone would derive the tag from the inner field's id
|
|
214
|
+
// (two distinct sub-commands wrapping a same-named field would collide).
|
|
215
|
+
const armNames = node.attrs.alts.map((alt, i) => {
|
|
216
|
+
if (alt.meta?.variantTag) return alt.meta.variantTag;
|
|
217
|
+
if (alt.meta?.name) return alt.meta.name;
|
|
218
|
+
if (alt.kind === "literal") return alt.attrs.str.replace(/^-+/, "");
|
|
219
|
+
return `variant_${i}`;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const variants = node.attrs.alts.map((alt, i) => {
|
|
223
|
+
const variantName = armNames[i]!;
|
|
224
|
+
const childGate = [
|
|
225
|
+
...gate,
|
|
226
|
+
{ kind: "variant" as const, binding: id, variant: variantName },
|
|
227
|
+
];
|
|
228
|
+
const childType =
|
|
229
|
+
solveNode(alt, [...path, `variant_${i}`], childGate) ??
|
|
230
|
+
(alt.kind === "literal" ? literalFromNode(alt) : { kind: "bool" as const });
|
|
231
|
+
return { name: variantName, type: childType, node: alt };
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Pattern: boolean pair -> bool. The pre-allocated variant atoms in
|
|
235
|
+
// child gates are unreached (literal arms produce no bindings).
|
|
236
|
+
if (isBooleanLiteralPair(variants)) {
|
|
237
|
+
const type: BoundType = { kind: "bool" };
|
|
238
|
+
registerBinding(id, node, name, type, gate);
|
|
239
|
+
return type;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Discriminate each variant. When an arm carries its own binding (a
|
|
243
|
+
// multi-field struct), retype it so that binding and the union agree;
|
|
244
|
+
// collapsed single-field arms keep their inner binding and the boxed
|
|
245
|
+
// form lives only in the union's `variants`.
|
|
246
|
+
for (const v of variants) {
|
|
247
|
+
v.type = taggedVariantType(v.name, v.type, v.node);
|
|
248
|
+
const armBinding = nodeToBinding.get(v.node);
|
|
249
|
+
if (armBinding) armBinding.type = v.type;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const type: BoundType = {
|
|
253
|
+
kind: "union",
|
|
254
|
+
variants: variants.map(({ name, type }) => ({ name, type })),
|
|
255
|
+
};
|
|
256
|
+
registerBinding(id, node, name, type, gate);
|
|
257
|
+
return type;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
case "int":
|
|
261
|
+
case "float":
|
|
262
|
+
case "str":
|
|
263
|
+
case "path": {
|
|
264
|
+
const type: BoundType = { kind: "scalar", scalar: node.kind };
|
|
265
|
+
createBinding(node, name, type, gate);
|
|
266
|
+
return type;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const rootType = solveNode(expr, [], []);
|
|
272
|
+
|
|
273
|
+
// Ensure a root binding always exists, even when the sequence collapsed
|
|
274
|
+
// (0 fields -> empty struct, 1 field that's not already a struct -> wrap in single-field struct)
|
|
275
|
+
if (!nodeToBinding.has(expr) && expr.kind === "sequence") {
|
|
276
|
+
const name = strategy.getName(expr, []);
|
|
277
|
+
if (rootType === null) {
|
|
278
|
+
createBinding(expr, name, { kind: "struct", fields: {} }, []);
|
|
279
|
+
} else if (rootType.kind === "struct") {
|
|
280
|
+
// Already a struct (e.g. joined seq with 2+ fields) - use it directly
|
|
281
|
+
createBinding(expr, name, rootType, []);
|
|
282
|
+
} else {
|
|
283
|
+
// Single scalar/optional/list field was collapsed - wrap it in a struct.
|
|
284
|
+
// The field's binding may not be a direct child: a nested sequence that
|
|
285
|
+
// collapsed (e.g. one preserved by flatten to keep its `meta.doc`) leaves
|
|
286
|
+
// the binding buried one or more levels down. Search through collapsed
|
|
287
|
+
// sequences for it. Using `binding.name` as the field name keeps the
|
|
288
|
+
// struct field aligned with the access path the backends render
|
|
289
|
+
// (`params.<binding.name>`).
|
|
290
|
+
const findCollapsedBinding = (node: Expr): Binding | undefined => {
|
|
291
|
+
const b = nodeToBinding.get(node);
|
|
292
|
+
if (b) return b;
|
|
293
|
+
// Only sequences collapse without leaving a binding; optional/repeat/
|
|
294
|
+
// alternative always register one, so no need to descend into them.
|
|
295
|
+
if (node.kind === "sequence") {
|
|
296
|
+
for (const child of node.attrs.nodes) {
|
|
297
|
+
const found = findCollapsedBinding(child);
|
|
298
|
+
if (found) return found;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
};
|
|
303
|
+
const childName = expr.attrs.nodes.map(findCollapsedBinding).find(Boolean)?.name;
|
|
304
|
+
if (childName) {
|
|
305
|
+
createBinding(expr, name, { kind: "struct", fields: { [childName]: rootType } }, []);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const resolve = (node: Expr) => nodeToBinding.get(node);
|
|
311
|
+
|
|
312
|
+
// Now that every binding's type has settled (sequence collapse, union arm
|
|
313
|
+
// retyping, root fixup), walk the IR once more to attach each binding's
|
|
314
|
+
// access path relative to top-level `params`. Backends render these paths
|
|
315
|
+
// instead of re-deriving them.
|
|
316
|
+
assignAccessPaths(expr, resolve);
|
|
317
|
+
|
|
318
|
+
return { bindings: registry, resolve };
|
|
319
|
+
}
|