From 9ce371584c0c4f0253482ec1b05e4534c5ee85d7 Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Tue, 10 Feb 2026 06:36:43 +0100 Subject: [PATCH 1/2] feat: formatting --- Bond.Parser.CLI/Bond.Parser.CLI.csproj | 8 +- Bond.Parser.CLI/Program.cs | 67 +++++ Bond.Parser.Tests/FormatterTests.cs | 79 ++++++ Bond.Parser/Bond.Parser.csproj | 21 +- Bond.Parser/Formatting/BondFormatter.cs | 345 ++++++++++++++++++++++++ README.md | 4 + examples/README.md | 10 - version | 2 +- 8 files changed, 515 insertions(+), 21 deletions(-) create mode 100644 Bond.Parser.Tests/FormatterTests.cs create mode 100644 Bond.Parser/Formatting/BondFormatter.cs delete mode 100644 examples/README.md 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..f6eabba --- /dev/null +++ b/Bond.Parser/Formatting/BondFormatter.cs @@ -0,0 +1,345 @@ +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.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(); + } + + return _builder.ToString(); + } + + 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++; + return; + } + + if (token.Type == BondLexer.SEMI) + { + WriteNewline(); + return; + } + + if (token.Type == BondLexer.RBRACE) + { + if (nextType == BondLexer.SEMI) + { + _pendingSpace = false; + return; + } + if (_indentLevel == 0) + { + _pendingTopLevelBlankLine = true; + } + WriteNewline(); + return; + } + + if (token.Type == BondLexer.SEMI && _indentLevel == 0) + { + _pendingTopLevelBlankLine = true; + } + } + + private void ApplyTopLevelSpacingIfNeeded(int tokenType) + { + if (!_pendingTopLevelBlankLine) + { + return; + } + + if (!TopLevelStartTokens.Contains(tokenType)) + { + return; + } + + var previous = _lastTopLevelKeyword; + _lastTopLevelKeyword = tokenType; + + _pendingTopLevelBlankLine = false; + + if (previous == BondLexer.IMPORT && tokenType == BondLexer.IMPORT) + { + return; + } + + EnsureBlankLine(); + } + + 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 From efaf3e7143a5c31a5a3135b082bb8e3c345206cc Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Tue, 10 Feb 2026 06:41:39 +0100 Subject: [PATCH 2/2] fix tests --- Bond.Parser/Formatting/BondFormatter.cs | 40 ++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/Bond.Parser/Formatting/BondFormatter.cs b/Bond.Parser/Formatting/BondFormatter.cs index f6eabba..023802b 100644 --- a/Bond.Parser/Formatting/BondFormatter.cs +++ b/Bond.Parser/Formatting/BondFormatter.cs @@ -59,6 +59,7 @@ private sealed class TokenFormatter BondLexer.RPAREN, BondLexer.RBRACKET, BondLexer.RANGLE, + BondLexer.LANGLE, BondLexer.DOT, BondLexer.COLON, BondLexer.RBRACE @@ -120,7 +121,12 @@ public string Format() WriteNewline(); } - return _builder.ToString(); + var text = _builder.ToString(); + if (text.EndsWith('\n')) + { + text = text[..^1]; + } + return text; } private void ProcessHiddenTokens(IToken token) @@ -197,12 +203,18 @@ private void WriteToken(IToken token, int nextType) { WriteNewline(); _indentLevel++; + UpdateTopLevelKeyword(token.Type); return; } if (token.Type == BondLexer.SEMI) { + if (_indentLevel == 0) + { + _pendingTopLevelBlankLine = true; + } WriteNewline(); + UpdateTopLevelKeyword(token.Type); return; } @@ -211,6 +223,7 @@ private void WriteToken(IToken token, int nextType) if (nextType == BondLexer.SEMI) { _pendingSpace = false; + UpdateTopLevelKeyword(token.Type); return; } if (_indentLevel == 0) @@ -218,19 +231,21 @@ private void WriteToken(IToken token, int nextType) _pendingTopLevelBlankLine = true; } WriteNewline(); + UpdateTopLevelKeyword(token.Type); return; } - if (token.Type == BondLexer.SEMI && _indentLevel == 0) - { - _pendingTopLevelBlankLine = true; - } + UpdateTopLevelKeyword(token.Type); } private void ApplyTopLevelSpacingIfNeeded(int tokenType) { if (!_pendingTopLevelBlankLine) { + if (tokenType == BondLexer.NAMESPACE && _lastTopLevelKeyword == BondLexer.IMPORT) + { + EnsureBlankLine(); + } return; } @@ -240,8 +255,6 @@ private void ApplyTopLevelSpacingIfNeeded(int tokenType) } var previous = _lastTopLevelKeyword; - _lastTopLevelKeyword = tokenType; - _pendingTopLevelBlankLine = false; if (previous == BondLexer.IMPORT && tokenType == BondLexer.IMPORT) @@ -252,6 +265,19 @@ private void ApplyTopLevelSpacingIfNeeded(int tokenType) 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)