diff --git a/lib/proto_plugin.rb b/lib/proto_plugin.rb index 67f2134..f07f206 100644 --- a/lib/proto_plugin.rb +++ b/lib/proto_plugin.rb @@ -6,8 +6,12 @@ module ProtoPlugin require_relative "proto_plugin/utils" require_relative "proto_plugin/context" +require_relative "proto_plugin/commentable" require_relative "proto_plugin/file_descriptor" require_relative "proto_plugin/enum_descriptor" +require_relative "proto_plugin/enum_value_descriptor" +require_relative "proto_plugin/field_descriptor" +require_relative "proto_plugin/oneof_descriptor" require_relative "proto_plugin/message_descriptor" require_relative "proto_plugin/service_descriptor" require_relative "proto_plugin/method_descriptor" diff --git a/lib/proto_plugin/commentable.rb b/lib/proto_plugin/commentable.rb new file mode 100644 index 0000000..6229444 --- /dev/null +++ b/lib/proto_plugin/commentable.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module ProtoPlugin + # A mixin providing access to the comments associated with a descriptor via + # its file's `SourceCodeInfo`. + # + # Including classes must respond to `#file` (returning the {FileDescriptor} + # the element belongs to) and `#descriptor` (returning the raw descriptor + # proto the comments are keyed against). + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L1213 + # Google::Protobuf::SourceCodeInfo::Location + module Commentable + # The `SourceCodeInfo::Location` associated with this element, if source + # info was included in the request. + # + # @return [Google::Protobuf::SourceCodeInfo::Location] + # @return [nil] if no location is available + def source_location + file&.location_for(descriptor) + end + + # The comment block appearing directly above this element. + # + # @return [String] the leading comment, as provided by `protoc` + # @return [nil] if there is no leading comment + def leading_comments + presence(source_location&.leading_comments) + end + + # The comment appearing directly after this element on the same or + # following line. + # + # @return [String] the trailing comment, as provided by `protoc` + # @return [nil] if there is no trailing comment + def trailing_comments + presence(source_location&.trailing_comments) + end + + # The comments attached to this element, in source order: the leading + # comment followed by the trailing comment. Absent blocks are omitted. + # + # Detached comments (blocks the author separated from the element with a + # blank line) are not exposed. Per protoc they appear before "but [are] + # not connected to" the element, so they are file organization rather than + # documentation of any element. + # + # @example + # field.comments.join("\n").strip + # + # @return [Array] + def comments + [leading_comments, trailing_comments].compact + end + + private + + def presence(value) + value unless value.nil? || value.empty? + end + end +end diff --git a/lib/proto_plugin/enum_descriptor.rb b/lib/proto_plugin/enum_descriptor.rb index 6a016cc..b09d3d3 100644 --- a/lib/proto_plugin/enum_descriptor.rb +++ b/lib/proto_plugin/enum_descriptor.rb @@ -10,6 +10,8 @@ module ProtoPlugin # # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L336 class EnumDescriptor < SimpleDelegator + include Commentable + # @return [Google::Protobuf::EnumDescriptorProto] attr_reader :descriptor @@ -28,6 +30,25 @@ def initialize(descriptor, parent) @parent = parent end + # The file descriptor this enum belongs to. + # + # @return [FileDescriptor] + def file + parent.file + end + + # The values defined for this enum. + # + # @return [Array] + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L343 + # Google::Protobuf::EnumDescriptorProto#value + def values + @values ||= @descriptor.value.map do |v| + EnumValueDescriptor.new(v, self) + end + end + # The full name of the enum, including parent namespace. # # @example diff --git a/lib/proto_plugin/enum_value_descriptor.rb b/lib/proto_plugin/enum_value_descriptor.rb new file mode 100644 index 0000000..e2aa264 --- /dev/null +++ b/lib/proto_plugin/enum_value_descriptor.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "delegate" + +module ProtoPlugin + # A wrapper class around `Google::Protobuf::EnumValueDescriptorProto` + # which provides helpers and more idiomatic Ruby access patterns. + # + # Any method not defined directly is delegated to the descriptor the wrapper was initialized with. + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L356 + # Google::Protobuf::EnumValueDescriptorProto + class EnumValueDescriptor < SimpleDelegator + include Commentable + + # @return [Google::Protobuf::EnumValueDescriptorProto] + attr_reader :descriptor + + # The enum descriptor this value was defined within. + # + # @return [EnumDescriptor] + attr_reader :enum + + # @param descriptor [Google::Protobuf::EnumValueDescriptorProto] + # @param enum [EnumDescriptor] The enum this value was defined within. + def initialize(descriptor, enum) + super(descriptor) + @descriptor = descriptor + @enum = enum + end + + # The file descriptor this enum value belongs to. + # + # @return [FileDescriptor] + def file + enum.file + end + + # The full name of the enum value, including parent namespace. + # + # @example + # "My::Ruby::Package::EnumName::VALUE_NAME" + # + # @return [String] + def full_name + @full_name ||= "#{enum.full_name}::#{name}" + end + end +end diff --git a/lib/proto_plugin/field_descriptor.rb b/lib/proto_plugin/field_descriptor.rb new file mode 100644 index 0000000..20159ca --- /dev/null +++ b/lib/proto_plugin/field_descriptor.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "delegate" + +module ProtoPlugin + # A wrapper class around `Google::Protobuf::FieldDescriptorProto` + # which provides helpers and more idiomatic Ruby access patterns. + # + # Any method not defined directly is delegated to the descriptor the wrapper was initialized with. + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L242 + # Google::Protobuf::FieldDescriptorProto + class FieldDescriptor < SimpleDelegator + include Commentable + + # @return [Google::Protobuf::FieldDescriptorProto] + attr_reader :descriptor + + # The message descriptor this field was defined within. + # + # @return [MessageDescriptor] + attr_reader :message + + # @param descriptor [Google::Protobuf::FieldDescriptorProto] + # @param message [MessageDescriptor] The message this field was defined within. + # @param context [Context] + def initialize(descriptor, message, context) + super(descriptor) + @descriptor = descriptor + @message = message + @context = context + end + + # The file descriptor this field belongs to. + # + # @return [FileDescriptor] + def file + message.file + end + + # Resolves the message or enum descriptor referenced by this field. + # + # Only message, enum, and group fields reference another type. For scalar + # fields (or when the referenced type was not included in the request), + # `nil` is returned. + # + # @return [MessageDescriptor] if the field is a message or group type + # @return [EnumDescriptor] if the field is an enum type + # @return [nil] if the field is a scalar type or the type was not found + def type_descriptor + return if scalar? + + @context.type_by_proto_name(type_name) + end + + # Returns true if the field is a message type. + # + # @return [Boolean] + def message? + type == :TYPE_MESSAGE + end + + # Returns true if the field is an enum type. + # + # @return [Boolean] + def enum? + type == :TYPE_ENUM + end + + # Returns true if the field is a group type. + # + # @return [Boolean] + def group? + type == :TYPE_GROUP + end + + # Returns true if the field is a scalar type (i.e. not a message, enum, or group). + # + # @return [Boolean] + def scalar? + !message? && !enum? && !group? + end + + # Returns true if the field has the `repeated` label. + # + # @return [Boolean] + def repeated? + label == :LABEL_REPEATED + end + + # Returns true if the field has the `required` label (proto2 only). + # + # @return [Boolean] + def required? + label == :LABEL_REQUIRED + end + + # Returns true if the field has the `optional` label. + # + # @note In proto3 all singular fields carry the `optional` label internally. + # Use {#proto3_optional?} to detect fields with explicit presence tracking. + # + # @return [Boolean] + def optional? + label == :LABEL_OPTIONAL + end + + # Returns true if the field was declared with proto3 explicit presence, + # i.e. an `optional` keyword in a proto3 file. + # + # @return [Boolean] + def proto3_optional? + descriptor.proto3_optional + end + + # Returns true if the field is a member of a oneof. + # + # @note Fields declared with the proto3 `optional` keyword are backed by a + # synthetic oneof. Those are not considered oneof members here. + # + # @return [Boolean] + def oneof? + descriptor.has_oneof_index? && !proto3_optional? + end + + # The oneof this field is a member of, if any. + # + # @return [OneofDescriptor] if the field is a member of a oneof + # @return [nil] otherwise + def oneof + return unless oneof? + + message.oneofs[descriptor.oneof_index] + end + end +end diff --git a/lib/proto_plugin/file_descriptor.rb b/lib/proto_plugin/file_descriptor.rb index 6515547..74d3eab 100644 --- a/lib/proto_plugin/file_descriptor.rb +++ b/lib/proto_plugin/file_descriptor.rb @@ -11,6 +11,8 @@ module ProtoPlugin # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L97 # Google::Protobuf::FileDescriptorProto class FileDescriptor < SimpleDelegator + include Commentable + # @return [Google::Protobuf::FileDescriptorProto] attr_reader :descriptor @@ -22,6 +24,26 @@ def initialize(context, descriptor) @descriptor = descriptor end + # The file descriptor this element belongs to. + # + # For a `FileDescriptor` this is the descriptor itself. Defined so that + # {Commentable} can resolve comments uniformly across all descriptor types. + # + # @return [FileDescriptor] + def file + self + end + + # Returns the `SourceCodeInfo::Location` for a given raw descriptor proto + # defined within this file, if source info was included in the request. + # + # @param proto [Object] a raw descriptor proto contained in this file + # @return [Google::Protobuf::SourceCodeInfo::Location] + # @return [nil] if no matching location is available + def location_for(proto) + source_locations[proto] + end + # The enums defined as children of this file. # # @return [Array] @@ -42,7 +64,7 @@ def enums # Google::Protobuf::DescriptorProto#message_type def messages @messages ||= @descriptor.message_type.map do |m| - MessageDescriptor.new(m, self) + MessageDescriptor.new(m, self, @context) end end @@ -85,5 +107,75 @@ def services ServiceDescriptor.new(s, self, @context) end end + + private + + # Field numbers of the relevant repeated fields within their parent + # descriptor proto, used to construct `SourceCodeInfo` paths. + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto + FILE_MESSAGE = 4 + FILE_ENUM = 5 + FILE_SERVICE = 6 + MESSAGE_FIELD = 2 + MESSAGE_NESTED = 3 + MESSAGE_ENUM = 4 + MESSAGE_ONEOF = 8 + ENUM_VALUE = 2 + SERVICE_METHOD = 2 + private_constant :FILE_MESSAGE, + :FILE_ENUM, + :FILE_SERVICE, + :MESSAGE_FIELD, + :MESSAGE_NESTED, + :MESSAGE_ENUM, + :MESSAGE_ONEOF, + :ENUM_VALUE, + :SERVICE_METHOD + + # Builds a map from each raw descriptor proto within this file to its + # `SourceCodeInfo::Location`, keyed by object identity. + # + # The `SourceCodeInfo` locations are addressed by a numeric path into the + # `FileDescriptorProto` tree. This walks that tree in the same order, + # reconstructing each path and associating it with the descriptor found + # there. + # + # @return [Hash] + def source_locations + @source_locations ||= begin + by_path = (@descriptor.source_code_info&.location || []).each_with_object({}) do |loc, hash| + hash[loc.path.to_a] = loc + end + + index = {}.compare_by_identity + assign = ->(proto, path) { (loc = by_path[path]) && index[proto] = loc } + + assign.call(@descriptor, []) + + visit_enum = ->(enum, path) { + assign.call(enum, path) + enum.value.each_with_index { |v, i| assign.call(v, path + [ENUM_VALUE, i]) } + } + + visit_message = ->(message, path) { + assign.call(message, path) + message.field.each_with_index { |f, i| assign.call(f, path + [MESSAGE_FIELD, i]) } + message.oneof_decl.each_with_index { |o, i| assign.call(o, path + [MESSAGE_ONEOF, i]) } + message.enum_type.each_with_index { |e, i| visit_enum.call(e, path + [MESSAGE_ENUM, i]) } + message.nested_type.each_with_index { |n, i| visit_message.call(n, path + [MESSAGE_NESTED, i]) } + } + + @descriptor.message_type.each_with_index { |m, i| visit_message.call(m, [FILE_MESSAGE, i]) } + @descriptor.enum_type.each_with_index { |e, i| visit_enum.call(e, [FILE_ENUM, i]) } + @descriptor.service.each_with_index do |s, i| + sp = [FILE_SERVICE, i] + assign.call(s, sp) + s["method"].each_with_index { |m, j| assign.call(m, sp + [SERVICE_METHOD, j]) } + end + + index + end + end end end diff --git a/lib/proto_plugin/message_descriptor.rb b/lib/proto_plugin/message_descriptor.rb index 88770f1..23c0814 100644 --- a/lib/proto_plugin/message_descriptor.rb +++ b/lib/proto_plugin/message_descriptor.rb @@ -11,6 +11,8 @@ module ProtoPlugin # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L134 # Google::Protobuf::DescriptorProto class MessageDescriptor < SimpleDelegator + include Commentable + # @return [Google::Protobuf::DescriptorProto] attr_reader :descriptor @@ -23,10 +25,43 @@ class MessageDescriptor < SimpleDelegator # @param descriptor [Google::Protobuf::DescriptorProto] # @param parent [FileDescriptorFileDescriptorProto, MessageDescriptor] # The file or message descriptor this message was defined within. - def initialize(descriptor, parent) + # @param context [Context] + def initialize(descriptor, parent, context) super(descriptor) @descriptor = descriptor @parent = parent + @context = context + end + + # The file descriptor this message belongs to. + # + # @return [FileDescriptor] + def file + parent.file + end + + # The fields defined on this message. + # + # @return [Array] + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L138 + # Google::Protobuf::DescriptorProto#field + def fields + @fields ||= @descriptor.field.map do |f| + FieldDescriptor.new(f, self, @context) + end + end + + # The oneofs defined on this message. + # + # @return [Array] + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L145 + # Google::Protobuf::DescriptorProto#oneof_decl + def oneofs + @oneofs ||= @descriptor.oneof_decl.each_with_index.map do |o, i| + OneofDescriptor.new(o, self, i) + end end # The enums defined as children of this message. @@ -49,7 +84,7 @@ def enums # Google::Protobuf::DescriptorProto#nested_type def messages @nested_messages ||= @descriptor.nested_type.map do |m| - MessageDescriptor.new(m, self) + MessageDescriptor.new(m, self, @context) end end diff --git a/lib/proto_plugin/method_descriptor.rb b/lib/proto_plugin/method_descriptor.rb index 199e85f..ddef1ea 100644 --- a/lib/proto_plugin/method_descriptor.rb +++ b/lib/proto_plugin/method_descriptor.rb @@ -10,6 +10,8 @@ module ProtoPlugin # # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L381 class MethodDescriptor < SimpleDelegator + include Commentable + # @return [Google::Protobuf::MethodDescriptorProto] attr_reader :descriptor @@ -28,6 +30,13 @@ def initialize(descriptor, service, context) @context = context end + # The file descriptor this method belongs to. + # + # @return [FileDescriptor] + def file + service.file + end + # Returns the `MessageDescriptor` of the method's input type. # # @return [MessageDescriptor] diff --git a/lib/proto_plugin/oneof_descriptor.rb b/lib/proto_plugin/oneof_descriptor.rb new file mode 100644 index 0000000..474b072 --- /dev/null +++ b/lib/proto_plugin/oneof_descriptor.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "delegate" + +module ProtoPlugin + # A wrapper class around `Google::Protobuf::OneofDescriptorProto` + # which provides helpers and more idiomatic Ruby access patterns. + # + # Any method not defined directly is delegated to the descriptor the wrapper was initialized with. + # + # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L349 + # Google::Protobuf::OneofDescriptorProto + class OneofDescriptor < SimpleDelegator + include Commentable + + # @return [Google::Protobuf::OneofDescriptorProto] + attr_reader :descriptor + + # The message descriptor this oneof was defined within. + # + # @return [MessageDescriptor] + attr_reader :message + + # The index of this oneof within its message's `oneof_decl` list. + # + # @return [Integer] + attr_reader :index + + # @param descriptor [Google::Protobuf::OneofDescriptorProto] + # @param message [MessageDescriptor] The message this oneof was defined within. + # @param index [Integer] The index of this oneof within the message. + def initialize(descriptor, message, index) + super(descriptor) + @descriptor = descriptor + @message = message + @index = index + end + + # The file descriptor this oneof belongs to. + # + # @return [FileDescriptor] + def file + message.file + end + + # The fields that are members of this oneof. + # + # @return [Array] + def fields + @fields ||= message.fields.select do |field| + field.oneof? && field.oneof_index == index + end + end + end +end diff --git a/lib/proto_plugin/service_descriptor.rb b/lib/proto_plugin/service_descriptor.rb index b4a53a6..c7f9dab 100644 --- a/lib/proto_plugin/service_descriptor.rb +++ b/lib/proto_plugin/service_descriptor.rb @@ -10,6 +10,8 @@ module ProtoPlugin # # @see https://github.com/protocolbuffers/protobuf/blob/v28.2/src/google/protobuf/descriptor.proto#L373 class ServiceDescriptor < SimpleDelegator + include Commentable + # @return [Google::Protobuf::ServiceDescriptorProto] attr_reader :descriptor @@ -28,6 +30,13 @@ def initialize(descriptor, parent, context) @context = context end + # The file descriptor this service belongs to. + # + # @return [FileDescriptor] + def file + parent.file + end + # The full name of the service, including parent namespace. # # @example diff --git a/test/fixtures/blog.cgr b/test/fixtures/blog.cgr index 32d5e69..a591532 100644 Binary files a/test/fixtures/blog.cgr and b/test/fixtures/blog.cgr differ diff --git a/test/fixtures/blog.fds b/test/fixtures/blog.fds index 844cefc..8fcdff2 100644 Binary files a/test/fixtures/blog.fds and b/test/fixtures/blog.fds differ diff --git a/test/fixtures/blog/article.proto b/test/fixtures/blog/article.proto index bb9521b..d936a63 100644 --- a/test/fixtures/blog/article.proto +++ b/test/fixtures/blog/article.proto @@ -6,10 +6,12 @@ import "google/protobuf/timestamp.proto"; import "comment.proto"; +// An article published on the blog. message Article { + // The unique identifier for the article. uint64 id = 1; - - string title = 2; + + string title = 2; // The article's headline. Author author = 3; @@ -19,7 +21,9 @@ message Article { repeated Comment comments = 6; + // The lifecycle status of an article. enum Status { + // The article is a work in progress. DRAFT = 0; PUBLISHED = 1; DELETED = 2; diff --git a/test/fixtures/blog/category.proto b/test/fixtures/blog/category.proto index 5e8e236..bebebfb 100644 --- a/test/fixtures/blog/category.proto +++ b/test/fixtures/blog/category.proto @@ -2,6 +2,7 @@ syntax = "proto3"; package proto_plugin.fixtures; +// A top-level content category. enum Category { CATEGORY_UNSPECIFIED = 0; CATEGORY_ANNOUNCEMENT = 1; diff --git a/test/fixtures/blog/comment.proto b/test/fixtures/blog/comment.proto index b98ff0e..7f2dd5b 100644 --- a/test/fixtures/blog/comment.proto +++ b/test/fixtures/blog/comment.proto @@ -23,3 +23,14 @@ message Comment { DELETED = 4; } } + +message CommentEvent { + uint64 comment_id = 1; + + // Describes what happened to the comment. + oneof payload { + string created = 2; + string edited = 3; + bool deleted = 4; + } +} diff --git a/test/fixtures/blog/service.proto b/test/fixtures/blog/service.proto index 82cbcce..11c29a0 100644 --- a/test/fixtures/blog/service.proto +++ b/test/fixtures/blog/service.proto @@ -4,7 +4,9 @@ package proto_plugin.fixtures; import "article.proto"; +// Provides access to articles. service ArticlesService { + // Fetches a single article by id. rpc GetArticle(GetArticleRequest) returns (GetArticleResponse); rpc GetArticles(GetArticlesRequest) returns (GetArticlesResponse); diff --git a/test/proto_plugin/commentable_test.rb b/test/proto_plugin/commentable_test.rb new file mode 100644 index 0000000..bfd13dc --- /dev/null +++ b/test/proto_plugin/commentable_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "test_helper" + +module ProtoPlugin + # Exercises comment resolution (via SourceCodeInfo) across every descriptor type. + class CommentableTest < Minitest::Test + def setup + @context = Context.new(request: load_request_fixture) + @article = @context.type_by_proto_name(".proto_plugin.fixtures.Article") + end + + def test_message_comment + assert_equal(" An article published on the blog.\n", @article.leading_comments) + end + + def test_field_leading_comment + id = @article.fields.find { |f| f.name == "id" } + assert_equal(" The unique identifier for the article.\n", id.leading_comments) + end + + def test_field_trailing_comment + title = @article.fields.find { |f| f.name == "title" } + assert_equal(" The article's headline.\n", title.trailing_comments) + end + + def test_nested_enum_comment + status = @article.enums.first + assert_equal(" The lifecycle status of an article.\n", status.leading_comments) + end + + def test_enum_value_comment + draft = @article.enums.first.values.first + assert_equal(" The article is a work in progress.\n", draft.leading_comments) + end + + def test_file_level_enum_comment + category = @context.type_by_proto_name(".proto_plugin.fixtures.Category") + assert_equal(" A top-level content category.\n", category.leading_comments) + end + + def test_service_comment + service = @context.file_by_filename("service.proto").services.first + assert_equal(" Provides access to articles.\n", service.leading_comments) + end + + def test_method_comment + method = @context.file_by_filename("service.proto").services.first.rpc_methods.first + assert_equal(" Fetches a single article by id.\n", method.leading_comments) + end + + def test_oneof_comment + event = @context.type_by_proto_name(".proto_plugin.fixtures.CommentEvent") + assert_equal(" Describes what happened to the comment.\n", event.oneofs.first.leading_comments) + end + + def test_comments_aggregates_leading_and_trailing + title = @article.fields.find { |f| f.name == "title" } + assert_equal([" The article's headline.\n"], title.comments) + + id = @article.fields.find { |f| f.name == "id" } + assert_equal([" The unique identifier for the article.\n"], id.comments) + end + + def test_comments_empty_when_absent + author = @article.fields.find { |f| f.name == "author" } + assert_empty(author.comments) + end + + def test_absent_comments_are_nil + author = @article.fields.find { |f| f.name == "author" } + + assert_nil(author.leading_comments) + assert_nil(author.trailing_comments) + end + + def test_file_resolves_to_owning_descriptor + status = @article.enums.first + + assert_instance_of(FileDescriptor, @article.file) + assert_equal("article.proto", @article.file.name) + assert_equal(@article.file, status.file) + assert_equal(@article.file, status.values.first.file) + end + end +end diff --git a/test/proto_plugin/enum_descriptor_test.rb b/test/proto_plugin/enum_descriptor_test.rb index e097158..7d799da 100644 --- a/test/proto_plugin/enum_descriptor_test.rb +++ b/test/proto_plugin/enum_descriptor_test.rb @@ -18,5 +18,16 @@ def test_full_name_of_message_enum enum = @article.enums.first assert_equal("ProtoPlugin::Fixtures::Article::Status", enum.full_name) end + + def test_values + assert_equal( + ["CATEGORY_UNSPECIFIED", "CATEGORY_ANNOUNCEMENT", "CATEGORY_PRODUCT_RELEASE"], + @category.values.map(&:name), + ) + + @category.values.each do |v| + assert_instance_of(EnumValueDescriptor, v) + end + end end end diff --git a/test/proto_plugin/enum_value_descriptor_test.rb b/test/proto_plugin/enum_value_descriptor_test.rb new file mode 100644 index 0000000..87f1c5b --- /dev/null +++ b/test/proto_plugin/enum_value_descriptor_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "test_helper" + +module ProtoPlugin + class EnumValueDescriptorTest < Minitest::Test + def setup + @context = Context.new(request: load_request_fixture) + @category = @context.type_by_proto_name(".proto_plugin.fixtures.Category") + @article = @context.type_by_proto_name(".proto_plugin.fixtures.Article") + end + + def test_name_and_number + value = @category.values.first + + assert_instance_of(EnumValueDescriptor, value) + assert_equal("CATEGORY_UNSPECIFIED", value.name) + assert_equal(0, value.number) + end + + def test_enum + value = @category.values.first + assert_equal(@category, value.enum) + end + + def test_full_name_of_file_enum_value + value = @category.values.last + assert_equal("ProtoPlugin::Fixtures::Category::CATEGORY_PRODUCT_RELEASE", value.full_name) + end + + def test_full_name_of_message_enum_value + status = @article.enums.first + value = status.values.first + assert_equal("ProtoPlugin::Fixtures::Article::Status::DRAFT", value.full_name) + end + end +end diff --git a/test/proto_plugin/field_descriptor_test.rb b/test/proto_plugin/field_descriptor_test.rb new file mode 100644 index 0000000..fa3326d --- /dev/null +++ b/test/proto_plugin/field_descriptor_test.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "test_helper" + +module ProtoPlugin + class FieldDescriptorTest < Minitest::Test + def setup + @context = Context.new(request: load_request_fixture) + @article = @context.type_by_proto_name(".proto_plugin.fixtures.Article") + @fields = @article.fields.each_with_object({}) do |field, hash| + hash[field.name] = field + end + end + + def test_message + assert_equal(@article, @fields["title"].message) + end + + def test_scalar_type_predicates + title = @fields["title"] + + assert(title.scalar?) + refute(title.message?) + refute(title.enum?) + refute(title.group?) + end + + def test_message_type_predicates + author = @fields["author"] + + assert(author.message?) + refute(author.scalar?) + refute(author.enum?) + end + + def test_type_descriptor_for_scalar + assert_nil(@fields["title"].type_descriptor) + end + + def test_type_descriptor_for_message + author = @fields["author"].type_descriptor + + assert_instance_of(MessageDescriptor, author) + assert_equal("ProtoPlugin::Fixtures::Article::Author", author.full_name) + end + + def test_type_descriptor_for_repeated_message + comment = @fields["comments"].type_descriptor + + assert_instance_of(MessageDescriptor, comment) + assert_equal("ProtoPlugin::Fixtures::Comment", comment.full_name) + end + + def test_type_descriptor_for_imported_message + timestamp = @fields["published_at"].type_descriptor + + assert_instance_of(MessageDescriptor, timestamp) + assert_equal("Timestamp", timestamp.name) + end + + def test_type_descriptor_returns_nil_for_unindexed_type + field = FieldDescriptor.new( + Google::Protobuf::FieldDescriptorProto.new( + name: "mystery", + type: :TYPE_MESSAGE, + type_name: ".does.not.Exist", + ), + @article, + @context, + ) + + assert(field.message?) + assert_nil(field.type_descriptor) + end + + def test_cardinality + assert(@fields["comments"].repeated?) + refute(@fields["comments"].optional?) + + assert(@fields["title"].optional?) + refute(@fields["title"].repeated?) + refute(@fields["title"].required?) + end + + def test_proto3_optional + refute(@fields["title"].proto3_optional?) + end + + def test_oneof_membership + event = @context.type_by_proto_name(".proto_plugin.fixtures.CommentEvent") + fields = event.fields.each_with_object({}) do |field, hash| + hash[field.name] = field + end + + created = fields["created"] + assert(created.oneof?) + assert_instance_of(OneofDescriptor, created.oneof) + assert_equal("payload", created.oneof.name) + + comment_id = fields["comment_id"] + refute(comment_id.oneof?) + assert_nil(comment_id.oneof) + end + + def test_delegates_to_descriptor + title = @fields["title"] + + assert_equal("title", title.name) + assert_equal(2, title.number) + end + end +end diff --git a/test/proto_plugin/message_descriptor_test.rb b/test/proto_plugin/message_descriptor_test.rb index 4c566bd..c4e6759 100644 --- a/test/proto_plugin/message_descriptor_test.rb +++ b/test/proto_plugin/message_descriptor_test.rb @@ -31,6 +31,31 @@ def test_messages assert_equal(@message, child_two.parent) end + def test_fields + assert_equal(6, @message.fields.count) + + @message.fields.each do |f| + assert_instance_of(FieldDescriptor, f) + assert_equal(@message, f.message) + end + + assert_equal( + ["id", "title", "author", "content", "published_at", "comments"], + @message.fields.map(&:name), + ) + end + + def test_oneofs + event = @context.type_by_proto_name(".proto_plugin.fixtures.CommentEvent") + + assert_equal(1, event.oneofs.count) + + oneof = event.oneofs.first + assert_instance_of(OneofDescriptor, oneof) + assert_equal("payload", oneof.name) + assert_equal(0, oneof.index) + end + def test_full_name child_one = @message.messages[0] child_two = @message.messages[1] diff --git a/test/proto_plugin/oneof_descriptor_test.rb b/test/proto_plugin/oneof_descriptor_test.rb new file mode 100644 index 0000000..d92c46c --- /dev/null +++ b/test/proto_plugin/oneof_descriptor_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +module ProtoPlugin + class OneofDescriptorTest < Minitest::Test + def setup + @context = Context.new(request: load_request_fixture) + @message = @context.type_by_proto_name(".proto_plugin.fixtures.CommentEvent") + @oneof = @message.oneofs.first + end + + def test_name_and_index + assert_instance_of(OneofDescriptor, @oneof) + assert_equal("payload", @oneof.name) + assert_equal(0, @oneof.index) + end + + def test_message + assert_equal(@message, @oneof.message) + end + + def test_fields + assert_equal( + ["created", "edited", "deleted"], + @oneof.fields.map(&:name), + ) + + @oneof.fields.each do |f| + assert_instance_of(FieldDescriptor, f) + end + end + + def test_fields_excludes_non_members + refute_includes(@oneof.fields.map(&:name), "comment_id") + end + end +end