diff --git a/protoc_plugin/lib/names.dart b/protoc_plugin/lib/names.dart index cf176a70..4052f954 100644 --- a/protoc_plugin/lib/names.dart +++ b/protoc_plugin/lib/names.dart @@ -669,3 +669,75 @@ int countRealOneofs(DescriptorProto descriptor) { String lowerCaseFirstLetter(String input) => input[0].toLowerCase() + input.substring(1); + +/// Converts a TitleCase or camelCase enum name to UPPER_SNAKE_CASE. +/// Examples: 'PhoneType' -> 'PHONE_TYPE', 'HTTPStatus' -> 'HTTP_STATUS' +String titleCaseToUpperSnakeCase(String input) { + if (input.isEmpty) return input; + + final buffer = StringBuffer(); + bool previousWasLower = false; + + for (var i = 0; i < input.length; i++) { + final char = input[i]; + final isUpper = char == char.toUpperCase(); + final isLower = char == char.toLowerCase(); + + if (i > 0 && + isUpper && + (previousWasLower || + (i + 1 < input.length && + input[i + 1] == input[i + 1].toLowerCase()))) { + buffer.write('_'); + } + + buffer.write(char.toUpperCase()); + previousWasLower = isLower; + } + + return buffer.toString(); +} + +/// Strips the enum name prefix from a protobuf-style enum value name and converts +/// the result to camelCase following Dart enum naming conventions. +/// Examples: +/// - enumName='PhoneType', valueName='PHONE_TYPE_MOBILE' -> 'mobile' +/// - enumName='PhoneType', valueName='PHONE_TYPE_UNSPECIFIED' -> 'unspecified' +/// - enumName='HTTPStatus', valueName='HTTP_STATUS_NOT_FOUND' -> 'notFound' +/// - enumName='HTTPStatus', valueName='HTTP_STATUS_OK' -> 'ok' +String stripEnumPrefix(String enumName, String valueName) { + final enumPrefix = titleCaseToUpperSnakeCase(enumName); + final prefixWithUnderscore = '${enumPrefix}_'; + + String strippedName; + if (valueName.startsWith(prefixWithUnderscore)) { + strippedName = valueName.substring(prefixWithUnderscore.length); + } else { + // If the value name doesn't start with the expected prefix, use as-is + strippedName = valueName; + } + + // Convert UPPER_SNAKE_CASE to camelCase + return upperSnakeCaseToCamelCase(strippedName); +} + +/// Converts UPPER_SNAKE_CASE to camelCase. +/// Examples: +/// - 'MOBILE' -> 'mobile' +/// - 'NOT_FOUND' -> 'notFound' +/// - 'UNSPECIFIED' -> 'unspecified' +String upperSnakeCaseToCamelCase(String input) { + if (input.isEmpty) return input; + + final parts = input.toLowerCase().split('_'); + if (parts.isEmpty) return input.toLowerCase(); + + final buffer = StringBuffer(parts[0]); + for (var i = 1; i < parts.length; i++) { + if (parts[i].isNotEmpty) { + buffer.write(parts[i][0].toUpperCase() + parts[i].substring(1)); + } + } + + return buffer.toString(); +} diff --git a/protoc_plugin/lib/src/enum_generator.dart b/protoc_plugin/lib/src/enum_generator.dart index d7e992a8..0569873c 100644 --- a/protoc_plugin/lib/src/enum_generator.dart +++ b/protoc_plugin/lib/src/enum_generator.dart @@ -64,8 +64,21 @@ class EnumGenerator extends ProtobufContainer { _aliases.add(EnumAlias(value, canonicalValue)); _originalAliasIndices.add(i); } + // Generate Dart name for the enum value + String dartName; + final isProtobufEnumStyle = + parent.fileGen?.options.protobufEnumStyle ?? false; + if (isProtobufEnumStyle) { + // Strip enum prefix from protobuf-style enum values + final strippedName = stripEnumPrefix(descriptor.name, value.name); + dartName = avoidInitialUnderscore(strippedName); + } else { + // Use original style + dartName = avoidInitialUnderscore(value.name); + } + dartNames[value.name] = disambiguateName( - avoidInitialUnderscore(value.name), + dartName, usedNames, enumSuffixes(), ); diff --git a/protoc_plugin/lib/src/options.dart b/protoc_plugin/lib/src/options.dart index 23b40048..ba5e40bc 100644 --- a/protoc_plugin/lib/src/options.dart +++ b/protoc_plugin/lib/src/options.dart @@ -54,11 +54,13 @@ class GenerationOptions { final bool useGrpc; final bool generateMetadata; final bool disableConstructorArgs; + final bool protobufEnumStyle; GenerationOptions({ this.useGrpc = false, this.generateMetadata = false, this.disableConstructorArgs = false, + this.protobufEnumStyle = false, }); } @@ -112,6 +114,19 @@ class DisableConstructorArgsParser implements SingleOptionParser { } } +class ProtobufEnumStyleParser implements SingleOptionParser { + bool protobufEnumStyleEnabled = false; + + @override + void parse(String name, String? value, OnError onError) { + if (value != null) { + onError('Invalid protobuf-enum-style option. No value expected.'); + return; + } + protobufEnumStyleEnabled = true; + } +} + /// Parser used by the compiler, which supports the `rpc` option (see /// [GrpcOptionParser]) and any additional option added in [parsers]. If /// [parsers] has a key for `rpc`, it will be ignored. @@ -132,11 +147,15 @@ GenerationOptions? parseGenerationOptions( final disableConstructorArgsParser = DisableConstructorArgsParser(); newParsers['disable_constructor_args'] = disableConstructorArgsParser; + final protobufEnumStyleParser = ProtobufEnumStyleParser(); + newParsers['protobuf-enum-style'] = protobufEnumStyleParser; + if (genericOptionsParser(request, response, newParsers)) { return GenerationOptions( useGrpc: grpcOptionParser.grpcEnabled, generateMetadata: generateMetadataParser.generateKytheInfo, disableConstructorArgs: disableConstructorArgsParser.value, + protobufEnumStyle: protobufEnumStyleParser.protobufEnumStyleEnabled, ); } return null; diff --git a/protoc_plugin/test/enum_generator_test.dart b/protoc_plugin/test/enum_generator_test.dart index f41bd0a2..c56328cd 100644 --- a/protoc_plugin/test/enum_generator_test.dart +++ b/protoc_plugin/test/enum_generator_test.dart @@ -42,4 +42,126 @@ void main() { expectGolden(writer.emitSource(format: false), 'enum.pbenum.dart'); expectGolden(writer.sourceLocationInfo.toString(), 'enum.pbenum.dart.meta'); }); + + test('testEnumGeneratorWithProtobufEnumStyle', () { + // Create an enum with protobuf-style names (enum name prefixed values) + final ed = + EnumDescriptorProto() + ..name = 'PhoneType' + ..value.addAll([ + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_UNSPECIFIED' + ..number = 0, + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_MOBILE' + ..number = 1, + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_HOME' + ..number = 2, + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_WORK' + ..number = 3, + ]); + + final writer = IndentingWriter( + generateMetadata: true, + fileName: 'sample.proto', + ); + + // Test with protobuf enum style enabled - this should strip prefixes + final optionsWithProtobufStyle = GenerationOptions(protobufEnumStyle: true); + final fg = FileGenerator(FileDescriptorProto(), optionsWithProtobufStyle); + final eg = EnumGenerator.topLevel(ed, fg, {}, 0); + eg.generate(writer); + + expectGolden( + writer.emitSource(format: false), + 'enum_protobuf_style.pbenum.dart', + ); + expectGolden( + writer.sourceLocationInfo.toString(), + 'enum_protobuf_style.pbenum.dart.meta', + ); + }); + + test('testEnumGeneratorWithoutProtobufEnumStyle', () { + // Same enum with protobuf-style names but without the flag + final ed = + EnumDescriptorProto() + ..name = 'PhoneType' + ..value.addAll([ + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_UNSPECIFIED' + ..number = 0, + EnumValueDescriptorProto() + ..name = 'PHONE_TYPE_MOBILE' + ..number = 1, + ]); + + final writer = IndentingWriter( + generateMetadata: true, + fileName: 'sample.proto', + ); + + // Test without protobuf enum style - should use names as-is + final optionsWithoutProtobufStyle = GenerationOptions( + protobufEnumStyle: false, + ); + final fg = FileGenerator( + FileDescriptorProto(), + optionsWithoutProtobufStyle, + ); + final eg = EnumGenerator.topLevel(ed, fg, {}, 0); + eg.generate(writer); + + expectGolden( + writer.emitSource(format: false), + 'enum_without_protobuf_style.pbenum.dart', + ); + expectGolden( + writer.sourceLocationInfo.toString(), + 'enum_without_protobuf_style.pbenum.dart.meta', + ); + }); + + test('testEnumGeneratorWithComplexProtobufEnumStyle', () { + // Test with more complex enum names to verify camelCase conversion + final ed = + EnumDescriptorProto() + ..name = 'HTTPStatusCode' + ..value.addAll([ + EnumValueDescriptorProto() + ..name = 'HTTP_STATUS_CODE_UNSPECIFIED' + ..number = 0, + EnumValueDescriptorProto() + ..name = 'HTTP_STATUS_CODE_NOT_FOUND' + ..number = 1, + EnumValueDescriptorProto() + ..name = 'HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR' + ..number = 2, + EnumValueDescriptorProto() + ..name = 'HTTP_STATUS_CODE_OK' + ..number = 3, + ]); + + final writer = IndentingWriter( + generateMetadata: true, + fileName: 'sample.proto', + ); + + // Test with protobuf enum style enabled + final optionsWithProtobufStyle = GenerationOptions(protobufEnumStyle: true); + final fg = FileGenerator(FileDescriptorProto(), optionsWithProtobufStyle); + final eg = EnumGenerator.topLevel(ed, fg, {}, 0); + eg.generate(writer); + + expectGolden( + writer.emitSource(format: false), + 'enum_complex_protobuf_style.pbenum.dart', + ); + expectGolden( + writer.sourceLocationInfo.toString(), + 'enum_complex_protobuf_style.pbenum.dart.meta', + ); + }); } diff --git a/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart b/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart new file mode 100644 index 00000000..20ac2c68 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart @@ -0,0 +1,21 @@ +class HTTPStatusCode extends $pb.ProtobufEnum { + static const HTTPStatusCode unspecified = HTTPStatusCode._(0, _omitEnumNames ? '' : 'HTTP_STATUS_CODE_UNSPECIFIED'); + static const HTTPStatusCode notFound = HTTPStatusCode._(1, _omitEnumNames ? '' : 'HTTP_STATUS_CODE_NOT_FOUND'); + static const HTTPStatusCode internalServerError = HTTPStatusCode._(2, _omitEnumNames ? '' : 'HTTP_STATUS_CODE_INTERNAL_SERVER_ERROR'); + static const HTTPStatusCode ok = HTTPStatusCode._(3, _omitEnumNames ? '' : 'HTTP_STATUS_CODE_OK'); + + static const $core.List values = [ + unspecified, + notFound, + internalServerError, + ok, + ]; + + static final $core.List _byValue = $pb.ProtobufEnum.$_initByValueList(values, 3); + static HTTPStatusCode? valueOf($core.int value) => value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const HTTPStatusCode._(super.value, super.name); +} + + +const $core.bool _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart.meta b/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart.meta new file mode 100644 index 00000000..0ef5a793 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_complex_protobuf_style.pbenum.dart.meta @@ -0,0 +1,43 @@ +annotation: { + path: 5 + path: 0 + sourceFile: sample.proto + begin: 6 + end: 20 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 0 + sourceFile: sample.proto + begin: 78 + end: 89 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 1 + sourceFile: sample.proto + begin: 197 + end: 205 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 2 + sourceFile: sample.proto + begin: 311 + end: 330 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 3 + sourceFile: sample.proto + begin: 448 + end: 450 +} diff --git a/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart b/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart new file mode 100644 index 00000000..64113b21 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart @@ -0,0 +1,21 @@ +class PhoneType extends $pb.ProtobufEnum { + static const PhoneType unspecified = PhoneType._(0, _omitEnumNames ? '' : 'PHONE_TYPE_UNSPECIFIED'); + static const PhoneType mobile = PhoneType._(1, _omitEnumNames ? '' : 'PHONE_TYPE_MOBILE'); + static const PhoneType home = PhoneType._(2, _omitEnumNames ? '' : 'PHONE_TYPE_HOME'); + static const PhoneType work = PhoneType._(3, _omitEnumNames ? '' : 'PHONE_TYPE_WORK'); + + static const $core.List values = [ + unspecified, + mobile, + home, + work, + ]; + + static final $core.List _byValue = $pb.ProtobufEnum.$_initByValueList(values, 3); + static PhoneType? valueOf($core.int value) => value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const PhoneType._(super.value, super.name); +} + + +const $core.bool _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart.meta b/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart.meta new file mode 100644 index 00000000..b3e4a520 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_protobuf_style.pbenum.dart.meta @@ -0,0 +1,43 @@ +annotation: { + path: 5 + path: 0 + sourceFile: sample.proto + begin: 6 + end: 15 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 0 + sourceFile: sample.proto + begin: 68 + end: 79 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 1 + sourceFile: sample.proto + begin: 171 + end: 177 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 2 + sourceFile: sample.proto + begin: 264 + end: 268 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 3 + sourceFile: sample.proto + begin: 353 + end: 357 +} diff --git a/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart b/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart new file mode 100644 index 00000000..e051fd34 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart @@ -0,0 +1,17 @@ +class PhoneType extends $pb.ProtobufEnum { + static const PhoneType PHONE_TYPE_UNSPECIFIED = PhoneType._(0, _omitEnumNames ? '' : 'PHONE_TYPE_UNSPECIFIED'); + static const PhoneType PHONE_TYPE_MOBILE = PhoneType._(1, _omitEnumNames ? '' : 'PHONE_TYPE_MOBILE'); + + static const $core.List values = [ + PHONE_TYPE_UNSPECIFIED, + PHONE_TYPE_MOBILE, + ]; + + static final $core.List _byValue = $pb.ProtobufEnum.$_initByValueList(values, 1); + static PhoneType? valueOf($core.int value) => value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const PhoneType._(super.value, super.name); +} + + +const $core.bool _omitEnumNames = $core.bool.fromEnvironment('protobuf.omit_enum_names'); diff --git a/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart.meta b/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart.meta new file mode 100644 index 00000000..41f32e85 --- /dev/null +++ b/protoc_plugin/test/goldens/enum_without_protobuf_style.pbenum.dart.meta @@ -0,0 +1,25 @@ +annotation: { + path: 5 + path: 0 + sourceFile: sample.proto + begin: 6 + end: 15 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 0 + sourceFile: sample.proto + begin: 68 + end: 90 +} +annotation: { + path: 5 + path: 0 + path: 2 + path: 1 + sourceFile: sample.proto + begin: 182 + end: 199 +}