From fdd0dd8d41ee2a2e5acdca300f9914420bbd6a18 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Mon, 23 Feb 2026 14:35:16 +0100 Subject: [PATCH 1/2] Add format support for Edition 2024 --- CHANGELOG.md | 1 + private/buf/bufformat/bufformat.go | 27 +++++++++++--- private/buf/bufformat/formatter.go | 33 +++++++++++++---- private/buf/bufformat/formatter_test.go | 16 +------- .../testdata/editions/2024/editions.golden | 37 +++++++++++++++++++ .../testdata/editions/2024/editions.proto | 30 +++++++++++++++ 6 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 private/buf/bufformat/testdata/editions/2024/editions.golden diff --git a/CHANGELOG.md b/CHANGELOG.md index ab88d81dd9..5a915b88b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Fix buf breaking module comparison when adding new modules. - Add LSP hover support for protovalidate CEL expressions - Fixed offset handling in CEL semantic tokens for non-ASCII content and proto escape sequences in multi-line string literal expressions +- Add support for Edition 2024 syntax to `buf format`. ## [v1.65.0] - 2026-02-03 diff --git a/private/buf/bufformat/bufformat.go b/private/buf/bufformat/bufformat.go index 0a1834aeda..cef3d96002 100644 --- a/private/buf/bufformat/bufformat.go +++ b/private/buf/bufformat/bufformat.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "io" + "strings" "sync/atomic" "github.com/bufbuild/buf/private/bufpkg/bufmodule" @@ -128,11 +129,23 @@ func formatFileNode(dest io.Writer, fileNode *ast.FileNode, options *formatOptio // formatFileNodeWithMatch formats the given file node and returns whether any deprecation prefix matched. func formatFileNodeWithMatch(dest io.Writer, fileNode *ast.FileNode, options *formatOptions) (bool, error) { - // Construct the file descriptor to ensure the AST is valid. This will - // capture unknown syntax like edition "2024" which at the current time is - // not supported. - if _, err := parser.ResultFromAST(fileNode, true, reporter.NewHandler(nil)); err != nil { - return false, err + // Construct the file descriptor to ensure the AST is valid. The + // reporter swallows the known edition 2024 unsupported error (the + // parser handles it but ResultFromAST does not yet) and propagates + // all other errors. + errReporter := reporter.NewReporter( + func(err reporter.ErrorWithPos) error { + if isEdition2024UnsupportedError(err) { + return nil + } + return err + }, + nil, + ) + if _, err := parser.ResultFromAST(fileNode, true, reporter.NewHandler(errReporter)); err != nil { + if !errors.Is(err, reporter.ErrInvalidSource) { + return false, err + } } formatter := newFormatter(dest, fileNode, options) if err := formatter.Run(); err != nil { @@ -140,3 +153,7 @@ func formatFileNodeWithMatch(dest io.Writer, fileNode *ast.FileNode, options *fo } return formatter.deprecationMatched, nil } + +func isEdition2024UnsupportedError(err error) bool { + return strings.Contains(err.Error(), `edition "2024" not yet fully supported`) +} diff --git a/private/buf/bufformat/formatter.go b/private/buf/bufformat/formatter.go index 29c556ff8f..ae9d4346d8 100644 --- a/private/buf/bufformat/formatter.go +++ b/private/buf/bufformat/formatter.go @@ -292,20 +292,20 @@ func (f *formatter) writeFileHeader() { sort.Slice(importNodes, func(i, j int) bool { iName := importNodes[i].Name.AsString() jName := importNodes[j].Name.AsString() - // sort by public > None > weak + // sort by import type (public > regular > weak > option), then by name iOrder := importSortOrder(importNodes[i]) jOrder := importSortOrder(importNodes[j]) - if iName < jName { + if iOrder > jOrder { return true } - if iName > jName { + if iOrder < jOrder { return false } - if iOrder > jOrder { + if iName < jName { return true } - if iOrder < jOrder { + if iName > jName { return false } @@ -447,6 +447,9 @@ func (f *formatter) writeImport(importNode *ast.ImportNode, forceCompact, first case importNode.Weak != nil: f.writeInline(importNode.Weak) f.Space() + case importNode.Modifier != nil: + f.writeInline(importNode.Modifier) + f.Space() } f.writeInline(importNode.Name) f.writeLineEnd(importNode.Semicolon) @@ -626,7 +629,13 @@ func (f *formatter) writeMessage(messageNode *ast.MessageNode) { f.writeDeprecatedOption() } } - f.writeStart(messageNode.Keyword, false) + if messageNode.Visibility != nil { + f.writeStart(messageNode.Visibility, false) + f.Space() + f.writeInline(messageNode.Keyword) + } else { + f.writeStart(messageNode.Keyword, false) + } f.Space() f.writeInline(messageNode.Name) f.Space() @@ -911,7 +920,13 @@ func (f *formatter) writeEnum(enumNode *ast.EnumNode) { f.writeDeprecatedOption() } } - f.writeStart(enumNode.Keyword, false) + if enumNode.Visibility != nil { + f.writeStart(enumNode.Visibility, false) + f.Space() + f.writeInline(enumNode.Keyword) + } else { + f.writeStart(enumNode.Keyword, false) + } f.Space() f.writeInline(enumNode.Name) f.Space() @@ -2538,13 +2553,15 @@ func (n infoWithTrailingComments) TrailingComments() ast.Comments { } // importSortOrder maps import types to a sort order number, so it can be compared and sorted. -// `import`=3, `import public`=2, `import weak`=1 +// `import`=3, `import public`=2, `import weak`=1 `import option`=0 func importSortOrder(node *ast.ImportNode) int { switch { case node.Public != nil: return 2 case node.Weak != nil: return 1 + case node.Modifier != nil: + return 0 default: return 3 } diff --git a/private/buf/bufformat/formatter_test.go b/private/buf/bufformat/formatter_test.go index f65ef860cb..20723b7363 100644 --- a/private/buf/bufformat/formatter_test.go +++ b/private/buf/bufformat/formatter_test.go @@ -43,7 +43,7 @@ func testFormatCustomOptions(t *testing.T) { func testFormatEditions(t *testing.T) { testFormatNoDiff(t, "testdata/editions/2023") - testFormatError(t, "testdata/editions/2024", `edition "2024" not yet fully supported; latest supported edition "2023"`) + testFormatNoDiff(t, "testdata/editions/2024") } func testFormatProto2(t *testing.T) { @@ -117,20 +117,6 @@ func testFormatNoDiff(t *testing.T, path string) { }) } -func testFormatError(t *testing.T, path string, errContains string) { - t.Run(path, func(t *testing.T) { - ctx := context.Background() - bucket, err := storageos.NewProvider().NewReadWriteBucket(path) - require.NoError(t, err) - moduleSetBuilder := bufmodule.NewModuleSetBuilder(ctx, slogtestext.NewLogger(t), bufmodule.NopModuleDataProvider, bufmodule.NopCommitProvider) - moduleSetBuilder.AddLocalModule(bucket, path, true) - moduleSet, err := moduleSetBuilder.Build() - require.NoError(t, err) - _, err = FormatModuleSet(ctx, moduleSet) - require.ErrorContains(t, err, errContains) - }) -} - func TestFormatterWithDeprecation(t *testing.T) { t.Parallel() // Test basic deprecation with prefix matching diff --git a/private/buf/bufformat/testdata/editions/2024/editions.golden b/private/buf/bufformat/testdata/editions/2024/editions.golden new file mode 100644 index 0000000000..4d98814e29 --- /dev/null +++ b/private/buf/bufformat/testdata/editions/2024/editions.golden @@ -0,0 +1,37 @@ +edition = "2024"; + +package a.b.c; + +import "other.proto"; +import option "custom_options.proto"; +import option "google/protobuf/descriptor.proto"; + +export message PublicApi { + string id = 1; +} + +local message InternalHelper { + int32 code = 1; +} + +message DefaultVisibility { + string name = 1; + export message Nested { + string field = 1; + } +} + +export enum Status { + STATUS_UNSPECIFIED = 0; + OK = 1; + ERROR = 2; +} + +local enum InternalCode { + INTERNAL_CODE_UNSPECIFIED = 0; + RETRY = 1; +} + +enum DefaultEnum { + DEFAULT_ENUM_UNSPECIFIED = 0; +} diff --git a/private/buf/bufformat/testdata/editions/2024/editions.proto b/private/buf/bufformat/testdata/editions/2024/editions.proto index 2ec2daa42e..15ac5a3114 100644 --- a/private/buf/bufformat/testdata/editions/2024/editions.proto +++ b/private/buf/bufformat/testdata/editions/2024/editions.proto @@ -3,3 +3,33 @@ edition = "2024"; package a.b.c; import option "google/protobuf/descriptor.proto"; +import "other.proto"; +import option "custom_options.proto"; + +export message PublicApi { + string id = 1; +} + +local message InternalHelper { + int32 code = 1; +} + +message DefaultVisibility { + string name = 1; + export message Nested { string field = 1; } +} + +export enum Status { + STATUS_UNSPECIFIED = 0; + OK = 1; + ERROR = 2; +} + +local enum InternalCode { + INTERNAL_CODE_UNSPECIFIED = 0; + RETRY = 1; +} + +enum DefaultEnum { + DEFAULT_ENUM_UNSPECIFIED = 0; +} From ef97ddeb846cd2538ac56a6d5aecf5f1079791f7 Mon Sep 17 00:00:00 2001 From: Edward McFarlane Date: Mon, 23 Feb 2026 15:40:37 +0100 Subject: [PATCH 2/2] Fix import sort --- private/buf/bufformat/bufformat.go | 14 ++++---- private/buf/bufformat/formatter.go | 35 ++++++++++--------- .../testdata/editions/2023/editions.golden | 4 +++ .../testdata/editions/2023/editions.proto | 4 +++ .../testdata/editions/2024/editions.golden | 5 +-- .../testdata/editions/2024/editions.proto | 5 +-- 6 files changed, 40 insertions(+), 27 deletions(-) diff --git a/private/buf/bufformat/bufformat.go b/private/buf/bufformat/bufformat.go index cef3d96002..7496b1659b 100644 --- a/private/buf/bufformat/bufformat.go +++ b/private/buf/bufformat/bufformat.go @@ -19,7 +19,6 @@ import ( "errors" "fmt" "io" - "strings" "sync/atomic" "github.com/bufbuild/buf/private/bufpkg/bufmodule" @@ -132,10 +131,15 @@ func formatFileNodeWithMatch(dest io.Writer, fileNode *ast.FileNode, options *fo // Construct the file descriptor to ensure the AST is valid. The // reporter swallows the known edition 2024 unsupported error (the // parser handles it but ResultFromAST does not yet) and propagates - // all other errors. + // all other errors. The error is identified by its span matching + // the edition value node. errReporter := reporter.NewReporter( func(err reporter.ErrorWithPos) error { - if isEdition2024UnsupportedError(err) { + if fileNode.Edition == nil || fileNode.Edition.Edition.AsString() != "2024" { + return err + } + editionValueSpan := fileNode.NodeInfo(fileNode.Edition.Edition) + if err.Start() == editionValueSpan.Start() && err.End() == editionValueSpan.End() { return nil } return err @@ -153,7 +157,3 @@ func formatFileNodeWithMatch(dest io.Writer, fileNode *ast.FileNode, options *fo } return formatter.deprecationMatched, nil } - -func isEdition2024UnsupportedError(err error) bool { - return strings.Contains(err.Error(), `edition "2024" not yet fully supported`) -} diff --git a/private/buf/bufformat/formatter.go b/private/buf/bufformat/formatter.go index ae9d4346d8..2f91be9f4f 100644 --- a/private/buf/bufformat/formatter.go +++ b/private/buf/bufformat/formatter.go @@ -292,21 +292,21 @@ func (f *formatter) writeFileHeader() { sort.Slice(importNodes, func(i, j int) bool { iName := importNodes[i].Name.AsString() jName := importNodes[j].Name.AsString() - // sort by import type (public > regular > weak > option), then by name - iOrder := importSortOrder(importNodes[i]) - jOrder := importSortOrder(importNodes[j]) - - if iOrder > jOrder { - return true - } - if iOrder < jOrder { - return false + // "import option" sorts after all other imports. Within each + // group, sort alphabetically by name, then by modifier + // (public > regular > weak), and finally by comment. + iOption := isOptionImport(importNodes[i]) + jOption := isOptionImport(importNodes[j]) + if iOption != jOption { + return !iOption } - if iName < jName { - return true + if iName != jName { + return iName < jName } - if iName > jName { - return false + iOrder := importSortOrder(importNodes[i]) + jOrder := importSortOrder(importNodes[j]) + if iOrder != jOrder { + return iOrder < jOrder } // put commented import first @@ -2553,20 +2553,23 @@ func (n infoWithTrailingComments) TrailingComments() ast.Comments { } // importSortOrder maps import types to a sort order number, so it can be compared and sorted. -// `import`=3, `import public`=2, `import weak`=1 `import option`=0 +// Higher values sort first: `import`=3, `import public`=2, `import weak`=1. func importSortOrder(node *ast.ImportNode) int { switch { case node.Public != nil: return 2 case node.Weak != nil: return 1 - case node.Modifier != nil: - return 0 default: return 3 } } +// isOptionImport reports whether the import has the "option" modifier. +func isOptionImport(node *ast.ImportNode) bool { + return node.Modifier != nil && node.Modifier.Val == "option" +} + // stringForOptionName returns the string representation of the given option name node. // This is used for sorting file-level options. func stringForOptionName(optionNameNode *ast.OptionNameNode) string { diff --git a/private/buf/bufformat/testdata/editions/2023/editions.golden b/private/buf/bufformat/testdata/editions/2023/editions.golden index 16b5663a25..29a3e74fa3 100644 --- a/private/buf/bufformat/testdata/editions/2023/editions.golden +++ b/private/buf/bufformat/testdata/editions/2023/editions.golden @@ -2,6 +2,10 @@ edition = "2023"; package a.b.c; +import "a.proto"; +import public "b.proto"; +import weak "c.proto"; +import "d.proto"; import "google/protobuf/descriptor.proto"; option features.(string_feature) = "abc"; diff --git a/private/buf/bufformat/testdata/editions/2023/editions.proto b/private/buf/bufformat/testdata/editions/2023/editions.proto index 80d8599d54..94be7eb391 100644 --- a/private/buf/bufformat/testdata/editions/2023/editions.proto +++ b/private/buf/bufformat/testdata/editions/2023/editions.proto @@ -3,6 +3,10 @@ edition = "2023"; package a.b.c; import "google/protobuf/descriptor.proto"; +import "a.proto"; +import "d.proto"; +import weak "c.proto"; +import public "b.proto"; extend google.protobuf.FeatureSet { string string_feature = 9995; diff --git a/private/buf/bufformat/testdata/editions/2024/editions.golden b/private/buf/bufformat/testdata/editions/2024/editions.golden index 4d98814e29..6625401387 100644 --- a/private/buf/bufformat/testdata/editions/2024/editions.golden +++ b/private/buf/bufformat/testdata/editions/2024/editions.golden @@ -2,8 +2,9 @@ edition = "2024"; package a.b.c; -import "other.proto"; -import option "custom_options.proto"; +import public "a.proto"; +import "c.proto"; +import option "b.proto"; import option "google/protobuf/descriptor.proto"; export message PublicApi { diff --git a/private/buf/bufformat/testdata/editions/2024/editions.proto b/private/buf/bufformat/testdata/editions/2024/editions.proto index 15ac5a3114..3378d4a751 100644 --- a/private/buf/bufformat/testdata/editions/2024/editions.proto +++ b/private/buf/bufformat/testdata/editions/2024/editions.proto @@ -3,8 +3,9 @@ edition = "2024"; package a.b.c; import option "google/protobuf/descriptor.proto"; -import "other.proto"; -import option "custom_options.proto"; +import "c.proto"; +import option "b.proto"; +import public "a.proto"; export message PublicApi { string id = 1;