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/7] 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/7] 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) From b58e3f84f108e462c3d0aeb2eaba5c268848697f Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Tue, 10 Feb 2026 10:04:51 +0100 Subject: [PATCH 3/7] fix: better formatting --- Bond.Parser/Formatting/BondFormatter.cs | 170 ++++++++++++++++++++---- README.md | 26 ++++ version | 2 +- 3 files changed, 172 insertions(+), 26 deletions(-) diff --git a/Bond.Parser/Formatting/BondFormatter.cs b/Bond.Parser/Formatting/BondFormatter.cs index 023802b..0d137e9 100644 --- a/Bond.Parser/Formatting/BondFormatter.cs +++ b/Bond.Parser/Formatting/BondFormatter.cs @@ -60,6 +60,7 @@ private sealed class TokenFormatter BondLexer.RBRACKET, BondLexer.RANGLE, BondLexer.LANGLE, + BondLexer.LPAREN, BondLexer.DOT, BondLexer.COLON, BondLexer.RBRACE @@ -69,10 +70,12 @@ private sealed class TokenFormatter [ BondLexer.LPAREN, BondLexer.LBRACKET, + BondLexer.RBRACKET, BondLexer.LANGLE, BondLexer.DOT, BondLexer.MINUS, - BondLexer.PLUS + BondLexer.PLUS, + BondLexer.L ]; private static readonly HashSet TopLevelStartTokens = @@ -83,7 +86,8 @@ private sealed class TokenFormatter BondLexer.STRUCT, BondLexer.ENUM, BondLexer.SERVICE, - BondLexer.VIEW_OF + BondLexer.VIEW_OF, + BondLexer.LBRACKET ]; private readonly CommonTokenStream _tokenStream; @@ -95,6 +99,12 @@ private sealed class TokenFormatter private int _indentLevel; private bool _pendingTopLevelBlankLine; private int? _lastTopLevelKeyword; + private bool _inTypeHeader; + private int? _pendingBlockKind; + private readonly Stack _blockStack = new(); + private IToken? _lastToken; + private bool _skipNextRBrace; + private bool _skipNextSemi; public TokenFormatter(CommonTokenStream tokenStream, FormatOptions options) { @@ -112,6 +122,24 @@ public string Format() var token = _defaultTokens[i]; var nextType = i + 1 < _defaultTokens.Count ? _defaultTokens[i + 1].Type : TokenConstants.EOF; + if (_skipNextRBrace && token.Type == BondLexer.RBRACE) + { + _skipNextRBrace = false; + if (nextType == BondLexer.SEMI) + { + _skipNextSemi = true; + } + _lastToken = token; + continue; + } + if (_skipNextSemi && token.Type == BondLexer.SEMI) + { + ProcessHiddenTokens(token); + _skipNextSemi = false; + _lastToken = token; + continue; + } + ProcessHiddenTokens(token); WriteToken(token, nextType); } @@ -141,16 +169,39 @@ private void ProcessHiddenTokens(IToken token) { 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) { + var inlineBefore = ht.Type == BondLexer.COMMENT && ht.Line == token.Line; + var inlineAfter = _lastToken != null && ht.Line == _lastToken.Line; + + if (inlineBefore || inlineAfter) + { + if (_atLineStart) + { + WriteIndent(); + } + else + { + _builder.Append(' '); + } + + _builder.Append(ht.Text.TrimEnd()); + + if (ht.Type == BondLexer.LINE_COMMENT) + { + WriteNewline(); + } + else + { + _pendingSpace = true; + } + + continue; + } + if (_pendingTopLevelBlankLine && _indentLevel == 0) { _pendingTopLevelBlankLine = false; @@ -173,6 +224,18 @@ private void WriteToken(IToken token, int nextType) { ApplyTopLevelSpacingIfNeeded(token.Type); + int? closingBlockKind = null; + if (StartsTypeHeader(token.Type)) + { + _inTypeHeader = _indentLevel == 0; + } + + var declaredBlockKind = GetBlockKind(token.Type); + if (declaredBlockKind.HasValue) + { + _pendingBlockKind = declaredBlockKind; + } + if (token.Type == BondLexer.RBRACE) { if (!_atLineStart) @@ -180,13 +243,14 @@ private void WriteToken(IToken token, int nextType) WriteNewline(); } _indentLevel = Math.Max(0, _indentLevel - 1); + closingBlockKind = _blockStack.Count > 0 ? _blockStack.Pop() : null; } if (_atLineStart) { WriteIndent(); } - else if (_pendingSpace && !NoSpaceBefore.Contains(token.Type)) + else if (_pendingSpace && (!NoSpaceBefore.Contains(token.Type) || (token.Type == BondLexer.COLON && _inTypeHeader))) { _builder.Append(' '); } @@ -194,6 +258,13 @@ private void WriteToken(IToken token, int nextType) _builder.Append(token.Text); _pendingSpace = ShouldSetPendingSpace(token.Type); + if (token.Type == BondLexer.RBRACKET) + { + WriteNewline(); + UpdateTopLevelKeyword(token.Type); + _lastToken = token; + return; + } if (token.Type == BondLexer.NAMESPACE && _indentLevel == 0) { _pendingTopLevelBlankLine = true; @@ -201,9 +272,25 @@ private void WriteToken(IToken token, int nextType) if (token.Type == BondLexer.LBRACE) { + var blockKind = _pendingBlockKind; + _pendingBlockKind = null; + if (blockKind == BondLexer.STRUCT && IsEmptyBlock(token, nextType)) + { + _builder.Append('}'); + _skipNextRBrace = true; + WriteNewline(); + _pendingTopLevelBlankLine |= _indentLevel == 0; + _inTypeHeader = false; + _lastToken = token; + return; + } + WriteNewline(); + _blockStack.Push(blockKind); _indentLevel++; + _inTypeHeader = false; UpdateTopLevelKeyword(token.Type); + _lastToken = token; return; } @@ -214,35 +301,41 @@ private void WriteToken(IToken token, int nextType) _pendingTopLevelBlankLine = true; } WriteNewline(); + _inTypeHeader = false; UpdateTopLevelKeyword(token.Type); + _lastToken = token; return; } if (token.Type == BondLexer.RBRACE) { - if (nextType == BondLexer.SEMI) + if (nextType == BondLexer.SEMI && (closingBlockKind == BondLexer.STRUCT || closingBlockKind == BondLexer.ENUM)) { _pendingSpace = false; - UpdateTopLevelKeyword(token.Type); - return; + _skipNextSemi = true; } if (_indentLevel == 0) { _pendingTopLevelBlankLine = true; } WriteNewline(); + _inTypeHeader = false; UpdateTopLevelKeyword(token.Type); + _lastToken = token; return; } UpdateTopLevelKeyword(token.Type); + _lastToken = token; } private void ApplyTopLevelSpacingIfNeeded(int tokenType) { if (!_pendingTopLevelBlankLine) { - if (tokenType == BondLexer.NAMESPACE && _lastTopLevelKeyword == BondLexer.IMPORT) + if (_lastTopLevelKeyword == BondLexer.IMPORT + && tokenType != BondLexer.IMPORT + && TopLevelStartTokens.Contains(tokenType)) { EnsureBlankLine(); } @@ -261,6 +354,10 @@ private void ApplyTopLevelSpacingIfNeeded(int tokenType) { return; } + if (previous == BondLexer.USING && tokenType == BondLexer.USING) + { + return; + } EnsureBlankLine(); } @@ -288,6 +385,30 @@ private bool ShouldSetPendingSpace(int type) return !NoSpaceAfter.Contains(type); } + private bool IsEmptyBlock(IToken token, int nextType) + { + if (nextType != BondLexer.RBRACE) + { + return false; + } + + var hidden = _tokenStream.GetHiddenTokensToRight(token.TokenIndex); + if (hidden == null) + { + return true; + } + + foreach (var ht in hidden) + { + if (ht.Type == BondLexer.LINE_COMMENT || ht.Type == BondLexer.COMMENT) + { + return false; + } + } + + return true; + } + private void WriteIndent() { if (!_atLineStart) @@ -350,22 +471,21 @@ private int CountTrailingNewlines() return count; } - private static int CountNewlines(string? text) + private static bool StartsTypeHeader(int tokenType) { - if (string.IsNullOrEmpty(text)) - { - return 0; - } + return tokenType is BondLexer.STRUCT or BondLexer.SERVICE; + } - var count = 0; - foreach (var ch in text) + private static int? GetBlockKind(int tokenType) + { + return tokenType switch { - if (ch == '\n') - { - count++; - } - } - return count; + BondLexer.STRUCT => BondLexer.STRUCT, + BondLexer.ENUM => BondLexer.ENUM, + BondLexer.SERVICE => BondLexer.SERVICE, + BondLexer.VIEW_OF => BondLexer.VIEW_OF, + _ => null + }; } } } diff --git a/README.md b/README.md index 3a8ede4..97efa99 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,29 @@ bbc breaking schema.bond --against .git#branch=main --ignore-imports bbc format schema.bond bbc format schema.bond --check ``` + +Pre-commit hook example (format check for staged `.bond` files): + +```bash +#!/usr/bin/env bash +set -euo pipefail + +files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.bond$' || true) +if [ -z "$files" ]; then + exit 0 +fi + +for f in $files; do + bbc format "$f" --check +done +``` + +PowerShell variant: + +```powershell +$files = git diff --cached --name-only --diff-filter=ACM | Where-Object { $_ -match '\.bond$' } +if (-not $files) { exit 0 } +foreach ($f in $files) { + bbc format $f --check +} +``` diff --git a/version b/version index 7179039..abd4105 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.2.3 +0.2.4 From 59e827589cb51275babd22d92ecbdcc34117c228 Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Tue, 10 Feb 2026 10:06:19 +0100 Subject: [PATCH 4/7] unit tests --- Bond.Parser.Tests/FormatterTests.cs | 180 ++++++++++++++++++++++++++-- 1 file changed, 168 insertions(+), 12 deletions(-) diff --git a/Bond.Parser.Tests/FormatterTests.cs b/Bond.Parser.Tests/FormatterTests.cs index a6444d4..d06dcb2 100644 --- a/Bond.Parser.Tests/FormatterTests.cs +++ b/Bond.Parser.Tests/FormatterTests.cs @@ -5,18 +5,20 @@ namespace Bond.Parser.Tests; public class FormatterTests { + private static string TrimEol(string text) => text.TrimEnd('\r', '\n'); + [Fact] public void Format_ReindentsAndSpaces() { var input = "namespace Test struct User{0:required string id;1:optional list tags;}"; - var expected = """ + var expected = TrimEol(""" namespace Test struct User { 0: required string id; 1: optional list tags; } - """; + """); var result = BondFormatter.Format(input, ""); @@ -27,21 +29,20 @@ struct User { [Fact] public void Format_PreservesComments() { - var input = """ + var input = TrimEol(""" namespace Test // user struct struct User { /* fields */ 0: required string id; } - """; + """); - var expected = """ + var expected = TrimEol(""" namespace Test // user struct struct User { - /* fields */ - 0: required string id; + /* fields */ 0: required string id; } - """; + """); var result = BondFormatter.Format(input, ""); @@ -52,11 +53,11 @@ struct User { [Fact] public void Format_TopLevelBlankLines() { - var input = """ + var input = TrimEol(""" import "a.bond";import "b.bond";namespace Test struct A{0:required int32 id;} struct B{0:required int32 id;} - """; + """); - var expected = """ + var expected = TrimEol(""" import "a.bond"; import "b.bond"; @@ -69,7 +70,162 @@ struct A { struct B { 0: required int32 id; } - """; + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_EmptyStructOnOneLine() + { + var input = TrimEol(""" + namespace Test + struct Empty + {} + """); + + var expected = TrimEol(""" + namespace Test + + struct Empty {} + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_AttributesAndEmptyDerivedStruct() + { + var input = TrimEol(""" + namespace Test + [StructAttribute1("one")][StructAttribute2("two")] + struct DerivedEmpty:Foo + {}; + """); + + var expected = TrimEol(""" + namespace Test + + [StructAttribute1("one")] + [StructAttribute2("two")] + struct DerivedEmpty : Foo {} + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_PreservesLiteralPrefix() + { + var input = "namespace Test struct Foo{0: optional wstring name = L\"hi\";}"; + var expected = TrimEol(""" + namespace Test + + struct Foo { + 0: optional wstring name = L"hi"; + } + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_UsingsStayTogether() + { + var input = "namespace Test using A = B;using C = D; struct Foo{}"; + var expected = TrimEol(""" + namespace Test + + using A = B; + using C = D; + + struct Foo {} + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_RemovesStructSemicolon() + { + var input = TrimEol(""" + namespace Test + struct Foo {}; + + struct Empty + {} + """); + + var expected = TrimEol(""" + namespace Test + + struct Foo {} + + struct Empty {} + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_RemovesEnumSemicolon() + { + var input = TrimEol(""" + namespace Test + enum Color { red, green } + ; + """); + + var expected = TrimEol(""" + namespace Test + + enum Color { + red, green + } + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_DoesNotStripFieldSemicolons() + { + var input = TrimEol(""" + namespace Test + struct Empty {} + struct WithField { 0: required int32 id; } + """); + + var expected = TrimEol(""" + namespace Test + + struct Empty {} + + struct WithField { + 0: required int32 id; + } + """); var result = BondFormatter.Format(input, ""); From ffc1bbd9c119409c72f12e46a95ff4315f02d24b Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Tue, 10 Feb 2026 10:11:05 +0100 Subject: [PATCH 5/7] better enum formatting --- Bond.Parser.Tests/FormatterTests.cs | 29 ++++++++++++++++++++++++- Bond.Parser/Formatting/BondFormatter.cs | 9 ++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Bond.Parser.Tests/FormatterTests.cs b/Bond.Parser.Tests/FormatterTests.cs index d06dcb2..3f8d4b2 100644 --- a/Bond.Parser.Tests/FormatterTests.cs +++ b/Bond.Parser.Tests/FormatterTests.cs @@ -198,7 +198,34 @@ enum Color { red, green } namespace Test enum Color { - red, green + red, + green + } + """); + + var result = BondFormatter.Format(input, ""); + + result.Success.Should().BeTrue(); + result.FormattedText.Should().Be(expected); + } + + [Fact] + public void Format_EnumValuesOnNewLines() + { + var input = TrimEol(""" + namespace Test + enum Consts { Zero, One, Three = 3, Four, Six = 6 } + """); + + var expected = TrimEol(""" + namespace Test + + enum Consts { + Zero, + One, + Three = 3, + Four, + Six = 6 } """); diff --git a/Bond.Parser/Formatting/BondFormatter.cs b/Bond.Parser/Formatting/BondFormatter.cs index 0d137e9..d9a3ffd 100644 --- a/Bond.Parser/Formatting/BondFormatter.cs +++ b/Bond.Parser/Formatting/BondFormatter.cs @@ -265,6 +265,10 @@ private void WriteToken(IToken token, int nextType) _lastToken = token; return; } + if ((token.Type == BondLexer.COMMA || token.Type == BondLexer.SEMI) && IsInsideEnumBlock()) + { + WriteNewline(); + } if (token.Type == BondLexer.NAMESPACE && _indentLevel == 0) { _pendingTopLevelBlankLine = true; @@ -487,5 +491,10 @@ private static bool StartsTypeHeader(int tokenType) _ => null }; } + + private bool IsInsideEnumBlock() + { + return _blockStack.Count > 0 && _blockStack.Peek() == BondLexer.ENUM; + } } } From 328e2d9b58a6ced8d7e66ea09e9f72a0317b9311 Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Sat, 21 Feb 2026 15:54:11 +0100 Subject: [PATCH 6/7] fix: file scoped aliases --- Bond.Parser.Tests/ParserFacadeTests.cs | 31 +++++++ Bond.Parser/Parser/SemanticAnalyzer.cs | 29 ++++-- Bond.Parser/Parser/SymbolTable.cs | 117 ++++++++++++++++++++++--- Bond.Parser/Parser/TypeResolver.cs | 10 ++- 4 files changed, 163 insertions(+), 24 deletions(-) diff --git a/Bond.Parser.Tests/ParserFacadeTests.cs b/Bond.Parser.Tests/ParserFacadeTests.cs index a636a24..d81c3c3 100644 --- a/Bond.Parser.Tests/ParserFacadeTests.cs +++ b/Bond.Parser.Tests/ParserFacadeTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading.Tasks; using Bond.Parser.Parser; using Bond.Parser.Syntax; @@ -940,5 +941,35 @@ struct User { result.Success.Should().BeTrue(); } + [Fact] + public async Task Alias_IsFileScoped_WhenImportDefinesSameAlias() + { + var input = """ + import "common.bond" + namespace Test + using ID = string; + struct User { 0: required ID id; } + """; + + async Task<(string, string)> Resolver(string _, string importPath) + { + var content = """ + namespace Test + using ID = int32; + struct Other { 0: required ID id; } + """; + return await Task.FromResult((importPath, content)); + } + + var result = await Parse(input, Resolver); + + result.Success.Should().BeTrue(); + var user = result.Ast!.Declarations.OfType().First(d => d.Name == "User"); + var fieldType = user.Fields[0].Type as BondType.UserDefined; + fieldType.Should().NotBeNull(); + var alias = fieldType!.Declaration.Should().BeOfType().Subject; + alias.AliasedType.Should().BeOfType(); + } + #endregion } diff --git a/Bond.Parser/Parser/SemanticAnalyzer.cs b/Bond.Parser/Parser/SemanticAnalyzer.cs index 72ceb63..72c5495 100644 --- a/Bond.Parser/Parser/SemanticAnalyzer.cs +++ b/Bond.Parser/Parser/SemanticAnalyzer.cs @@ -29,17 +29,30 @@ public SemanticAnalyzer(SymbolTable symbolTable, ImportResolver importResolver, /// public async Task AnalyzeAsync(Syntax.Bond bond) { - // Process imports first - foreach (var import in bond.Imports) + _symbolTable.PushAliasScope(); + try { - await ProcessImportAsync(import); - } + // Process imports first + foreach (var import in bond.Imports) + { + await ProcessImportAsync(import); + } - // Add all declarations to symbol table - foreach (var declaration in bond.Declarations) + // Add all declarations to symbol table + foreach (var declaration in bond.Declarations) + { + _symbolTable.AddDeclaration(declaration); + } + + // Validate declarations after all symbols are registered + foreach (var declaration in bond.Declarations) + { + ValidateDeclaration(declaration); + } + } + finally { - _symbolTable.AddDeclaration(declaration, bond.Namespaces); - ValidateDeclaration(declaration); + _symbolTable.PopAliasScope(); } } diff --git a/Bond.Parser/Parser/SymbolTable.cs b/Bond.Parser/Parser/SymbolTable.cs index 67d4b31..9f6f0b9 100644 --- a/Bond.Parser/Parser/SymbolTable.cs +++ b/Bond.Parser/Parser/SymbolTable.cs @@ -10,17 +10,24 @@ namespace Bond.Parser.Parser; /// public class SymbolTable { - private readonly List _declarations = []; + private readonly List _globalDeclarations = []; + private readonly Stack> _aliasScopes = new(); private readonly HashSet _processedImports = []; /// /// Adds a declaration to the symbol table with duplicate checking /// - public void AddDeclaration(Declaration declaration, Namespace[] currentNamespaces) + public void AddDeclaration(Declaration declaration) { + if (declaration is AliasDeclaration alias) + { + AddAliasDeclaration(alias); + return; + } + // Find duplicates in the same namespace - var duplicates = _declarations - .Where(d => d.Name == declaration.Name && d.Namespaces.Any(ns1 => currentNamespaces.Any(ns2 => NamespacesMatch(ns1, ns2)))) + var duplicates = _globalDeclarations + .Where(d => d.Name == declaration.Name && d.Namespaces.Any(ns1 => declaration.Namespaces.Any(ns2 => NamespacesMatch(ns1, ns2)))) .ToList(); foreach (var duplicate in duplicates) @@ -33,18 +40,32 @@ public void AddDeclaration(Declaration declaration, Namespace[] currentNamespace } } - _declarations.Add(declaration); + _globalDeclarations.Add(declaration); } /// /// Finds a symbol by qualified name in the given namespaces /// public Declaration? FindSymbol(string[] qualifiedName, Namespace[] currentNamespaces) + { + var alias = FindAlias(qualifiedName, currentNamespaces); + if (alias != null) + { + return alias; + } + + return FindGlobalSymbol(qualifiedName, currentNamespaces); + } + + /// + /// Finds a symbol by qualified name in the given namespaces from global declarations + /// + private Declaration? FindGlobalSymbol(string[] qualifiedName, Namespace[] currentNamespaces) { if (qualifiedName.Length == 1) { // Unqualified name - search in current namespaces - return _declarations.FirstOrDefault(d => + return _globalDeclarations.FirstOrDefault(d => d.Name == qualifiedName[0] && d.Namespaces.Any(ns1 => currentNamespaces.Any(ns2 => NamespacesMatch(ns1, ns2)))); } @@ -54,7 +75,7 @@ public void AddDeclaration(Declaration declaration, Namespace[] currentNamespace var namespacePart = qualifiedName[..^1]; var namePart = qualifiedName[^1]; - return _declarations.FirstOrDefault(d => + return _globalDeclarations.FirstOrDefault(d => d.Name == namePart && d.Namespaces.Any(ns => ns.Name.SequenceEqual(namespacePart))); } @@ -65,7 +86,7 @@ public void AddDeclaration(Declaration declaration, Namespace[] currentNamespace /// public StructDeclaration? FindStruct(string[] qualifiedName, Namespace[] currentNamespaces) { - var symbol = FindSymbol(qualifiedName, currentNamespaces); + var symbol = FindGlobalSymbol(qualifiedName, currentNamespaces); return symbol as StructDeclaration; } @@ -88,15 +109,87 @@ public void MarkImportProcessed(string canonicalPath) /// /// Gets all declarations /// - public IReadOnlyList Declarations => _declarations.AsReadOnly(); + public IReadOnlyList GlobalDeclarations => _globalDeclarations.AsReadOnly(); + + /// + /// Pushes a new alias scope for a file. + /// + public void PushAliasScope() + { + _aliasScopes.Push([]); + } + + /// + /// Pops the current alias scope. + /// + public void PopAliasScope() + { + if (_aliasScopes.Count == 0) + { + throw new InvalidOperationException("Alias scope stack is empty"); + } + + _aliasScopes.Pop(); + } /// /// Clears all declarations from the symbol table /// - public void Clear() + public void ClearGlobalDeclarations() { - _declarations.Clear(); - // Don't clear processed imports as those are still valid + _globalDeclarations.Clear(); + } + + /// + /// Clears all alias scopes + /// + public void ClearAliasScopes() + { + _aliasScopes.Clear(); + } + + private void AddAliasDeclaration(AliasDeclaration alias) + { + if (_aliasScopes.Count == 0) + { + throw new InvalidOperationException("Alias scope is not initialized"); + } + + var scope = _aliasScopes.Peek(); + var duplicates = scope + .Where(d => d.Name == alias.Name && d.Namespaces.Any(ns1 => alias.Namespaces.Any(ns2 => NamespacesMatch(ns1, ns2)))) + .ToList(); + + if (duplicates.Count > 0) + { + throw new InvalidOperationException( + $"Duplicate declaration: alias '{alias.Name}' was already declared as alias"); + } + + scope.Add(alias); + } + + private AliasDeclaration? FindAlias(string[] qualifiedName, Namespace[] currentNamespaces) + { + if (_aliasScopes.Count == 0) + { + return null; + } + + var scope = _aliasScopes.Peek(); + + if (qualifiedName.Length == 1) + { + return scope.FirstOrDefault(d => + d.Name == qualifiedName[0] && + d.Namespaces.Any(ns1 => currentNamespaces.Any(ns2 => NamespacesMatch(ns1, ns2)))); + } + + var namespacePart = qualifiedName[..^1]; + var namePart = qualifiedName[^1]; + return scope.FirstOrDefault(d => + d.Name == namePart && + d.Namespaces.Any(ns => ns.Name.SequenceEqual(namespacePart))); } private static bool NamespacesMatch(Namespace ns1, Namespace ns2) diff --git a/Bond.Parser/Parser/TypeResolver.cs b/Bond.Parser/Parser/TypeResolver.cs index bcf8de2..7218314 100644 --- a/Bond.Parser/Parser/TypeResolver.cs +++ b/Bond.Parser/Parser/TypeResolver.cs @@ -20,21 +20,23 @@ public Syntax.Bond ResolveTypes(Syntax.Bond ast) const int maxPasses = 10; // Prevent infinite loops // Preserve declarations that came from imports so we can re-add them each pass - var importedDeclarations = symbolTable.Declarations + var importedDeclarations = symbolTable.GlobalDeclarations .Where(d => !ast.Declarations.Contains(d)) .ToArray(); for (int pass = 0; pass < maxPasses; pass++) { // Update symbol table with current declarations - symbolTable.Clear(); + symbolTable.ClearGlobalDeclarations(); + symbolTable.ClearAliasScopes(); + symbolTable.PushAliasScope(); foreach (var importDecl in importedDeclarations) { - symbolTable.AddDeclaration(importDecl, currentAst.Namespaces); + symbolTable.AddDeclaration(importDecl); } foreach (var decl in currentAst.Declarations) { - symbolTable.AddDeclaration(decl, currentAst.Namespaces); + symbolTable.AddDeclaration(decl); } // Resolve all declarations From 01425b94f171c1a3e0dd7d8d6e5013af82a7259a Mon Sep 17 00:00:00 2001 From: Mirco De Zorzi Date: Sat, 21 Feb 2026 15:55:08 +0100 Subject: [PATCH 7/7] version dump --- version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version b/version index abd4105..3a4036f 100644 --- a/version +++ b/version @@ -1 +1 @@ -0.2.4 +0.2.5