Skip to content

Commit f754272

Browse files
Copilotaarne
andauthored
Add strict LSP-backed Bridge formatter with syntax-safe aborts and editor-config-aware indentation (#99)
* Initial plan * Add LSP document formatting and safe pretty-printer API Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * Address review feedback for formatter API and LSP handler Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * Remove formatBridge compatibility API and switch playground/tests to strict formatter Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com>
1 parent 90cb697 commit f754272

12 files changed

Lines changed: 223 additions & 60 deletions

File tree

packages/bridge-parser/src/bridge-printer.ts

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,34 @@
66
* from the token stream, applying consistent formatting rules.
77
*/
88
import type { IToken } from "chevrotain";
9+
import type { CstNode } from "chevrotain";
910
import { BridgeLexer } from "./parser/lexer.ts";
10-
11-
const INDENT = " ";
11+
import { parseBridgeCst } from "./parser/parser.ts";
12+
13+
const DEFAULT_FORMATTING_OPTIONS = {
14+
tabSize: 2,
15+
insertSpaces: true,
16+
} as const;
17+
18+
export type BridgeFormattingOptions = {
19+
tabSize: number;
20+
insertSpaces: boolean;
21+
};
22+
23+
function resolveFormattingOptions(
24+
options?: Partial<BridgeFormattingOptions>,
25+
): BridgeFormattingOptions {
26+
const rawTabSize = options?.tabSize;
27+
const tabSize =
28+
typeof rawTabSize === "number" && Number.isInteger(rawTabSize)
29+
? Math.max(1, rawTabSize)
30+
: DEFAULT_FORMATTING_OPTIONS.tabSize;
31+
32+
return {
33+
tabSize,
34+
insertSpaces: options?.insertSpaces ?? DEFAULT_FORMATTING_OPTIONS.insertSpaces,
35+
};
36+
}
1237

1338
// ── Comment handling ─────────────────────────────────────────────────────────
1439

@@ -142,10 +167,29 @@ function isTopLevelBlockStart(group: IToken[]): boolean {
142167
/**
143168
* Format Bridge DSL source code with consistent styling.
144169
*
145-
* @param source - The Bridge DSL source text to format
146-
* @returns Formatted source text, or the original if parsing fails
170+
* Throws on syntax-invalid input when called with a source string.
147171
*/
148-
export function formatBridge(source: string): string {
172+
type PrettyPrintInput = string | { source: string; cst: CstNode };
173+
174+
export function prettyPrintToSource(
175+
input: PrettyPrintInput,
176+
options?: Partial<BridgeFormattingOptions>,
177+
): string {
178+
const source = typeof input === "string" ? input : input.source;
179+
if (typeof input === "string") {
180+
parseBridgeCst(source);
181+
}
182+
return formatBridgeInternal(source, options);
183+
}
184+
185+
function formatBridgeInternal(
186+
source: string,
187+
options?: Partial<BridgeFormattingOptions>,
188+
): string {
189+
const formatting = resolveFormattingOptions(options);
190+
const indentUnit = formatting.insertSpaces
191+
? " ".repeat(formatting.tabSize)
192+
: "\t";
149193
const lexResult = BridgeLexer.tokenize(source);
150194

151195
if (lexResult.errors.length > 0) {
@@ -313,7 +357,7 @@ export function formatBridge(source: string): string {
313357
if (lastCommentLine > 0 && commentLine > lastCommentLine + 1) {
314358
output.push("\n"); // Preserve blank line between comments
315359
}
316-
output.push(INDENT.repeat(lineStartDepth) + comment.image + "\n");
360+
output.push(indentUnit.repeat(lineStartDepth) + comment.image + "\n");
317361
lastCommentLine = commentLine;
318362
}
319363
}
@@ -371,7 +415,7 @@ export function formatBridge(source: string): string {
371415
);
372416
if (hasContentAfter && lineOutput.length > 0) {
373417
// Emit the line with the brace, content will continue on next iteration
374-
output.push(INDENT.repeat(currentIndent) + lineOutput + "\n");
418+
output.push(indentUnit.repeat(currentIndent) + lineOutput + "\n");
375419
lineOutput = "";
376420
lastType = null;
377421
currentIndent = depth; // Update indentation for remaining content
@@ -390,7 +434,7 @@ export function formatBridge(source: string): string {
390434

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

407451
// Emit the closing brace immediately
408-
output.push(INDENT.repeat(depth) + braceOutput + "\n");
452+
output.push(indentUnit.repeat(depth) + braceOutput + "\n");
409453
continue;
410454
}
411455

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

459503
// Emit the line
460504
if (lineOutput.length > 0) {
461-
output.push(INDENT.repeat(currentIndent) + lineOutput + "\n");
505+
output.push(indentUnit.repeat(currentIndent) + lineOutput + "\n");
462506
}
463507

464508
lastOutputLine = originalLine;

packages/bridge-parser/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
export {
1111
parseBridgeChevrotain as parseBridge,
1212
parseBridgeChevrotain,
13+
parseBridgeCst,
1314
parseBridgeDiagnostics,
1415
PARSER_VERSION,
1516
} from "./parser/index.ts";
@@ -25,7 +26,10 @@ export {
2526

2627
// ── Formatter ───────────────────────────────────────────────────────────────
2728

28-
export { formatBridge } from "./bridge-printer.ts";
29+
export {
30+
prettyPrintToSource,
31+
type BridgeFormattingOptions,
32+
} from "./bridge-printer.ts";
2933

3034
// ── Language service ────────────────────────────────────────────────────────
3135

packages/bridge-parser/src/parser/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
export {
77
parseBridgeChevrotain,
8+
parseBridgeCst,
89
parseBridgeDiagnostics,
910
PARSER_VERSION,
1011
} from "./parser.ts";

packages/bridge-parser/src/parser/parser.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1240,6 +1240,23 @@ export function parseBridgeChevrotain(text: string): BridgeDocument {
12401240
return internalParse(text);
12411241
}
12421242

1243+
export function parseBridgeCst(text: string): CstNode {
1244+
const lexResult = BridgeLexer.tokenize(text);
1245+
if (lexResult.errors.length > 0) {
1246+
const e = lexResult.errors[0];
1247+
throw new Error(`Line ${e.line}: Unexpected character "${e.message}"`);
1248+
}
1249+
1250+
parserInstance.input = lexResult.tokens;
1251+
const cst = parserInstance.program();
1252+
if (parserInstance.errors.length > 0) {
1253+
const e = parserInstance.errors[0];
1254+
throw new Error(e.message);
1255+
}
1256+
1257+
return cst;
1258+
}
1259+
12431260
// ── Diagnostic types ──────────────────────────────────────────────────────
12441261

12451262
export type BridgeDiagnostic = {

packages/bridge-syntax-highlight/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Full IDE support for [The Bridge](https://github.com/stackables/bridge): a decla
1515
- Tool hover: function name, deps, wires
1616
- Define hover: subgraph details
1717
- Const hover: name and raw value
18+
- **Document formatting** — format `.bridge` files through the standard `textDocument/formatting` LSP request
19+
- Formatter respects your local editor settings (`editor.tabSize`, `editor.insertSpaces`) and works with `editor.formatOnSave`
1820
- Error recovery — partial AST is built even on broken files, so diagnostics remain accurate while you're mid-edit
1921

2022
### Syntax Highlighting

packages/bridge-syntax-highlight/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@
3939
"scopeName": "source.bridge",
4040
"path": "./syntaxes/bridge.tmLanguage.json"
4141
}
42-
]
42+
],
43+
"configurationDefaults": {
44+
"[bridge]": {
45+
"editor.defaultFormatter": "stackables.bridge-syntax-highlight"
46+
}
47+
}
4348
},
4449
"scripts": {
4550
"prebuild": "pnpm --filter @stackables/bridge build",

packages/bridge-syntax-highlight/src/server.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ import {
2222
DiagnosticSeverity,
2323
CompletionItemKind,
2424
MarkupKind,
25+
Range,
26+
TextEdit,
2527
} from "vscode-languageserver/node";
2628
import { TextDocument } from "vscode-languageserver-textdocument";
27-
import { BridgeLanguageService } from "@stackables/bridge";
29+
import { BridgeLanguageService, parseBridgeCst, prettyPrintToSource } from "@stackables/bridge";
2830
import type { CompletionKind } from "@stackables/bridge";
2931

3032
// ── Connection & document manager ──────────────────────────────────────────
@@ -49,6 +51,7 @@ connection.onInitialize(
4951
capabilities: {
5052
textDocumentSync: TextDocumentSyncKind.Incremental,
5153
hoverProvider: true,
54+
documentFormattingProvider: true,
5255
completionProvider: {
5356
triggerCharacters: ["."],
5457
},
@@ -131,6 +134,37 @@ connection.onCompletion((params) => {
131134
}));
132135
});
133136

137+
connection.onDocumentFormatting((params) => {
138+
const doc = documents.get(params.textDocument.uri);
139+
if (!doc) return null;
140+
141+
const text = doc.getText();
142+
143+
try {
144+
const cst = parseBridgeCst(text);
145+
const formatted = prettyPrintToSource(
146+
{ source: text, cst },
147+
{
148+
tabSize: params.options.tabSize,
149+
insertSpaces: params.options.insertSpaces,
150+
},
151+
);
152+
153+
const range = Range.create(
154+
{ line: 0, character: 0 },
155+
doc.positionAt(text.length),
156+
);
157+
158+
return [TextEdit.replace(range, formatted)];
159+
} catch (error) {
160+
const message = error instanceof Error ? error.message : String(error);
161+
connection.console.warn(
162+
`Bridge formatting aborted due to syntax errors: ${message}`,
163+
);
164+
return null;
165+
}
166+
});
167+
134168
// ── Start ──────────────────────────────────────────────────────────────────
135169

136170
documents.listen(connection);

packages/bridge/test/bridge-printer-examples.test.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from "node:assert/strict";
22
import { describe, test } from "node:test";
3-
import { formatBridge } from "../src/index.ts";
3+
import { formatSnippet } from "./formatter-test-utils.ts";
44

55
/**
66
* ============================================================================
@@ -19,7 +19,7 @@ tool geo from std.httpCall`;
1919
2020
tool geo from std.httpCall
2121
`;
22-
assert.equal(formatBridge(input), expected);
22+
assert.equal(formatSnippet(input), expected);
2323
});
2424

2525
test("tool with body", () => {
@@ -36,7 +36,7 @@ tool geo from std.httpCall {
3636
.method = GET
3737
}
3838
`;
39-
assert.equal(formatBridge(input), expected);
39+
assert.equal(formatSnippet(input), expected);
4040
});
4141

4242
test("bridge block with assignments", () => {
@@ -56,7 +56,7 @@ bridge Query.test {
5656
o.value <- i.value
5757
}
5858
`;
59-
assert.equal(formatBridge(input), expected);
59+
assert.equal(formatSnippet(input), expected);
6060
});
6161

6262
test("define block", () => {
@@ -70,7 +70,7 @@ o.x<-i.y
7070
o.x <- i.y
7171
}
7272
`;
73-
assert.equal(formatBridge(input), expected);
73+
assert.equal(formatSnippet(input), expected);
7474
});
7575

7676
test("bridge with comment, tool handles, and pipes", () => {
@@ -102,7 +102,7 @@ bridge Query.greet {
102102
o.lower <- lc:i.name
103103
}
104104
`;
105-
assert.equal(formatBridge(input), expected);
105+
assert.equal(formatSnippet(input), expected);
106106
});
107107

108108
test("ternary expressions preserve formatting", () => {
@@ -123,7 +123,7 @@ bridge Query.pricing {
123123
}
124124
`;
125125
// Should not change
126-
assert.equal(formatBridge(input), input);
126+
assert.equal(formatSnippet(input), input);
127127
});
128128

129129
test("blank line between top-level blocks", () => {
@@ -158,14 +158,14 @@ define helper {
158158
with input as i
159159
}
160160
`;
161-
assert.equal(formatBridge(input), expected);
161+
assert.equal(formatSnippet(input), expected);
162162
});
163163

164164
test("not operator preserves space", () => {
165165
const input = `o.requireMFA <- not i.verified
166166
`;
167167
// Should not change
168-
assert.equal(formatBridge(input), input);
168+
assert.equal(formatSnippet(input), input);
169169
});
170170

171171
test("blank lines between comments are preserved", () => {
@@ -174,6 +174,6 @@ define helper {
174174
#sdasdsd
175175
`;
176176
// Should not change
177-
assert.equal(formatBridge(input), input);
177+
assert.equal(formatSnippet(input), input);
178178
});
179179
});

0 commit comments

Comments
 (0)