From 108738b9f933f3015f9a26df80cfe4a4e1c1420a Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 08:37:49 +0100 Subject: [PATCH 1/2] Spread syntax in playground --- packages/playground/src/codemirror/bridge-lang.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/playground/src/codemirror/bridge-lang.ts b/packages/playground/src/codemirror/bridge-lang.ts index a5ebcdfb..5a63e3dd 100644 --- a/packages/playground/src/codemirror/bridge-lang.ts +++ b/packages/playground/src/codemirror/bridge-lang.ts @@ -353,6 +353,12 @@ function token(stream: StringStream, state: State): string | null { return "builtin"; } + // ── Spread operator (...handle) ─────────────────────────────────────── + if (stream.match(/^\.\.\./)) { + state.lineStart = false; + return "operator"; + } + // ── Dot-prefixed property — only when first token on the line ───────── // .baseUrl = "..." .headers.Authorization <- ctx.token if (state.lineStart && ch === ".") { From 3e725af180616db2fbefc42d23b59e18973aeb64 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 08:57:59 +0100 Subject: [PATCH 2/2] fix: object spread functionality in path-scoped blocks --- .changeset/cute-states-wash.md | 1 + packages/bridge-compiler/src/codegen.ts | 91 +++++++++++-- packages/bridge-core/src/ExecutionTree.ts | 83 ++++++++++-- packages/bridge-core/src/types.ts | 3 + packages/bridge-parser/src/parser/parser.ts | 4 + packages/bridge/test/path-scoping.test.ts | 141 ++++++++++++++++++++ 6 files changed, 306 insertions(+), 17 deletions(-) diff --git a/.changeset/cute-states-wash.md b/.changeset/cute-states-wash.md index c615f0a6..47d0fc82 100644 --- a/.changeset/cute-states-wash.md +++ b/.changeset/cute-states-wash.md @@ -1,6 +1,7 @@ --- "@stackables/bridge-compiler": minor "@stackables/bridge-parser": minor +"@stackables/bridge-core": minor --- Support object spread in path-scoped scope blocks diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 14e9076b..3f24da3d 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -1348,13 +1348,24 @@ class CodegenContext { const arrayIterators = this.bridge.arrayIterators ?? {}; const isRootArray = "" in arrayIterators; - // Check for root passthrough (wire with empty path) — but not if it's a root array source - const rootWire = outputWires.find((w) => w.to.path.length === 0); - if (rootWire && !isRootArray) { - lines.push(` return ${this.wireToExpr(rootWire)};`); + // Separate root wires into passthrough vs spread + const rootWires = outputWires.filter((w) => w.to.path.length === 0); + const spreadRootWires = rootWires.filter( + (w) => "from" in w && "spread" in w && w.spread, + ); + const passthroughRootWire = rootWires.find( + (w) => !("from" in w && "spread" in w && w.spread), + ); + + // Passthrough (non-spread root wire) — return directly + if (passthroughRootWire && !isRootArray) { + lines.push(` return ${this.wireToExpr(passthroughRootWire)};`); return; } + // Check for root passthrough (wire with empty path) — but not if it's a root array source + const rootWire = rootWires[0]; // for backwards compat with array handling below + // Handle root array output (o <- src.items[] as item { ... }) if (isRootArray && rootWire) { const elemWires = outputWires.filter( @@ -1461,6 +1472,13 @@ class CodegenContext { } else if (arrayFields.has(topField) && w.to.path.length === 1) { // Root wire for an array field arraySourceWires.set(topField, w); + } else if ( + "from" in w && + "spread" in w && + w.spread && + w.to.path.length === 0 + ) { + // Spread root wire — handled separately via spreadRootWires } else { scalarWires.push(w); } @@ -1470,11 +1488,42 @@ class CodegenContext { interface TreeNode { expr?: string; terminal?: boolean; + spreadExprs?: string[]; children: Map; } const tree: TreeNode = { children: new Map() }; - for (const w of scalarWires) { + // First pass: handle nested spread wires (spread with path.length > 0) + const nestedSpreadWires = scalarWires.filter( + (w) => "from" in w && "spread" in w && w.spread && w.to.path.length > 0, + ); + const normalScalarWires = scalarWires.filter( + (w) => !("from" in w && "spread" in w && w.spread), + ); + + // Add nested spread expressions to tree nodes + for (const w of nestedSpreadWires) { + const path = w.to.path; + let current = tree; + // Navigate to parent of the target + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i]!; + if (!current.children.has(seg)) { + current.children.set(seg, { children: new Map() }); + } + current = current.children.get(seg)!; + } + const lastSeg = path[path.length - 1]!; + if (!current.children.has(lastSeg)) { + current.children.set(lastSeg, { children: new Map() }); + } + const node = current.children.get(lastSeg)!; + // Add spread expression to this node + if (!node.spreadExprs) node.spreadExprs = []; + node.spreadExprs.push(this.wireToExpr(w)); + } + + for (const w of normalScalarWires) { const path = w.to.path; let current = tree; for (let i = 0; i < path.length - 1; i++) { @@ -1561,7 +1610,9 @@ class CodegenContext { } // Serialize the tree to a return statement - const objStr = this.serializeOutputTree(tree, 4); + // Include spread expressions at the start if present + const spreadExprs = spreadRootWires.map((w) => this.wireToExpr(w)); + const objStr = this.serializeOutputTree(tree, 4, spreadExprs); lines.push(` return ${objStr};`); } @@ -1571,15 +1622,37 @@ class CodegenContext { children: Map }>; }, indent: number, + spreadExprs?: string[], ): string { const pad = " ".repeat(indent); const entries: string[] = []; + // Add spread expressions first (they come before field overrides) + if (spreadExprs) { + for (const expr of spreadExprs) { + entries.push(`${pad}...${expr}`); + } + } + for (const [key, child] of node.children) { - if (child.expr != null && child.children.size === 0) { + // Check if child has spread expressions + const childSpreadExprs = (child as { spreadExprs?: string[] }) + .spreadExprs; + + if ( + child.expr != null && + child.children.size === 0 && + !childSpreadExprs + ) { + // Simple leaf with just an expression entries.push(`${pad}${JSON.stringify(key)}: ${child.expr}`); - } else if (child.children.size > 0 && child.expr == null) { - const nested = this.serializeOutputTree(child, indent + 2); + } else if (childSpreadExprs || child.children.size > 0) { + // Nested object: may have spreads, children, or both + const nested = this.serializeOutputTree( + child, + indent + 2, + childSpreadExprs, + ); entries.push(`${pad}${JSON.stringify(key)}: ${nested}`); } else { // Has both expr and children — use expr (children override handled elsewhere) diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 04f9e76b..ee42fa62 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -618,7 +618,16 @@ export class ExecutionTree implements TreeContext { w.to.field === field && pathEquals(w.to.path, prefix), ); - if (exactWires.length > 0) { + + // Separate spread wires from regular wires + const spreadWires = exactWires.filter( + (w) => "from" in w && "spread" in w && w.spread, + ); + const regularWires = exactWires.filter( + (w) => !("from" in w && "spread" in w && w.spread), + ); + + if (regularWires.length > 0) { // Check for array mapping: exact wires (the array source) PLUS // element-level wires deeper than prefix (the field mappings). // E.g. `o.entries <- src[] as x { .id <- x.item_id }` produces @@ -639,15 +648,16 @@ export class ExecutionTree implements TreeContext { if (hasElementWires) { // Array mapping on a sub-field: resolve the array source, // create shadow trees, and materialise with field mappings. - const resolved = await this.resolveWires(exactWires); + const resolved = await this.resolveWires(regularWires); if (!Array.isArray(resolved)) return resolved; const shadows = this.createShadowArray(resolved); return this.materializeShadows(shadows, prefix); } - return this.resolveWires(exactWires); + return this.resolveWires(regularWires); } + // Collect sub-fields from deeper wires const subFields = new Set(); for (const wire of bridge.wires) { const p = wire.to.path; @@ -661,6 +671,37 @@ export class ExecutionTree implements TreeContext { subFields.add(p[prefix.length]!); } } + + // Spread wires: resolve and merge, then overlay sub-field wires + if (spreadWires.length > 0) { + const result: Record = {}; + + // First resolve spread sources (in order) + for (const wire of spreadWires) { + const spreadValue = await this.resolveWires([wire]); + if (spreadValue != null && typeof spreadValue === "object") { + Object.assign(result, spreadValue); + } + } + + // Then resolve sub-fields and overlay on spread result + const prefixStr = prefix.join("."); + const activeSubFields = this.requestedFields + ? [...subFields].filter((sub) => { + const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub; + return matchesRequestedFields(fullPath, this.requestedFields); + }) + : [...subFields]; + + await Promise.all( + activeSubFields.map(async (sub) => { + result[sub] = await this.resolveNestedField([...prefix, sub]); + }), + ); + + return result; + } + if (subFields.size === 0) return undefined; // Apply sparse fieldset filter at nested level @@ -792,8 +833,8 @@ export class ExecutionTree implements TreeContext { const { type, field } = this.trunk; - // Is there a root-level wire targeting the output with path []? - const hasRootWire = bridge.wires.some( + // Separate root-level wires into passthrough vs spread + const rootWires = bridge.wires.filter( (w) => "from" in w && w.to.module === SELF_MODULE && @@ -802,6 +843,18 @@ export class ExecutionTree implements TreeContext { w.to.path.length === 0, ); + // Passthrough wire: root wire without spread flag + const hasPassthroughWire = rootWires.some( + (w) => "from" in w && !("spread" in w && w.spread), + ); + + // Spread wires: root wires with spread flag + const spreadWires = rootWires.filter( + (w) => "from" in w && "spread" in w && w.spread, + ); + + const hasRootWire = rootWires.length > 0; + // Array-mapped output (`o <- items[] as x { ... }`) has BOTH a root wire // AND element-level wires (from.element === true). A plain passthrough // (`o <- api.user`) only has the root wire. @@ -827,8 +880,8 @@ export class ExecutionTree implements TreeContext { return this.materializeShadows(shadows, []); } - // Whole-object passthrough: `o <- api.user` - if (hasRootWire) { + // Whole-object passthrough: `o <- api.user` (non-spread root wire) + if (hasPassthroughWire) { const [result] = await Promise.all([ this.pullOutputField([]), ...forcePromises, @@ -849,7 +902,11 @@ export class ExecutionTree implements TreeContext { } } - if (outputFields.size === 0) { + // Spread wires: resolve and merge source objects + // Later field wires will override spread properties + const hasSpreadWires = spreadWires.length > 0; + + if (outputFields.size === 0 && !hasSpreadWires) { throw new Error( `Bridge "${type}.${field}" has no output wires. ` + `Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`, @@ -861,6 +918,16 @@ export class ExecutionTree implements TreeContext { const result: Record = {}; + // First resolve spread wires (in order) to build base object + // Each spread source's properties are merged into result + for (const wire of spreadWires) { + const spreadValue = await this.resolveWires([wire]); + if (spreadValue != null && typeof spreadValue === "object") { + Object.assign(result, spreadValue); + } + } + + // Then resolve explicit field wires - these override spread properties await Promise.all([ ...[...activeFields].map(async (name) => { result[name] = await this.resolveNestedField([name]); diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index f532654c..f818ca2c 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -33,12 +33,15 @@ export type NodeRef = { * Pipe wires (`pipe: true`) are generated by the `<- h1:h2:source` shorthand * and route data through declared tool handles; the serializer collapses them * back to pipe notation. + * Spread wires (`spread: true`) merge source object properties into the target. */ export type Wire = | { from: NodeRef; to: NodeRef; pipe?: true; + /** When true, this wire merges source properties into target (from `...source` syntax). */ + spread?: true; safe?: true; falsyFallbackRefs?: NodeRef[]; falsyFallback?: string; diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 97e43183..837999dd 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -2221,6 +2221,7 @@ function processElementLines( element: true, path: elemToPath, }, + spread: true as const, ...(spreadSafe ? { safe: true as const } : {}), }); } @@ -2368,6 +2369,7 @@ function processElementScopeLines( element: true, path: spreadToPath, }, + spread: true as const, ...(spreadSafe ? { safe: true as const } : {}), }); } @@ -4228,6 +4230,7 @@ function buildBridgeBody( wires.push({ from: fromRef, to: nestedToRef, + spread: true as const, ...(spreadSafe ? { safe: true as const } : {}), }); } @@ -4880,6 +4883,7 @@ function buildBridgeBody( wires.push({ from: fromRef, to: toRef, + spread: true as const, ...(spreadSafe ? { safe: true as const } : {}), }); } diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 80c3724c..43a41041 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -936,6 +936,147 @@ bridge Query.test { }); }); +// ── Spread into output ──────────────────────────────────────────────────────── + +forEachEngine("path scoping – spread into output", (run, _ctx) => { + test("basic spread of input into output", async () => { + const bridge = `version 1.5 + +bridge Query.greet { + with input as i + with output as o + + o { + ...i + } +}`; + const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); + assert.deepStrictEqual(result.data, { name: "Hello Bridge" }); + }); + + test("spread with explicit field overrides", async () => { + const bridge = `version 1.5 + +bridge Query.greet { + with input as i + with output as o + + o { + ...i + .message <- i.name + } +}`; + const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); + assert.deepStrictEqual(result.data, { + name: "Hello Bridge", + message: "Hello Bridge", + }); + }); + + test("spread with multiple sources in order", async () => { + const bridge = `version 1.5 + +bridge Query.test { + with input as i + with output as o + + o { + ...i.first + ...i.second + } +}`; + const result = await run(bridge, "Query.test", { + first: { a: 1, b: 2 }, + second: { b: 3, c: 4 }, + }); + // second should override b from first + assert.deepStrictEqual(result.data, { a: 1, b: 3, c: 4 }); + }); + + test("spread with explicit override taking precedence", async () => { + const bridge = `version 1.5 + +bridge Query.test { + with input as i + with output as o + + o { + ...i + .name = "overridden" + } +}`; + const result = await run(bridge, "Query.test", { + name: "original", + age: 30, + }); + // explicit .name should override spread + assert.deepStrictEqual(result.data, { name: "overridden", age: 30 }); + }); + + test("spread with deep path source", async () => { + const bridge = `version 1.5 + +bridge Query.test { + with input as i + with output as o + + o { + ...i.user.profile + } +}`; + const result = await run(bridge, "Query.test", { + user: { profile: { email: "test@test.com", verified: true } }, + }); + assert.deepStrictEqual(result.data, { + email: "test@test.com", + verified: true, + }); + }); + + test("spread combined with pipe operators", async () => { + const bridge = `version 1.5 + +bridge Query.greet { + with std.str.toUpperCase as uc + with std.str.toLowerCase as lc + with input as i + with output as o + + o { + ...i + .upper <- uc:i.name + .lower <- lc:i.name + } +}`; + const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); + assert.deepStrictEqual(result.data, { + name: "Hello Bridge", + upper: "HELLO BRIDGE", + lower: "hello bridge", + }); + }); + + test("spread into nested output scope", async () => { + const bridge = `version 1.5 + +bridge Query.test { + with input as i + with output as o + + o.result { + ...i.data + .extra = "added" + } +}`; + const result = await run(bridge, "Query.test", { + data: { x: 1, y: 2 }, + }); + assert.deepStrictEqual(result.data, { + result: { x: 1, y: 2, extra: "added" }, + }); + }); +}); + // ── Null intermediate path access ──────────────────────────────────────────── forEachEngine("path traversal: null intermediate segment", (run, _ctx) => {