diff --git a/packages/lang-core/src/parser/__tests__/serialize.test.ts b/packages/lang-core/src/parser/__tests__/serialize.test.ts index 6652292d6..cb8d81029 100644 --- a/packages/lang-core/src/parser/__tests__/serialize.test.ts +++ b/packages/lang-core/src/parser/__tests__/serialize.test.ts @@ -539,6 +539,41 @@ describe("jsonToOpenUI", () => { const children = result.root!.props.children as ElementNode[]; expect(children[0].props.text).toBe("New"); }); + + // A multi-line ternary is part of the accepted grammar (parse() keeps it), + // so merging a patch that doesn't touch it must leave it intact (#821). + it("keeps a multi-line ternary in an untouched statement", () => { + const existing = [ + "root = Stack([a, b])", + "a = $ok", + ' ? Title("Yes")', + ' : Title("No")', + 'b = Title("Footer")', + ].join("\n"); + + const merged = mergeStatements(existing, 'b = Title("Updated")'); + + expect(merged).toBe( + [ + "root = Stack([a, b])", + "a = $ok", + ' ? Title("Yes")', + ' : Title("No")', + 'b = Title("Updated")', + ].join("\n"), + ); + }); + + it("replaces a multi-line ternary when the patch targets it", () => { + const existing = ["root = Stack([a])", "a = $ok", ' ? Title("Yes")', ' : Title("No")'].join( + "\n", + ); + + const merged = mergeStatements(existing, 'a = Title("Changed")'); + + expect(merged).toBe(["root = Stack([a])", 'a = Title("Changed")'].join("\n")); + expect(merged).not.toContain('? Title("Yes")'); + }); }); // ── Edge cases ───────────────────────────────────────────────────────── diff --git a/packages/lang-core/src/parser/merge.ts b/packages/lang-core/src/parser/merge.ts index 69ba9a093..9c090de20 100644 --- a/packages/lang-core/src/parser/merge.ts +++ b/packages/lang-core/src/parser/merge.ts @@ -18,6 +18,7 @@ interface ParsedStatement { function splitStatementSource(input: string): string[] { const stmts: string[] = []; let depth = 0; + let ternaryDepth = 0; let inStr: false | '"' | "'" = false; let esc = false; let start = 0; @@ -44,7 +45,18 @@ function splitStatementSource(input: string): string[] { if (c === "(" || c === "[" || c === "{") depth++; else if (c === ")" || c === "]" || c === "}") depth = Math.max(0, depth - 1); + // Track ternary `?`/`:` at bracket depth 0 so a multi-line ternary + // (condition on one line, `?`/`:` continuation on the next) stays a single + // statement — mirrors split() in statements.ts. Without this the char-level + // splitter disagrees with the parser and drops the branches (#821). + else if (c === "?" && depth === 0) ternaryDepth++; + else if (c === ":" && depth === 0 && ternaryDepth > 0) ternaryDepth--; else if (c === "\n" && depth <= 0) { + if (ternaryDepth > 0) continue; // mid-ternary, awaiting the `:` branch + // Peek past whitespace/newlines: a `?` continuation keeps the statement. + let j = i + 1; + while (j < input.length && /\s/.test(input[j])) j++; + if (input[j] === "?") continue; const stmt = input.slice(start, i).trim(); if (stmt) stmts.push(stmt); start = i + 1;