diff --git a/CHANGELOG.md b/CHANGELOG.md index 999784928c..67b89adbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## [Unreleased] -- No changes yet. +- Add support for Edition 2024 syntax to `buf format`. ## [v1.66.0] - 2026-02-23 diff --git a/private/buf/bufformat/bufformat.go b/private/buf/bufformat/bufformat.go index 0a1834aeda..7496b1659b 100644 --- a/private/buf/bufformat/bufformat.go +++ b/private/buf/bufformat/bufformat.go @@ -128,11 +128,28 @@ 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. The error is identified by its span matching + // the edition value node. + errReporter := reporter.NewReporter( + func(err reporter.ErrorWithPos) error { + 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 + }, + 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 { diff --git a/private/buf/bufformat/formatter.go b/private/buf/bufformat/formatter.go index 29c556ff8f..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 public > None > weak - iOrder := importSortOrder(importNodes[i]) - jOrder := importSortOrder(importNodes[j]) - - if iName < jName { - return true - } - if iName > jName { - 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 iOrder > jOrder { - return true + if iName != jName { + return iName < jName } - if iOrder < jOrder { - return false + iOrder := importSortOrder(importNodes[i]) + jOrder := importSortOrder(importNodes[j]) + if iOrder != jOrder { + return iOrder < jOrder } // put commented import first @@ -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,7 +2553,7 @@ 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 +// Higher values sort first: `import`=3, `import public`=2, `import weak`=1. func importSortOrder(node *ast.ImportNode) int { switch { case node.Public != nil: @@ -2550,6 +2565,11 @@ func importSortOrder(node *ast.ImportNode) int { } } +// 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/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/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 new file mode 100644 index 0000000000..6625401387 --- /dev/null +++ b/private/buf/bufformat/testdata/editions/2024/editions.golden @@ -0,0 +1,38 @@ +edition = "2024"; + +package a.b.c; + +import public "a.proto"; +import "c.proto"; +import option "b.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..3378d4a751 100644 --- a/private/buf/bufformat/testdata/editions/2024/editions.proto +++ b/private/buf/bufformat/testdata/editions/2024/editions.proto @@ -3,3 +3,34 @@ edition = "2024"; package a.b.c; import option "google/protobuf/descriptor.proto"; +import "c.proto"; +import option "b.proto"; +import public "a.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; +}