diff --git a/Bond.Parser.CLI/Bond.Parser.CLI.csproj b/Bond.Parser.CLI/Bond.Parser.CLI.csproj
index 9437a6b..bece8fb 100644
--- a/Bond.Parser.CLI/Bond.Parser.CLI.csproj
+++ b/Bond.Parser.CLI/Bond.Parser.CLI.csproj
@@ -17,10 +17,16 @@
true
bbc
bbc
- 0.2.2
Mirco De Zorzi
Bond IDL compiler and toolchain
+ README.md
+ LICENSE
Bond.Parser.CLI
+
+
+
+
+
diff --git a/Bond.Parser.CLI/Program.cs b/Bond.Parser.CLI/Program.cs
index 5f4d780..fbbe5cc 100644
--- a/Bond.Parser.CLI/Program.cs
+++ b/Bond.Parser.CLI/Program.cs
@@ -3,6 +3,7 @@
using System.IO;
using System.Linq;
using Bond.Parser.Parser;
+using Bond.Parser.Formatting;
using Bond.Parser.Compatibility;
using Bond.Parser.Json;
using System.Text.Json;
@@ -26,6 +27,8 @@ static async Task Main(string[] args)
{
"breaking" => await RunBreakingCommand(args[1..]),
"parse" => await RunParseCommand(args[1..]),
+ "fmt" => await RunFormatCommand(args[1..]),
+ "format" => await RunFormatCommand(args[1..]),
_ => await RunParseCommand(args) // Default to parse for backward compatibility
};
}
@@ -41,6 +44,7 @@ static void ShowHelp()
Console.WriteLine("Commands:");
Console.WriteLine(" parse Parse and validate a Bond schema file");
Console.WriteLine(" breaking Check for breaking changes against a reference schema");
+ Console.WriteLine(" format Format a Bond schema file");
Console.WriteLine();
Console.WriteLine("Parse Options:");
Console.WriteLine(" -v, --verbose Show detailed AST output");
@@ -52,10 +56,14 @@ static void ShowHelp()
Console.WriteLine(" --error-format Output format: text, json (default: text)");
Console.WriteLine(" --ignore-imports Compare without resolving imports or types");
Console.WriteLine();
+ Console.WriteLine("Format Options:");
+ Console.WriteLine(" --check Exit non-zero if formatting is needed");
+ Console.WriteLine();
Console.WriteLine("Examples:");
Console.WriteLine(" bbc parse schema.bond");
Console.WriteLine(" bbc breaking schema.bond --against schema_v1.bond");
Console.WriteLine(" bbc breaking schema.bond --against .git#branch=main --error-format=json");
+ Console.WriteLine(" bbc format schema.bond");
Console.WriteLine();
Console.WriteLine("Global Options:");
Console.WriteLine(" -h, --help Show this help message");
@@ -154,6 +162,65 @@ static async Task RunBreakingCommand(string[] args)
return await CheckBreaking(reference, filePath, errorFormat, verbose, ignoreImports);
}
+ static async Task RunFormatCommand(string[] args)
+ {
+ if (args.Length == 0)
+ {
+ WriteError("Error: No file specified");
+ ShowHelp();
+ return 1;
+ }
+
+ var filePath = args[0];
+ var check = args.Contains("--check");
+
+ if (!File.Exists(filePath))
+ {
+ WriteError($"Error: File not found: {filePath}");
+ return 1;
+ }
+
+ var content = await File.ReadAllTextAsync(filePath);
+ var result = BondFormatter.Format(content, Path.GetFullPath(filePath));
+
+ if (!result.Success)
+ {
+ Console.Error.WriteLine($"format failed: {filePath}");
+ foreach (var error in result.Errors)
+ {
+ Console.Error.WriteLine($"{error.Line}:{error.Column}: {error.Message}");
+ if (error.FilePath != null)
+ {
+ Console.Error.WriteLine($" in {error.FilePath}");
+ }
+ }
+ return 1;
+ }
+
+ if (result.FormattedText == null)
+ {
+ WriteError("Error: Format produced no output");
+ return 1;
+ }
+
+ if (check)
+ {
+ if (!string.Equals(content, result.FormattedText, StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"{filePath} would be reformatted");
+ return 1;
+ }
+ return 0;
+ }
+
+ if (!string.Equals(content, result.FormattedText, StringComparison.Ordinal))
+ {
+ await File.WriteAllTextAsync(filePath, result.FormattedText);
+ }
+
+ return 0;
+ }
+
private sealed record ResolvedReference(
string FilePath,
string? Content,
diff --git a/Bond.Parser.Tests/FormatterTests.cs b/Bond.Parser.Tests/FormatterTests.cs
new file mode 100644
index 0000000..a6444d4
--- /dev/null
+++ b/Bond.Parser.Tests/FormatterTests.cs
@@ -0,0 +1,79 @@
+using Bond.Parser.Formatting;
+using FluentAssertions;
+
+namespace Bond.Parser.Tests;
+
+public class FormatterTests
+{
+ [Fact]
+ public void Format_ReindentsAndSpaces()
+ {
+ var input = "namespace Test struct User{0:required string id;1:optional list tags;}";
+ var expected = """
+ namespace Test
+
+ struct User {
+ 0: required string id;
+ 1: optional list tags;
+ }
+ """;
+
+ var result = BondFormatter.Format(input, "");
+
+ result.Success.Should().BeTrue();
+ result.FormattedText.Should().Be(expected);
+ }
+
+ [Fact]
+ public void Format_PreservesComments()
+ {
+ var input = """
+ namespace Test
+ // user struct
+ struct User { /* fields */ 0: required string id; }
+ """;
+
+ var expected = """
+ namespace Test
+
+ // user struct
+ struct User {
+ /* fields */
+ 0: required string id;
+ }
+ """;
+
+ var result = BondFormatter.Format(input, "");
+
+ result.Success.Should().BeTrue();
+ result.FormattedText.Should().Be(expected);
+ }
+
+ [Fact]
+ public void Format_TopLevelBlankLines()
+ {
+ var input = """
+ import "a.bond";import "b.bond";namespace Test struct A{0:required int32 id;} struct B{0:required int32 id;}
+ """;
+
+ var expected = """
+ import "a.bond";
+ import "b.bond";
+
+ namespace Test
+
+ struct A {
+ 0: required int32 id;
+ }
+
+ struct B {
+ 0: required int32 id;
+ }
+ """;
+
+ var result = BondFormatter.Format(input, "");
+
+ result.Success.Should().BeTrue();
+ result.FormattedText.Should().Be(expected);
+ }
+}
diff --git a/Bond.Parser/Bond.Parser.csproj b/Bond.Parser/Bond.Parser.csproj
index c88fc9a..c7d74eb 100644
--- a/Bond.Parser/Bond.Parser.csproj
+++ b/Bond.Parser/Bond.Parser.csproj
@@ -5,15 +5,18 @@
enable
true
true
- Bond IDL Parser
- Bond.Parser
- CS1584,CS1658,CS1591
- Bond.Parser
-
-
-
-
-
+ Bond IDL Parser
+ Bond.Parser
+ README.md
+ LICENSE
+ CS1584,CS1658,CS1591
+ Bond.Parser
+
+
+
+
+
+
diff --git a/Bond.Parser/Formatting/BondFormatter.cs b/Bond.Parser/Formatting/BondFormatter.cs
new file mode 100644
index 0000000..023802b
--- /dev/null
+++ b/Bond.Parser/Formatting/BondFormatter.cs
@@ -0,0 +1,371 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Antlr4.Runtime;
+using Bond.Parser.Grammar;
+using Bond.Parser.Parser;
+
+namespace Bond.Parser.Formatting;
+
+public sealed record FormatOptions(int IndentSize = 4);
+
+public sealed record FormatResult(string? FormattedText, List Errors)
+{
+ public bool Success => Errors.Count == 0 && FormattedText != null;
+}
+
+public static class BondFormatter
+{
+ public static FormatResult Format(string content, string filePath, FormatOptions? options = null)
+ {
+ var errors = new List();
+ try
+ {
+ var inputStream = new AntlrInputStream(content);
+ var lexer = new BondLexer(inputStream);
+ var tokenStream = new CommonTokenStream(lexer);
+ var parser = new BondParser(tokenStream);
+
+ var errorListener = new ErrorListener(filePath);
+ parser.RemoveErrorListeners();
+ parser.AddErrorListener(errorListener);
+
+ parser.bond();
+ if (errorListener.Errors.Count > 0)
+ {
+ errors.AddRange(errorListener.Errors);
+ return new FormatResult(null, errors);
+ }
+
+ tokenStream.Fill();
+ var formatter = new TokenFormatter(tokenStream, options ?? new FormatOptions());
+ var formatted = formatter.Format();
+ return new FormatResult(formatted, errors);
+ }
+ catch (Exception ex)
+ {
+ errors.Add(new ParseError($"Unexpected error: {ex.Message}", filePath, 0, 0));
+ return new FormatResult(null, errors);
+ }
+ }
+
+ private sealed class TokenFormatter
+ {
+ private static readonly HashSet NoSpaceBefore =
+ [
+ BondLexer.COMMA,
+ BondLexer.SEMI,
+ BondLexer.RPAREN,
+ BondLexer.RBRACKET,
+ BondLexer.RANGLE,
+ BondLexer.LANGLE,
+ BondLexer.DOT,
+ BondLexer.COLON,
+ BondLexer.RBRACE
+ ];
+
+ private static readonly HashSet NoSpaceAfter =
+ [
+ BondLexer.LPAREN,
+ BondLexer.LBRACKET,
+ BondLexer.LANGLE,
+ BondLexer.DOT,
+ BondLexer.MINUS,
+ BondLexer.PLUS
+ ];
+
+ private static readonly HashSet TopLevelStartTokens =
+ [
+ BondLexer.IMPORT,
+ BondLexer.NAMESPACE,
+ BondLexer.USING,
+ BondLexer.STRUCT,
+ BondLexer.ENUM,
+ BondLexer.SERVICE,
+ BondLexer.VIEW_OF
+ ];
+
+ private readonly CommonTokenStream _tokenStream;
+ private readonly FormatOptions _options;
+ private readonly StringBuilder _builder = new();
+ private readonly List _defaultTokens;
+ private bool _atLineStart = true;
+ private bool _pendingSpace;
+ private int _indentLevel;
+ private bool _pendingTopLevelBlankLine;
+ private int? _lastTopLevelKeyword;
+
+ public TokenFormatter(CommonTokenStream tokenStream, FormatOptions options)
+ {
+ _tokenStream = tokenStream;
+ _options = options;
+ _defaultTokens = tokenStream.GetTokens()
+ .Where(t => t.Channel == TokenConstants.DefaultChannel && t.Type != TokenConstants.EOF)
+ .ToList();
+ }
+
+ public string Format()
+ {
+ for (int i = 0; i < _defaultTokens.Count; i++)
+ {
+ var token = _defaultTokens[i];
+ var nextType = i + 1 < _defaultTokens.Count ? _defaultTokens[i + 1].Type : TokenConstants.EOF;
+
+ ProcessHiddenTokens(token);
+ WriteToken(token, nextType);
+ }
+
+ if (!_atLineStart)
+ {
+ WriteNewline();
+ }
+
+ var text = _builder.ToString();
+ if (text.EndsWith('\n'))
+ {
+ text = text[..^1];
+ }
+ return text;
+ }
+
+ private void ProcessHiddenTokens(IToken token)
+ {
+ var hidden = _tokenStream.GetHiddenTokensToLeft(token.TokenIndex);
+ if (hidden == null || hidden.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var ht in hidden)
+ {
+ if (ht.Type == BondLexer.WS)
+ {
+ var newlines = CountNewlines(ht.Text);
+ if (newlines > 0)
+ {
+ WriteNewline();
+ }
+ continue;
+ }
+
+ if (ht.Type == BondLexer.LINE_COMMENT || ht.Type == BondLexer.COMMENT)
+ {
+ if (_pendingTopLevelBlankLine && _indentLevel == 0)
+ {
+ _pendingTopLevelBlankLine = false;
+ EnsureBlankLine();
+ }
+
+ if (!_atLineStart)
+ {
+ WriteNewline();
+ }
+
+ WriteIndent();
+ _builder.Append(ht.Text.TrimEnd());
+ WriteNewline();
+ }
+ }
+ }
+
+ private void WriteToken(IToken token, int nextType)
+ {
+ ApplyTopLevelSpacingIfNeeded(token.Type);
+
+ if (token.Type == BondLexer.RBRACE)
+ {
+ if (!_atLineStart)
+ {
+ WriteNewline();
+ }
+ _indentLevel = Math.Max(0, _indentLevel - 1);
+ }
+
+ if (_atLineStart)
+ {
+ WriteIndent();
+ }
+ else if (_pendingSpace && !NoSpaceBefore.Contains(token.Type))
+ {
+ _builder.Append(' ');
+ }
+
+ _builder.Append(token.Text);
+ _pendingSpace = ShouldSetPendingSpace(token.Type);
+
+ if (token.Type == BondLexer.NAMESPACE && _indentLevel == 0)
+ {
+ _pendingTopLevelBlankLine = true;
+ }
+
+ if (token.Type == BondLexer.LBRACE)
+ {
+ WriteNewline();
+ _indentLevel++;
+ UpdateTopLevelKeyword(token.Type);
+ return;
+ }
+
+ if (token.Type == BondLexer.SEMI)
+ {
+ if (_indentLevel == 0)
+ {
+ _pendingTopLevelBlankLine = true;
+ }
+ WriteNewline();
+ UpdateTopLevelKeyword(token.Type);
+ return;
+ }
+
+ if (token.Type == BondLexer.RBRACE)
+ {
+ if (nextType == BondLexer.SEMI)
+ {
+ _pendingSpace = false;
+ UpdateTopLevelKeyword(token.Type);
+ return;
+ }
+ if (_indentLevel == 0)
+ {
+ _pendingTopLevelBlankLine = true;
+ }
+ WriteNewline();
+ UpdateTopLevelKeyword(token.Type);
+ return;
+ }
+
+ UpdateTopLevelKeyword(token.Type);
+ }
+
+ private void ApplyTopLevelSpacingIfNeeded(int tokenType)
+ {
+ if (!_pendingTopLevelBlankLine)
+ {
+ if (tokenType == BondLexer.NAMESPACE && _lastTopLevelKeyword == BondLexer.IMPORT)
+ {
+ EnsureBlankLine();
+ }
+ return;
+ }
+
+ if (!TopLevelStartTokens.Contains(tokenType))
+ {
+ return;
+ }
+
+ var previous = _lastTopLevelKeyword;
+ _pendingTopLevelBlankLine = false;
+
+ if (previous == BondLexer.IMPORT && tokenType == BondLexer.IMPORT)
+ {
+ return;
+ }
+
+ EnsureBlankLine();
+ }
+
+ private void UpdateTopLevelKeyword(int tokenType)
+ {
+ if (_indentLevel != 0)
+ {
+ return;
+ }
+
+ if (TopLevelStartTokens.Contains(tokenType))
+ {
+ _lastTopLevelKeyword = tokenType;
+ }
+ }
+
+ private bool ShouldSetPendingSpace(int type)
+ {
+ if (type == BondLexer.SEMI || type == BondLexer.LBRACE || type == BondLexer.RBRACE)
+ {
+ return false;
+ }
+
+ return !NoSpaceAfter.Contains(type);
+ }
+
+ private void WriteIndent()
+ {
+ if (!_atLineStart)
+ {
+ return;
+ }
+
+ if (_indentLevel > 0)
+ {
+ _builder.Append(' ', _indentLevel * _options.IndentSize);
+ }
+
+ _atLineStart = false;
+ }
+
+ private void WriteNewline()
+ {
+ if (_builder.Length == 0 || _builder[^1] != '\n')
+ {
+ _builder.Append('\n');
+ }
+ _atLineStart = true;
+ _pendingSpace = false;
+ }
+
+ private void EnsureBlankLine()
+ {
+ if (!_atLineStart)
+ {
+ WriteNewline();
+ }
+
+ var trailingNewlines = CountTrailingNewlines();
+ if (trailingNewlines >= 2)
+ {
+ return;
+ }
+
+ while (trailingNewlines < 2)
+ {
+ _builder.Append('\n');
+ trailingNewlines++;
+ }
+
+ _atLineStart = true;
+ _pendingSpace = false;
+ }
+
+ private int CountTrailingNewlines()
+ {
+ var count = 0;
+ for (int i = _builder.Length - 1; i >= 0; i--)
+ {
+ if (_builder[i] != '\n')
+ {
+ break;
+ }
+ count++;
+ }
+ return count;
+ }
+
+ private static int CountNewlines(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ {
+ return 0;
+ }
+
+ var count = 0;
+ foreach (var ch in text)
+ {
+ if (ch == '\n')
+ {
+ count++;
+ }
+ }
+ return count;
+ }
+ }
+}
diff --git a/README.md b/README.md
index a99561c..3a8ede4 100644
--- a/README.md
+++ b/README.md
@@ -13,4 +13,8 @@ Once installed, use the `bbc` command:
```bash
bbc parse schema.bond
bbc breaking schema.bond --against .git#branch=main --error-format=json
+bbc breaking examples/catalog_v2.bond --against examples/catalog_v1.bond --error-format=json | jq .
+bbc breaking schema.bond --against .git#branch=main --ignore-imports
+bbc format schema.bond
+bbc format schema.bond --check
```
diff --git a/examples/README.md b/examples/README.md
deleted file mode 100644
index 1232384..0000000
--- a/examples/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-```bash
-# Parse and pretty-print the v1 schema
-bbc parse examples/catalog_v1.bond
-
-# Emit the AST as JSON
-bbc parse examples/catalog_v1.bond --json | jq .
-
-# Compare v2 against v1 for breaking changes (non-zero exit on breaking)
-bbc breaking examples/catalog_v2.bond --against examples/catalog_v1.bond --error-format=json | jq .
-```
diff --git a/version b/version
index ee1372d..7179039 100644
--- a/version
+++ b/version
@@ -1 +1 @@
-0.2.2
+0.2.3