@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,131 @@
|
|
|
1
|
+
import type { AccessPath, BindingId, BoundType } from "../../bindings/index.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Map a BoundType to its Python type expression.
|
|
5
|
+
*
|
|
6
|
+
* `resolve` is the named-type resolver from `collectNamedTypes` - returns the
|
|
7
|
+
* declared name for struct/union types so they can be referenced symbolically
|
|
8
|
+
* rather than inlined.
|
|
9
|
+
*
|
|
10
|
+
* Python target: 3.10+ (uses `X | None`, `list[T]`, `int`/`str`/etc.).
|
|
11
|
+
*/
|
|
12
|
+
export function mapType(type: BoundType, resolve: (type: BoundType) => string | undefined): string {
|
|
13
|
+
switch (type.kind) {
|
|
14
|
+
case "scalar":
|
|
15
|
+
return { int: "int", float: "float", str: "str", path: "InputPathType" }[type.scalar];
|
|
16
|
+
case "bool":
|
|
17
|
+
return "bool";
|
|
18
|
+
case "count":
|
|
19
|
+
return "int";
|
|
20
|
+
case "literal":
|
|
21
|
+
return typeof type.value === "string"
|
|
22
|
+
? `typing.Literal[${pyStr(type.value)}]`
|
|
23
|
+
: `typing.Literal[${type.value}]`;
|
|
24
|
+
case "optional":
|
|
25
|
+
// The solver has no nullable type: `optional` means "omittable" (the key
|
|
26
|
+
// may be absent), never "the value may be None". Omittability is expressed
|
|
27
|
+
// structurally at the field level (`typing.NotRequired[...]`); the value
|
|
28
|
+
// type itself is just the inner type. So in any value position (nested
|
|
29
|
+
// list/union arm, validator messages) we render the inner type with no
|
|
30
|
+
// `| None`.
|
|
31
|
+
return mapType(type.inner, resolve);
|
|
32
|
+
case "list":
|
|
33
|
+
return `list[${mapType(type.item, resolve)}]`;
|
|
34
|
+
case "struct": {
|
|
35
|
+
const name = resolve(type);
|
|
36
|
+
if (name) return name;
|
|
37
|
+
// Fallback: inline as Mapping[str, object]. Real struct types should always
|
|
38
|
+
// resolve to a declared TypedDict.
|
|
39
|
+
return "typing.Mapping[str, object]";
|
|
40
|
+
}
|
|
41
|
+
case "union": {
|
|
42
|
+
const name = resolve(type);
|
|
43
|
+
if (name) return name;
|
|
44
|
+
return type.variants.map((v) => mapType(v.type, resolve)).join(" | ");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Render a JS default value as a Python literal (signatures, `.get(k, default)`). */
|
|
50
|
+
export function renderPyLiteral(value: string | number | boolean): string {
|
|
51
|
+
if (typeof value === "boolean") return value ? "True" : "False";
|
|
52
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "float('nan')";
|
|
53
|
+
return pyStr(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Python double-quoted string literal with minimal escaping. */
|
|
57
|
+
export function pyStr(value: string): string {
|
|
58
|
+
// For any value containing control characters, JSON encoding produces a valid
|
|
59
|
+
// Python double-quoted literal (with the same `\n`/`\t`/`\uXXXX` escapes).
|
|
60
|
+
// Otherwise we hand-escape backslashes and double quotes only.
|
|
61
|
+
for (let i = 0; i < value.length; i++) {
|
|
62
|
+
if (value.charCodeAt(i) < 0x20) return JSON.stringify(value);
|
|
63
|
+
}
|
|
64
|
+
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options controlling how `renderAccess` renders an access path. */
|
|
68
|
+
export interface RenderAccessOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Render the LAST segment - if it is a `field` - as `base.get(key)` instead of
|
|
71
|
+
* `base[key]`. Used when binding the value of an optional field to a narrowed
|
|
72
|
+
* local: the params factory omits optional-without-default keys entirely (they
|
|
73
|
+
* are `typing.NotRequired`), so a bare subscript would raise `KeyError`. `.get()`
|
|
74
|
+
* yields `None` for an absent key, matching the present-and-None case. (Unlike
|
|
75
|
+
* JS, where `obj[missing]` is `undefined`, Python subscript on a missing key
|
|
76
|
+
* raises - so the structurally-mirrored TS walk needs this read in Python.)
|
|
77
|
+
*/
|
|
78
|
+
finalFieldGet?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Render the LAST segment - if it is a `field` - as `base.get(key, <default>)`,
|
|
81
|
+
* supplying a fallback value for an absent key. Used for fields that carry a
|
|
82
|
+
* Boutiques default and are read unconditionally (e.g. an output-basename like
|
|
83
|
+
* `maskfile="img_bet"`): the field is `NotRequired`, so a bare subscript would
|
|
84
|
+
* raise `KeyError`, and a bare `.get()` would yield `None` (wrong - the default
|
|
85
|
+
* must be substituted). The string is an already-rendered Python literal.
|
|
86
|
+
* Takes precedence over `finalFieldGet`.
|
|
87
|
+
*/
|
|
88
|
+
finalFieldDefault?: string;
|
|
89
|
+
/**
|
|
90
|
+
* Prefix substitutions: maps a rendered access prefix (e.g. `params["cfg"]`) to
|
|
91
|
+
* a local variable name. After each segment is appended, if the rendered prefix
|
|
92
|
+
* so far matches a key, it is replaced by the local. This lets reads of an
|
|
93
|
+
* optional field (and anything nested under it) resolve to the `.get()`-narrowed
|
|
94
|
+
* local bound by the enclosing presence guard - one lookup, absent-safe, and
|
|
95
|
+
* mypy can narrow it (a re-subscript or a fresh `.get()` cannot be narrowed).
|
|
96
|
+
*/
|
|
97
|
+
subst?: ReadonlyMap<string, string>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render a solver-assigned `AccessPath` to a Python expression. Starts from
|
|
102
|
+
* `params`; each `field` segment subscripts a key, and each `iter` segment
|
|
103
|
+
* resets the base to the loop variable bound to that repeat binding (resolved
|
|
104
|
+
* via `lookupLoopVar`). Mirrors the TypeScript `renderAccess`.
|
|
105
|
+
*/
|
|
106
|
+
export function renderAccess(
|
|
107
|
+
path: AccessPath,
|
|
108
|
+
lookupLoopVar: (binding: BindingId) => string,
|
|
109
|
+
options: RenderAccessOptions = {},
|
|
110
|
+
): string {
|
|
111
|
+
const { finalFieldGet = false, finalFieldDefault, subst } = options;
|
|
112
|
+
let cur = "params";
|
|
113
|
+
path.forEach((seg, i) => {
|
|
114
|
+
if (seg.kind !== "field") {
|
|
115
|
+
cur = lookupLoopVar(seg.binding);
|
|
116
|
+
} else {
|
|
117
|
+
const isLast = i === path.length - 1;
|
|
118
|
+
if (isLast && finalFieldDefault !== undefined) {
|
|
119
|
+
cur = `${cur}.get(${pyStr(seg.name)}, ${finalFieldDefault})`;
|
|
120
|
+
} else if (isLast && finalFieldGet) {
|
|
121
|
+
cur = `${cur}.get(${pyStr(seg.name)})`;
|
|
122
|
+
} else {
|
|
123
|
+
cur = `${cur}[${pyStr(seg.name)}]`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Swap in a narrowed local if this prefix was bound by an enclosing guard.
|
|
127
|
+
const sub = subst?.get(cur);
|
|
128
|
+
if (sub !== undefined) cur = sub;
|
|
129
|
+
});
|
|
130
|
+
return cur;
|
|
131
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Re-export shared utilities for convenience. All logic lives in shared
|
|
2
|
+
// backend modules.
|
|
3
|
+
|
|
4
|
+
export { structKey, unionKey } from "../type-keys.js";
|
|
5
|
+
export type { NamedType } from "../collect-named-types.js";
|
|
6
|
+
export { collectNamedTypes, resolveTypeName } from "../collect-named-types.js";
|
|
7
|
+
export type { FieldInfo } from "../collect-field-info.js";
|
|
8
|
+
export { collectFieldInfo } from "../collect-field-info.js";
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import type { BoundType } from "../../bindings/index.js";
|
|
2
|
+
import type { Expr } from "../../ir/index.js";
|
|
3
|
+
import type { CodegenContext } from "../../manifest/index.js";
|
|
4
|
+
import type { CodeBuilder } from "../code-builder.js";
|
|
5
|
+
import type { Scope } from "../scope.js";
|
|
6
|
+
import {
|
|
7
|
+
findAlternativeNode,
|
|
8
|
+
findRangeNode,
|
|
9
|
+
findRepeatNode,
|
|
10
|
+
structFields,
|
|
11
|
+
} from "../validate-walk.js";
|
|
12
|
+
import { structVariants } from "../union-variants.js";
|
|
13
|
+
import { emitDocstring } from "./emit.js";
|
|
14
|
+
import { mapType, pyStr } from "./typemap.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Emit a `<tool>_validate(params)` function that walks the solved root
|
|
18
|
+
* `BoundType` and raises `StyxValidationError` on invalid input. Mirrors the
|
|
19
|
+
* runtime validation v1 niwrap emits, but as a single inlined function (the v2
|
|
20
|
+
* backends inline `_cargs`/`_outputs` too, rather than v1's per-struct
|
|
21
|
+
* dispatch).
|
|
22
|
+
*
|
|
23
|
+
* Constraints checked: presence (required fields), `isinstance`, int/float
|
|
24
|
+
* range, list length, union `@type` membership + per-variant recursion, and
|
|
25
|
+
* nested struct recursion.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
type Resolve = (t: BoundType) => string | undefined;
|
|
29
|
+
|
|
30
|
+
interface Emit {
|
|
31
|
+
ctx: CodegenContext;
|
|
32
|
+
resolve: Resolve;
|
|
33
|
+
/** Per-function scope for generated locals (loop vars), reserving `params`. */
|
|
34
|
+
scope: Scope;
|
|
35
|
+
cb: CodeBuilder;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function emitValidate(
|
|
39
|
+
ctx: CodegenContext,
|
|
40
|
+
rootType: BoundType,
|
|
41
|
+
rootNode: Expr,
|
|
42
|
+
paramsType: string,
|
|
43
|
+
funcName: string,
|
|
44
|
+
resolve: Resolve,
|
|
45
|
+
scope: Scope,
|
|
46
|
+
cb: CodeBuilder,
|
|
47
|
+
): void {
|
|
48
|
+
const e: Emit = { ctx, resolve, scope: scope.child(["params"]), cb };
|
|
49
|
+
// Untyped input: a boundary guard usable on a parsed dict / config blob, not
|
|
50
|
+
// just an already-typed value (mirrors styx v1's `typing.Any` validator).
|
|
51
|
+
cb.line(`def ${funcName}(params: typing.Any) -> None:`);
|
|
52
|
+
cb.indent(() => {
|
|
53
|
+
emitDocstring(
|
|
54
|
+
cb,
|
|
55
|
+
`Validate untrusted parameters. Raises StyxValidationError if \`params\` is not a valid ${paramsType}.`,
|
|
56
|
+
);
|
|
57
|
+
emitRoot(e, rootType, rootNode);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function emitRoot(e: Emit, rootType: BoundType, rootNode: Expr): void {
|
|
62
|
+
if (rootType.kind === "struct") {
|
|
63
|
+
e.cb.line("if params is None or not isinstance(params, dict):");
|
|
64
|
+
e.cb.indent(() => raise(e, wrongObjectTypeMsg("params")));
|
|
65
|
+
for (const f of structFields(e.ctx, rootType, rootNode)) {
|
|
66
|
+
emitField(e, f.name, f.type, f.node, f.hasDefault, "params");
|
|
67
|
+
}
|
|
68
|
+
} else if (rootType.kind === "union") {
|
|
69
|
+
emitUnion(e, rootType, rootNode, "params", "params");
|
|
70
|
+
} else {
|
|
71
|
+
emitValue(e, rootType, rootNode, "params", "params", expectedType(e, rootType));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Validate one struct field, handling required-presence vs optional gating. */
|
|
76
|
+
function emitField(
|
|
77
|
+
e: Emit,
|
|
78
|
+
name: string,
|
|
79
|
+
fieldType: BoundType,
|
|
80
|
+
node: Expr | undefined,
|
|
81
|
+
hasDefault: boolean,
|
|
82
|
+
base: string,
|
|
83
|
+
): void {
|
|
84
|
+
// `@type` and other fixed literals have no user-supplied runtime value.
|
|
85
|
+
if (fieldType.kind === "literal") return;
|
|
86
|
+
|
|
87
|
+
const getExpr = `${base}.get(${pyStr(name)}, None)`;
|
|
88
|
+
const idxExpr = `${base}[${pyStr(name)}]`;
|
|
89
|
+
const expected = expectedType(e, fieldType);
|
|
90
|
+
const valueType = fieldType.kind === "optional" ? fieldType.inner : fieldType;
|
|
91
|
+
|
|
92
|
+
// Optionals and defaulted fields/flags accept None (None = "use default"), so
|
|
93
|
+
// gate the body instead of requiring presence.
|
|
94
|
+
if (fieldType.kind === "optional" || hasDefault) {
|
|
95
|
+
e.cb.line(`if ${getExpr} is not None:`);
|
|
96
|
+
e.cb.indent(() => emitValue(e, valueType, node, name, idxExpr, expected));
|
|
97
|
+
} else {
|
|
98
|
+
e.cb.line(`if ${getExpr} is None:`);
|
|
99
|
+
e.cb.indent(() => raise(e, str("`" + name + "` must not be None")));
|
|
100
|
+
emitValue(e, valueType, node, name, idxExpr, expected);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Validate a known-non-null value at `valueExpr` against `type`. */
|
|
105
|
+
function emitValue(
|
|
106
|
+
e: Emit,
|
|
107
|
+
type: BoundType,
|
|
108
|
+
node: Expr | undefined,
|
|
109
|
+
wireKey: string,
|
|
110
|
+
valueExpr: string,
|
|
111
|
+
expected: string,
|
|
112
|
+
): void {
|
|
113
|
+
switch (type.kind) {
|
|
114
|
+
case "optional":
|
|
115
|
+
emitValue(e, type.inner, node, wireKey, valueExpr, expected);
|
|
116
|
+
return;
|
|
117
|
+
case "literal":
|
|
118
|
+
return;
|
|
119
|
+
case "scalar":
|
|
120
|
+
switch (type.scalar) {
|
|
121
|
+
case "str":
|
|
122
|
+
checkType(e, valueExpr, "str", wireKey, expected);
|
|
123
|
+
return;
|
|
124
|
+
case "int":
|
|
125
|
+
checkType(e, valueExpr, "int", wireKey, expected);
|
|
126
|
+
emitRange(e, node, wireKey, valueExpr);
|
|
127
|
+
return;
|
|
128
|
+
case "float":
|
|
129
|
+
checkType(e, valueExpr, "(float, int)", wireKey, expected);
|
|
130
|
+
emitRange(e, node, wireKey, valueExpr);
|
|
131
|
+
return;
|
|
132
|
+
case "path":
|
|
133
|
+
checkType(e, valueExpr, "(pathlib.Path, str)", wireKey, expected);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
case "bool":
|
|
138
|
+
checkType(e, valueExpr, "bool", wireKey, expected);
|
|
139
|
+
return;
|
|
140
|
+
case "count":
|
|
141
|
+
checkType(e, valueExpr, "int", wireKey, expected);
|
|
142
|
+
return;
|
|
143
|
+
case "list": {
|
|
144
|
+
checkType(e, valueExpr, "list", wireKey, expected);
|
|
145
|
+
emitListLength(e, node, wireKey, valueExpr);
|
|
146
|
+
const itemNode = findRepeatNode(node)?.attrs.node;
|
|
147
|
+
const elem = e.scope.add("e");
|
|
148
|
+
e.cb.line(`for ${elem} in ${valueExpr}:`);
|
|
149
|
+
e.cb.indent(() => emitValue(e, type.item, itemNode, wireKey, elem, expected));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
case "struct": {
|
|
153
|
+
e.cb.line(`if not isinstance(${valueExpr}, dict):`);
|
|
154
|
+
e.cb.indent(() => raise(e, wrongObjectTypeMsg(valueExpr)));
|
|
155
|
+
for (const f of structFields(e.ctx, type, node)) {
|
|
156
|
+
emitField(e, f.name, f.type, f.node, f.hasDefault, valueExpr);
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
case "union":
|
|
161
|
+
emitUnion(e, type, node, wireKey, valueExpr);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function emitUnion(
|
|
167
|
+
e: Emit,
|
|
168
|
+
unionType: Extract<BoundType, { kind: "union" }>,
|
|
169
|
+
node: Expr | undefined,
|
|
170
|
+
wireKey: string,
|
|
171
|
+
valueExpr: string,
|
|
172
|
+
): void {
|
|
173
|
+
const litVariants = unionType.variants.filter((v) => v.type.kind === "literal");
|
|
174
|
+
const hasStruct = unionType.variants.some((v) => v.type.kind === "struct");
|
|
175
|
+
|
|
176
|
+
// Pure enum/choice: no struct variants, just literal values (no `@type`).
|
|
177
|
+
if (!hasStruct) {
|
|
178
|
+
const values = litVariants.map(
|
|
179
|
+
(v) => (v.type as Extract<BoundType, { kind: "literal" }>).value,
|
|
180
|
+
);
|
|
181
|
+
const allStr = values.every((x) => typeof x === "string");
|
|
182
|
+
checkType(e, valueExpr, allStr ? "str" : "(float, int)", wireKey, expectedType(e, unionType));
|
|
183
|
+
emitLiteralMembership(e, values, wireKey, valueExpr);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const altNode = findAlternativeNode(node);
|
|
188
|
+
// Struct variants with their indices; throws if two share an `@type` (a
|
|
189
|
+
// duplicate-tagged variant is unreachable and a mypy `comparison-overlap` -
|
|
190
|
+
// frontends must dodge duplicate tags before codegen). The index keeps each
|
|
191
|
+
// arm aligned with the IR `alts`.
|
|
192
|
+
const structVars = structVariants(unionType);
|
|
193
|
+
const emitStructArm = (): void => {
|
|
194
|
+
// `valueExpr` is known to be a dict here.
|
|
195
|
+
e.cb.line(`if "@type" not in ${valueExpr}:`);
|
|
196
|
+
e.cb.indent(() => raise(e, str("Params object is missing `@type`")));
|
|
197
|
+
const names = structVars
|
|
198
|
+
.map(({ variant }) => variant.name)
|
|
199
|
+
.filter((n): n is string => n !== undefined)
|
|
200
|
+
.map((n) => pyStr(n))
|
|
201
|
+
.join(", ");
|
|
202
|
+
e.cb.line(`if ${valueExpr}["@type"] not in [${names}]:`);
|
|
203
|
+
e.cb.indent(() =>
|
|
204
|
+
raise(e, str("Parameter `" + wireKey + "`s `@type` must be one of [" + names + "]")),
|
|
205
|
+
);
|
|
206
|
+
structVars.forEach(({ variant, i }, k) => {
|
|
207
|
+
const vt = variant.type as Extract<BoundType, { kind: "struct" }>;
|
|
208
|
+
const keyword = k === 0 ? "if" : "elif";
|
|
209
|
+
e.cb.line(`${keyword} ${valueExpr}["@type"] == ${pyStr(variant.name ?? "")}:`);
|
|
210
|
+
e.cb.indent(() => {
|
|
211
|
+
const fields = structFields(e.ctx, vt, altNode?.attrs.alts[i]).filter(
|
|
212
|
+
(f) => f.type.kind !== "literal",
|
|
213
|
+
);
|
|
214
|
+
if (fields.length === 0) {
|
|
215
|
+
e.cb.line("pass");
|
|
216
|
+
} else {
|
|
217
|
+
for (const f of fields) emitField(e, f.name, f.type, f.node, f.hasDefault, valueExpr);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Pure discriminated union: every variant is a struct with an `@type`.
|
|
224
|
+
if (litVariants.length === 0) {
|
|
225
|
+
e.cb.line(`if not isinstance(${valueExpr}, dict):`);
|
|
226
|
+
e.cb.indent(() => raise(e, wrongObjectTypeMsg(valueExpr)));
|
|
227
|
+
emitStructArm();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Mixed union: a value is either a struct (dict with `@type`) or a bare
|
|
232
|
+
// literal. Branch on the runtime shape.
|
|
233
|
+
e.cb.line(`if isinstance(${valueExpr}, dict):`);
|
|
234
|
+
e.cb.indent(emitStructArm);
|
|
235
|
+
e.cb.line("else:");
|
|
236
|
+
e.cb.indent(() => {
|
|
237
|
+
const values = litVariants.map(
|
|
238
|
+
(v) => (v.type as Extract<BoundType, { kind: "literal" }>).value,
|
|
239
|
+
);
|
|
240
|
+
emitLiteralMembership(e, values, wireKey, valueExpr);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Emit a `not in [...]` membership check over literal values. */
|
|
245
|
+
function emitLiteralMembership(
|
|
246
|
+
e: Emit,
|
|
247
|
+
values: (string | number)[],
|
|
248
|
+
wireKey: string,
|
|
249
|
+
valueExpr: string,
|
|
250
|
+
): void {
|
|
251
|
+
const rendered = values.map((x) => (typeof x === "string" ? pyStr(x) : pyNum(x))).join(", ");
|
|
252
|
+
e.cb.line(`if ${valueExpr} not in [${rendered}]:`);
|
|
253
|
+
e.cb.indent(() => raise(e, str("Parameter `" + wireKey + "` must be one of [" + rendered + "]")));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function checkType(
|
|
257
|
+
e: Emit,
|
|
258
|
+
valueExpr: string,
|
|
259
|
+
pyType: string,
|
|
260
|
+
wireKey: string,
|
|
261
|
+
expected: string,
|
|
262
|
+
): void {
|
|
263
|
+
e.cb.line(`if not isinstance(${valueExpr}, ${pyType}):`);
|
|
264
|
+
e.cb.indent(() => raise(e, wrongTypeMsg(wireKey, valueExpr, expected)));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emitRange(e: Emit, node: Expr | undefined, wireKey: string, valueExpr: string): void {
|
|
268
|
+
const term = findRangeNode(node);
|
|
269
|
+
if (!term) return;
|
|
270
|
+
const { minValue, maxValue } = term.attrs;
|
|
271
|
+
if (minValue !== undefined && maxValue !== undefined) {
|
|
272
|
+
e.cb.line(`if not (${pyNum(minValue)} <= ${valueExpr} <= ${pyNum(maxValue)}):`);
|
|
273
|
+
e.cb.indent(() =>
|
|
274
|
+
raise(
|
|
275
|
+
e,
|
|
276
|
+
str(`Parameter \`${wireKey}\` must be between ${minValue} and ${maxValue} (inclusive)`),
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
} else if (minValue !== undefined) {
|
|
280
|
+
e.cb.line(`if ${valueExpr} < ${pyNum(minValue)}:`);
|
|
281
|
+
e.cb.indent(() => raise(e, str(`Parameter \`${wireKey}\` must be at least ${minValue}`)));
|
|
282
|
+
} else if (maxValue !== undefined) {
|
|
283
|
+
e.cb.line(`if ${valueExpr} > ${pyNum(maxValue)}:`);
|
|
284
|
+
e.cb.indent(() => raise(e, str(`Parameter \`${wireKey}\` must be at most ${maxValue}`)));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function emitListLength(e: Emit, node: Expr | undefined, wireKey: string, valueExpr: string): void {
|
|
289
|
+
const rep = findRepeatNode(node);
|
|
290
|
+
if (!rep) return;
|
|
291
|
+
const { countMin, countMax } = rep.attrs;
|
|
292
|
+
if (countMin !== undefined && countMax !== undefined) {
|
|
293
|
+
e.cb.line(`if not (${countMin} <= len(${valueExpr}) <= ${countMax}):`);
|
|
294
|
+
e.cb.indent(() =>
|
|
295
|
+
raise(
|
|
296
|
+
e,
|
|
297
|
+
str(
|
|
298
|
+
`Parameter \`${wireKey}\` must contain between ${countMin} and ${countMax} elements (inclusive)`,
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
} else if (countMin !== undefined) {
|
|
303
|
+
e.cb.line(`if len(${valueExpr}) < ${countMin}:`);
|
|
304
|
+
e.cb.indent(() =>
|
|
305
|
+
raise(
|
|
306
|
+
e,
|
|
307
|
+
str(
|
|
308
|
+
`Parameter \`${wireKey}\` must contain at least ${countMin} ${plural("element", countMin)}`,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
);
|
|
312
|
+
} else if (countMax !== undefined) {
|
|
313
|
+
e.cb.line(`if len(${valueExpr}) > ${countMax}:`);
|
|
314
|
+
e.cb.indent(() =>
|
|
315
|
+
raise(
|
|
316
|
+
e,
|
|
317
|
+
str(
|
|
318
|
+
`Parameter \`${wireKey}\` must contain at most ${countMax} ${plural("element", countMax)}`,
|
|
319
|
+
),
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// -- Message + literal rendering --
|
|
326
|
+
|
|
327
|
+
function raise(e: Emit, messageExpr: string): void {
|
|
328
|
+
e.cb.line(`raise StyxValidationError(${messageExpr})`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** A plain double-quoted Python string message. */
|
|
332
|
+
function str(text: string): string {
|
|
333
|
+
return pyStr(text);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** A single-quoted f-string "wrong type" message referencing the runtime type. */
|
|
337
|
+
function wrongTypeMsg(wireKey: string, valueExpr: string, expected: string): string {
|
|
338
|
+
return `f'\`${wireKey}\` has the wrong type: Received \`{type(${valueExpr})}\` expected \`${expected}\`'`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The generic "Params object has the wrong type" f-string message. */
|
|
342
|
+
function wrongObjectTypeMsg(valueExpr: string): string {
|
|
343
|
+
return `f'Params object has the wrong type \\'{type(${valueExpr})}\\''`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function expectedType(e: Emit, type: BoundType): string {
|
|
347
|
+
return mapType(type, e.resolve);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function pyNum(n: number): string {
|
|
351
|
+
return Number.isFinite(n) ? String(n) : "float('nan')";
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function plural(word: string, count: number): string {
|
|
355
|
+
return count === 1 ? word : `${word}s`;
|
|
356
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Binding, BoundType } from "../bindings/index.js";
|
|
2
|
+
import type { Expr } from "../ir/index.js";
|
|
3
|
+
import type { CodegenContext } from "../manifest/index.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve a struct child node to its field binding, handling collapsed sequences.
|
|
7
|
+
*
|
|
8
|
+
* When the solver's simplify pass collapses `seq(lit("--flag"), terminal)` into
|
|
9
|
+
* just the terminal, the binding ends up on the inner node while metadata (doc,
|
|
10
|
+
* defaultValue) may remain on the outermost wrapper. This function recursively
|
|
11
|
+
* descends through collapsed sequences to find the binding, tracking the outermost
|
|
12
|
+
* node for metadata recovery.
|
|
13
|
+
*
|
|
14
|
+
* Uses type identity (`===`) to verify the binding matches the struct's field type,
|
|
15
|
+
* preventing cross-nesting name collisions where an inner struct has a field with
|
|
16
|
+
* the same name as the outer struct.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveFieldBinding(
|
|
19
|
+
node: Expr,
|
|
20
|
+
ctx: CodegenContext,
|
|
21
|
+
structType: Extract<BoundType, { kind: "struct" }>,
|
|
22
|
+
outermost?: Expr,
|
|
23
|
+
): { binding: Binding; wrapperNode: Expr } | undefined {
|
|
24
|
+
const wrapper = outermost ?? node;
|
|
25
|
+
const binding = ctx.resolve(node);
|
|
26
|
+
if (
|
|
27
|
+
binding &&
|
|
28
|
+
binding.name in structType.fields &&
|
|
29
|
+
binding.type === structType.fields[binding.name]
|
|
30
|
+
) {
|
|
31
|
+
return { binding, wrapperNode: wrapper };
|
|
32
|
+
}
|
|
33
|
+
// Recurse into collapsed sequences to find the binding deeper
|
|
34
|
+
if (node.kind === "sequence") {
|
|
35
|
+
for (const inner of node.attrs.nodes) {
|
|
36
|
+
const result = resolveFieldBinding(inner, ctx, structType, wrapper);
|
|
37
|
+
if (result) return result;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BindingRegistry,
|
|
3
|
+
GateAtom,
|
|
4
|
+
OutputScope,
|
|
5
|
+
ResolvedOutput,
|
|
6
|
+
ResolvedToken,
|
|
7
|
+
} from "../bindings/index.js";
|
|
8
|
+
import { outputGate } from "../bindings/index.js";
|
|
9
|
+
|
|
10
|
+
// Re-export the core helper so backends have a single entry point.
|
|
11
|
+
export { outputGate };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compact consecutive literal tokens. Backends that emit string concatenation
|
|
15
|
+
* benefit from a shorter token stream; backends that template each token
|
|
16
|
+
* individually can ignore this and use `output.tokens` directly.
|
|
17
|
+
*/
|
|
18
|
+
export function compactTokens(tokens: ResolvedToken[]): ResolvedToken[] {
|
|
19
|
+
const out: ResolvedToken[] = [];
|
|
20
|
+
for (const tok of tokens) {
|
|
21
|
+
const last = out[out.length - 1];
|
|
22
|
+
if (tok.kind === "literal" && last && last.kind === "literal") {
|
|
23
|
+
out[out.length - 1] = { kind: "literal", value: last.value + tok.value };
|
|
24
|
+
} else {
|
|
25
|
+
out.push(tok);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One output ready for codegen. `gate` is the wrapper sequence (outermost
|
|
33
|
+
* first); the backend renders each atom as the appropriate scope-introducing
|
|
34
|
+
* statement, then emits the path expression inside the innermost layer.
|
|
35
|
+
*/
|
|
36
|
+
export interface OutputEmitPlan {
|
|
37
|
+
name: string;
|
|
38
|
+
gate: GateAtom[];
|
|
39
|
+
tokens: ResolvedToken[];
|
|
40
|
+
resolved: ResolvedOutput;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function planOutput(
|
|
44
|
+
scopeGate: GateAtom[],
|
|
45
|
+
output: ResolvedOutput,
|
|
46
|
+
bindings: BindingRegistry,
|
|
47
|
+
): OutputEmitPlan {
|
|
48
|
+
return {
|
|
49
|
+
name: output.name,
|
|
50
|
+
gate: outputGate(scopeGate, output, bindings),
|
|
51
|
+
tokens: compactTokens(output.tokens),
|
|
52
|
+
resolved: output,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Does the output have any conditional wrapper? Equivalent to "is at least one
|
|
58
|
+
* atom a `present` or `variant`?". `iter` alone means the output emits a list
|
|
59
|
+
* and is not conditionally absent.
|
|
60
|
+
*/
|
|
61
|
+
export function isGated(plan: OutputEmitPlan): boolean {
|
|
62
|
+
return plan.gate.some((a) => a.kind === "present" || a.kind === "variant");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Does the output iterate (emit zero-or-more values)? */
|
|
66
|
+
export function isIterated(plan: OutputEmitPlan): boolean {
|
|
67
|
+
return plan.gate.some((a) => a.kind === "iter");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Convenience for backends emitting all outputs of a scope at once. The caller
|
|
72
|
+
* provides the scope's gate (typically `bindings.get(scope.scope)?.gate ?? []`).
|
|
73
|
+
*/
|
|
74
|
+
export function planScope(
|
|
75
|
+
scope: OutputScope,
|
|
76
|
+
scopeGate: GateAtom[],
|
|
77
|
+
bindings: BindingRegistry,
|
|
78
|
+
): OutputEmitPlan[] {
|
|
79
|
+
return scope.outputs.map((output) => planOutput(scopeGate, output, bindings));
|
|
80
|
+
}
|