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
64 changes: 54 additions & 10 deletions packages/bridge-parser/src/bridge-printer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,34 @@
* from the token stream, applying consistent formatting rules.
*/
import type { IToken } from "chevrotain";
import type { CstNode } from "chevrotain";
import { BridgeLexer } from "./parser/lexer.ts";

const INDENT = " ";
import { parseBridgeCst } from "./parser/parser.ts";

const DEFAULT_FORMATTING_OPTIONS = {
tabSize: 2,
insertSpaces: true,
} as const;

export type BridgeFormattingOptions = {
tabSize: number;
insertSpaces: boolean;
};

function resolveFormattingOptions(
options?: Partial<BridgeFormattingOptions>,
): BridgeFormattingOptions {
const rawTabSize = options?.tabSize;
const tabSize =
typeof rawTabSize === "number" && Number.isInteger(rawTabSize)
? Math.max(1, rawTabSize)
: DEFAULT_FORMATTING_OPTIONS.tabSize;

return {
tabSize,
insertSpaces: options?.insertSpaces ?? DEFAULT_FORMATTING_OPTIONS.insertSpaces,
};
}

// ── Comment handling ─────────────────────────────────────────────────────────

Expand Down Expand Up @@ -142,10 +167,29 @@ function isTopLevelBlockStart(group: IToken[]): boolean {
/**
* 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
* Throws on syntax-invalid input when called with a source string.
*/
export function formatBridge(source: string): string {
type PrettyPrintInput = string | { source: string; cst: CstNode };

export function prettyPrintToSource(
input: PrettyPrintInput,
options?: Partial<BridgeFormattingOptions>,
): string {
const source = typeof input === "string" ? input : input.source;
if (typeof input === "string") {
parseBridgeCst(source);
}
return formatBridgeInternal(source, options);
}

function formatBridgeInternal(
source: string,
options?: Partial<BridgeFormattingOptions>,
): string {
const formatting = resolveFormattingOptions(options);
const indentUnit = formatting.insertSpaces
? " ".repeat(formatting.tabSize)
: "\t";
const lexResult = BridgeLexer.tokenize(source);

if (lexResult.errors.length > 0) {
Expand Down Expand Up @@ -313,7 +357,7 @@ export function formatBridge(source: string): string {
if (lastCommentLine > 0 && commentLine > lastCommentLine + 1) {
output.push("\n"); // Preserve blank line between comments
}
output.push(INDENT.repeat(lineStartDepth) + comment.image + "\n");
output.push(indentUnit.repeat(lineStartDepth) + comment.image + "\n");
lastCommentLine = commentLine;
}
}
Expand Down Expand Up @@ -371,7 +415,7 @@ export function formatBridge(source: string): string {
);
if (hasContentAfter && lineOutput.length > 0) {
// Emit the line with the brace, content will continue on next iteration
output.push(INDENT.repeat(currentIndent) + lineOutput + "\n");
output.push(indentUnit.repeat(currentIndent) + lineOutput + "\n");
lineOutput = "";
lastType = null;
currentIndent = depth; // Update indentation for remaining content
Expand All @@ -390,7 +434,7 @@ export function formatBridge(source: string): string {

// Output anything accumulated first
if (lineOutput.length > 0) {
output.push(INDENT.repeat(depth) + lineOutput + "\n");
output.push(indentUnit.repeat(depth) + lineOutput + "\n");
lineOutput = "";
}
// Decrement depth, then emit brace at new (outer) depth
Expand All @@ -405,7 +449,7 @@ export function formatBridge(source: string): string {
}

// Emit the closing brace immediately
output.push(INDENT.repeat(depth) + braceOutput + "\n");
output.push(indentUnit.repeat(depth) + braceOutput + "\n");
continue;
}

Expand Down Expand Up @@ -458,7 +502,7 @@ export function formatBridge(source: string): string {

// Emit the line
if (lineOutput.length > 0) {
output.push(INDENT.repeat(currentIndent) + lineOutput + "\n");
output.push(indentUnit.repeat(currentIndent) + lineOutput + "\n");
}

lastOutputLine = originalLine;
Expand Down
6 changes: 5 additions & 1 deletion packages/bridge-parser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
export {
parseBridgeChevrotain as parseBridge,
parseBridgeChevrotain,
parseBridgeCst,
parseBridgeDiagnostics,
PARSER_VERSION,
} from "./parser/index.ts";
Expand All @@ -25,7 +26,10 @@ export {

// ── Formatter ───────────────────────────────────────────────────────────────

export { formatBridge } from "./bridge-printer.ts";
export {
prettyPrintToSource,
type BridgeFormattingOptions,
} from "./bridge-printer.ts";

// ── Language service ────────────────────────────────────────────────────────

Expand Down
1 change: 1 addition & 0 deletions packages/bridge-parser/src/parser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
export {
parseBridgeChevrotain,
parseBridgeCst,
parseBridgeDiagnostics,
PARSER_VERSION,
} from "./parser.ts";
Expand Down
17 changes: 17 additions & 0 deletions packages/bridge-parser/src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,23 @@ export function parseBridgeChevrotain(text: string): BridgeDocument {
return internalParse(text);
}

export function parseBridgeCst(text: string): CstNode {
const lexResult = BridgeLexer.tokenize(text);
if (lexResult.errors.length > 0) {
const e = lexResult.errors[0];
throw new Error(`Line ${e.line}: Unexpected character "${e.message}"`);
}

parserInstance.input = lexResult.tokens;
const cst = parserInstance.program();
if (parserInstance.errors.length > 0) {
const e = parserInstance.errors[0];
throw new Error(e.message);
}

return cst;
}

// ── Diagnostic types ──────────────────────────────────────────────────────

export type BridgeDiagnostic = {
Expand Down
2 changes: 2 additions & 0 deletions packages/bridge-syntax-highlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Full IDE support for [The Bridge](https://github.com/stackables/bridge): a decla
- Tool hover: function name, deps, wires
- Define hover: subgraph details
- Const hover: name and raw value
- **Document formatting** — format `.bridge` files through the standard `textDocument/formatting` LSP request
- Formatter respects your local editor settings (`editor.tabSize`, `editor.insertSpaces`) and works with `editor.formatOnSave`
- Error recovery — partial AST is built even on broken files, so diagnostics remain accurate while you're mid-edit

### Syntax Highlighting
Expand Down
7 changes: 6 additions & 1 deletion packages/bridge-syntax-highlight/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,12 @@
"scopeName": "source.bridge",
"path": "./syntaxes/bridge.tmLanguage.json"
}
]
],
"configurationDefaults": {
"[bridge]": {
"editor.defaultFormatter": "stackables.bridge-syntax-highlight"
}
}
},
"scripts": {
"prebuild": "pnpm --filter @stackables/bridge build",
Expand Down
36 changes: 35 additions & 1 deletion packages/bridge-syntax-highlight/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import {
DiagnosticSeverity,
CompletionItemKind,
MarkupKind,
Range,
TextEdit,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
import { BridgeLanguageService } from "@stackables/bridge";
import { BridgeLanguageService, parseBridgeCst, prettyPrintToSource } from "@stackables/bridge";
import type { CompletionKind } from "@stackables/bridge";

// ── Connection & document manager ──────────────────────────────────────────
Expand All @@ -49,6 +51,7 @@ connection.onInitialize(
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
hoverProvider: true,
documentFormattingProvider: true,
completionProvider: {
triggerCharacters: ["."],
},
Expand Down Expand Up @@ -131,6 +134,37 @@ connection.onCompletion((params) => {
}));
});

connection.onDocumentFormatting((params) => {
const doc = documents.get(params.textDocument.uri);
if (!doc) return null;

const text = doc.getText();

try {
const cst = parseBridgeCst(text);
const formatted = prettyPrintToSource(
{ source: text, cst },
{
tabSize: params.options.tabSize,
insertSpaces: params.options.insertSpaces,
},
);

const range = Range.create(
{ line: 0, character: 0 },
doc.positionAt(text.length),
);

return [TextEdit.replace(range, formatted)];
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
connection.console.warn(
`Bridge formatting aborted due to syntax errors: ${message}`,
);
return null;
}
});

// ── Start ──────────────────────────────────────────────────────────────────

documents.listen(connection);
Expand Down
20 changes: 10 additions & 10 deletions packages/bridge/test/bridge-printer-examples.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import { formatBridge } from "../src/index.ts";
import { formatSnippet } from "./formatter-test-utils.ts";

/**
* ============================================================================
Expand All @@ -19,7 +19,7 @@ tool geo from std.httpCall`;

tool geo from std.httpCall
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("tool with body", () => {
Expand All @@ -36,7 +36,7 @@ tool geo from std.httpCall {
.method = GET
}
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("bridge block with assignments", () => {
Expand All @@ -56,7 +56,7 @@ bridge Query.test {
o.value <- i.value
}
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("define block", () => {
Expand All @@ -70,7 +70,7 @@ o.x<-i.y
o.x <- i.y
}
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("bridge with comment, tool handles, and pipes", () => {
Expand Down Expand Up @@ -102,7 +102,7 @@ bridge Query.greet {
o.lower <- lc:i.name
}
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("ternary expressions preserve formatting", () => {
Expand All @@ -123,7 +123,7 @@ bridge Query.pricing {
}
`;
// Should not change
assert.equal(formatBridge(input), input);
assert.equal(formatSnippet(input), input);
});

test("blank line between top-level blocks", () => {
Expand Down Expand Up @@ -158,14 +158,14 @@ define helper {
with input as i
}
`;
assert.equal(formatBridge(input), expected);
assert.equal(formatSnippet(input), expected);
});

test("not operator preserves space", () => {
const input = `o.requireMFA <- not i.verified
`;
// Should not change
assert.equal(formatBridge(input), input);
assert.equal(formatSnippet(input), input);
});

test("blank lines between comments are preserved", () => {
Expand All @@ -174,6 +174,6 @@ define helper {
#sdasdsd
`;
// Should not change
assert.equal(formatBridge(input), input);
assert.equal(formatSnippet(input), input);
});
});
Loading
Loading