Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cute-states-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@stackables/bridge-compiler": minor
"@stackables/bridge-parser": minor
---

Support object spread in path-scoped scope blocks
49 changes: 42 additions & 7 deletions packages/bridge-compiler/src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,9 +961,13 @@ class CodegenContext {
}

// Bridge wires override ToolDef wires
let spreadExprForToolDef: string | undefined;
for (const bw of bridgeWires) {
const path = bw.to.path;
if (path.length >= 1) {
if (path.length === 0) {
// Spread wire: ...sourceExpr — captures all fields from source
spreadExprForToolDef = this.wireToExpr(bw);
} else if (path.length >= 1) {
const key = path[0]!;
inputEntries.set(
key,
Expand All @@ -974,8 +978,16 @@ class CodegenContext {

const inputParts = [...inputEntries.values()];

const inputObj =
inputParts.length > 0 ? `{\n${inputParts.join(",\n")},\n }` : "{}";
let inputObj: string;
if (spreadExprForToolDef !== undefined) {
// Spread wire present: { ...spreadExpr, field1: ..., field2: ... }
const spreadEntry = ` ...${spreadExprForToolDef}`;
const allParts = [spreadEntry, ...inputParts];
inputObj = `{\n${allParts.join(",\n")},\n }`;
} else {
inputObj =
inputParts.length > 0 ? `{\n${inputParts.join(",\n")},\n }` : "{}";
}

if (onErrorWire) {
// Wrap in try/catch for onError
Expand Down Expand Up @@ -2584,17 +2596,34 @@ class CodegenContext {
): string {
if (wires.length === 0) return "{}";

// Build tree
// Separate root wire (path=[]) from field-specific wires
let rootExpr: string | undefined;
const fieldWires: Wire[] = [];

for (const w of wires) {
const path = getPath(w);
if (path.length === 0) {
rootExpr = this.wireToExpr(w);
} else {
fieldWires.push(w);
}
}

// Only a root wire — simple passthrough expression
if (rootExpr !== undefined && fieldWires.length === 0) {
return rootExpr;
}

// Build tree from field-specific wires
interface TreeNode {
expr?: string;
terminal?: boolean;
children: Map<string, TreeNode>;
}
const root: TreeNode = { children: new Map() };

for (const w of wires) {
for (const w of fieldWires) {
const path = getPath(w);
if (path.length === 0) return this.wireToExpr(w);
let current = root;
for (let i = 0; i < path.length - 1; i++) {
const seg = path[i]!;
Expand All @@ -2611,18 +2640,24 @@ class CodegenContext {
this.mergeOverdefinedExpr(node, w);
}

return this.serializeTreeNode(root, indent);
// Spread + field overrides: { ...rootExpr, field1: ..., field2: ... }
return this.serializeTreeNode(root, indent, rootExpr);
}

private serializeTreeNode(
node: {
children: Map<string, { expr?: string; children: Map<string, unknown> }>;
},
indent: number,
spreadExpr?: string,
): string {
const pad = " ".repeat(indent);
const entries: string[] = [];

if (spreadExpr !== undefined) {
entries.push(`${pad}...${spreadExpr}`);
}

for (const [key, child] of node.children) {
if (child.children.size === 0) {
entries.push(
Expand Down
2 changes: 2 additions & 0 deletions packages/bridge-parser/src/parser/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export const RCurly = createToken({ name: "RCurly", pattern: /\}/ });
export const LSquare = createToken({ name: "LSquare", pattern: /\[/ });
export const RSquare = createToken({ name: "RSquare", pattern: /\]/ });
export const Equals = createToken({ name: "Equals", pattern: /=/ });
export const Spread = createToken({ name: "Spread", pattern: /\.\.\./ });
export const Dot = createToken({ name: "Dot", pattern: /\./ });
export const Colon = createToken({ name: "Colon", pattern: /:/ });
export const Comma = createToken({ name: "Comma", pattern: /,/ });
Expand Down Expand Up @@ -258,6 +259,7 @@ export const allTokens = [
LSquare,
RSquare,
Equals,
Spread,
Dot,
Colon,
Comma,
Expand Down
126 changes: 120 additions & 6 deletions packages/bridge-parser/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
LSquare,
RSquare,
Equals,
Spread,
Dot,
Colon,
Comma,
Expand Down Expand Up @@ -547,7 +548,7 @@ class BridgeParser extends CstParser {
},
},
{
// Path scoping block: target { .field <- source | .field = value | .field { ... } | alias ... as ... }
// Path scoping block: target { lines: .field <- source, .field = value, .field { ... }, alias ... as ..., ...source }
ALT: () => {
this.CONSUME(LCurly, { LABEL: "scopeBlock" });
this.MANY3(() =>
Expand All @@ -557,6 +558,7 @@ class BridgeParser extends CstParser {
this.SUBRULE(this.bridgeNodeAlias, { LABEL: "scopeAlias" }),
},
{ ALT: () => this.SUBRULE(this.pathScopeLine) },
{ ALT: () => this.SUBRULE(this.scopeSpreadLine) },
]),
);
this.CONSUME(RCurly);
Expand Down Expand Up @@ -681,11 +683,22 @@ class BridgeParser extends CstParser {
},
},
{
// Path scope block: .field { .subField <- source | .subField = value | ... }
// Path scope block: .field { lines: .subField <- source, ...source, .subField = value, ... }
ALT: () => {
this.CONSUME(LCurly, { LABEL: "elemScopeBlock" });
this.MANY3(() =>
this.SUBRULE(this.pathScopeLine, { LABEL: "elemScopeLine" }),
this.OR3([
{
ALT: () =>
this.SUBRULE(this.pathScopeLine, { LABEL: "elemScopeLine" }),
},
{
ALT: () =>
this.SUBRULE(this.scopeSpreadLine, {
LABEL: "elemSpreadLine",
}),
},
]),
);
this.CONSUME(RCurly);
},
Expand Down Expand Up @@ -787,6 +800,7 @@ class BridgeParser extends CstParser {
this.SUBRULE(this.bridgeNodeAlias, { LABEL: "scopeAlias" }),
},
{ ALT: () => this.SUBRULE(this.pathScopeLine) },
{ ALT: () => this.SUBRULE(this.scopeSpreadLine) },
]),
);
this.CONSUME(RCurly);
Expand All @@ -795,6 +809,18 @@ class BridgeParser extends CstParser {
]);
});

/**
* Spread line inside a path scope block:
* ...sourceExpr
*
* Wires all fields of the source to the current scope target path.
* Equivalent to writing `target <- sourceExpr` at the outer level.
*/
public scopeSpreadLine = this.RULE("scopeSpreadLine", () => {
this.CONSUME(Spread);
this.SUBRULE(this.sourceExpr, { LABEL: "spreadSource" });
});

/** A coalesce alternative: either a JSON literal or a source expression */
public coalesceAlternative = this.RULE("coalesceAlternative", () => {
// Need to distinguish literal values from source references.
Expand Down Expand Up @@ -2172,8 +2198,32 @@ function processElementLines(
wires.push(...nullishFallbackInternalWires);
wires.push(...catchFallbackInternalWires);
} else if (elemC.elemScopeBlock) {
// ── Path scope block inside array mapping: .field { .sub <- ... } ──
// ── Path scope block inside array mapping: .field { lines: .sub <- ..., ...source } ──
const scopeLines = subs(elemLine, "elemScopeLine");
// Process spread lines at the top level of this scope block
const spreadLines = subs(elemLine, "elemSpreadLine");
for (const spreadLine of spreadLines) {
const spreadLineNum = line(findFirstToken(spreadLine));
const sourceNode = sub(spreadLine, "spreadSource")!;
const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterName);
// Propagate safe navigation (?.) flag from source expression
const headNode = sub(sourceNode, "head")!;
const pipeNodes = subs(sourceNode, "pipeSegment");
const actualNode =
pipeNodes.length > 0 ? pipeNodes[pipeNodes.length - 1]! : headNode;
const { safe: spreadSafe } = extractAddressPath(actualNode);
wires.push({
from: fromRef,
to: {
module: SELF_MODULE,
type: bridgeType,
field: bridgeField,
element: true,
path: elemToPath,
},
...(spreadSafe ? { safe: true as const } : {}),
});
}
processElementScopeLines(
scopeLines,
elemToPath,
Expand Down Expand Up @@ -2291,7 +2341,36 @@ function processElementScopeLines(

// ── Nested scope: .field { ... } ──
const nestedScopeLines = subs(scopeLine, "pathScopeLine");
if (nestedScopeLines.length > 0 && !sc.scopeEquals && !sc.scopeArrow) {
const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine");
if (
(nestedScopeLines.length > 0 || nestedSpreadLines.length > 0) &&
!sc.scopeEquals &&
!sc.scopeArrow
) {
// Process spread lines inside this nested scope block: ...sourceExpr
const spreadToPath = [...arrayToPath, ...fullSegs];
for (const spreadLine of nestedSpreadLines) {
const spreadLineNum = line(findFirstToken(spreadLine));
const sourceNode = sub(spreadLine, "spreadSource")!;
const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterName);
// Propagate safe navigation (?.) flag from source expression
const headNode = sub(sourceNode, "head")!;
const pipeNodes = subs(sourceNode, "pipeSegment");
const actualNode =
pipeNodes.length > 0 ? pipeNodes[pipeNodes.length - 1]! : headNode;
const { safe: spreadSafe } = extractAddressPath(actualNode);
wires.push({
from: fromRef,
to: {
module: SELF_MODULE,
type: bridgeType,
field: bridgeField,
element: true,
path: spreadToPath,
},
...(spreadSafe ? { safe: true as const } : {}),
});
}
processElementScopeLines(
nestedScopeLines,
arrayToPath,
Expand Down Expand Up @@ -4097,7 +4176,12 @@ function buildBridgeBody(

// ── Nested scope: .field { ... } ──
const nestedScopeLines = subs(scopeLine, "pathScopeLine");
if (nestedScopeLines.length > 0 && !sc.scopeEquals && !sc.scopeArrow) {
const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine");
if (
(nestedScopeLines.length > 0 || nestedSpreadLines.length > 0) &&
!sc.scopeEquals &&
!sc.scopeArrow
) {
// Process alias declarations inside the nested scope block first
const scopeAliases = subs(scopeLine, "scopeAlias");
for (const aliasNode of scopeAliases) {
Expand Down Expand Up @@ -4132,6 +4216,21 @@ function buildBridgeBody(
...(aliasSafe ? { safe: true as const } : {}),
});
}
// Process spread lines inside this nested scope block: ...sourceExpr
const nestedToRef = resolveAddress(targetRoot, fullSegs, scopeLineNum);
for (const spreadLine of nestedSpreadLines) {
const spreadLineNum = line(findFirstToken(spreadLine));
const sourceNode = sub(spreadLine, "spreadSource")!;
const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe(
sourceNode,
spreadLineNum,
);
wires.push({
from: fromRef,
to: nestedToRef,
...(spreadSafe ? { safe: true as const } : {}),
});
}
processScopeLines(nestedScopeLines, targetRoot, fullSegs);
continue;
}
Expand Down Expand Up @@ -4769,6 +4868,21 @@ function buildBridgeBody(
});
}
const scopeLines = subs(wireNode, "pathScopeLine");
// Process spread lines inside the scope block: ...sourceExpr
const spreadLines = subs(wireNode, "scopeSpreadLine");
for (const spreadLine of spreadLines) {
const spreadLineNum = line(findFirstToken(spreadLine));
const sourceNode = sub(spreadLine, "spreadSource")!;
const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe(
sourceNode,
spreadLineNum,
);
wires.push({
from: fromRef,
to: toRef,
...(spreadSafe ? { safe: true as const } : {}),
});
}
processScopeLines(scopeLines, targetRoot, targetSegs);
continue;
}
Expand Down
10 changes: 10 additions & 0 deletions packages/bridge-syntax-highlight/syntaxes/bridge.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
{ "include": "#block-headers" },
{ "include": "#block-braces" },
{ "include": "#force-lines" },
{ "include": "#spread-lines" },
{ "include": "#wire-lines" },
{ "include": "#const-lines" },
{ "include": "#reserved-handles" },
Expand Down Expand Up @@ -166,6 +167,15 @@
}
},

"spread-lines": {
"comment": "Spread line inside a path scope block: ...sourceExpr",
"match": "^\\s*(\\.\\.\\.)([A-Za-z_][A-Za-z0-9_.]*)",
"captures": {
"1": { "name": "keyword.operator.spread.bridge" },
"2": { "name": "variable.other.source.bridge" }
}
},

"wire-lines": {
"comment": "Wire line: target <- source [|| …] [?? …] [catch …] — target may be dot-prefixed (.field) inside array-map blocks",
"begin": "^(\\s*)(\\.?[A-Za-z_][A-Za-z0-9_.]*)\\s*(<-)",
Expand Down
Loading