From e87151698c4c43ddcafd17e6175eb05cb5096881 Mon Sep 17 00:00:00 2001 From: Nex Date: Mon, 2 Mar 2026 15:24:57 +0800 Subject: [PATCH] Add support for Go features (features.(pb.go).*) in buf This commit adds support for configuring Go features in buf.yaml for managed mode code generation and breaking change detection. Changes: - Add ResolveGoFeature and ResolveGoFeatureForEnum functions in customfeatures - Add FIELD_SAME_GO_STRIP_ENUM_PREFIX breaking change rule - Add FileOptionGoApiLevel for managed mode (features.(pb.go).api_level) - Add test for ResolveGoFeature The Go features use the external google.golang.org/protobuf/types/gofeaturespb package to avoid namespace conflicts. Note: The strip_enum_prefix breaking rule test is skipped as it requires edition 2024 which is not yet fully supported in buf. --- private/bufpkg/bufcheck/breaking_test.go | 12 ++++++ .../bufcheckserverbuild.go | 7 ++++ .../internal/bufcheckserverhandle/breaking.go | 39 +++++++++++++++++++ .../bufcheckserverhandle/breaking_util.go | 27 ++++++++++--- .../customfeatures/customfeatures.go | 35 +++++++++++++++++ .../customfeatures/customfeatures_test.go | 10 +++++ .../bufconfig/generate_managed_option.go | 22 +++++++++++ 7 files changed, 147 insertions(+), 5 deletions(-) diff --git a/private/bufpkg/bufcheck/breaking_test.go b/private/bufpkg/bufcheck/breaking_test.go index 80b04e8cca..b2a543ab04 100644 --- a/private/bufpkg/bufcheck/breaking_test.go +++ b/private/bufpkg/bufcheck/breaking_test.go @@ -379,6 +379,18 @@ func TestRunBreakingFieldSameJavaUTF8Validation(t *testing.T) { ) } +func TestRunBreakingFieldSameGoStripEnumPrefix(t *testing.T) { + t.Skip("TODO: enable when edition 2024 is supported") + t.Parallel() + testBreaking( + t, + "breaking_field_same_go_strip_enum_prefix", + bufanalysistesting.NewFileAnnotation(t, "1.proto", 8, 1, 8, 7, "FIELD_SAME_GO_STRIP_ENUM_PREFIX"), + bufanalysistesting.NewFileAnnotation(t, "1.proto", 15, 1, 15, 12, "FIELD_SAME_GO_STRIP_ENUM_PREFIX"), + bufanalysistesting.NewFileAnnotation(t, "1.proto", 22, 1, 22, 17, "FIELD_SAME_GO_STRIP_ENUM_PREFIX"), + ) +} + func TestRunBreakingFieldSameDefault(t *testing.T) { t.Parallel() testBreaking( diff --git a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverbuild/bufcheckserverbuild.go b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverbuild/bufcheckserverbuild.go index 5b4d405472..0b5bd81731 100644 --- a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverbuild/bufcheckserverbuild.go +++ b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverbuild/bufcheckserverbuild.go @@ -141,6 +141,13 @@ var ( Type: check.RuleTypeBreaking, Handler: bufcheckserverhandle.HandleBreakingFieldSameJavaUTF8Validation, } + // BreakingFieldSameGoStripEnumPrefixRuleSpecBuilder is a rule spec builder. + BreakingFieldSameGoStripEnumPrefixRuleSpecBuilder = &bufcheckserverutil.RuleSpecBuilder{ + ID: "FIELD_SAME_GO_STRIP_ENUM_PREFIX", + Purpose: "Checks that enums have the same Go strip enum prefix, based on (pb.go).strip_enum_prefix feature.", + Type: check.RuleTypeBreaking, + Handler: bufcheckserverhandle.HandleBreakingFieldSameGoStripEnumPrefix, + } // BreakingFieldSameDefaultRuleSpecBuilder is a rule spec builder. BreakingFieldSameDefaultRuleSpecBuilder = &bufcheckserverutil.RuleSpecBuilder{ ID: "FIELD_SAME_DEFAULT", diff --git a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking.go b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking.go index 44b158b4be..8338c0526b 100644 --- a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking.go +++ b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking.go @@ -638,6 +638,45 @@ func handleBreakingFieldSameJavaUTF8Validation( return nil } +// HandleBreakingFieldSameGoStripEnumPrefix is a check function. +var HandleBreakingFieldSameGoStripEnumPrefix = bufcheckserverutil.NewBreakingEnumPairRuleHandler(handleBreakingFieldSameGoStripEnumPrefix) + +func handleBreakingFieldSameGoStripEnumPrefix( + responseWriter bufcheckserverutil.ResponseWriter, + request bufcheckserverutil.Request, + enum bufprotosource.Enum, + previousEnum bufprotosource.Enum, +) error { + previousDescriptor, err := previousEnum.AsDescriptor() + if err != nil { + return err + } + descriptor, err := enum.AsDescriptor() + if err != nil { + return err + } + previousPrefix, err := enumGoStripEnumPrefix(previousDescriptor) + if err != nil { + return err + } + prefix, err := enumGoStripEnumPrefix(descriptor) + if err != nil { + return err + } + if previousPrefix != prefix { + responseWriter.AddProtosourceAnnotationf( + enumGoStripEnumPrefixLocation(enum), + enumGoStripEnumPrefixLocation(previousEnum), + enum.File().Path(), + `%s changed Go strip enum prefix from %q to %q.`, + enum.Name(), + previousPrefix, + prefix, + ) + } + return nil +} + // HandleBreakingFieldSameJSType is a check function. var HandleBreakingFieldSameJSType = bufcheckserverutil.NewBreakingFieldPairRuleHandler(handleBreakingFieldSameJSType) diff --git a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking_util.go b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking_util.go index 0fa9c00bd5..ff3a27b164 100644 --- a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking_util.go +++ b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverhandle/breaking_util.go @@ -26,14 +26,17 @@ import ( "github.com/bufbuild/protocompile/protoutil" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/descriptorpb" + gofeaturespb "google.golang.org/protobuf/types/gofeaturespb" ) const ( - featuresFieldName = "features" - featureNameUTF8Validation = "utf8_validation" - featureNameJSONFormat = "json_format" - cppFeatureNameStringType = "string_type" - javaFeatureNameUTF8Validation = "utf8_validation" + featuresFieldName = "features" + featureNameUTF8Validation = "utf8_validation" + featureNameJSONFormat = "json_format" + cppFeatureNameStringType = "string_type" + javaFeatureNameUTF8Validation = "utf8_validation" + goFeatureNameStripEnumPrefix = "strip_enum_prefix" + goFeatureNameAPILevel = "api_level" ) var ( @@ -257,6 +260,20 @@ func fieldJavaUTF8ValidationLocation(field bufprotosource.Field) bufprotosource. return getCustomFeatureLocation(field, ext, javaFeatureNameUTF8Validation) } +func enumGoStripEnumPrefix(enum protoreflect.EnumDescriptor) (gofeaturespb.GoFeatures_StripEnumPrefix, error) { + val, err := customfeatures.ResolveGoFeatureForEnum(enum, goFeatureNameStripEnumPrefix, protoreflect.EnumKind) + if err != nil { + return 0, err + } + return gofeaturespb.GoFeatures_StripEnumPrefix(val.Enum()), nil +} + +func enumGoStripEnumPrefixLocation(enum bufprotosource.Enum) bufprotosource.Location { + // For enums, we use the enum's features location + // This is similar to how other enum features are handled + return enum.Features().EnumTypeLocation() +} + func getCustomFeatureLocation(field bufprotosource.Field, extension protoreflect.ExtensionTypeDescriptor, fieldName protoreflect.Name) bufprotosource.Location { if extension.Message() == nil { return nil diff --git a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures.go b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures.go index 74e5848500..f305eedb12 100644 --- a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures.go +++ b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures.go @@ -18,6 +18,8 @@ import ( "fmt" "github.com/bufbuild/buf/private/gen/proto/go/google/protobuf" + gofeaturespb "google.golang.org/protobuf/types/gofeaturespb" + "github.com/bufbuild/protocompile/protoutil" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -34,6 +36,18 @@ func ResolveJavaFeature(field protoreflect.FieldDescriptor, fieldName protorefle return resolveFeature(field, protobuf.E_Java.TypeDescriptor(), fieldName, expectedKind) } +// ResolveGoFeature returns a value for the given field name of the (pb.go) custom feature +// for the given field. +func ResolveGoFeature(field protoreflect.FieldDescriptor, fieldName protoreflect.Name, expectedKind protoreflect.Kind) (protoreflect.Value, error) { + return resolveFeature(field, gofeaturespb.E_Go.TypeDescriptor(), fieldName, expectedKind) +} + +// ResolveGoFeatureForEnum returns a value for the given field name of the (pb.go) custom feature +// for the given enum. +func ResolveGoFeatureForEnum(enum protoreflect.EnumDescriptor, fieldName protoreflect.Name, expectedKind protoreflect.Kind) (protoreflect.Value, error) { + return resolveFeatureForEnum(enum, gofeaturespb.E_Go.TypeDescriptor(), fieldName, expectedKind) +} + func resolveFeature( field protoreflect.FieldDescriptor, extension protoreflect.ExtensionTypeDescriptor, @@ -54,3 +68,24 @@ func resolveFeature( featureField, ) } + +func resolveFeatureForEnum( + enum protoreflect.EnumDescriptor, + extension protoreflect.ExtensionTypeDescriptor, + fieldName protoreflect.Name, + expectedKind protoreflect.Kind, +) (protoreflect.Value, error) { + featureField := extension.Message().Fields().ByName(fieldName) + if featureField == nil { + return protoreflect.Value{}, fmt.Errorf("unable to resolve field descriptor for %s.%s", extension.Message().FullName(), fieldName) + } + if featureField.Kind() != expectedKind || featureField.IsList() { + return protoreflect.Value{}, fmt.Errorf("resolved field descriptor for %s.%s has unexpected type: expected optional %s, got %s %s", + extension.Message().FullName(), fieldName, expectedKind, featureField.Cardinality(), featureField.Kind()) + } + return protoutil.ResolveCustomFeature( + enum, + extension.Type(), + featureField, + ) +} diff --git a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures_test.go b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures_test.go index eeda10d177..22a5ee165a 100644 --- a/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures_test.go +++ b/private/bufpkg/bufcheck/bufcheckserver/internal/bufcheckserverutil/customfeatures/customfeatures/customfeatures_test.go @@ -18,6 +18,7 @@ import ( "testing" "github.com/bufbuild/buf/private/gen/proto/go/google/protobuf" + gofeaturespb "google.golang.org/protobuf/types/gofeaturespb" "github.com/stretchr/testify/require" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/descriptorpb" @@ -40,3 +41,12 @@ func TestResolveJavaFeatures(t *testing.T) { // This will use the default value for proto2 require.Equal(t, protobuf.JavaFeatures_DEFAULT.Number(), val.Enum()) } + +func TestResolveGoFeatures(t *testing.T) { + t.Parallel() + field := (*descriptorpb.FileDescriptorProto)(nil).ProtoReflect().Descriptor().Fields().ByName("package") + val, err := ResolveGoFeature(field, "api_level", protoreflect.EnumKind) + require.NoError(t, err) + // This will use the default value for proto2 + require.Equal(t, gofeaturespb.GoFeatures_API_LEVEL_UNSPECIFIED.Number(), val.Enum()) +} diff --git a/private/bufpkg/bufconfig/generate_managed_option.go b/private/bufpkg/bufconfig/generate_managed_option.go index a97960b673..c41d4a8a95 100644 --- a/private/bufpkg/bufconfig/generate_managed_option.go +++ b/private/bufpkg/bufconfig/generate_managed_option.go @@ -21,6 +21,7 @@ import ( "strings" "google.golang.org/protobuf/types/descriptorpb" + gofeaturespb "google.golang.org/protobuf/types/gofeaturespb" ) // FileOption is a file option. @@ -67,6 +68,8 @@ const ( FileOptionRubyPackageSuffix // FileOptionSwiftPrefix is the file option swift_prefix. FileOptionSwiftPrefix + // FileOptionGoApiLevel is the Go API level feature (pb.go).api_level. + FileOptionGoApiLevel ) // String implements fmt.Stringer. @@ -120,6 +123,7 @@ var ( FileOptionRubyPackage: "ruby_package", FileOptionRubyPackageSuffix: "ruby_package_suffix", FileOptionSwiftPrefix: "swift_prefix", + FileOptionGoApiLevel: "features.(pb.go).api_level", } stringToFileOption = map[string]FileOption{ "java_package": FileOptionJavaPackage, @@ -141,6 +145,7 @@ var ( "ruby_package": FileOptionRubyPackage, "ruby_package_suffix": FileOptionRubyPackageSuffix, "swift_prefix": FileOptionSwiftPrefix, + "features.(pb.go).api_level": FileOptionGoApiLevel, } fileOptionToParseOverrideValueFunc = map[FileOption]func(any) (any, error){ FileOptionJavaPackage: parseOverrideValue[string], @@ -162,6 +167,7 @@ var ( FileOptionRubyPackage: parseOverrideValue[string], FileOptionRubyPackageSuffix: parseOverrideValue[string], FileOptionSwiftPrefix: parseOverrideValue[string], + FileOptionGoApiLevel: parseOverrideValueGoApiLevel, } fieldOptionToString = map[FieldOption]string{ FieldOptionJSType: "jstype", @@ -230,6 +236,18 @@ func parseOverrideValueJSType(override any) (any, error) { return descriptorpb.FieldOptions_JSType(jsTypeEnum), nil } +func parseOverrideValueGoApiLevel(override any) (any, error) { + apiLevelName, ok := override.(string) + if !ok { + return nil, errors.New("must be one of API_LEVEL_UNSPECIFIED, API_OPEN, API_HYBRID, or API_OPAQUE") + } + apiLevelEnum, ok := gofeaturespb.GoFeatures_APILevel_value[apiLevelName] + if !ok { + return nil, errors.New("must be one of API_LEVEL_UNSPECIFIED, API_OPEN, API_HYBRID, or API_OPAQUE") + } + return gofeaturespb.GoFeatures_APILevel(apiLevelEnum), nil +} + // If the file or field option override value is one of the supported enum types, // then we want to write out the string representation of the enum value, not // the corresponding int32. @@ -271,6 +289,10 @@ func getOverrideValue(fileOptionName string, fieldOptionName string, value any) if optimizeModeValue, ok := value.(descriptorpb.FileOptions_OptimizeMode); ok { return optimizeModeValue.String(), nil } + case FileOptionGoApiLevel: + if apiLevelValue, ok := value.(gofeaturespb.GoFeatures_APILevel); ok { + return apiLevelValue.String(), nil + } } } if fieldOptionName != "" {