diff --git a/packages/bridge-parser/src/bridge-printer.ts b/packages/bridge-parser/src/bridge-printer.ts new file mode 100644 index 0000000..0fc3d30 --- /dev/null +++ b/packages/bridge-parser/src/bridge-printer.ts @@ -0,0 +1,481 @@ +/** + * Token-based Bridge DSL formatter with comment preservation. + * + * Uses the Chevrotain lexer directly to preserve comments that would be lost + * during AST-based serialization. This approach reconstructs formatted source + * from the token stream, applying consistent formatting rules. + */ +import type { IToken } from "chevrotain"; +import { BridgeLexer } from "./parser/lexer.ts"; + +const INDENT = " "; + +// ── Comment handling ───────────────────────────────────────────────────────── + +interface CommentAttachment { + leading: IToken[]; + trailing: IToken | null; +} + +/** + * Attach comments to their logical positions in the token stream. + */ +function attachComments( + tokens: IToken[], + comments: IToken[], +): Map { + const result = new Map(); + if (comments.length === 0) return result; + + const sortedComments = [...comments].sort( + (a, b) => a.startOffset - b.startOffset, + ); + + for (const comment of sortedComments) { + const commentLine = comment.startLine ?? 1; + let prevToken: IToken | null = null; + let nextToken: IToken | null = null; + + for (const t of tokens) { + if (t.endOffset !== undefined && t.endOffset < comment.startOffset) { + if (!prevToken || t.endOffset > (prevToken.endOffset ?? 0)) { + prevToken = t; + } + } + if (t.startOffset > comment.startOffset) { + if (!nextToken || t.startOffset < nextToken.startOffset) { + nextToken = t; + } + } + } + + const isTrailing = + prevToken?.endLine !== undefined && prevToken.endLine === commentLine; + + if (isTrailing && prevToken) { + const key = prevToken.startOffset; + const existing = result.get(key) ?? { leading: [], trailing: null }; + existing.trailing = comment; + result.set(key, existing); + } else if (nextToken) { + const key = nextToken.startOffset; + const existing = result.get(key) ?? { leading: [], trailing: null }; + existing.leading.push(comment); + result.set(key, existing); + } + } + + return result; +} + +// ── Spacing rules ──────────────────────────────────────────────────────────── + +const NO_SPACE_BEFORE = new Set([ + "Dot", + "Comma", + "RSquare", + "RParen", + "LParen", + "LSquare", + "Colon", + "VersionTag", + "SafeNav", +]); + +const NO_SPACE_AFTER = new Set([ + "Dot", + "LParen", + "LSquare", + "LCurly", + "Colon", + "Spread", + "SafeNav", +]); + +const SPACE_AROUND = new Set([ + "Equals", + "Arrow", + "NullCoalesce", + "ErrorCoalesce", + "DoubleEquals", + "NotEquals", + "GreaterEqual", + "LessEqual", + "GreaterThan", + "LessThan", + "Plus", + "Star", + "Minus", + "AndKw", + "OrKw", + "CatchKw", + "AsKw", + "FromKw", + "QuestionMark", +]); + +// ── Line classification helpers ───────────────────────────────────────────── + +function isWithLine(group: IToken[]): boolean { + return group[0]?.tokenType.name === "WithKw"; +} + +function isWireLine(group: IToken[]): boolean { + // A wire line contains an Arrow (<-) + return group.some((t) => t.tokenType.name === "Arrow"); +} + +function isJsonObject(tokens: IToken[], startIdx: number): boolean { + // Check if this LCurly starts a JSON object (next token is a string literal) + const next = tokens[startIdx + 1]; + return next?.tokenType.name === "StringLiteral"; +} + +const TOP_LEVEL_BLOCK_STARTERS = new Set(["ToolKw", "BridgeKw", "DefineKw"]); + +function isTopLevelBlockStart(group: IToken[]): boolean { + return TOP_LEVEL_BLOCK_STARTERS.has(group[0]?.tokenType.name ?? ""); +} + +// ── Main formatter ─────────────────────────────────────────────────────────── + +/** + * Format Bridge DSL source code with consistent styling. + * + * @param source - The Bridge DSL source text to format + * @returns Formatted source text, or the original if parsing fails + */ +export function formatBridge(source: string): string { + const lexResult = BridgeLexer.tokenize(source); + + if (lexResult.errors.length > 0) { + return source; + } + + const tokens = lexResult.tokens; + const comments = (lexResult.groups["comments"] as IToken[]) ?? []; + + if (tokens.length === 0) { + // Comment-only file: preserve blank lines between comments (collapse 2+ to 1) + if (comments.length === 0) return ""; + const sortedComments = [...comments].sort( + (a, b) => a.startOffset - b.startOffset, + ); + const lines: string[] = []; + let lastLine = 0; + for (const comment of sortedComments) { + const commentLine = comment.startLine ?? 1; + if (lastLine > 0 && commentLine > lastLine + 1) { + lines.push(""); // Add one blank line + } + lines.push(comment.image); + lastLine = commentLine; + } + return lines.join("\n") + "\n"; + } + + const commentMap = attachComments(tokens, comments); + + // Tokens that start new logical lines even if on the same source line + const LINE_STARTERS = new Set([ + "ToolKw", + "BridgeKw", + "DefineKw", + "VersionKw", + ]); + + // Group tokens by original source line, tracking original line numbers + // Also split when a top-level block starter appears mid-line + const lineGroups: { tokens: IToken[]; originalLine: number }[] = []; + let currentLine = -1; + let currentGroup: IToken[] = []; + + for (const token of tokens) { + const line = token.startLine ?? 1; + const tokenType = token.tokenType.name; + + // Split on new line OR on top-level block starter (like }tool on same line) + const isLineStarter = LINE_STARTERS.has(tokenType); + const shouldSplit = + line !== currentLine || (isLineStarter && currentGroup.length > 0); + + if (shouldSplit) { + if (currentGroup.length > 0) { + lineGroups.push({ tokens: currentGroup, originalLine: currentLine }); + } + currentGroup = [token]; + currentLine = line; + } else { + currentGroup.push(token); + } + } + if (currentGroup.length > 0) { + lineGroups.push({ tokens: currentGroup, originalLine: currentLine }); + } + + // Merge continuation lines (standalone `as`, identifier, or `{` that continue previous line) + const mergedGroups: { tokens: IToken[]; originalLine: number }[] = []; + for (let i = 0; i < lineGroups.length; i++) { + const group = lineGroups[i]; + const firstType = group.tokens[0]?.tokenType.name; + + // Check if this is a continuation line that should merge with previous + const isContinuation = + mergedGroups.length > 0 && // Line is just `as` keyword (not part of 'with') + ((firstType === "AsKw" && group.tokens.length <= 2) || + // Line is just an identifier (alias name) + (firstType === "Identifier" && group.tokens.length === 1) || + // Line is just `{` + (firstType === "LCurly" && group.tokens.length === 1)); + + if (isContinuation) { + // Merge with previous group + const prev = mergedGroups[mergedGroups.length - 1]; + prev.tokens.push(...group.tokens); + } else { + mergedGroups.push({ + tokens: [...group.tokens], + originalLine: group.originalLine, + }); + } + } + + const output: string[] = []; + let depth = 0; + let lastOutputLine = 0; // Track which original line we last output + let lastWasWithLine = false; // Track if previous line was a 'with' declaration + let lastWasOpenBrace = false; // Track if previous output was an opening brace + + for (let gi = 0; gi < mergedGroups.length; gi++) { + const { tokens: group, originalLine } = mergedGroups[gi]; + const firstToken = group[0]; + + // Track depth at start of line for proper indentation + const lineStartDepth = depth; + // Current indentation for output (may change mid-line) + let currentIndent = lineStartDepth; + + // Classify current line + const currentIsWithLine = isWithLine(group); + const currentIsWireLine = isWireLine(group); + const currentIsTopLevelBlock = isTopLevelBlockStart(group); + + // Preserve blank lines from original source (but collapse 2+ to 1) + let needsBlankLine = + lastOutputLine > 0 && originalLine > lastOutputLine + 1; + + // Add blank line before top-level blocks (tool, bridge, define) at depth 0 + if (currentIsTopLevelBlock && lineStartDepth === 0 && output.length > 0) { + const lastOutput = output[output.length - 1]; + if (lastOutput !== "\n") { + needsBlankLine = true; + } + } + + // Don't add blank lines between consecutive 'with' lines + if (lastWasWithLine && currentIsWithLine) { + needsBlankLine = false; + } + + // Don't add blank line right after opening brace + if (lastWasOpenBrace) { + needsBlankLine = false; + } + + // Add blank line when transitioning from 'with' declarations to wire expressions + if (lastWasWithLine && !currentIsWithLine && currentIsWireLine) { + needsBlankLine = true; + } + + if (needsBlankLine) { + output.push("\n"); + } + + // Add blank line after version declaration + if (gi > 0) { + const prevGroup = mergedGroups[gi - 1]; + const prevFirstType = prevGroup.tokens[0]?.tokenType.name; + if (prevFirstType === "VersionKw" && output.length > 0) { + // Check if we already have a blank line + const lastOutput = output[output.length - 1]; + if (lastOutput !== "\n") { + output.push("\n"); + } + } + } + + // Leading comments - emit them on their own lines, preserving blank lines + const attached = commentMap.get(firstToken.startOffset); + if (attached?.leading) { + let lastCommentLine = 0; + for (const comment of attached.leading) { + const commentLine = comment.startLine ?? 1; + if (lastCommentLine > 0 && commentLine > lastCommentLine + 1) { + output.push("\n"); // Preserve blank line between comments + } + output.push(INDENT.repeat(lineStartDepth) + comment.image + "\n"); + lastCommentLine = commentLine; + } + } + + // Build the line + let lineOutput = ""; + let lastType: string | null = null; + let jsonObjectDepth = 0; // Track inline JSON object depth + let inTernary = false; // Track if we've seen a ? (ternary operator) on this line + + for (let ti = 0; ti < group.length; ti++) { + const token = group[ti]; + const tokenType = token.tokenType.name; + + // Check if this LCurly starts an inline JSON object + const isJsonStart = tokenType === "LCurly" && isJsonObject(group, ti); + + // Handle brace depth + if (tokenType === "LCurly") { + if (isJsonStart || jsonObjectDepth > 0) { + // JSON object - stay inline + // Space before { if after = or other content, but no space after { + if ( + lineOutput.length > 0 && + !lineOutput.endsWith(" ") && + !lineOutput.endsWith("{") + ) { + lineOutput += " "; + } + jsonObjectDepth++; + lineOutput += "{"; + lastType = tokenType; + continue; + } + + // Space before brace + if (lineOutput.length > 0 && !lineOutput.endsWith(" ")) { + lineOutput += " "; + } + lineOutput += "{"; + depth++; + lastType = tokenType; + + // Trailing comment on brace + const tokenAttached = commentMap.get(token.startOffset); + if (tokenAttached?.trailing) { + lineOutput += " " + tokenAttached.trailing.image; + } + + // If there are more tokens after the brace (and they aren't just RCurly), + // emit the line now so content goes on a new line + const remainingTokens = group.slice(ti + 1); + const hasContentAfter = remainingTokens.some( + (t) => t.tokenType.name !== "RCurly", + ); + if (hasContentAfter && lineOutput.length > 0) { + // Emit the line with the brace, content will continue on next iteration + output.push(INDENT.repeat(currentIndent) + lineOutput + "\n"); + lineOutput = ""; + lastType = null; + currentIndent = depth; // Update indentation for remaining content + } + continue; + } + + if (tokenType === "RCurly") { + if (jsonObjectDepth > 0) { + // JSON object - stay inline + jsonObjectDepth--; + lineOutput += "}"; + lastType = tokenType; + continue; + } + + // Output anything accumulated first + if (lineOutput.length > 0) { + output.push(INDENT.repeat(depth) + lineOutput + "\n"); + lineOutput = ""; + } + // Decrement depth, then emit brace at new (outer) depth + depth = Math.max(0, depth - 1); + let braceOutput = "}"; + lastType = tokenType; + + // Trailing comment + const tokenAttached = commentMap.get(token.startOffset); + if (tokenAttached?.trailing) { + braceOutput += " " + tokenAttached.trailing.image; + } + + // Emit the closing brace immediately + output.push(INDENT.repeat(depth) + braceOutput + "\n"); + continue; + } + + // Spacing - context-aware to handle paths like c.from.station + const afterDot = lastType === "Dot" || lastType === "SafeNav"; + const beforeDot = tokenType === "Dot" || tokenType === "SafeNav"; + + // JSON object colon: "key": value - add space after colon + const afterJsonColon = jsonObjectDepth > 0 && lastType === "Colon"; + + // Ternary colon: needs space around it (condition ? true : false) + const isTernaryColon = tokenType === "Colon" && inTernary; + const afterTernaryColon = lastType === "Colon" && inTernary; + + const needsSpace = + lineOutput.length > 0 && + !NO_SPACE_BEFORE.has(tokenType) && + !(lastType && NO_SPACE_AFTER.has(lastType)); + + // Only force space around operators if not part of a dotted path + const forceSpace = + afterJsonColon || + isTernaryColon || + afterTernaryColon || + (!afterDot && + !beforeDot && + (SPACE_AROUND.has(tokenType) || + (lastType !== null && SPACE_AROUND.has(lastType)))); + + if (needsSpace || forceSpace) { + if (!lineOutput.endsWith(" ")) { + lineOutput += " "; + } + } + + lineOutput += token.image; + lastType = tokenType; + + // Track ternary state + if (tokenType === "QuestionMark") { + inTernary = true; + } + + // Trailing comment + const tokenAttached = commentMap.get(token.startOffset); + if (tokenAttached?.trailing) { + lineOutput += " " + tokenAttached.trailing.image; + } + } + + // Emit the line + if (lineOutput.length > 0) { + output.push(INDENT.repeat(currentIndent) + lineOutput + "\n"); + } + + lastOutputLine = originalLine; + lastWasWithLine = currentIsWithLine; + // Check if this line ends with an opening brace (for next iteration) + lastWasOpenBrace = group.some((t) => t.tokenType.name === "LCurly"); + } + + let result = output.join(""); + + // Clean up excessive blank lines (3+ consecutive newlines -> 2) + result = result.replace(/\n{3,}/g, "\n\n"); + + // Ensure trailing newline + if (!result.endsWith("\n")) { + result += "\n"; + } + + return result; +} diff --git a/packages/bridge-parser/src/index.ts b/packages/bridge-parser/src/index.ts index 07aa8f0..d2568e6 100644 --- a/packages/bridge-parser/src/index.ts +++ b/packages/bridge-parser/src/index.ts @@ -23,6 +23,10 @@ export { serializeBridge, } from "./bridge-format.ts"; +// ── Formatter ─────────────────────────────────────────────────────────────── + +export { formatBridge } from "./bridge-printer.ts"; + // ── Language service ──────────────────────────────────────────────────────── export { BridgeLanguageService } from "./language-service.ts"; diff --git a/packages/bridge-parser/src/parser/lexer.ts b/packages/bridge-parser/src/parser/lexer.ts index 6fcfccc..e6b41b5 100644 --- a/packages/bridge-parser/src/parser/lexer.ts +++ b/packages/bridge-parser/src/parser/lexer.ts @@ -23,7 +23,7 @@ export const WS = createToken({ export const Comment = createToken({ name: "Comment", pattern: /#[^\r\n]*/, - group: Lexer.SKIPPED, + group: "comments", }); // ── Identifiers (defined first — keywords reference via longer_alt) ──────── diff --git a/packages/bridge/test/bridge-printer-examples.test.ts b/packages/bridge/test/bridge-printer-examples.test.ts new file mode 100644 index 0000000..9c2c58e --- /dev/null +++ b/packages/bridge/test/bridge-printer-examples.test.ts @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { formatBridge } from "../src/index.ts"; + +/** + * ============================================================================ + * FULL EXAMPLE TEST CASES + * + * These tests show complete Bridge DSL snippets with expected formatting. + * Edit these to define the canonical style. + * ============================================================================ + */ + +describe("formatBridge - full examples", () => { + test("simple tool declaration", () => { + const input = `version 1.5 +tool geo from std.httpCall`; + const expected = `version 1.5 + +tool geo from std.httpCall +`; + assert.equal(formatBridge(input), expected); + }); + + test("tool with body", () => { + const input = `version 1.5 + +tool geo from std.httpCall{ +.baseUrl="https://example.com" +.method=GET +}`; + const expected = `version 1.5 + +tool geo from std.httpCall { + .baseUrl = "https://example.com" + .method = GET +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("bridge block with assignments", () => { + const input = `version 1.5 + +bridge Query.test{ +with input as i +with output as o +o.value<-i.value +}`; + const expected = `version 1.5 + +bridge Query.test { + with input as i + with output as o + + o.value <- i.value +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("define block", () => { + const input = `define myHelper{ +with input as i +o.x<-i.y +}`; + const expected = `define myHelper { + with input as i + + o.x <- i.y +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("bridge with comment, tool handles, and pipes", () => { + const input = `version 1.5 + +bridge Query.greet { +#comment + with std.str.toUpperCase as uc + with std.str.toLowerCase as lc + + with input as i + with output as o + + o.message <- i.name + o.upper <- uc: i.name + o.lower <- lc: i.name +}`; + const expected = `version 1.5 + +bridge Query.greet { + #comment + with std.str.toUpperCase as uc + with std.str.toLowerCase as lc + with input as i + with output as o + + o.message <- i.name + o.upper <- uc:i.name + o.lower <- lc:i.name +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("ternary expressions preserve formatting", () => { + const input = `version 1.5 + +bridge Query.pricing { + with input as i + with output as o + + # String literal branches + o.tier <- i.isPro ? "premium" : "basic" + + # Numeric literal branches + o.discount <- i.isPro ? 20 : 5 + + # Source ref branches — selects proPrice or basicPrice + o.price <- i.isPro ? i.proPrice : i.basicPrice +} +`; + // Should not change + assert.equal(formatBridge(input), input); + }); + + test("blank line between top-level blocks", () => { + const input = `version 1.5 + +tool geo from std.httpCall +tool weather from std.httpCall +bridge Query.a { + with input as i +} +bridge Query.b { + with input as i +} +define helper { + with input as i +}`; + const expected = `version 1.5 + +tool geo from std.httpCall + +tool weather from std.httpCall + +bridge Query.a { + with input as i +} + +bridge Query.b { + with input as i +} + +define helper { + with input as i +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("not operator preserves space", () => { + const input = `o.requireMFA <- not i.verified +`; + // Should not change + assert.equal(formatBridge(input), input); + }); + + test("blank lines between comments are preserved", () => { + const input = `#asdasd + +#sdasdsd +`; + // Should not change + assert.equal(formatBridge(input), input); + }); +}); diff --git a/packages/bridge/test/bridge-printer.test.ts b/packages/bridge/test/bridge-printer.test.ts new file mode 100644 index 0000000..b0c0cdd --- /dev/null +++ b/packages/bridge/test/bridge-printer.test.ts @@ -0,0 +1,298 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { formatBridge } from "../src/index.ts"; + +/** + * ============================================================================ + * EASY-TO-REVIEW TEST CASES + * + * Each test shows: + * INPUT → what the user wrote (possibly messy) + * EXPECTED → what the formatter should produce (canonical form) + * ============================================================================ + */ + +describe("formatBridge - spacing", () => { + test("operator spacing: '<-' gets spaces", () => { + const input = `o.x<-i.y`; + const expected = `o.x <- i.y\n`; + assert.equal(formatBridge(input), expected); + }); + + test("operator spacing: '=' gets spaces", () => { + const input = `.baseUrl="https://example.com"`; + const expected = `.baseUrl = "https://example.com"\n`; + assert.equal(formatBridge(input), expected); + }); + + test("brace spacing: space before '{'", () => { + const input = `bridge Query.test{`; + const expected = `bridge Query.test {\n`; + assert.equal(formatBridge(input), expected); + }); + + test("no space after '.' in paths", () => { + const input = `o.foo.bar`; + const expected = `o.foo.bar\n`; + assert.equal(formatBridge(input), expected); + }); + + test("no space around '.' even with 'from' as property name", () => { + const input = `c.from.station.id`; + const expected = `c.from.station.id\n`; + assert.equal(formatBridge(input), expected); + }); + + test("'from' keyword gets spaces when used as keyword", () => { + const input = `tool geo from std.httpCall`; + const expected = `tool geo from std.httpCall\n`; + assert.equal(formatBridge(input), expected); + }); + + test("safe navigation '?.' has no spaces", () => { + const input = `o.x?.y`; + const expected = `o.x?.y\n`; + assert.equal(formatBridge(input), expected); + }); + + test("parentheses: no space inside", () => { + const input = `foo( a , b )`; + const expected = `foo(a, b)\n`; + assert.equal(formatBridge(input), expected); + }); + + test("brackets: no space inside", () => { + const input = `arr[ 0 ]`; + const expected = `arr[0]\n`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - indentation", () => { + test("bridge body is indented 2 spaces", () => { + const input = `bridge Query.test { +with input as i +o.x <- i.y +}`; + const expected = `bridge Query.test { + with input as i + + o.x <- i.y +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("nested braces increase indentation", () => { + const input = `bridge Query.test { +on error { +.retry = true +} +}`; + const expected = `bridge Query.test { + on error { + .retry = true + } +} +`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - blank lines", () => { + test("blank line after version", () => { + const input = `version 1.5 +tool geo from std.httpCall`; + const expected = `version 1.5 + +tool geo from std.httpCall +`; + assert.equal(formatBridge(input), expected); + }); + + test("preserve single blank line (user grouping)", () => { + const input = `bridge Query.test { + with input as i + + o.x <- i.y +}`; + const expected = `bridge Query.test { + with input as i + + o.x <- i.y +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("collapse multiple blank lines to one", () => { + const input = `bridge Query.test { + with input as i + + + o.x <- i.y +}`; + const expected = `bridge Query.test { + with input as i + + o.x <- i.y +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("at least a single blank line between wires", () => { + const input = `bridge Query.test { + with input as i + o.x <- i.y +}`; + const expected = `bridge Query.test { + with input as i + + o.x <- i.y +} +`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - comments", () => { + test("standalone comment preserved", () => { + const input = `# This is a comment +tool geo from std.httpCall`; + const expected = `# This is a comment +tool geo from std.httpCall +`; + assert.equal(formatBridge(input), expected); + }); + + test("inline comment stays on same line", () => { + const input = `tool geo from std.httpCall # inline`; + const expected = `tool geo from std.httpCall # inline\n`; + assert.equal(formatBridge(input), expected); + }); + + test("trailing comment on brace line", () => { + const input = `bridge Query.test { # comment +}`; + const expected = `bridge Query.test { # comment +} +`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - on error blocks", () => { + test("on error with simple value", () => { + const input = `on error=null`; + const expected = `on error = null\n`; + assert.equal(formatBridge(input), expected); + }); + + test("on error with JSON object stays on one line", () => { + const input = `on error = { "connections": [] }`; + const expected = `on error = {"connections": []}\n`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - edge cases", () => { + test("empty input", () => { + assert.equal(formatBridge(""), ""); + }); + + test("whitespace only input", () => { + assert.equal(formatBridge(" \n \n"), ""); + }); + + test("returns original on lexer errors", () => { + const invalid = `bridge @invalid { }`; + const output = formatBridge(invalid); + assert.ok(output.includes("@invalid")); + }); + + test("comment-only file", () => { + const input = `# comment 1 +# comment 2`; + const expected = `# comment 1 +# comment 2 +`; + assert.equal(formatBridge(input), expected); + }); +}); + +describe("formatBridge - line splitting and joining", () => { + test("content after '{' moves to new indented line", () => { + const input = `bridge Query.greet { + with output as o + + o {.message <- i.name + .upper <- uc:i.name + } +}`; + const expected = `bridge Query.greet { + with output as o + + o { + .message <- i.name + .upper <- uc:i.name + } +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("standalone 'as', identifier, and '{' merge with previous line", () => { + const input = `bridge Query.test { + with output as o + + o <- api.items[] + as + c + { + .id <- c.id + } +}`; + const expected = `bridge Query.test { + with output as o + + o <- api.items[] as c { + .id <- c.id + } +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("'as' in 'with' declaration is not merged incorrectly", () => { + // with lines should stay separate + const input = `bridge Query.test { + with input as i + with output as o +}`; + const expected = `bridge Query.test { + with input as i + with output as o +} +`; + assert.equal(formatBridge(input), expected); + }); + + test("adjacent blocks on same line get separated with blank line", () => { + // }tool on same line should split into separate lines with blank line + const input = `tool a from std.httpCall { + .path = "/a" +}tool b from std.httpCall { + .path = "/b" +}`; + const expected = `tool a from std.httpCall { + .path = "/a" +} + +tool b from std.httpCall { + .path = "/b" +} +`; + assert.equal(formatBridge(input), expected); + }); +}); diff --git a/packages/playground/src/App.tsx b/packages/playground/src/App.tsx index 933f283..2587d2e 100644 --- a/packages/playground/src/App.tsx +++ b/packages/playground/src/App.tsx @@ -18,6 +18,7 @@ import { extractInputSkeleton, mergeInputSkeleton, clearHttpCache, + formatBridge, } from "./engine"; import type { RunResult } from "./engine"; import { buildSchema, type GraphQLSchema } from "graphql"; @@ -507,6 +508,11 @@ export function App() { } }, [activeQuery, mode, schema, bridge, context]); + const handleFormatBridge = useCallback(() => { + const formatted = formatBridge(bridge); + setBridge(formatted); + }, [bridge]); + const diagnostics = getDiagnostics(bridge).diagnostics; const hasErrors = diagnostics.some((d) => d.severity === "error"); const isActiveRunning = @@ -722,6 +728,7 @@ export function App() { onChange={setBridge} language="bridge" autoHeight + onFormat={handleFormatBridge} /> @@ -837,6 +844,7 @@ export function App() { value={bridge} onChange={setBridge} language="bridge" + onFormat={handleFormatBridge} /> @@ -876,6 +884,7 @@ export function App() { value={bridge} onChange={setBridge} language="bridge" + onFormat={handleFormatBridge} /> diff --git a/packages/playground/src/components/Editor.tsx b/packages/playground/src/components/Editor.tsx index 379cd6d..c59f78b 100644 --- a/packages/playground/src/components/Editor.tsx +++ b/packages/playground/src/components/Editor.tsx @@ -7,6 +7,7 @@ import { diagnosticCount, lintGutter } from "@codemirror/lint"; import { json } from "@codemirror/lang-json"; import { graphql, graphqlLanguageSupport, updateSchema } from "cm6-graphql"; import type { GraphQLSchema } from "graphql"; +import { Paintbrush } from "lucide-react"; import { bridgeLanguage } from "@/codemirror/bridge-lang"; import { bridgeLinter } from "@/codemirror/bridge-lint"; import { bridgeAutocomplete } from "@/codemirror/bridge-completion"; @@ -40,6 +41,8 @@ type Props = { autoHeight?: boolean; /** GraphQL schema for query editors — enables autocomplete & validation. */ graphqlSchema?: GraphQLSchema; + /** Optional callback to format the code. When provided, shows a format button. */ + onFormat?: () => void; }; function languageExtension( @@ -68,6 +71,7 @@ export function Editor({ readOnly = false, autoHeight = false, graphqlSchema, + onFormat, }: Props) { const containerRef = useRef(null); const viewRef = useRef(null); @@ -147,18 +151,30 @@ export function Editor({ {label} )} -
+
+ {onFormat && ( + )} - /> +
); } diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 28281ae..f10a8c8 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -8,7 +8,9 @@ import { parseBridgeChevrotain, parseBridgeDiagnostics, executeBridge, + formatBridge, } from "@stackables/bridge"; +export { formatBridge }; import type { BridgeDiagnostic, Bridge, @@ -437,6 +439,9 @@ export function extractInputSkeleton( * Build a new skeleton and fill in values from the previous JSON where * keys match exactly. Keys that no longer exist in the skeleton are dropped; * new skeleton keys get `""` placeholders. + * + * If the skeleton is empty `{}`, preserve the existing JSON unchanged to + * avoid losing user input when the DSL has a temporary syntax error. */ export function mergeInputSkeleton( existingJson: string, @@ -446,6 +451,11 @@ export function mergeInputSkeleton( const existing = JSON.parse(existingJson) as Record; const skeleton = JSON.parse(skeletonJson) as Record; + // If skeleton is empty, preserve existing input (DSL may have a typo) + if (Object.keys(skeleton).length === 0) { + return existingJson; + } + function fill( skel: Record, prev: Record, diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index ada4db6..f4ab7af 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -863,123 +863,4 @@ bridge Query.evaluate { ], context: `{}`, }, - // ── Standalone (no-GraphQL) examples ──────────────────────────────────── - { - name: "Weather (Standalone)", - description: - "Geocode a city and fetch its current temperature — no GraphQL schema needed", - mode: "standalone", - schema: "", - bridge: `version 1.5 - -# Tool 1: Geocoder — city name → lat/lon (Nominatim, no auth required) -tool geo from std.httpCall { - .baseUrl = "https://nominatim.openstreetmap.org" - .method = GET - .path = /search - .format = "json" - .limit = "1" - .headers.User-Agent = "BridgeDemo/1.0" -} - -# Tool 2: Weather forecast — lat/lon → temperature (Open-Meteo, no auth required) -tool weather from std.httpCall { - .baseUrl = "https://api.open-meteo.com/v1" - .method = GET - .path = /forecast - .current_weather = "true" -} - -bridge Query.getWeather { - with geo - with weather as w - with input as i - with output as o - - geo.q <- i.city - - w.latitude <- geo[0].lat - w.longitude <- geo[0].lon - - o.city <- i.city - o.lat <- geo[0].lat - o.lon <- geo[0].lon - o.temperature <- w.current_weather.temperature - o.unit = "°C" - o.timezone <- w.timezone -}`, - queries: [{ name: "Berlin", query: "" }], - standaloneQueries: [ - { - operation: "Query.getWeather", - outputFields: "", - input: { city: "Berlin" }, - }, - ], - context: `{}`, - }, - { - name: "SBB Trains (Standalone)", - description: - "Search Swiss train connections — standalone execution without GraphQL", - mode: "standalone", - schema: "", - bridge: `version 1.5 - -tool sbbApi from std.httpCall { - .baseUrl = "https://transport.opendata.ch/v1" - .method = GET - .path = "/connections" - .cache = 60 - on error = { "connections": [] } -} - -bridge Query.searchTrains { - with sbbApi as api - with input as i - with output as o - - api.from <- i.from - api.to <- i.to - - o <- api.connections[] as c { - .id <- c.from.station.id - .provider = "SBB" - .departureTime <- c.from.departure - .arrivalTime <- c.to.arrival - .transfers <- c.transfers || 0 - - .legs <- c.sections[] as s { - .trainName <- s.journey.name || s.journey.category || "Walk" - - .origin.station.id <- s.departure.station.id - .origin.station.name <- s.departure.station.name - .origin.plannedTime <- s.departure.departure - .origin.platform <- s.departure.platform - - .destination.station.id <- s.arrival.station.id - .destination.station.name <- s.arrival.station.name - .destination.plannedTime <- s.arrival.arrival - .destination.platform <- s.arrival.platform - } - } -}`, - queries: [ - { name: "Bern → Zürich", query: "" }, - { name: "Zürich → Genève", query: "" }, - ], - standaloneQueries: [ - { - operation: "Query.searchTrains", - outputFields: "", - input: { from: "Bern", to: "Zürich" }, - }, - { - operation: "Query.searchTrains", - outputFields: "departureTime,arrivalTime,transfers", - input: { from: "Zürich", to: "Genève" }, - }, - ], - context: `{}`, - }, ];