diff --git a/.changeset/unified-fallbacks-array.md b/.changeset/unified-fallbacks-array.md new file mode 100644 index 0000000..6afb0be --- /dev/null +++ b/.changeset/unified-fallbacks-array.md @@ -0,0 +1,8 @@ +--- +"@stackables/bridge-core": minor +"@stackables/bridge-parser": minor +"@stackables/bridge-compiler": minor +"@stackables/bridge": minor +--- + +Migrate wire shape from separate `falsyFallback*`/`nullishFallback*` properties to a unified `fallbacks: WireFallback[]` array, enabling mixed `||` and `??` chains in any order (e.g. `A ?? B || C ?? D`). diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index fd3228d..f4245c8 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -144,11 +144,10 @@ function detectControlFlow( wires: Wire[], ): "break" | "continue" | "throw" | "panic" | null { for (const w of wires) { - if ("nullishControl" in w && w.nullishControl) { - return w.nullishControl.kind as "break" | "continue" | "throw" | "panic"; - } - if ("falsyControl" in w && w.falsyControl) { - return w.falsyControl.kind as "break" | "continue" | "throw" | "panic"; + if ("fallbacks" in w && w.fallbacks) { + for (const fb of w.fallbacks) { + if (fb.control) return fb.control.kind as "break" | "continue" | "throw" | "panic"; + } } if ("catchControl" in w && w.catchControl) { return w.catchControl.kind as "break" | "continue" | "throw" | "panic"; @@ -705,11 +704,10 @@ class CodegenContext { keys.add(refTrunkKey(w.condOr.leftRef)); if (w.condOr.rightRef) keys.add(refTrunkKey(w.condOr.rightRef)); } - if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) { - for (const ref of w.falsyFallbackRefs) keys.add(refTrunkKey(ref)); - } - if ("nullishFallbackRefs" in w && w.nullishFallbackRefs?.length) { - for (const ref of w.nullishFallbackRefs) keys.add(refTrunkKey(ref)); + if ("fallbacks" in w && w.fallbacks) { + for (const fb of w.fallbacks) { + if (fb.ref) keys.add(refTrunkKey(fb.ref)); + } } if ("catchFallbackRef" in w && w.catchFallbackRef) { keys.add(refTrunkKey(w.catchFallbackRef)); @@ -1812,8 +1810,7 @@ class CodegenContext { const controlWire = elemWires.find( (w) => w.to.path.length === 1 && - (("nullishControl" in w && w.nullishControl != null) || - ("falsyControl" in w && w.falsyControl != null) || + (("fallbacks" in w && w.fallbacks?.some(fb => fb.control != null)) || ("catchControl" in w && w.catchControl != null)), ); @@ -1836,13 +1833,13 @@ class CodegenContext { // Determine the check type const isNullish = - "nullishControl" in controlWire && controlWire.nullishControl != null; + controlWire.fallbacks?.some(fb => fb.type === "nullish" && fb.control != null) ?? false; if (mode === "continue") { if (isNullish) { return `${pad} if (${checkExpr} == null) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`; } - // falsyControl + // falsy fallback control return `${pad} if (!${checkExpr}) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`; } @@ -2317,39 +2314,36 @@ class CodegenContext { /** Apply falsy (||), nullish (??) and catch fallback chains to an expression. */ private applyFallbacks(w: Wire, expr: string): string { - // Falsy fallback chain (||) - if ("falsyFallbackRefs" in w && w.falsyFallbackRefs?.length) { - for (const ref of w.falsyFallbackRefs) { - expr = `(${expr} || ${this.refToExpr(ref)})`; // lgtm [js/code-injection] - } - } - if ("falsyFallback" in w && w.falsyFallback != null) { - expr = `(${expr} || ${emitCoerced(w.falsyFallback)})`; // lgtm [js/code-injection] - } - // Falsy control flow (throw/panic on || gate) - if ("falsyControl" in w && w.falsyControl) { - const ctrl = w.falsyControl; - if (ctrl.kind === "throw") { - expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] - } else if (ctrl.kind === "panic") { - expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] - } - } - - // Nullish coalescing (??) - if ("nullishFallbackRefs" in w && w.nullishFallbackRefs?.length) { - const refsExpr = w.nullishFallbackRefs.map(r => this.refToExpr(r)).join(' ?? '); - expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${refsExpr}))`; // lgtm [js/code-injection] - } else if ("nullishFallback" in w && w.nullishFallback != null) { - expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(w.nullishFallback)}))`; // lgtm [js/code-injection] - } - // Nullish control flow (throw/panic on ?? gate) - if ("nullishControl" in w && w.nullishControl) { - const ctrl = w.nullishControl; - if (ctrl.kind === "throw") { - expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] - } else if (ctrl.kind === "panic") { - expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] + if ("fallbacks" in w && w.fallbacks) { + for (const fb of w.fallbacks) { + if (fb.type === "falsy") { + if (fb.ref) { + expr = `(${expr} || ${this.refToExpr(fb.ref)})`; // lgtm [js/code-injection] + } else if (fb.value != null) { + expr = `(${expr} || ${emitCoerced(fb.value)})`; // lgtm [js/code-injection] + } else if (fb.control) { + const ctrl = fb.control; + if (ctrl.kind === "throw") { + expr = `(${expr} || (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] + } else if (ctrl.kind === "panic") { + expr = `(${expr} || (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] + } + } + } else { + // nullish + if (fb.ref) { + expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(fb.ref)}))`; // lgtm [js/code-injection] + } else if (fb.value != null) { + expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(fb.value)}))`; // lgtm [js/code-injection] + } else if (fb.control) { + const ctrl = fb.control; + if (ctrl.kind === "throw") { + expr = `(${expr} ?? (() => { throw new Error(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] + } else if (ctrl.kind === "panic") { + expr = `(${expr} ?? (() => { throw new __BridgePanicError(${JSON.stringify(ctrl.message)}); })())`; // lgtm [js/code-injection] + } + } + } } } @@ -2601,11 +2595,10 @@ class CodegenContext { if (w.condOr.rightRef) allRefs.add(refTrunkKey(w.condOr.rightRef)); } // Fallback refs - if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) { - for (const ref of w.falsyFallbackRefs) allRefs.add(refTrunkKey(ref)); - } - if ("nullishFallbackRefs" in w && w.nullishFallbackRefs?.length) { - for (const ref of w.nullishFallbackRefs) allRefs.add(refTrunkKey(ref)); + if ("fallbacks" in w && w.fallbacks) { + for (const fb of w.fallbacks) { + if (fb.ref) allRefs.add(refTrunkKey(fb.ref)); + } } if ("catchFallbackRef" in w && w.catchFallbackRef) allRefs.add(refTrunkKey(w.catchFallbackRef)); @@ -2927,10 +2920,10 @@ class CodegenContext { if ("from" in w) { collectTrunk(w.from); - if ("falsyFallbackRefs" in w && w.falsyFallbackRefs) - w.falsyFallbackRefs.forEach(collectTrunk); - if ("nullishFallbackRefs" in w && w.nullishFallbackRefs?.length) { - w.nullishFallbackRefs.forEach(collectTrunk); + if (w.fallbacks) { + for (const fb of w.fallbacks) { + if (fb.ref) collectTrunk(fb.ref); + } } if ("catchFallbackRef" in w && w.catchFallbackRef) collectTrunk(w.catchFallbackRef); diff --git a/packages/bridge-compiler/test/codegen.test.ts b/packages/bridge-compiler/test/codegen.test.ts index 4cd838e..7cc7ebe 100644 --- a/packages/bridge-compiler/test/codegen.test.ts +++ b/packages/bridge-compiler/test/codegen.test.ts @@ -331,7 +331,7 @@ bridge Query.refFallback { field: "nullishProbe", path: ["k"], }, - nullishFallback: "null", + fallbacks: [{ type: "nullish", value: "null" }], }, ], }, diff --git a/packages/bridge-compiler/test/fuzz-compile.test.ts b/packages/bridge-compiler/test/fuzz-compile.test.ts index 3628675..16a28b0 100644 --- a/packages/bridge-compiler/test/fuzz-compile.test.ts +++ b/packages/bridge-compiler/test/fuzz-compile.test.ts @@ -7,6 +7,7 @@ import type { BridgeDocument, NodeRef, Wire, + WireFallback, } from "@stackables/bridge-core"; import { executeBridge as executeRuntime } from "@stackables/bridge-core"; import { compileBridge, executeBridge as executeAot } from "../src/index.ts"; @@ -82,8 +83,13 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { { from: fromArb, to: toArb, - falsyFallback: constantValueArb, - nullishFallback: constantValueArb, + fallbacks: fc.array( + fc.oneof( + fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }), + fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }), + ) as fc.Arbitrary, + { minLength: 0, maxLength: 2 }, + ), catchFallback: constantValueArb, }, { requiredKeys: ["from", "to"] }, // Fallbacks are randomly omitted @@ -263,8 +269,13 @@ const fallbackHeavyBridgeArb: fc.Arbitrary = fc fc.record({ from: flatPathArb.map((path) => inputRef(type, field, path)), to: flatPathArb.map((path) => outputRef(type, field, path)), - falsyFallback: constantValueArb, - nullishFallback: constantValueArb, + fallbacks: fc.array( + fc.oneof( + fc.record({ type: fc.constant<"falsy">("falsy"), value: constantValueArb }), + fc.record({ type: fc.constant<"nullish">("nullish"), value: constantValueArb }), + ) as fc.Arbitrary, + { minLength: 0, maxLength: 2 }, + ), catchFallback: constantValueArb, }), { diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 0e7f560..e80a651 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -64,6 +64,7 @@ export type { ToolWire, VersionDecl, Wire, + WireFallback, } from "./types.ts"; // ── Utilities ─────────────────────────────────────────────────────────────── diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index 925e8ce..89175cc 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -18,7 +18,7 @@ import { coerceConstant, getSimplePullRef } from "./tree-utils.ts"; /** * A non-constant wire — any Wire variant that carries gate modifiers - * (`falsyFallback`, `nullishFallbackRefs`, `catchFallback`, etc.). + * (`fallbacks`, `catchFallback`, etc.). * Excludes the `{ value: string; to: NodeRef }` constant wire which has no * modifier slots. */ @@ -31,19 +31,20 @@ type WireWithGates = Exclude; * * Architecture: two distinct resolution axes — * - * **Falsy Gate** (`||`, within a wire): `falsyFallbackRefs` + `falsyFallback` - * → truthy check — falsy values (0, "", false) trigger fallback chain. + * **Fallback Gates** (`||` / `??`, within a wire): unified `fallbacks` array + * → falsy gates trigger on falsy values (0, "", false, null, undefined) + * → nullish gates trigger only on null/undefined + * → gates are processed left-to-right, allowing mixed `||` and `??` chains * * **Overdefinition** (across wires): multiple wires target the same path * → nullish check — only null/undefined falls through to the next wire. * * Per-wire layers: * Layer 1 — Execution (pullSingle + safe modifier) - * Layer 2a — Falsy Gate (falsyFallbackRefs → falsyFallback / falsyControl) - * Layer 2b — Nullish Gate (nullishFallbackRefs / nullishFallback / nullishControl) + * Layer 2 — Fallback Gates (unified fallbacks array: || and ?? in order) * Layer 3 — Catch (catchFallbackRef / catchFallback / catchControl) * - * After layers 1–2b, the overdefinition boundary (`!= null`) decides whether + * After layers 1–2, the overdefinition boundary (`!= null`) decides whether * to return or continue to the next wire. * * --- @@ -90,11 +91,8 @@ async function resolveWiresAsync( // Layer 1: Execution let value = await evaluateWireSource(ctx, w, pullChain); - // Layer 2a: Falsy Gate (||) - value = await applyFalsyGate(ctx, w, value, pullChain); - - // Layer 2b: Nullish Gate (??) - value = await applyNullishGate(ctx, w, value, pullChain); + // Layer 2: Fallback Gates (unified || and ?? chain) + value = await applyFallbackGates(ctx, w, value, pullChain); // Overdefinition Boundary if (value != null) return value; @@ -113,60 +111,39 @@ async function resolveWiresAsync( return undefined; } -// ── Layer 2a: Falsy Gate (||) ──────────────────────────────────────────────── +// ── Layer 2: Fallback Gates (unified || and ??) ───────────────────────────── /** - * Apply the Falsy Gate (Layer 2a) to a resolved value. + * Apply the unified Fallback Gates (Layer 2) to a resolved value. * - * If the value is already truthy the gate is a no-op. Otherwise the gate - * walks `falsyFallbackRefs` (chained `||` refs) in order, returning the first - * truthy result. If none yields a truthy value, `falsyControl` or - * `falsyFallback` is tried as a last resort. + * Walks the `fallbacks` array in order. Each entry is either a falsy gate + * (`||`) or a nullish gate (`??`). A falsy gate opens when `!value`; + * a nullish gate opens when `value == null`. When a gate is open, the + * fallback is applied (control flow, ref pull, or constant coercion) and + * the result replaces `value` for subsequent gates. */ -export async function applyFalsyGate( +export async function applyFallbackGates( ctx: TreeContext, w: WireWithGates, value: unknown, pullChain?: Set, ): Promise { - if (value) return value; // already truthy — gate is closed - - if (w.falsyFallbackRefs?.length) { - for (const ref of w.falsyFallbackRefs) { - const fallback = await ctx.pullSingle(ref, pullChain); - if (fallback) return fallback; + if (!w.fallbacks?.length) return value; + + for (const fallback of w.fallbacks) { + const isFalsyGateOpen = fallback.type === "falsy" && !value; + const isNullishGateOpen = fallback.type === "nullish" && value == null; + + if (isFalsyGateOpen || isNullishGateOpen) { + if (fallback.control) return applyControlFlow(fallback.control); + if (fallback.ref) { + value = await ctx.pullSingle(fallback.ref, pullChain); + } else if (fallback.value !== undefined) { + value = coerceConstant(fallback.value); + } } } - if (w.falsyControl) return applyControlFlow(w.falsyControl); - if (w.falsyFallback != null) return coerceConstant(w.falsyFallback); - return value; -} - -// ── Layer 2b: Nullish Gate (??) ────────────────────────────────────────────── - -/** - * Apply the Nullish Gate (Layer 2b) to a resolved value. - * - * If the value is non-nullish the gate is a no-op. Otherwise `nullishControl`, - * `nullishFallbackRefs`, or `nullishFallback` is applied (in priority order). - */ -export async function applyNullishGate( - ctx: TreeContext, - w: WireWithGates, - value: unknown, - pullChain?: Set, -): Promise { - if (value != null) return value; // non-nullish — gate is closed - - if (w.nullishControl) return applyControlFlow(w.nullishControl); - if (w.nullishFallbackRefs?.length) { - for (const ref of w.nullishFallbackRefs) { - const fallback = await ctx.pullSingle(ref, pullChain); - if (fallback != null) return fallback; - } - } - if (w.nullishFallback != null) return coerceConstant(w.nullishFallback); return value; } diff --git a/packages/bridge-core/src/tree-utils.ts b/packages/bridge-core/src/tree-utils.ts index 204115e..558f24a 100644 --- a/packages/bridge-core/src/tree-utils.ts +++ b/packages/bridge-core/src/tree-utils.ts @@ -172,7 +172,7 @@ export const SIMPLE_PULL_CACHE = Symbol.for("bridge.simplePull"); /** * Returns the `from` NodeRef when a wire qualifies for the simple-pull fast - * path (single `from` wire, no safe/falsy/nullish/catch modifiers). Returns + * path (single `from` wire, no safe/fallbacks/catch modifiers). Returns * `null` otherwise. The result is cached on the wire via a Symbol key so * subsequent calls are a single property read without affecting V8 shapes. * See docs/performance.md (#11). @@ -183,12 +183,7 @@ export function getSimplePullRef(w: Wire): NodeRef | null { if (cached !== undefined) return cached; const ref = !w.safe && - !w.falsyFallbackRefs?.length && - w.falsyControl == null && - w.falsyFallback == null && - w.nullishControl == null && - !w.nullishFallbackRefs?.length && - w.nullishFallback == null && + !w.fallbacks?.length && !w.catchControl && !w.catchFallbackRef && w.catchFallback == null diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index a3bf2a0..97cec2a 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -24,6 +24,23 @@ export type NodeRef = { pathSafe?: boolean[]; }; +/** + * A single entry in a wire's fallback chain. + * + * Each entry is either a falsy gate (`||`) or a nullish gate (`??`). + * The unified array allows mixing `||` and `??` in any order: + * + * `o.x <- a.x || b.x ?? "default" || c.x` + * + * Exactly one of `ref`, `value`, or `control` should be set. + */ +export interface WireFallback { + type: "falsy" | "nullish"; + ref?: NodeRef; + value?: string; + control?: ControlFlowInstruction; +} + /** * A wire connects a data source (from) to a data sink (to). * Execution is pull-based: when "to" is demanded, "from" is resolved. @@ -43,12 +60,7 @@ export type Wire = /** When true, this wire merges source properties into target (from `...source` syntax). */ spread?: true; safe?: true; - falsyFallbackRefs?: NodeRef[]; - falsyFallback?: string; - falsyControl?: ControlFlowInstruction; - nullishFallback?: string; - nullishFallbackRefs?: NodeRef[]; - nullishControl?: ControlFlowInstruction; + fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -61,12 +73,7 @@ export type Wire = elseRef?: NodeRef; elseValue?: string; to: NodeRef; - falsyFallbackRefs?: NodeRef[]; - falsyFallback?: string; - falsyControl?: ControlFlowInstruction; - nullishFallback?: string; - nullishFallbackRefs?: NodeRef[]; - nullishControl?: ControlFlowInstruction; + fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -81,12 +88,7 @@ export type Wire = rightSafe?: true; }; to: NodeRef; - falsyFallbackRefs?: NodeRef[]; - falsyFallback?: string; - falsyControl?: ControlFlowInstruction; - nullishFallback?: string; - nullishFallbackRefs?: NodeRef[]; - nullishControl?: ControlFlowInstruction; + fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -101,12 +103,7 @@ export type Wire = rightSafe?: true; }; to: NodeRef; - falsyFallbackRefs?: NodeRef[]; - falsyFallback?: string; - falsyControl?: ControlFlowInstruction; - nullishFallback?: string; - nullishFallbackRefs?: NodeRef[]; - nullishControl?: ControlFlowInstruction; + fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; diff --git a/packages/bridge-core/test/resolve-wires-gates.test.ts b/packages/bridge-core/test/resolve-wires-gates.test.ts index 803a3ac..48c0f25 100644 --- a/packages/bridge-core/test/resolve-wires-gates.test.ts +++ b/packages/bridge-core/test/resolve-wires-gates.test.ts @@ -7,8 +7,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { BREAK_SYM, CONTINUE_SYM } from "../src/tree-types.ts"; import { - applyFalsyGate, - applyNullishGate, + applyFallbackGates, applyCatchGate, } from "../src/resolveWires.ts"; import type { TreeContext } from "../src/tree-types.ts"; @@ -43,66 +42,72 @@ function fromWire(overrides: Partial = {}): TestWire { return { from: REF, to: REF, ...overrides } as TestWire; } -// ── applyFalsyGate ──────────────────────────────────────────────────────────── +// ── applyFallbackGates — falsy (||) ───────────────────────────────────────── -describe("applyFalsyGate", () => { +describe("applyFallbackGates — falsy (||)", () => { test("passes through a truthy value unchanged", async () => { const ctx = makeCtx(); const w = fromWire(); - assert.equal(await applyFalsyGate(ctx, w, "hello"), "hello"); - assert.equal(await applyFalsyGate(ctx, w, 42), 42); - assert.equal(await applyFalsyGate(ctx, w, true), true); - assert.deepEqual(await applyFalsyGate(ctx, w, { x: 1 }), { x: 1 }); + assert.equal(await applyFallbackGates(ctx, w, "hello"), "hello"); + assert.equal(await applyFallbackGates(ctx, w, 42), 42); + assert.equal(await applyFallbackGates(ctx, w, true), true); + assert.deepEqual(await applyFallbackGates(ctx, w, { x: 1 }), { x: 1 }); }); test("returns falsy value when no fallback is configured", async () => { const ctx = makeCtx(); const w = fromWire(); - assert.equal(await applyFalsyGate(ctx, w, 0), 0); - assert.equal(await applyFalsyGate(ctx, w, ""), ""); - assert.equal(await applyFalsyGate(ctx, w, false), false); - assert.equal(await applyFalsyGate(ctx, w, null), null); + assert.equal(await applyFallbackGates(ctx, w, 0), 0); + assert.equal(await applyFallbackGates(ctx, w, ""), ""); + assert.equal(await applyFallbackGates(ctx, w, false), false); + assert.equal(await applyFallbackGates(ctx, w, null), null); }); - test("returns first truthy ref from falsyFallbackRefs", async () => { + test("returns first truthy ref from falsy fallback refs", async () => { const ctx = makeCtx({ "m.a": null, "m.b": "found" }); - const w = fromWire({ falsyFallbackRefs: [ref("a"), ref("b")] }); - assert.equal(await applyFalsyGate(ctx, w, null), "found"); + const w = fromWire({ fallbacks: [ + { type: "falsy", ref: ref("a") }, + { type: "falsy", ref: ref("b") }, + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), "found"); }); - test("skips falsy refs and falls through to falsyFallback constant", async () => { + test("skips falsy refs and falls through to falsy constant", async () => { const ctx = makeCtx({ "m.a": 0 }); - const w = fromWire({ falsyFallbackRefs: [ref("a")], falsyFallback: "42" }); - assert.equal(await applyFalsyGate(ctx, w, null), 42); + const w = fromWire({ fallbacks: [ + { type: "falsy", ref: ref("a") }, + { type: "falsy", value: "42" }, + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), 42); }); - test("applies falsyFallback constant when value is falsy and no refs given", async () => { + test("applies falsy constant when value is falsy and no refs given", async () => { const ctx = makeCtx(); - const w = fromWire({ falsyFallback: "default" }); - assert.equal(await applyFalsyGate(ctx, w, null), "default"); - assert.equal(await applyFalsyGate(ctx, w, false), "default"); - assert.equal(await applyFalsyGate(ctx, w, ""), "default"); + const w = fromWire({ fallbacks: [{ type: "falsy", value: "default" }] }); + assert.equal(await applyFallbackGates(ctx, w, null), "default"); + assert.equal(await applyFallbackGates(ctx, w, false), "default"); + assert.equal(await applyFallbackGates(ctx, w, ""), "default"); }); - test("applies falsyControl when value is falsy", async () => { + test("applies falsy control when value is falsy", async () => { const ctx = makeCtx(); - const w = fromWire({ falsyControl: { kind: "continue" } }); - assert.equal(await applyFalsyGate(ctx, w, 0), CONTINUE_SYM); + const w = fromWire({ fallbacks: [{ type: "falsy", control: { kind: "continue" } }] }); + assert.equal(await applyFallbackGates(ctx, w, 0), CONTINUE_SYM); }); - test("falsyControl kind=break returns BREAK_SYM", async () => { + test("falsy control kind=break returns BREAK_SYM", async () => { const ctx = makeCtx(); - const w = fromWire({ falsyControl: { kind: "break" } }); - assert.equal(await applyFalsyGate(ctx, w, false), BREAK_SYM); + const w = fromWire({ fallbacks: [{ type: "falsy", control: { kind: "break" } }] }); + assert.equal(await applyFallbackGates(ctx, w, false), BREAK_SYM); }); - test("falsyControl kind=throw throws an error", async () => { + test("falsy control kind=throw throws an error", async () => { const ctx = makeCtx(); - const w = fromWire({ falsyControl: { kind: "throw", message: "boom" } }); - await assert.rejects(() => applyFalsyGate(ctx, w, null), /boom/); + const w = fromWire({ fallbacks: [{ type: "falsy", control: { kind: "throw", message: "boom" } }] }); + await assert.rejects(() => applyFallbackGates(ctx, w, null), /boom/); }); - test("forwards pullChain to ctx.pullSingle for falsyFallbackRefs", async () => { + test("forwards pullChain to ctx.pullSingle for falsy ref", async () => { let capturedChain: Set | undefined; const ctx: TreeContext = { pullSingle(_ref, pullChain) { @@ -111,60 +116,60 @@ describe("applyFalsyGate", () => { }, }; const chain = new Set(["some:key"]); - const w = fromWire({ falsyFallbackRefs: [ref("a")] }); - await applyFalsyGate(ctx, w, null, chain); + const w = fromWire({ fallbacks: [{ type: "falsy", ref: ref("a") }] }); + await applyFallbackGates(ctx, w, null, chain); assert.equal(capturedChain, chain); }); }); -// ── applyNullishGate ────────────────────────────────────────────────────────── +// ── applyFallbackGates — nullish (??) ──────────────────────────────────────── -describe("applyNullishGate", () => { +describe("applyFallbackGates — nullish (??)", () => { test("passes through a non-nullish value unchanged", async () => { const ctx = makeCtx(); - const w = fromWire(); - assert.equal(await applyNullishGate(ctx, w, "hello"), "hello"); - assert.equal(await applyNullishGate(ctx, w, 0), 0); - assert.equal(await applyNullishGate(ctx, w, false), false); - assert.equal(await applyNullishGate(ctx, w, ""), ""); + const w = fromWire({ fallbacks: [{ type: "nullish", value: "99" }] }); + assert.equal(await applyFallbackGates(ctx, w, "hello"), "hello"); + assert.equal(await applyFallbackGates(ctx, w, 0), 0); + assert.equal(await applyFallbackGates(ctx, w, false), false); + assert.equal(await applyFallbackGates(ctx, w, ""), ""); }); test("returns null/undefined when no fallback is configured", async () => { const ctx = makeCtx(); const w = fromWire(); - assert.equal(await applyNullishGate(ctx, w, null), null); - assert.equal(await applyNullishGate(ctx, w, undefined), undefined); + assert.equal(await applyFallbackGates(ctx, w, null), null); + assert.equal(await applyFallbackGates(ctx, w, undefined), undefined); }); - test("resolves nullishFallbackRefs when value is null", async () => { + test("resolves nullish ref when value is null", async () => { const ctx = makeCtx({ "m.fallback": "resolved" }); - const w = fromWire({ nullishFallbackRefs: [ref("fallback")] }); - assert.equal(await applyNullishGate(ctx, w, null), "resolved"); + const w = fromWire({ fallbacks: [{ type: "nullish", ref: ref("fallback") }] }); + assert.equal(await applyFallbackGates(ctx, w, null), "resolved"); }); - test("applies nullishFallback constant when value is null", async () => { + test("applies nullish constant when value is null", async () => { const ctx = makeCtx(); - const w = fromWire({ nullishFallback: "99" }); - assert.equal(await applyNullishGate(ctx, w, null), 99); - assert.equal(await applyNullishGate(ctx, w, undefined), 99); + const w = fromWire({ fallbacks: [{ type: "nullish", value: "99" }] }); + assert.equal(await applyFallbackGates(ctx, w, null), 99); + assert.equal(await applyFallbackGates(ctx, w, undefined), 99); }); - test("applies nullishControl when value is null", async () => { + test("applies nullish control when value is null", async () => { const ctx = makeCtx(); - const w = fromWire({ nullishControl: { kind: "continue" } }); - assert.equal(await applyNullishGate(ctx, w, null), CONTINUE_SYM); + const w = fromWire({ fallbacks: [{ type: "nullish", control: { kind: "continue" } }] }); + assert.equal(await applyFallbackGates(ctx, w, null), CONTINUE_SYM); }); - test("nullishControl takes priority over nullishFallbackRefs", async () => { + test("nullish control takes priority (returns immediately)", async () => { const ctx = makeCtx({ "m.f": "should-not-be-used" }); - const w = fromWire({ - nullishControl: { kind: "break" }, - nullishFallbackRefs: [REF], - }); - assert.equal(await applyNullishGate(ctx, w, null), BREAK_SYM); + const w = fromWire({ fallbacks: [ + { type: "nullish", control: { kind: "break" } }, + { type: "nullish", ref: REF }, + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), BREAK_SYM); }); - test("forwards pullChain to ctx.pullSingle for nullishFallbackRefs", async () => { + test("forwards pullChain to ctx.pullSingle for nullish ref", async () => { let capturedChain: Set | undefined; const ctx: TreeContext = { pullSingle(_ref, pullChain) { @@ -173,12 +178,62 @@ describe("applyNullishGate", () => { }, }; const chain = new Set(["some:key"]); - const w = fromWire({ nullishFallbackRefs: [REF] }); - await applyNullishGate(ctx, w, null, chain); + const w = fromWire({ fallbacks: [{ type: "nullish", ref: REF }] }); + await applyFallbackGates(ctx, w, null, chain); assert.equal(capturedChain, chain); }); }); +// ── applyFallbackGates — mixed chains ──────────────────────────────────────── + +describe("applyFallbackGates — mixed || and ??", () => { + test("A ?? B || C — nullish then falsy", async () => { + const ctx = makeCtx({ "m.b": 0, "m.c": "found" }); + const w = fromWire({ fallbacks: [ + { type: "nullish", ref: ref("b") }, // ?? B → 0 (non-nullish, stops ?? but falsy) + { type: "falsy", ref: ref("c") }, // || C → "found" + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), "found"); + }); + + test("A || B ?? C — falsy then nullish", async () => { + const ctx = makeCtx({ "m.b": null, "m.c": "fallback" }); + const w = fromWire({ fallbacks: [ + { type: "falsy", ref: ref("b") }, // || B → null (still falsy) + { type: "nullish", ref: ref("c") }, // ?? C → "fallback" + ] }); + assert.equal(await applyFallbackGates(ctx, w, ""), "fallback"); + }); + + test("A ?? B || C ?? D — four-item chain", async () => { + const ctx = makeCtx({ "m.b": null, "m.c": null }); + const w = fromWire({ fallbacks: [ + { type: "nullish", ref: ref("b") }, // ?? B → null (still nullish) + { type: "falsy", ref: ref("c") }, // || C → null (still falsy) + { type: "nullish", value: "final" }, // ?? D → "final" + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), "final"); + }); + + test("mixed chain stops when value becomes truthy and non-nullish", async () => { + const ctx = makeCtx({ "m.b": "good" }); + const w = fromWire({ fallbacks: [ + { type: "nullish", ref: ref("b") }, // ?? B → "good" + { type: "falsy", value: "unused" }, // || ... gate closed, value is truthy + ] }); + assert.equal(await applyFallbackGates(ctx, w, null), "good"); + }); + + test("falsy gate open but nullish gate closed for 0", async () => { + const ctx = makeCtx(); + const w = fromWire({ fallbacks: [ + { type: "nullish", value: "unused" }, // ?? gate closed: 0 != null + { type: "falsy", value: "fallback" }, // || gate open: !0 is true + ] }); + assert.equal(await applyFallbackGates(ctx, w, 0), "fallback"); + }); +}); + // ── applyCatchGate ──────────────────────────────────────────────────────────── describe("applyCatchGate", () => { diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index 1d9bc4d..6f298a5 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -180,7 +180,7 @@ function serializeToolBlock(tool: ToolDef): string { * Otherwise delegates to `serializeRef`. * * This is used to emit `catch handle.path` or `catch pipe:source` for wire - * `catchFallbackRef` values, or `?? ref` for `nullishFallbackRefs`. + * `catchFallbackRef` values, or `|| ref` / `?? ref` for `fallbacks`. */ function serializePipeOrRef( ref: NodeRef, @@ -855,23 +855,12 @@ function serializeBridgeBlock(bridge: Bridge): string { const fieldPath = ew.to.path.slice(pathDepth); const elemTo = "." + serPath(fieldPath); - const ffr = - ew.falsyFallbackRefs?.map((r) => ` || ${sPipeOrRef(r)}`).join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in ew && ew.falsyControl - ? ` || ${serializeControl(ew.falsyControl)}` - : "falsyFallback" in ew && ew.falsyFallback - ? ` || ${ew.falsyFallback}` - : ""); - const nuf = - "nullishControl" in ew && ew.nullishControl - ? ` ?? ${serializeControl(ew.nullishControl)}` - : "nullishFallbackRefs" in ew && ew.nullishFallbackRefs?.length - ? ew.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : "nullishFallback" in ew && ew.nullishFallback - ? ` ?? ${ew.nullishFallback}` - : ""; + const fallbackStr = (ew.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in ew && ew.catchControl ? ` catch ${serializeControl(ew.catchControl)}` @@ -880,7 +869,7 @@ function serializeBridgeBlock(bridge: Bridge): string { : "catchFallback" in ew && ew.catchFallback ? ` catch ${ew.catchFallback}` : ""; - lines.push(`${indent}${elemTo} <- ${fromPart}${nfb}${nuf}${errf}`); + lines.push(`${indent}${elemTo} <- ${fromPart}${fallbackStr}${errf}`); } // Emit expression element wires at this level @@ -971,23 +960,12 @@ function serializeBridgeBlock(bridge: Bridge): string { const elseStr = w.elseRef ? sRef(w.elseRef, true) : (w.elseValue ?? "null"); - const ffr = - w.falsyFallbackRefs?.map((r) => ` || ${sPipeOrRef(r)}`).join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in w && w.falsyControl - ? ` || ${serializeControl(w.falsyControl)}` - : w.falsyFallback - ? ` || ${w.falsyFallback}` - : ""); - const nuf = - "nullishControl" in w && w.nullishControl - ? ` ?? ${serializeControl(w.nullishControl)}` - : w.nullishFallbackRefs?.length - ? w.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : w.nullishFallback - ? ` ?? ${w.nullishFallback}` - : ""; + const fallbackStr = (w.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in w && w.catchControl ? ` catch ${serializeControl(w.catchControl)}` @@ -997,7 +975,7 @@ function serializeBridgeBlock(bridge: Bridge): string { ? ` catch ${w.catchFallback}` : ""; lines.push( - `${toStr} <- ${condStr} ? ${thenStr} : ${elseStr}${nfb}${nuf}${errf}`, + `${toStr} <- ${condStr} ? ${thenStr} : ${elseStr}${fallbackStr}${errf}`, ); continue; } @@ -1050,23 +1028,12 @@ function serializeBridgeBlock(bridge: Bridge): string { } } const toStr = sRef(w.to, false); - const ffr = - w.falsyFallbackRefs?.map((r) => ` || ${sPipeOrRef(r)}`).join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in w && w.falsyControl - ? ` || ${serializeControl(w.falsyControl)}` - : w.falsyFallback - ? ` || ${w.falsyFallback}` - : ""); - const nuf = - "nullishControl" in w && w.nullishControl - ? ` ?? ${serializeControl(w.nullishControl)}` - : w.nullishFallbackRefs?.length - ? w.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : w.nullishFallback - ? ` ?? ${w.nullishFallback}` - : ""; + const fallbackStr = (w.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in w && w.catchControl ? ` catch ${serializeControl(w.catchControl)}` @@ -1075,7 +1042,7 @@ function serializeBridgeBlock(bridge: Bridge): string { : w.catchFallback ? ` catch ${w.catchFallback}` : ""; - lines.push(`${toStr} <- ${fromStr}${nfb}${nuf}${errf}`); + lines.push(`${toStr} <- ${fromStr}${fallbackStr}${errf}`); } // ── Top-level alias declarations ───────────────────────────────────── @@ -1250,25 +1217,12 @@ function serializeBridgeBlock(bridge: Bridge): string { const exprStr = serializeExprTree(tk); if (exprStr) { const destStr = sRef(outWire.to, false); - const ffr = - outWire.falsyFallbackRefs - ?.map((r) => ` || ${sPipeOrRef(r)}`) - .join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in outWire && outWire.falsyControl - ? ` || ${serializeControl(outWire.falsyControl)}` - : outWire.falsyFallback - ? ` || ${outWire.falsyFallback}` - : ""); - const nuf = - "nullishControl" in outWire && outWire.nullishControl - ? ` ?? ${serializeControl(outWire.nullishControl)}` - : outWire.nullishFallbackRefs?.length - ? outWire.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : outWire.nullishFallback - ? ` ?? ${outWire.nullishFallback}` - : ""; + const fallbackStr = (outWire.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` @@ -1277,7 +1231,7 @@ function serializeBridgeBlock(bridge: Bridge): string { : outWire.catchFallback ? ` catch ${outWire.catchFallback}` : ""; - lines.push(`${destStr} <- ${exprStr}${nfb}${nuf}${errf}`); + lines.push(`${destStr} <- ${exprStr}${fallbackStr}${errf}`); } continue; } @@ -1288,25 +1242,12 @@ function serializeBridgeBlock(bridge: Bridge): string { const templateStr = reconstructTemplateString(tk); if (templateStr) { const destStr = sRef(outWire.to, false); - const ffr = - outWire.falsyFallbackRefs - ?.map((r) => ` || ${sPipeOrRef(r)}`) - .join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in outWire && outWire.falsyControl - ? ` || ${serializeControl(outWire.falsyControl)}` - : outWire.falsyFallback - ? ` || ${outWire.falsyFallback}` - : ""); - const nuf = - "nullishControl" in outWire && outWire.nullishControl - ? ` ?? ${serializeControl(outWire.nullishControl)}` - : outWire.nullishFallbackRefs?.length - ? outWire.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : outWire.nullishFallback - ? ` ?? ${outWire.nullishFallback}` - : ""; + const fallbackStr = (outWire.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` @@ -1315,7 +1256,7 @@ function serializeBridgeBlock(bridge: Bridge): string { : outWire.catchFallback ? ` catch ${outWire.catchFallback}` : ""; - lines.push(`${destStr} <- ${templateStr}${nfb}${nuf}${errf}`); + lines.push(`${destStr} <- ${templateStr}${fallbackStr}${errf}`); } continue; } @@ -1345,25 +1286,12 @@ function serializeBridgeBlock(bridge: Bridge): string { if (actualSourceRef && handleChain.length > 0) { const sourceStr = sRef(actualSourceRef, true); const destStr = sRef(outWire.to, false); - const ffr = - outWire.falsyFallbackRefs - ?.map((r) => ` || ${sPipeOrRef(r)}`) - .join("") ?? ""; - const nfb = - ffr + - ("falsyControl" in outWire && outWire.falsyControl - ? ` || ${serializeControl(outWire.falsyControl)}` - : outWire.falsyFallback - ? ` || ${outWire.falsyFallback}` - : ""); - const nuf = - "nullishControl" in outWire && outWire.nullishControl - ? ` ?? ${serializeControl(outWire.nullishControl)}` - : outWire.nullishFallbackRefs?.length - ? outWire.nullishFallbackRefs.map(r => ` ?? ${sPipeOrRef(r)}`).join('') - : outWire.nullishFallback - ? ` ?? ${outWire.nullishFallback}` - : ""; + const fallbackStr = (outWire.fallbacks ?? []).map(f => { + const op = f.type === "falsy" ? "||" : "??"; + if (f.control) return ` ${op} ${serializeControl(f.control)}`; + if (f.ref) return ` ${op} ${sPipeOrRef(f.ref)}`; + return ` ${op} ${f.value}`; + }).join(""); const errf = "catchControl" in outWire && outWire.catchControl ? ` catch ${serializeControl(outWire.catchControl)}` @@ -1373,7 +1301,7 @@ function serializeBridgeBlock(bridge: Bridge): string { ? ` catch ${outWire.catchFallback}` : ""; lines.push( - `${destStr} <- ${handleChain.join(":")}:${sourceStr}${nfb}${nuf}${errf}`, + `${destStr} <- ${handleChain.join(":")}:${sourceStr}${fallbackStr}${errf}`, ); } } diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index d767e58..990efba 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -80,6 +80,7 @@ import type { ToolDep, ToolWire, Wire, + WireFallback, } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; @@ -371,15 +372,9 @@ class BridgeParser extends CstParser { }, }, ]); - // || coalesce chain + // || / ?? coalesce chain (mixed order) this.MANY(() => { - this.CONSUME(NullCoalesce); - this.SUBRULE(this.coalesceAlternative, { LABEL: "aliasNullAlt" }); - }); - // ?? nullish fallback - this.MANY4(() => { - this.CONSUME(ErrorCoalesce); - this.SUBRULE2(this.coalesceAlternative, { LABEL: "aliasNullishAlt" }); + this.SUBRULE4(this.coalesceChainItem, { LABEL: "aliasCoalesceItem" }); }); // catch error fallback this.OPTION2(() => { @@ -530,15 +525,9 @@ class BridgeParser extends CstParser { ]); // Optional array mapping: [] as { ... } this.OPTION(() => this.SUBRULE(this.arrayMapping)); - // || coalesce chain + // || / ?? coalesce chain (mixed order) this.MANY(() => { - this.CONSUME(NullCoalesce); - this.SUBRULE(this.coalesceAlternative, { LABEL: "nullAlt" }); - }); - // ?? nullish fallback - this.MANY4(() => { - this.CONSUME(ErrorCoalesce); - this.SUBRULE2(this.coalesceAlternative, { LABEL: "nullishAlt" }); + this.SUBRULE(this.coalesceChainItem, { LABEL: "coalesceItem" }); }); // catch error fallback this.OPTION5(() => { @@ -663,16 +652,10 @@ class BridgeParser extends CstParser { this.OPTION2(() => this.SUBRULE(this.arrayMapping, { LABEL: "nestedArrayMapping" }), ); - // || coalesce chain (only when no nested array mapping) + // || / ?? coalesce chain (mixed order, only when no nested array mapping) this.MANY(() => { - this.CONSUME(NullCoalesce); - this.SUBRULE(this.coalesceAlternative, { LABEL: "elemNullAlt" }); - }); - // ?? nullish fallback - this.MANY4(() => { - this.CONSUME(ErrorCoalesce); - this.SUBRULE2(this.coalesceAlternative, { - LABEL: "elemNullishAlt", + this.SUBRULE2(this.coalesceChainItem, { + LABEL: "elemCoalesceItem", }); }); // catch error fallback @@ -770,16 +753,10 @@ class BridgeParser extends CstParser { }, }, ]); - // || coalesce chain + // || / ?? coalesce chain (mixed order) this.MANY2(() => { - this.CONSUME(NullCoalesce); - this.SUBRULE(this.coalesceAlternative, { LABEL: "scopeNullAlt" }); - }); - // ?? nullish fallback - this.MANY4(() => { - this.CONSUME(ErrorCoalesce); - this.SUBRULE2(this.coalesceAlternative, { - LABEL: "scopeNullishAlt", + this.SUBRULE3(this.coalesceChainItem, { + LABEL: "scopeCoalesceItem", }); }); // catch error fallback @@ -862,6 +839,19 @@ class BridgeParser extends CstParser { ]); }); + /** + * A single item in a coalesce chain: either `|| alt` or `?? alt`. + * Grouping both operators into one rule preserves their relative order + * when mixing `||` and `??` in a single wire. + */ + public coalesceChainItem = this.RULE("coalesceChainItem", () => { + this.OR([ + { ALT: () => this.CONSUME(NullCoalesce, { LABEL: "falsyOp" }) }, + { ALT: () => this.CONSUME(ErrorCoalesce, { LABEL: "nullishOp" }) }, + ]); + this.SUBRULE(this.coalesceAlternative, { LABEL: "altValue" }); + }); + // ── Define block ─────────────────────────────────────────────────────── public defineBlock = this.RULE("defineBlock", () => { @@ -1755,37 +1745,20 @@ function processElementLines( }; // Process coalesce modifiers - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(elemLine, "elemNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, elemLineNum); - if ("literal" in altResult) { - falsyFallback = altResult.literal; - } else if ("control" in altResult) { - falsyControl = altResult.control; - } else { - nullAltRefs.push(altResult.sourceRef); - } - } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(elemLine, "elemNullishAlt"); - for (const nullishAlt of nullishAltList) { + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(elemLine, "elemCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAltIterAware( - nullishAlt, - elemLineNum, - ); + const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); if ("literal" in altResult) { - nullishFallback = altResult.literal; + fallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - nullishControl = altResult.control; + fallbacks.push({ type, control: altResult.control }); } else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; @@ -1807,12 +1780,7 @@ function processElementLines( } const lastAttrs = { - ...(nullAltRefs.length > 0 ? { falsyFallbackRefs: nullAltRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -1834,7 +1802,7 @@ function processElementLines( } else { wires.push({ value: raw, to: elemToRef, ...lastAttrs }); } - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } @@ -2027,40 +1995,21 @@ function processElementLines( iterName, ); - // Process || null-coalesce alternatives. - let elemFalsyFallback: string | undefined; - let elemFalsyControl: ControlFlowInstruction | undefined; - const elemNullAltRefs: NodeRef[] = []; - for (const alt of subs(elemLine, "elemNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, elemLineNum); - if ("literal" in altResult) { - elemFalsyFallback = altResult.literal; - } else if ("control" in altResult) { - elemFalsyControl = altResult.control; - } else { - elemNullAltRefs.push(altResult.sourceRef); - } - } - - // Process ?? nullish fallback. - let elemNullishFallback: string | undefined; - let elemNullishControl: ControlFlowInstruction | undefined; - const elemNullishFallbackRefs: NodeRef[] = []; - const elemNullishFallbackInternalWires: Wire[] = []; - const elemNullishAltList = subs(elemLine, "elemNullishAlt"); - for (const elemNullishAlt of elemNullishAltList) { + // Process coalesce alternatives. + const elemFallbacks: WireFallback[] = []; + const elemFallbackInternalWires: Wire[] = []; + for (const item of subs(elemLine, "elemCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAltIterAware( - elemNullishAlt, - elemLineNum, - ); + const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); if ("literal" in altResult) { - elemNullishFallback = altResult.literal; + elemFallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - elemNullishControl = altResult.control; + elemFallbacks.push({ type, control: altResult.control }); } else { - elemNullishFallbackRefs.push(altResult.sourceRef); - elemNullishFallbackInternalWires.push(...wires.splice(preLen)); + elemFallbacks.push({ type, ref: altResult.sourceRef }); + elemFallbackInternalWires.push(...wires.splice(preLen)); } } @@ -2094,18 +2043,7 @@ function processElementLines( ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), - ...(elemNullAltRefs.length > 0 - ? { falsyFallbackRefs: elemNullAltRefs } - : {}), - ...(elemFalsyFallback !== undefined - ? { falsyFallback: elemFalsyFallback } - : {}), - ...(elemFalsyControl ? { falsyControl: elemFalsyControl } : {}), - ...(elemNullishFallback !== undefined - ? { nullishFallback: elemNullishFallback } - : {}), - ...(elemNullishFallbackRefs.length > 0 ? { nullishFallbackRefs: elemNullishFallbackRefs } : {}), - ...(elemNullishControl ? { nullishControl: elemNullishControl } : {}), + ...(elemFallbacks.length > 0 ? { fallbacks: elemFallbacks } : {}), ...(elemCatchFallback !== undefined ? { catchFallback: elemCatchFallback } : {}), @@ -2115,46 +2053,31 @@ function processElementLines( ...(elemCatchControl ? { catchControl: elemCatchControl } : {}), to: elemToRef, }); - wires.push(...elemNullishFallbackInternalWires); + wires.push(...elemFallbackInternalWires); wires.push(...elemCatchFallbackInternalWires); continue; } sourceParts.push({ ref: elemCondRef, isPipeFork: elemCondIsPipeFork }); - // || alternatives - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - for (const alt of subs(elemLine, "elemNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, elemLineNum); + // Coalesce alternatives (|| and ??) + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(elemLine, "elemCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; + const preLen = wires.length; + const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); if ("literal" in altResult) { - falsyFallback = altResult.literal; + fallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - falsyControl = altResult.control; + fallbacks.push({ type, control: altResult.control }); } else { - sourceParts.push({ ref: altResult.sourceRef, isPipeFork: false }); + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } - // ?? nullish fallback - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(elemLine, "elemNullishAlt"); - for (const alt of nullishAltList) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(alt, elemLineNum); - if ("literal" in altResult) { - nullishFallback = altResult.literal; - } else if ("control" in altResult) { - nullishControl = altResult.control; - } else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); - } - } - // catch error fallback let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; @@ -2176,24 +2099,15 @@ function processElementLines( // Emit wire const { ref: fromRef, isPipeFork } = sourceParts[0]; - const fallbackRefs = - sourceParts.length > 1 - ? sourceParts.slice(1).map((p) => p.ref) - : undefined; const wireAttrs = { ...(isPipeFork ? { pipe: true as const } : {}), - ...(fallbackRefs ? { falsyFallbackRefs: fallbackRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push({ from: fromRef, to: elemToRef, ...wireAttrs }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } else if (elemC.elemScopeBlock) { // ── Path scope block inside array mapping: .field { lines: .sub <- ..., ...source } ── @@ -2425,31 +2339,20 @@ function processElementScopeLines( const raw = stringSourceToken.image.slice(1, -1); const segs = parseTemplateString(raw); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else nullAltRefs.push(altResult.sourceRef); - } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAltIterAware( - nullishAlt, - scopeLineNum, - ); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; @@ -2468,12 +2371,7 @@ function processElementScopeLines( } } const lastAttrs = { - ...(nullAltRefs.length > 0 ? { falsyFallbackRefs: nullAltRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -2493,7 +2391,7 @@ function processElementScopeLines( } else { wires.push({ value: raw, to: elemToRef, ...lastAttrs }); } - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } @@ -2601,31 +2499,20 @@ function processElementScopeLines( iterName, ); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else nullAltRefs.push(altResult.sourceRef); - } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAltIterAware( - nullishAlt, - scopeLineNum, - ); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; @@ -2651,18 +2538,13 @@ function processElementScopeLines( ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), - ...(nullAltRefs.length > 0 ? { falsyFallbackRefs: nullAltRefs } : {}), - ...(falsyFallback !== undefined ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback !== undefined ? { nullishFallback } : {}), - ...(nullishFallbackRefs !== undefined ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback !== undefined ? { catchFallback } : {}), ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), to: elemToRef, }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } @@ -2670,28 +2552,21 @@ function processElementScopeLines( const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAltIterAware(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else sourceParts.push({ ref: altResult.sourceRef, isPipeFork: false }); - } - - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + // Coalesce alternatives (|| and ??) + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(nullishAlt, scopeLineNum); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } @@ -2712,24 +2587,15 @@ function processElementScopeLines( } const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - const fallbackRefs = - sourceParts.length > 1 - ? sourceParts.slice(1).map((p) => p.ref) - : undefined; const wireAttrs = { ...(isPipe ? { pipe: true as const } : {}), - ...(fallbackRefs ? { falsyFallbackRefs: fallbackRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push({ from: fromRef, to: elemToRef, ...wireAttrs }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } } @@ -4256,28 +4122,20 @@ function buildBridgeBody( const raw = stringSourceToken.image.slice(1, -1); const segs = parseTemplateString(raw); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAlt(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else nullAltRefs.push(altResult.sourceRef); - } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAlt(nullishAlt, scopeLineNum); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAlt(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; @@ -4296,14 +4154,7 @@ function buildBridgeBody( } } const lastAttrs = { - ...(nullAltRefs.length > 0 - ? { falsyFallbackRefs: nullAltRefs } - : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -4319,7 +4170,7 @@ function buildBridgeBody( } else { wires.push({ value: raw, to: toRef, ...lastAttrs }); } - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } @@ -4400,28 +4251,20 @@ function buildBridgeBody( const elseNode = sub(scopeLine, "scopeElseBranch")!; const thenBranch = extractTernaryBranch(thenNode, scopeLineNum); const elseBranch = extractTernaryBranch(elseNode, scopeLineNum); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAlt(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else nullAltRefs.push(altResult.sourceRef); - } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAlt(nullishAlt, scopeLineNum); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAlt(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; @@ -4447,49 +4290,34 @@ function buildBridgeBody( ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), - ...(nullAltRefs.length > 0 - ? { falsyFallbackRefs: nullAltRefs } - : {}), - ...(falsyFallback !== undefined ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback !== undefined ? { nullishFallback } : {}), - ...(nullishFallbackRefs !== undefined ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback !== undefined ? { catchFallback } : {}), ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), to: toRef, }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - for (const alt of subs(scopeLine, "scopeNullAlt")) { - const altResult = extractCoalesceAlt(alt, scopeLineNum); - if ("literal" in altResult) falsyFallback = altResult.literal; - else if ("control" in altResult) falsyControl = altResult.control; - else - sourceParts.push({ ref: altResult.sourceRef, isPipeFork: false }); - } - - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(scopeLine, "scopeNullishAlt"); - for (const nullishAlt of nullishAltList) { + // Coalesce alternatives (|| and ??) + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(scopeLine, "scopeCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAlt(nullishAlt, scopeLineNum); - if ("literal" in altResult) nullishFallback = altResult.literal; - else if ("control" in altResult) nullishControl = altResult.control; - else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); + const altResult = extractCoalesceAlt(altNode, scopeLineNum); + if ("literal" in altResult) { + fallbacks.push({ type, value: altResult.literal }); + } else if ("control" in altResult) { + fallbacks.push({ type, control: altResult.control }); + } else { + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } @@ -4510,24 +4338,15 @@ function buildBridgeBody( } const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - const fallbackRefs = - sourceParts.length > 1 - ? sourceParts.slice(1).map((p) => p.ref) - : undefined; const wireAttrs = { ...(isPipe ? { pipe: true as const } : {}), - ...(fallbackRefs ? { falsyFallbackRefs: fallbackRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push({ from: fromRef, to: toRef, ...wireAttrs }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } } @@ -4552,36 +4371,22 @@ function buildBridgeBody( } // ── Extract coalesce modifiers FIRST (shared by ternary + pull paths) ── - let aliasFalsyFallback: string | undefined; - let aliasFalsyControl: ControlFlowInstruction | undefined; - const aliasNullAltRefs: NodeRef[] = []; - for (const alt of subs(nodeAliasNode, "aliasNullAlt")) { - const altResult = extractCoalesceAlt(alt, lineNum); + const aliasFallbacks: WireFallback[] = []; + const aliasFallbackInternalWires: Wire[] = []; + for (const item of subs(nodeAliasNode, "aliasCoalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; + const preLen = wires.length; + const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - aliasFalsyFallback = altResult.literal; + aliasFallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - aliasFalsyControl = altResult.control; + aliasFallbacks.push({ type, control: altResult.control }); } else { - aliasNullAltRefs.push(altResult.sourceRef); + aliasFallbacks.push({ type, ref: altResult.sourceRef }); + aliasFallbackInternalWires.push(...wires.splice(preLen)); } } - let aliasNullishFallback: string | undefined; - let aliasNullishControl: ControlFlowInstruction | undefined; - const aliasNullishFallbackRefs: NodeRef[] = []; - const aliasNullishFallbackInternalWires: Wire[] = []; - const aliasNullishAltList = subs(nodeAliasNode, "aliasNullishAlt"); - for (const alt of aliasNullishAltList) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(alt, lineNum); - if ("literal" in altResult) { - aliasNullishFallback = altResult.literal; - } else if ("control" in altResult) { - aliasNullishControl = altResult.control; - } else { - aliasNullishFallbackRefs.push(altResult.sourceRef); - aliasNullishFallbackInternalWires.push(...wires.splice(preLen)); - } - } let aliasCatchFallback: string | undefined; let aliasCatchControl: ControlFlowInstruction | undefined; let aliasCatchFallbackRef: NodeRef | undefined; @@ -4600,16 +4405,7 @@ function buildBridgeBody( } } const modifierAttrs = { - ...(aliasNullAltRefs.length > 0 - ? { falsyFallbackRefs: aliasNullAltRefs } - : {}), - ...(aliasFalsyFallback ? { falsyFallback: aliasFalsyFallback } : {}), - ...(aliasFalsyControl ? { falsyControl: aliasFalsyControl } : {}), - ...(aliasNullishFallback - ? { nullishFallback: aliasNullishFallback } - : {}), - ...(aliasNullishFallbackRefs.length > 0 ? { nullishFallbackRefs: aliasNullishFallbackRefs } : {}), - ...(aliasNullishControl ? { nullishControl: aliasNullishControl } : {}), + ...(aliasFallbacks.length > 0 ? { fallbacks: aliasFallbacks } : {}), ...(aliasCatchFallback ? { catchFallback: aliasCatchFallback } : {}), ...(aliasCatchFallbackRef ? { catchFallbackRef: aliasCatchFallbackRef } @@ -4675,7 +4471,7 @@ function buildBridgeBody( ...modifierAttrs, to: ternaryToRef, }); - wires.push(...aliasNullishFallbackInternalWires); + wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); continue; } @@ -4768,7 +4564,7 @@ function buildBridgeBody( ...modifierAttrs, to: ternaryToRef, }); - wires.push(...aliasNullishFallbackInternalWires); + wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); continue; } @@ -4797,7 +4593,7 @@ function buildBridgeBody( ...modifierAttrs, }; wires.push({ from: sourceRef, to: localToRef, ...aliasAttrs }); - wires.push(...aliasNullishFallbackInternalWires); + wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); } } @@ -4896,36 +4692,22 @@ function buildBridgeBody( const segs = parseTemplateString(raw); // Process coalesce modifiers - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(wireNode, "nullAlt")) { - const altResult = extractCoalesceAlt(alt, lineNum); + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(wireNode, "coalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; + const preLen = wires.length; + const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - falsyFallback = altResult.literal; + fallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - falsyControl = altResult.control; + fallbacks.push({ type, control: altResult.control }); } else { - nullAltRefs.push(altResult.sourceRef); + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(wireNode, "nullishAlt"); - for (const alt of nullishAltList) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(alt, lineNum); - if ("literal" in altResult) { - nullishFallback = altResult.literal; - } else if ("control" in altResult) { - nullishControl = altResult.control; - } else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); - } - } let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; @@ -4945,11 +4727,7 @@ function buildBridgeBody( } const lastAttrs = { - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -4963,10 +4741,7 @@ function buildBridgeBody( // Plain string without interpolation — emit constant wire wires.push({ value: raw, to: toRef, ...lastAttrs }); } - for (const ref of nullAltRefs) { - wires.push({ from: ref, to: toRef }); - } - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } @@ -4981,34 +4756,20 @@ function buildBridgeBody( : buildSourceExpr(firstSourceNode!, lineNum); // Process coalesce modifiers on the array wire (same as plain pull wires) - let arrayFalsyFallback: string | undefined; - let arrayFalsyControl: ControlFlowInstruction | undefined; - const arrayNullAltRefs: NodeRef[] = []; - for (const alt of subs(wireNode, "nullAlt")) { - const altResult = extractCoalesceAlt(alt, lineNum); - if ("literal" in altResult) { - arrayFalsyFallback = altResult.literal; - } else if ("control" in altResult) { - arrayFalsyControl = altResult.control; - } else { - arrayNullAltRefs.push(altResult.sourceRef); - } - } - let arrayNullishFallback: string | undefined; - let arrayNullishControl: ControlFlowInstruction | undefined; - const arrayNullishFallbackRefs: NodeRef[] = []; - const arrayNullishFallbackInternalWires: Wire[] = []; - const arrayNullishAltList = subs(wireNode, "nullishAlt"); - for (const arrayNullishAlt of arrayNullishAltList) { + const arrayFallbacks: WireFallback[] = []; + const arrayFallbackInternalWires: Wire[] = []; + for (const item of subs(wireNode, "coalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; const preLen = wires.length; - const altResult = extractCoalesceAlt(arrayNullishAlt, lineNum); + const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - arrayNullishFallback = altResult.literal; + arrayFallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - arrayNullishControl = altResult.control; + arrayFallbacks.push({ type, control: altResult.control }); } else { - arrayNullishFallbackRefs.push(altResult.sourceRef); - arrayNullishFallbackInternalWires.push(...wires.splice(preLen)); + arrayFallbacks.push({ type, ref: altResult.sourceRef }); + arrayFallbackInternalWires.push(...wires.splice(preLen)); } } let arrayCatchFallback: string | undefined; @@ -5029,13 +4790,7 @@ function buildBridgeBody( } } const arrayWireAttrs = { - ...(arrayFalsyFallback ? { falsyFallback: arrayFalsyFallback } : {}), - ...(arrayFalsyControl ? { falsyControl: arrayFalsyControl } : {}), - ...(arrayNullishFallback - ? { nullishFallback: arrayNullishFallback } - : {}), - ...(arrayNullishFallbackRefs.length > 0 ? { nullishFallbackRefs: arrayNullishFallbackRefs } : {}), - ...(arrayNullishControl ? { nullishControl: arrayNullishControl } : {}), + ...(arrayFallbacks.length > 0 ? { fallbacks: arrayFallbacks } : {}), ...(arrayCatchFallback ? { catchFallback: arrayCatchFallback } : {}), ...(arrayCatchFallbackRef ? { catchFallbackRef: arrayCatchFallbackRef } @@ -5043,10 +4798,7 @@ function buildBridgeBody( ...(arrayCatchControl ? { catchControl: arrayCatchControl } : {}), }; wires.push({ from: srcRef, to: toRef, ...arrayWireAttrs }); - for (const ref of arrayNullAltRefs) { - wires.push({ from: ref, to: toRef }); - } - wires.push(...arrayNullishFallbackInternalWires); + wires.push(...arrayFallbackInternalWires); wires.push(...arrayCatchFallbackInternalWires); const iterName = extractNameToken(sub(arrayMappingNode, "iterName")!); @@ -5149,41 +4901,24 @@ function buildBridgeBody( const thenBranch = extractTernaryBranch(thenNode, lineNum); const elseBranch = extractTernaryBranch(elseNode, lineNum); - // Process || null-coalesce alternatives. - // Literals → stored on the ternary wire; source refs → sibling pull wires. - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; - const nullAltRefs: NodeRef[] = []; - for (const alt of subs(wireNode, "nullAlt")) { - const altResult = extractCoalesceAlt(alt, lineNum); + // Process coalesce alternatives. + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; + for (const item of subs(wireNode, "coalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + const altNode = sub(item, "altValue")!; + const preLen = wires.length; + const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - falsyFallback = altResult.literal; + fallbacks.push({ type, value: altResult.literal }); } else if ("control" in altResult) { - falsyControl = altResult.control; + fallbacks.push({ type, control: altResult.control }); } else { - nullAltRefs.push(altResult.sourceRef); + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } - // Process ?? nullish fallback. - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(wireNode, "nullishAlt"); - for (const alt of nullishAltList) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(alt, lineNum); - if ("literal" in altResult) { - nullishFallback = altResult.literal; - } else if ("control" in altResult) { - nullishControl = altResult.control; - } else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); - } - } - // Process catch error fallback. let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; @@ -5211,60 +4946,41 @@ function buildBridgeBody( ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), - ...(nullAltRefs.length > 0 ? { falsyFallbackRefs: nullAltRefs } : {}), - ...(falsyFallback !== undefined ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback !== undefined ? { nullishFallback } : {}), - ...(nullishFallbackRefs !== undefined ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback !== undefined ? { catchFallback } : {}), ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), to: toRef, }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; } sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - let falsyFallback: string | undefined; - let falsyControl: ControlFlowInstruction | undefined; + const fallbacks: WireFallback[] = []; + const fallbackInternalWires: Wire[] = []; let hasTruthyLiteralFallback = false; - for (const alt of subs(wireNode, "nullAlt")) { - if (hasTruthyLiteralFallback) break; - const altResult = extractCoalesceAlt(alt, lineNum); + for (const item of subs(wireNode, "coalesceItem")) { + const type = tok(item, "falsyOp") ? "falsy" as const : "nullish" as const; + if (type === "falsy" && hasTruthyLiteralFallback) break; + const altNode = sub(item, "altValue")!; + const preLen = wires.length; + const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - falsyFallback = altResult.literal; - hasTruthyLiteralFallback = Boolean(JSON.parse(altResult.literal)); + fallbacks.push({ type, value: altResult.literal }); + if (type === "falsy") { + hasTruthyLiteralFallback = Boolean(JSON.parse(altResult.literal)); + } } else if ("control" in altResult) { - falsyFallback = undefined; - falsyControl = altResult.control; + fallbacks.push({ type, control: altResult.control }); } else { - falsyFallback = undefined; - sourceParts.push({ ref: altResult.sourceRef, isPipeFork: false }); + fallbacks.push({ type, ref: altResult.sourceRef }); + fallbackInternalWires.push(...wires.splice(preLen)); } } - let nullishFallback: string | undefined; - let nullishControl: ControlFlowInstruction | undefined; - const nullishFallbackRefs: NodeRef[] = []; - const nullishFallbackInternalWires: Wire[] = []; - const nullishAltList = subs(wireNode, "nullishAlt"); - for (const alt of nullishAltList) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(alt, lineNum); - if ("literal" in altResult) { - nullishFallback = altResult.literal; - } else if ("control" in altResult) { - nullishControl = altResult.control; - } else { - nullishFallbackRefs.push(altResult.sourceRef); - nullishFallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; @@ -5284,25 +5000,16 @@ function buildBridgeBody( } const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - const fallbackRefs = - sourceParts.length > 1 - ? sourceParts.slice(1).map((p) => p.ref) - : undefined; const wireAttrs = { ...(isSafe ? { safe: true as const } : {}), ...(isPipe ? { pipe: true as const } : {}), - ...(fallbackRefs ? { falsyFallbackRefs: fallbackRefs } : {}), - ...(falsyFallback ? { falsyFallback } : {}), - ...(falsyControl ? { falsyControl } : {}), - ...(nullishFallback ? { nullishFallback } : {}), - ...((nullishFallbackRefs && nullishFallbackRefs.length > 0) ? { nullishFallbackRefs } : {}), - ...(nullishControl ? { nullishControl } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push({ from: fromRef, to: toRef, ...wireAttrs }); - wires.push(...nullishFallbackInternalWires); + wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } @@ -5426,9 +5133,9 @@ function inlineDefine( wire.to = { ...wire.to, module: inModule }; if (wire.from.module === genericModule) wire.from = { ...wire.from, module: outModule }; - if (wire.nullishFallbackRefs) { - wire.nullishFallbackRefs = wire.nullishFallbackRefs.map(r => r.module === genericModule ? { ...r, module: outModule } : r); -} + if (wire.fallbacks) { + wire.fallbacks = wire.fallbacks.map(f => f.ref && f.ref.module === genericModule ? { ...f, ref: { ...f.ref, module: outModule } } : f); + } if (wire.catchFallbackRef?.module === genericModule) wire.catchFallbackRef = { ...wire.catchFallbackRef, module: outModule }; } @@ -5476,9 +5183,9 @@ function inlineDefine( if ("from" in cloned) { cloned.from = remapRef(cloned.from, "from"); cloned.to = remapRef(cloned.to, "to"); - if (cloned.nullishFallbackRefs) { - cloned.nullishFallbackRefs = cloned.nullishFallbackRefs.map(r => remapRef(r, "from")); -} + if (cloned.fallbacks) { + cloned.fallbacks = cloned.fallbacks.map(f => f.ref ? { ...f, ref: remapRef(f.ref, "from") } : f); + } if (cloned.catchFallbackRef) cloned.catchFallbackRef = remapRef(cloned.catchFallbackRef, "from"); } else { diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index 6dfb874..ee3cd5e 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -738,3 +738,172 @@ bridge Query.lookup { assert.ok(safePull, "round-tripped wire has safe: true"); }); }); + +// ── Mixed || and ?? chains ────────────────────────────────────────────────── + +describe("mixed || and ?? chains", () => { + test("A ?? B || C — nullish gate then falsy gate", async () => { + const { data } = await run( + `version 1.5 +bridge Query.lookup { + with primary as p + with backup as b + with input as i + with output as o + + p.q <- i.q + b.q <- i.q + o.label <- p.label ?? b.label || "fallback" +}`, + "Query.lookup", + { q: "test" }, + { + "primary": async () => ({ label: null }), + "backup": async () => ({ label: "" }), + }, + ); + // p.label is null → ?? gate opens → b.label is "" (non-nullish, gate closes) + // b.label is "" → || gate opens → "fallback" + assert.equal(data.label, "fallback"); + }); + + test("A || B ?? C — falsy gate then nullish gate", async () => { + const { data } = await run( + `version 1.5 +bridge Query.lookup { + with primary as p + with backup as b + with input as i + with output as o + + p.q <- i.q + b.q <- i.q + o.label <- p.label || b.label ?? "default" +}`, + "Query.lookup", + { q: "test" }, + { + "primary": async () => ({ label: "" }), + "backup": async () => ({ label: null }), + }, + ); + // p.label is "" → || gate opens → b.label is null (still falsy) + // b.label is null → ?? gate opens → "default" + assert.equal(data.label, "default"); + }); + + test("A ?? B || C ?? D — four-item mixed chain", async () => { + const { data } = await run( + `version 1.5 +bridge Query.lookup { + with a as a + with b as b + with c as c + with input as i + with output as o + + a.q <- i.q + b.q <- i.q + c.q <- i.q + o.label <- a.label ?? b.label || c.label ?? "last" +}`, + "Query.lookup", + { q: "test" }, + { + "a": async () => ({ label: null }), + "b": async () => ({ label: 0 }), + "c": async () => ({ label: null }), + }, + ); + // a.label null → ?? opens → b.label is 0 (non-nullish, ?? closes) + // 0 is falsy → || opens → c.label is null (still falsy) + // null → ?? opens → "last" + assert.equal(data.label, "last"); + }); + + test("mixed chain short-circuits when value becomes truthy", async () => { + const { data } = await run( + `version 1.5 +bridge Query.lookup { + with a as a + with b as b + with input as i + with output as o + + a.q <- i.q + b.q <- i.q + o.label <- a.label ?? b.label || "unused" +}`, + "Query.lookup", + { q: "test" }, + { + "a": async () => ({ label: null }), + "b": async () => ({ label: "found" }), + }, + ); + // a.label null → ?? opens → b.label is "found" (truthy) + // "found" is truthy → || gate closed → "unused" skipped + assert.equal(data.label, "found"); + }); + + test("mixed chain round-trips through serializer", () => { + const src = `version 1.5 + +bridge Query.lookup { + with a as a + with b as b + with input as i + with output as o + + a.q <- i.q + b.q <- i.q + o.label <- a.label ?? b.label || "fallback" + +}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + const reparsed = parseBridge(serialized); + assert.deepStrictEqual(reparsed, doc); + }); + + test("?? then || with literals round-trips", () => { + const src = `version 1.5 + +bridge Query.lookup { + with input as i + with output as o + + o.label <- i.label ?? "nullish-default" || "falsy-default" + +}`; + const doc = parseBridge(src); + const serialized = serializeBridge(doc); + const reparsed = parseBridge(serialized); + assert.deepStrictEqual(reparsed, doc); + }); + + test("parser produces correct fallbacks array for mixed chain", () => { + const doc = parseBridge(`version 1.5 + +bridge Query.lookup { + with a as a + with b as b + with input as i + with output as o + + a.q <- i.q + b.q <- i.q + o.label <- a.label ?? b.label || "default" +}`); + const bridge = doc.instructions.find((i) => i.kind === "bridge")!; + const wire = bridge.wires.find( + (w) => "from" in w && (w as any).to.path[0] === "label" && !("pipe" in w), + ) as Extract; + assert.ok(wire.fallbacks, "wire should have fallbacks"); + assert.equal(wire.fallbacks!.length, 2); + assert.equal(wire.fallbacks![0].type, "nullish"); + assert.ok(wire.fallbacks![0].ref, "first fallback should be a ref"); + assert.equal(wire.fallbacks![1].type, "falsy"); + assert.equal(wire.fallbacks![1].value, '"default"'); + }); +}); diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index b0dbf19..af5c8e2 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -26,10 +26,10 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.falsyControl, { - kind: "throw", - message: "name is required", - }); + assert.deepStrictEqual(pullWire.fallbacks, [{ + type: "falsy", + control: { kind: "throw", message: "name is required" }, + }]); }); test("panic on ?? gate", () => { @@ -45,10 +45,10 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.nullishControl, { - kind: "panic", - message: "fatal: name cannot be null", - }); + assert.deepStrictEqual(pullWire.fallbacks, [{ + type: "nullish", + control: { kind: "panic", message: "fatal: name cannot be null" }, + }]); }); test("continue on ?? gate", () => { @@ -69,7 +69,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.nullishControl, { kind: "continue" }); + assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]); }); test("break on ?? gate", () => { @@ -90,7 +90,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.nullishControl, { kind: "break" }); + assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "break" } }]); }); test("throw on catch gate", () => { @@ -160,10 +160,10 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.falsyControl, { - kind: "throw", - message: "name is required", - }); + assert.deepStrictEqual(pullWire.fallbacks, [{ + type: "falsy", + control: { kind: "throw", message: "name is required" }, + }]); }); test("panic on ?? gate round-trips", () => { @@ -186,10 +186,10 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.nullishControl, { - kind: "panic", - message: "fatal", - }); + assert.deepStrictEqual(pullWire.fallbacks, [{ + type: "nullish", + control: { kind: "panic", message: "fatal" }, + }]); }); test("continue on ?? gate round-trips", () => { @@ -217,7 +217,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.nullishControl, { kind: "continue" }); + assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]); }); test("break on catch gate round-trips", () => { diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 43a4104..4b317b0 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -163,7 +163,7 @@ bridge Query.test { ); const nameWire = pullWires.find((w) => w.to.path.join(".") === "data.name"); assert.ok(nameWire); - assert.equal(nameWire.falsyFallback, '"anonymous"'); + assert.deepStrictEqual(nameWire.fallbacks, [{ type: "falsy", value: '"anonymous"' }]); const valueWire = pullWires.find( (w) => w.to.path.join(".") === "data.value", diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 488267b..310124f 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -821,7 +821,7 @@ o.name <- i.name || "World" (i): i is Bridge => i.kind === "bridge", )!; const wire = bridge.wires[0] as Extract; - assert.equal(wire.falsyFallback, '"World"'); + assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '"World"' }]); assert.equal(wire.catchFallback, undefined); }); @@ -839,7 +839,7 @@ o.name <- i.name || "World" catch "Error" (i): i is Bridge => i.kind === "bridge", )!; const wire = bridge.wires[0] as Extract; - assert.equal(wire.falsyFallback, '"World"'); + assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '"World"' }]); assert.equal(wire.catchFallback, '"Error"'); }); @@ -861,10 +861,10 @@ o.result <- a.data || {"lat":0,"lon":0} const wire = bridge.wires.find( (w) => "from" in w && (w as any).from.path[0] === "data", ) as Extract; - assert.equal(wire.falsyFallback, '{"lat":0,"lon":0}'); + assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '{"lat":0,"lon":0}' }]); }); - test("wire without || has no falsyFallback", () => { + test("wire without || has no fallbacks", () => { const doc = parseBridge(`version 1.5 bridge Query.greet { @@ -878,7 +878,7 @@ o.name <- i.name (i): i is Bridge => i.kind === "bridge", )!; const wire = bridge.wires[0] as Extract; - assert.equal(wire.falsyFallback, undefined); + assert.equal(wire.fallbacks, undefined); }); test("pipe wire with || falsy-fallback", () => { @@ -895,12 +895,12 @@ o.result <- up:i.text || "N/A" const bridge = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - // Terminal pipe wire (from fork root to result) carries the falsyFallback + // Terminal pipe wire (from fork root to result) carries the fallbacks const terminalWire = bridge.wires.find( (w) => "from" in w && (w as any).pipe && (w as any).from.path.length === 0, ) as Extract; - assert.equal(terminalWire?.falsyFallback, '"N/A"'); + assert.deepStrictEqual(terminalWire?.fallbacks, [{ type: "falsy", value: '"N/A"' }]); }); }); @@ -1169,7 +1169,7 @@ o.textPart <- i.htmlBody || "empty" // ══════════════════════════════════════════════════════════════════════════════ describe("parseBridge: || source references", () => { - test("|| source produces one wire with falsyFallbackRefs", () => { + test("|| source produces one wire with fallbacks", () => { const doc = parseBridge(`version 1.5 bridge Query.lookup { @@ -1190,14 +1190,14 @@ o.label <- p.label || b.label (w) => "from" in w && (w as any).to.path[0] === "label", ) as Extract[]; assert.equal(labelWires.length, 1, "should be one wire, not two"); - assert.ok(labelWires[0].falsyFallbackRefs, "should have falsyFallbackRefs"); - assert.equal(labelWires[0].falsyFallbackRefs!.length, 1); - assert.deepEqual(labelWires[0].falsyFallbackRefs![0].path, ["label"]); - assert.equal(labelWires[0].falsyFallback, undefined); + assert.ok(labelWires[0].fallbacks, "should have fallbacks"); + assert.equal(labelWires[0].fallbacks!.length, 1); + assert.equal(labelWires[0].fallbacks![0].type, "falsy"); + assert.deepEqual(labelWires[0].fallbacks![0].ref!.path, ["label"]); assert.equal(labelWires[0].catchFallback, undefined); }); - test("|| source || literal — one wire with falsyFallbackRefs + falsyFallback", () => { + test("|| source || literal — one wire with fallbacks", () => { const doc = parseBridge(`version 1.5 bridge Query.lookup { @@ -1218,9 +1218,12 @@ o.label <- a.label || b.label || "default" (w) => "from" in w && (w as any).to.path[0] === "label", ) as Extract[]; assert.equal(labelWires.length, 1); - assert.ok(labelWires[0].falsyFallbackRefs, "should have falsyFallbackRefs"); - assert.equal(labelWires[0].falsyFallbackRefs!.length, 1); - assert.equal(labelWires[0].falsyFallback, '"default"'); + assert.ok(labelWires[0].fallbacks, "should have fallbacks"); + assert.equal(labelWires[0].fallbacks!.length, 2); + assert.equal(labelWires[0].fallbacks![0].type, "falsy"); + assert.ok(labelWires[0].fallbacks![0].ref); + assert.equal(labelWires[0].fallbacks![1].type, "falsy"); + assert.equal(labelWires[0].fallbacks![1].value, '"default"'); }); }); @@ -1281,7 +1284,7 @@ o.label <- api.label catch up:i.errorDefault ); }); - test("full chain: A || B || literal catch source — one wire with falsyFallbackRefs + catchFallbackRef", () => { + test("full chain: A || B || literal catch source — one wire with fallbacks + catchFallbackRef", () => { const doc = parseBridge(`version 1.5 bridge Query.lookup { @@ -1302,9 +1305,12 @@ o.label <- p.label || b.label || "default" catch i.errorLabel (w) => "from" in w && !("pipe" in w) && (w as any).to.path[0] === "label", ) as Extract[]; assert.equal(labelWires.length, 1); - assert.ok(labelWires[0].falsyFallbackRefs, "should have falsyFallbackRefs"); - assert.equal(labelWires[0].falsyFallbackRefs!.length, 1); - assert.equal(labelWires[0].falsyFallback, '"default"'); + assert.ok(labelWires[0].fallbacks, "should have fallbacks"); + assert.equal(labelWires[0].fallbacks!.length, 2); + assert.equal(labelWires[0].fallbacks![0].type, "falsy"); + assert.ok(labelWires[0].fallbacks![0].ref); + assert.equal(labelWires[0].fallbacks![1].type, "falsy"); + assert.equal(labelWires[0].fallbacks![1].value, '"default"'); assert.ok( labelWires[0].catchFallbackRef, "wire should have catchFallbackRef", diff --git a/packages/bridge/test/ternary.test.ts b/packages/bridge/test/ternary.test.ts index 06ab00a..94c15ad 100644 --- a/packages/bridge/test/ternary.test.ts +++ b/packages/bridge/test/ternary.test.ts @@ -121,7 +121,7 @@ bridge Query.pricing { const bridge = doc.instructions.find((inst) => inst.kind === "bridge")!; const condWire = bridge.wires.find((w) => "cond" in w); assert.ok(condWire && "cond" in condWire); - assert.equal(condWire.falsyFallback, "0"); + assert.deepStrictEqual(condWire.fallbacks, [{ type: "falsy", value: "0" }]); }); test("catch literal fallback stored on conditional wire", () => { diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index f4ab7af..af9b2a6 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -588,6 +588,9 @@ bridge Query.profile { # 3. Nullish fallback — only override if value is strictly null/undefined alias api.website ?? "https://example.com" as site + # 4. Mixed chain — ?? then || in any order + alias api.nickname ?? api.username || "Guest" as greeting + # 4. Error boundary — if the pipe tool throws, default to "UNKNOWN" alias uc:api.name catch "UNKNOWN" as upperName