diff --git a/fixtures/edition2023.proto b/fixtures/edition2023.proto new file mode 100644 index 0000000..b497de5 --- /dev/null +++ b/fixtures/edition2023.proto @@ -0,0 +1,57 @@ +// Top-level comments are attached to the edition directive. +edition = "2023"; + +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; +import "extend.proto"; +option go_package = "edition2023"; + +// The official documentation for the Edition 2023 test. +// +// This file demonstrates edition 2023 syntax and features, +// including explicit field presence by default. +package com.pseudomuto.protokit.edition2023; + +option (com.pseudomuto.protokit.v1.extend_file) = true; + +// A service for testing edition 2023 features. +service Edition2023Service { + option (com.pseudomuto.protokit.v1.extend_service) = true; + + // Test method for edition 2023 + rpc TestMethod(TestRequest) returns (TestResponse) { + option (com.pseudomuto.protokit.v1.extend_method) = true; + } +} + +// Test enumeration for edition 2023 +enum TestEnum { + option (com.pseudomuto.protokit.v1.extend_enum) = true; + + UNKNOWN = 0; // Unknown value + VALUE_A = 1 [(com.pseudomuto.protokit.v1.extend_enum_value) = true]; // First value + VALUE_B = 2; // Second value +} + +// Test message for edition 2023 with explicit field presence by default +message TestMessage { + option (com.pseudomuto.protokit.v1.extend_message) = true; + + int64 id = 1; // Message ID with explicit presence + string name = 2 [(com.pseudomuto.protokit.v1.extend_field) = true]; // Message name + TestEnum type = 3; // Message type + google.protobuf.Timestamp created_at = 4; // Creation timestamp + google.protobuf.Any metadata = 5; // Additional metadata +} + +// Request message for testing +message TestRequest { + string query = 1; // Search query + int32 limit = 2; // Result limit +} + +// Response message for testing +message TestResponse { + repeated TestMessage results = 1; // Search results + int32 total_count = 2; // Total result count +} \ No newline at end of file diff --git a/fixtures/edition2023_implicit.proto b/fixtures/edition2023_implicit.proto new file mode 100644 index 0000000..e8c64c5 --- /dev/null +++ b/fixtures/edition2023_implicit.proto @@ -0,0 +1,56 @@ +// Edition 2023 with implicit field presence (proto3-like semantics) +edition = "2023"; + +import "google/protobuf/any.proto"; +import "google/protobuf/timestamp.proto"; +import "extend.proto"; + +option go_package = "edition2023_implicit"; +option features.field_presence = IMPLICIT; + +// Test package with proto3-like semantics using editions +package com.pseudomuto.protokit.edition2023.implicit; + +option (com.pseudomuto.protokit.v1.extend_file) = true; + +// A service for testing edition 2023 with implicit field presence +service Edition2023ImplicitService { + option (com.pseudomuto.protokit.v1.extend_service) = true; + + // Test method for edition 2023 implicit + rpc TestMethod(TestRequest) returns (TestResponse) { + option (com.pseudomuto.protokit.v1.extend_method) = true; + } +} + +// Test enumeration for edition 2023 implicit +enum TestEnum { + option (com.pseudomuto.protokit.v1.extend_enum) = true; + + UNKNOWN = 0; // Unknown value + VALUE_A = 1 [(com.pseudomuto.protokit.v1.extend_enum_value) = true]; // First value + VALUE_B = 2; // Second value +} + +// Test message for edition 2023 with implicit field presence (like proto3) +message TestMessage { + option (com.pseudomuto.protokit.v1.extend_message) = true; + + int64 id = 1; // Message ID with implicit presence + string name = 2 [(com.pseudomuto.protokit.v1.extend_field) = true]; // Message name + TestEnum type = 3; // Message type + google.protobuf.Timestamp created_at = 4; // Creation timestamp + google.protobuf.Any metadata = 5; // Additional metadata +} + +// Request message for testing +message TestRequest { + string query = 1; // Search query + int32 limit = 2; // Result limit +} + +// Response message for testing +message TestResponse { + repeated TestMessage results = 1; // Search results + int32 total_count = 2; // Total result count +} \ No newline at end of file diff --git a/fixtures/edition2024.proto b/fixtures/edition2024.proto new file mode 100644 index 0000000..169afda --- /dev/null +++ b/fixtures/edition2024.proto @@ -0,0 +1,71 @@ +// Top-level comments are attached to the edition directive. +edition = "2024"; + +import "google/protobuf/any.proto"; +import "google/protobuf/duration.proto"; +import "extend.proto"; +option go_package = "edition2024"; + +// The official documentation for the Edition 2024 test. +// +// This file demonstrates edition 2024 syntax and features, +// including enhanced symbol visibility controls. +package com.pseudomuto.protokit.edition2024; + +option (com.pseudomuto.protokit.v1.extend_file) = true; + +// A service for testing edition 2024 features. +service Edition2024Service { + option (com.pseudomuto.protokit.v1.extend_service) = true; + + // Test method for edition 2024 + rpc TestMethod(TestRequest) returns (TestResponse) { + option (com.pseudomuto.protokit.v1.extend_method) = true; + } + + // Another test method + rpc AnotherMethod(TestRequest) returns (TestResponse); +} + +// Test enumeration for edition 2024 +enum TestEnum { + option (com.pseudomuto.protokit.v1.extend_enum) = true; + + UNKNOWN = 0; // Unknown value + OPTION_X = 1 [(com.pseudomuto.protokit.v1.extend_enum_value) = true]; // First option + OPTION_Y = 2; // Second option + OPTION_Z = 3; // Third option +} + +// Test message for edition 2024 +message TestMessage { + option (com.pseudomuto.protokit.v1.extend_message) = true; + + int64 id = 1; // Message ID + string title = 2 [(com.pseudomuto.protokit.v1.extend_field) = true]; // Message title + TestEnum category = 3; // Message category + google.protobuf.Duration timeout = 4; // Timeout duration + google.protobuf.Any payload = 5; // Message payload + + // Nested message for testing + message NestedData { + string key = 1; // Data key + bytes value = 2; // Data value + } + + repeated NestedData data = 6; // Nested data entries +} + +// Request message for testing +message TestRequest { + string filter = 1; // Filter criteria + int32 page_size = 2; // Page size + string page_token = 3; // Page token for pagination +} + +// Response message for testing +message TestResponse { + repeated TestMessage items = 1; // Response items + string next_page_token = 2; // Next page token + int64 total_size = 3; // Total items available +} \ No newline at end of file diff --git a/fixtures/fileset.pb b/fixtures/fileset.pb index 84f7bd8..632c5d6 100644 Binary files a/fixtures/fileset.pb and b/fixtures/fileset.pb differ diff --git a/fixtures/generate.go b/fixtures/generate.go index e201706..6430412 100644 --- a/fixtures/generate.go +++ b/fixtures/generate.go @@ -1,3 +1,3 @@ package main -//go:generate protoc --descriptor_set_out=fileset.pb --include_imports --include_source_info -I. ./booking.proto ./todo.proto ./extend.proto +//go:generate protoc --descriptor_set_out=fileset.pb --include_imports --include_source_info -I. ./booking.proto ./todo.proto ./extend.proto ./edition2023.proto ./edition2024.proto ./edition2023_implicit.proto diff --git a/parser.go b/parser.go index 1e36770..68ade99 100644 --- a/parser.go +++ b/parser.go @@ -18,6 +18,7 @@ const ( serviceCommentPath = 6 extensionCommentPath = 7 syntaxCommentPath = 12 + editionCommentPath = 14 // tag numbers in DescriptorProto messageFieldCommentPath = 2 // field @@ -62,6 +63,7 @@ func parseFile(ctx context.Context, fd *descriptorpb.FileDescriptorProto) *FileD FileDescriptorProto: fd, PackageComments: comments.Get(strconv.Itoa(packageCommentPath)), SyntaxComments: comments.Get(strconv.Itoa(syntaxCommentPath)), + EditionComments: comments.Get(strconv.Itoa(editionCommentPath)), } if fd.Options != nil { diff --git a/parser_test.go b/parser_test.go index b2ed7ae..d6d3d4a 100644 --- a/parser_test.go +++ b/parser_test.go @@ -336,3 +336,138 @@ func TestExtendedOptions(t *testing.T) { require.True(t, ok) require.True(t, *extendedValue) } + +func setupEditionsTest(t *testing.T) (*protokit.FileDescriptor, *protokit.FileDescriptor) { + set, err := utils.LoadDescriptorSet("fixtures", "fileset.pb") + require.NoError(t, err) + + req := utils.CreateGenRequest(set, "edition2023.proto", "edition2024.proto") + files := protokit.ParseCodeGenRequest(req) + edition2023 := files[0] + edition2024 := files[1] + + return edition2023, edition2024 +} + +func TestEditionsParsing(t *testing.T) { + t.Parallel() + + edition2023, edition2024 := setupEditionsTest(t) + + // Test edition 2023 + require.True(t, edition2023.IsEditions()) + require.False(t, edition2023.IsProto3()) + require.Equal(t, "2023", edition2023.GetEditionName()) + require.Equal(t, "editions", edition2023.GetSyntaxType()) + require.True(t, edition2023.HasExplicitFieldPresence()) + require.Equal(t, "Top-level comments are attached to the edition directive.", edition2023.GetSyntaxComments().String()) + require.Contains(t, edition2023.GetPackageComments().String(), "The official documentation for the Edition 2023 test.") + + // Test edition 2024 + require.True(t, edition2024.IsEditions()) + require.False(t, edition2024.IsProto3()) + require.Equal(t, "2024", edition2024.GetEditionName()) + require.Equal(t, "editions", edition2024.GetSyntaxType()) + require.True(t, edition2024.HasExplicitFieldPresence()) + require.Equal(t, "Top-level comments are attached to the edition directive.", edition2024.GetSyntaxComments().String()) + require.Contains(t, edition2024.GetPackageComments().String(), "The official documentation for the Edition 2024 test.") +} + +func TestEditionsServices(t *testing.T) { + t.Parallel() + + edition2023, edition2024 := setupEditionsTest(t) + + // Test edition 2023 service + require.Len(t, edition2023.GetServices(), 1) + svc2023 := edition2023.GetService("Edition2023Service") + require.NotNil(t, svc2023) + require.True(t, svc2023.IsEditions()) + require.False(t, svc2023.IsProto3()) + require.Len(t, svc2023.GetMethods(), 1) + + // Test edition 2024 service + require.Len(t, edition2024.GetServices(), 1) + svc2024 := edition2024.GetService("Edition2024Service") + require.NotNil(t, svc2024) + require.True(t, svc2024.IsEditions()) + require.False(t, svc2024.IsProto3()) + require.Len(t, svc2024.GetMethods(), 2) +} + +func TestEditionsEnums(t *testing.T) { + t.Parallel() + + edition2023, edition2024 := setupEditionsTest(t) + + // Test edition 2023 enum + require.Len(t, edition2023.GetEnums(), 1) + enum2023 := edition2023.GetEnum("TestEnum") + require.NotNil(t, enum2023) + require.True(t, enum2023.IsEditions()) + require.False(t, enum2023.IsProto3()) + + // Test edition 2024 enum + require.Len(t, edition2024.GetEnums(), 1) + enum2024 := edition2024.GetEnum("TestEnum") + require.NotNil(t, enum2024) + require.True(t, enum2024.IsEditions()) + require.False(t, enum2024.IsProto3()) +} + +func TestEditionsMessages(t *testing.T) { + t.Parallel() + + edition2023, edition2024 := setupEditionsTest(t) + + // Test edition 2023 messages + require.Len(t, edition2023.GetMessages(), 3) + msg2023 := edition2023.GetMessage("TestMessage") + require.NotNil(t, msg2023) + require.True(t, msg2023.IsEditions()) + require.False(t, msg2023.IsProto3()) + + // Test edition 2024 messages + require.Len(t, edition2024.GetMessages(), 3) + msg2024 := edition2024.GetMessage("TestMessage") + require.NotNil(t, msg2024) + require.True(t, msg2024.IsEditions()) + require.False(t, msg2024.IsProto3()) + + // Test nested message in edition 2024 + nested := msg2024.GetMessage("NestedData") + require.NotNil(t, nested) + require.True(t, nested.IsEditions()) + require.False(t, nested.IsProto3()) +} + +func TestFieldPresenceBehavior(t *testing.T) { + t.Parallel() + + set, err := utils.LoadDescriptorSet("fixtures", "fileset.pb") + require.NoError(t, err) + + req := utils.CreateGenRequest(set, "todo.proto", "edition2023.proto", "edition2023_implicit.proto") + files := protokit.ParseCodeGenRequest(req) + + proto3File := files[0] // todo.proto (proto3) + editionExplicitFile := files[1] // edition2023.proto (explicit field presence) + editionImplicitFile := files[2] // edition2023_implicit.proto (implicit field presence) + + // Test proto3 file + require.True(t, proto3File.IsProto3()) + require.False(t, proto3File.HasExplicitFieldPresence()) + require.Equal(t, "proto3", proto3File.GetSyntax()) + + // Test editions file with explicit field presence (default for editions) + require.False(t, editionExplicitFile.IsProto3()) + require.True(t, editionExplicitFile.HasExplicitFieldPresence()) + require.True(t, editionExplicitFile.IsEditions()) + require.Equal(t, "editions", editionExplicitFile.GetSyntax()) + + // Test editions file with implicit field presence (proto3-like semantics) + require.True(t, editionImplicitFile.IsProto3()) + require.False(t, editionImplicitFile.HasExplicitFieldPresence()) + require.True(t, editionImplicitFile.IsEditions()) + require.Equal(t, "editions", editionImplicitFile.GetSyntax()) +} diff --git a/types.go b/types.go index effef50..fac396a 100644 --- a/types.go +++ b/types.go @@ -33,6 +33,7 @@ type ( PackageComments *Comment SyntaxComments *Comment + EditionComments *Comment Enums []*EnumDescriptor Extensions []*ExtensionDescriptor @@ -105,21 +106,6 @@ type ( } ) -// newCommon creates a new common struct with the given parameters. -func newCommon(f *FileDescriptor, path, longName string) common { - fn := longName - if !strings.HasPrefix(fn, ".") { - fn = fmt.Sprintf("%s.%s", f.GetPackage(), longName) - } - - return common{ - file: f, - path: path, - LongName: longName, - FullName: fn, - } -} - // GetFile returns the FileDescriptor that contains this object func (c *common) GetFile() *FileDescriptor { return c.file } @@ -132,8 +118,14 @@ func (c *common) GetLongName() string { return c.LongName } // GetFullName returns the `LongName` prefixed with the package this object is in func (c *common) GetFullName() string { return c.FullName } -// IsProto3 returns whether or not this is a proto3 object -func (c *common) IsProto3() bool { return c.file.GetSyntax() == "proto3" } +// IsProto3 returns whether or not this is a proto3 object or uses proto3-like semantics +func (c *common) IsProto3() bool { return c.file.IsProto3() } + +// GetEdition returns the edition of the file this object belongs to +func (c *common) GetEdition() descriptorpb.Edition { return c.file.GetEdition() } + +// IsEditions returns whether or not this object belongs to a file using editions syntax +func (c *common) IsEditions() bool { return c.file.IsEditions() } func getOptions(options proto.Message) (m map[string]any) { // In protobuf v2, we need to access extension fields through reflection @@ -274,8 +266,61 @@ func (c *common) setOptions(options proto.Message) { // FileDescriptor methods -// IsProto3 returns whether or not this file is a proto3 file -func (f *FileDescriptor) IsProto3() bool { return f.GetSyntax() == "proto3" } +// IsProto3 returns whether or not this file is a proto3 file or uses proto3-like semantics +func (f *FileDescriptor) IsProto3() bool { + // Original proto3 syntax + if f.GetSyntax() == "proto3" { + return true + } + // Editions with proto3-like behavior (IMPLICIT field presence) match proto3 semantics + if f.IsEditions() { + if options := f.GetOptions(); options != nil { + if features := options.GetFeatures(); features != nil { + return features.GetFieldPresence() == descriptorpb.FeatureSet_IMPLICIT + } + } + } + return false +} + +// GetEdition returns the edition of this file +func (f *FileDescriptor) GetEdition() descriptorpb.Edition { return f.FileDescriptorProto.GetEdition() } + +// IsEditions returns whether or not this file uses the editions syntax +func (f *FileDescriptor) IsEditions() bool { return f.GetSyntax() == "editions" } + +// GetEditionName returns the edition name as a string (e.g., "2023", "2024") +func (f *FileDescriptor) GetEditionName() string { + if !f.IsEditions() { + return "" + } + switch f.GetEdition() { + case descriptorpb.Edition_EDITION_2023: + return "2023" + case descriptorpb.Edition_EDITION_2024: + return "2024" + case descriptorpb.Edition_EDITION_PROTO2: + return "proto2" + case descriptorpb.Edition_EDITION_PROTO3: + return "proto3" + case descriptorpb.Edition_EDITION_UNKNOWN, descriptorpb.Edition_EDITION_LEGACY: + return "unknown" + case descriptorpb.Edition_EDITION_1_TEST_ONLY: + return "1_test_only" + case descriptorpb.Edition_EDITION_2_TEST_ONLY: + return "2_test_only" + case descriptorpb.Edition_EDITION_99997_TEST_ONLY: + return "99997_test_only" + case descriptorpb.Edition_EDITION_99998_TEST_ONLY: + return "99998_test_only" + case descriptorpb.Edition_EDITION_99999_TEST_ONLY: + return "99999_test_only" + case descriptorpb.Edition_EDITION_MAX: + return "max" + default: + return f.GetEdition().String() + } +} // GetPackageComments returns the file's package comments func (f *FileDescriptor) GetPackageComments() *Comment { return f.PackageComments } @@ -283,6 +328,42 @@ func (f *FileDescriptor) GetPackageComments() *Comment { return f.PackageComment // GetSyntaxComments returns the file's syntax comments func (f *FileDescriptor) GetSyntaxComments() *Comment { return f.SyntaxComments } +// GetEditionComments returns the file's edition comments +func (f *FileDescriptor) GetEditionComments() *Comment { return f.EditionComments } + +// HasExplicitFieldPresence returns whether this file defaults to explicit field presence +// In editions 2023+, field presence is explicit by default (like proto2) +// In proto3, field presence is implicit by default +func (f *FileDescriptor) HasExplicitFieldPresence() bool { + if f.IsEditions() { + // Check custom field presence setting in editions + if options := f.GetOptions(); options != nil { + if features := options.GetFeatures(); features != nil { + switch features.GetFieldPresence() { + case descriptorpb.FeatureSet_IMPLICIT: + return false + case descriptorpb.FeatureSet_EXPLICIT, descriptorpb.FeatureSet_LEGACY_REQUIRED: + return true + case descriptorpb.FeatureSet_FIELD_PRESENCE_UNKNOWN: + // Fall through to default behavior + } + } + } + // Editions 2023+ default to explicit field presence + return true + } + // proto2 has explicit field presence, proto3 has implicit + return f.GetSyntax() == "proto2" +} + +// GetSyntaxType returns a more detailed syntax classification +func (f *FileDescriptor) GetSyntaxType() string { + if f.IsEditions() { + return "editions" + } + return f.GetSyntax() +} + // GetEnums returns the top-level enumerations defined in this file func (f *FileDescriptor) GetEnums() []*EnumDescriptor { return f.Enums } @@ -471,3 +552,18 @@ func (m *MethodDescriptor) GetComments() *Comment { return m.Comments } // GetService returns the service descriptor that defines this method func (m *MethodDescriptor) GetService() *ServiceDescriptor { return m.Service } + +// newCommon creates a new common struct with the given parameters. +func newCommon(f *FileDescriptor, path, longName string) common { + fn := longName + if !strings.HasPrefix(fn, ".") { + fn = fmt.Sprintf("%s.%s", f.GetPackage(), longName) + } + + return common{ + file: f, + path: path, + LongName: longName, + FullName: fn, + } +} diff --git a/utils/protobuf_test.go b/utils/protobuf_test.go index 31275de..36a4f9b 100644 --- a/utils/protobuf_test.go +++ b/utils/protobuf_test.go @@ -22,13 +22,17 @@ func TestCreateGenRequest(t *testing.T) { "google/protobuf/any.proto", "google/protobuf/descriptor.proto", "google/protobuf/timestamp.proto", + "google/protobuf/duration.proto", "extend.proto", "todo.proto", "todo_import.proto", + "edition2023.proto", + "edition2024.proto", + "edition2023_implicit.proto", } for _, pf := range req.GetProtoFile() { - require.True(t, slices.Contains(expectedProtos, pf.GetName())) + require.True(t, slices.Contains(expectedProtos, pf.GetName()), "Unexpected proto file: %s", pf.GetName()) } } @@ -49,7 +53,7 @@ func TestLoadDescriptorSet(t *testing.T) { set, err := utils.LoadDescriptorSet("..", "fixtures", "fileset.pb") require.NoError(t, err) - require.Len(t, set.GetFile(), 7) + require.Len(t, set.GetFile(), 11) require.NotNil(t, utils.FindDescriptor(set, "todo.proto")) require.Nil(t, utils.FindDescriptor(set, "whodis.proto"))