diff --git a/lib/yard/code_objects/base.rb b/lib/yard/code_objects/base.rb index 561481317..9794f660c 100644 --- a/lib/yard/code_objects/base.rb +++ b/lib/yard/code_objects/base.rb @@ -63,6 +63,9 @@ def push(value) # Regular expression to match a fully qualified method def (self.foo, Class.foo). METHODMATCH = /(?:(?:#{NAMESPACEMATCH}|[a-z]\w*)\s*(?:#{CSEPQ}|#{NSEPQ})\s*)?#{METHODNAMEMATCH}/ + # Regular expression to match symbol and string literals + LITERALMATCH = /:\w+|'[^']*'|"[^"]*"/ + # All builtin Ruby exception classes for inheritance tree. BUILTIN_EXCEPTIONS = ["ArgumentError", "ClosedQueueError", "EncodingError", "EOFError", "Exception", "FiberError", "FloatDomainError", "IndexError", diff --git a/lib/yard/tags/types_explainer.rb b/lib/yard/tags/types_explainer.rb index d87657665..64e3cedef 100644 --- a/lib/yard/tags/types_explainer.rb +++ b/lib/yard/tags/types_explainer.rb @@ -31,16 +31,14 @@ def initialize(name) end def to_s(singular = true) - if name[0, 1] == "#" - singular ? "an object that responds to #{name}" : "objects that respond to #{name}" - elsif name[0, 1] =~ /[A-Z]/ + if name[0, 1] =~ /[A-Z]/ singular ? "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} " + name : "#{name}#{name[-1, 1] =~ /[A-Z]/ ? "'" : ''}s" else name end end - private + protected def list_join(list) index = 0 @@ -54,6 +52,20 @@ def list_join(list) end end + # @private + class LiteralType < Type + def to_s(_singular = true) + "a literal value #{name}" + end + end + + # @private + class DuckType < Type + def to_s(singular = true) + singular ? "an object that responds to #{name}" : "objects that respond to #{name}" + end + end + # @private class CollectionType < Type attr_accessor :types @@ -77,18 +89,56 @@ def to_s(_singular = true) # @private class HashCollectionType < Type - attr_accessor :key_types, :value_types + attr_accessor :key_value_pairs - def initialize(name, key_types, value_types) + def initialize(name, key_types_or_pairs, value_types = nil) @name = name - @key_types = key_types - @value_types = value_types + + if value_types.nil? + # New signature: (name, key_value_pairs) + @key_value_pairs = key_types_or_pairs || [] + else + # Old signature: (name, key_types, value_types) + @key_value_pairs = [[key_types_or_pairs, value_types]] + end + end + + # Backward compatibility accessors + def key_types + return [] if @key_value_pairs.empty? + @key_value_pairs.first[0] || [] + end + + def key_types=(types) + if @key_value_pairs.empty? + @key_value_pairs = [[types, []]] + else + @key_value_pairs[0][0] = types + end + end + + def value_types + return [] if @key_value_pairs.empty? + @key_value_pairs.first[1] || [] + end + + def value_types=(types) + if @key_value_pairs.empty? + @key_value_pairs = [[[], types]] + else + @key_value_pairs[0][1] = types + end end def to_s(_singular = true) - "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name} with keys made of (" + - list_join(key_types.map {|t| t.to_s(false) }) + - ") and values of (" + list_join(value_types.map {|t| t.to_s(false) }) + ")" + return "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name}" if @key_value_pairs.empty? + + result = "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name} with " + parts = @key_value_pairs.map do |keys, values| + "keys made of (" + list_join(keys.map {|t| t.to_s(false) }) + + ") and values of (" + list_join(values.map {|t| t.to_s(false) }) + ")" + end + result + parts.join(" and ") end end @@ -101,12 +151,14 @@ class Parser :collection_end => />/, :fixed_collection_start => /\(/, :fixed_collection_end => /\)/, - :type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|\w+/, - :type_next => /[,;]/, + :type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|#{LITERALMATCH}|\w+/, + :type_next => /[,]/, :whitespace => /\s+/, :hash_collection_start => /\{/, - :hash_collection_next => /=>/, + :hash_collection_value => /=>/, + :hash_collection_value_end => /;/, :hash_collection_end => /\}/, + # :symbol_start => /:/, :parse_end => nil } @@ -118,10 +170,45 @@ def initialize(string) @scanner = StringScanner.new(string) end - def parse - types = [] + # @return [Array(Boolean, Array)] - finished, types + def parse(until_tokens: [:parse_end]) + current_parsed_types = [] type = nil name = nil + finished = false + parse_with_handlers do |token_type, token| + case token_type + when *until_tokens + raise SyntaxError, "expecting name, got '#{token}'" if name.nil? + type = create_type(name) unless type + current_parsed_types << type + finished = true + when :type_name + raise SyntaxError, "expecting END, got name '#{token}'" if name + name = token + when :type_next + raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil? + type = create_type(name) unless type + current_parsed_types << type + name = nil + type = nil + when :fixed_collection_start, :collection_start + name ||= "Array" + klass = token_type == :collection_start ? CollectionType : FixedCollectionType + type = klass.new(name, parse(until_tokens: [:fixed_collection_end, :collection_end, :parse_end])) + when :hash_collection_start + name ||= "Hash" + type = parse_hash_collection(name) + end + + [finished, current_parsed_types] + end + end + + private + + # @return [Array] + def parse_with_handlers loop do found = false TOKENS.each do |token_type, match| @@ -129,32 +216,55 @@ def parse # rubocop:disable Lint/AssignmentInCondition next unless (match.nil? && @scanner.eos?) || (match && token = @scanner.scan(match)) found = true - case token_type - when :type_name - raise SyntaxError, "expecting END, got name '#{token}'" if name - name = token - when :type_next - raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil? - type = Type.new(name) unless type - types << type - type = nil - name = nil - when :fixed_collection_start, :collection_start - name ||= "Array" - klass = token_type == :collection_start ? CollectionType : FixedCollectionType - type = klass.new(name, parse) - when :hash_collection_start - name ||= "Hash" - type = HashCollectionType.new(name, parse, parse) - when :hash_collection_next, :hash_collection_end, :fixed_collection_end, :collection_end, :parse_end - raise SyntaxError, "expecting name, got '#{token}'" if name.nil? - type = Type.new(name) unless type - types << type - return types - end + # @type [Array] + finished, types = yield(token_type, token) + return types if finished + break end raise SyntaxError, "invalid character at #{@scanner.peek(1)}" unless found end + nil + end + + def parse_hash_collection(name) + key_value_pairs = [] + current_keys = [] + finished = false + + parse_with_handlers do |token_type, token| + case token_type + when :type_name + current_keys << create_type(token) + when :type_next + # Comma - continue collecting keys unless we just processed a value + # In that case, start a new key group + when :hash_collection_value + # => - current keys map to the next value(s) + raise SyntaxError, "no keys before =>" if current_keys.empty? + values = parse(until_tokens: [:hash_collection_value_end, :parse_end]) + key_value_pairs << [current_keys, values] + current_keys = [] + when :hash_collection_end, :parse_end + # End of hash + finished = true + when :whitespace + # Ignore whitespace + end + + [finished, HashCollectionType.new(name, key_value_pairs)] + end + end + + private + + def create_type(name) + if name[0, 1] == ":" || (name[0, 1] =~ /['"]/ && name[-1, 1] =~ /['"]/) + LiteralType.new(name) + elsif name[0, 1] == "#" + DuckType.new(name) + else + Type.new(name) + end end end end diff --git a/spec/code_objects/constants_spec.rb b/spec/code_objects/constants_spec.rb index 3c6b9180d..e9ce5f316 100644 --- a/spec/code_objects/constants_spec.rb +++ b/spec/code_objects/constants_spec.rb @@ -56,6 +56,26 @@ def silence_warnings end end + describe :LITERALMATCH do + it "matches symbol literals" do + expect(":symbol"[CodeObjects::LITERALMATCH]).to eq ":symbol" + expect(":some_symbol"[CodeObjects::LITERALMATCH]).to eq ":some_symbol" + expect("not_a_symbol"[CodeObjects::LITERALMATCH]).to be nil + end + + it "matches single-quoted string literals" do + expect("'string'"[CodeObjects::LITERALMATCH]).to eq "'string'" + expect("'some string with spaces'"[CodeObjects::LITERALMATCH]).to eq "'some string with spaces'" + expect("not_quoted"[CodeObjects::LITERALMATCH]).to be nil + end + + it "matches double-quoted string literals" do + expect('"string"'[CodeObjects::LITERALMATCH]).to eq '"string"' + expect('"some string with spaces"'[CodeObjects::LITERALMATCH]).to eq '"some string with spaces"' + expect("not_quoted"[CodeObjects::LITERALMATCH]).to be nil + end + end + describe :BUILTIN_EXCEPTIONS do it "includes all base exceptions" do bad_names = [] diff --git a/spec/tags/types_explainer_spec.rb b/spec/tags/types_explainer_spec.rb index 8075790de..cdab00b9b 100644 --- a/spec/tags/types_explainer_spec.rb +++ b/spec/tags/types_explainer_spec.rb @@ -2,6 +2,8 @@ RSpec.describe YARD::Tags::TypesExplainer do Type = YARD::Tags::TypesExplainer::Type + LiteralType = YARD::Tags::TypesExplainer::LiteralType + DuckType = YARD::Tags::TypesExplainer::DuckType CollectionType = YARD::Tags::TypesExplainer::CollectionType FixedCollectionType = YARD::Tags::TypesExplainer::FixedCollectionType HashCollectionType = YARD::Tags::TypesExplainer::HashCollectionType @@ -32,12 +34,6 @@ def parse_fail(types) expect(@t.to_s(false)).to eq "Arrays" end - it "works for a method (ducktype)" do - @t.name = "#mymethod" - expect(@t.to_s).to eq "an object that responds to #mymethod" - expect(@t.to_s(false)).to eq "objects that respond to #mymethod" - end - it "works for a constant value" do ['false', 'true', 'nil', '4'].each do |name| @t.name = name @@ -47,6 +43,24 @@ def parse_fail(types) end end + describe DuckType, '#to_s' do + it "works for a method (ducktype)" do + duck_type = DuckType.new("#mymethod") + expect(duck_type.to_s).to eq "an object that responds to #mymethod" + expect(duck_type.to_s(false)).to eq "objects that respond to #mymethod" + end + end + + describe LiteralType, '#to_s' do + it "works for literal values" do + [':symbol', "'5'"].each do |name| + literal_type = LiteralType.new(name) + expect(literal_type.to_s).to eq "a literal value #{name}" + expect(literal_type.to_s(false)).to eq "a literal value #{name}" + end + end + end + describe CollectionType, '#to_s' do before { @t = CollectionType.new("Array", nil) } @@ -85,7 +99,7 @@ def parse_fail(types) end end - describe FixedCollectionType, '#to_s' do + describe HashCollectionType, '#to_s' do before { @t = HashCollectionType.new("Hash", nil, nil) } it "can contain a single key type and value type" do @@ -131,6 +145,17 @@ def parse_fail(types) expect(type[3].name).to eq "E" end + it 'parses a list of literal values' do + type = parse("true, false, nil, 4, :symbol, '5'") + expect(type.size).to eq 6 + expect(type[0].name).to eq "true" + expect(type[1].name).to eq "false" + expect(type[2].name).to eq "nil" + expect(type[3].name).to eq "4" + expect(type[4].name).to eq ":symbol" + expect(type[5].name).to eq "'5'" + end + it "parses a collection type" do type = parse("MyList") expect(type.first).to be_a(CollectionType) @@ -192,7 +217,11 @@ def parse_fail(types) a Hash with keys made of (Foos or Bars) and values of (Symbols or Numbers)", "#weird_method?, #<=>, #!=" => "an object that responds to #weird_method?; an object that responds to #<=>; - an object that responds to #!=" + an object that responds to #!=", + ":symbol, 'string'" => "a literal value :symbol; a literal value 'string'", + "Hash{:key_one, :key_two => String; :key_three => Symbol}" => "a Hash with keys made of (a literal value :key_one or a literal value :key_two) and values of (Strings) and keys made of (a literal value :key_three) and values of (Symbols)", + "Hash{:key_one, :key_two => String; :key_three => Symbol; :key_four => Hash{:sub_key_one => String}}" => "a Hash with keys made of (a literal value :key_one or a literal value :key_two) and values of (Strings) and keys made of (a literal value :key_three) and values of (Symbols) and keys made of (a literal value :key_four) and values of (a Hash with keys made of (a literal value :sub_key_one) and values of (Strings))", + "Hash{:key_one => String, Number; :key_two => String}" => "a Hash with keys made of (a literal value :key_one) and values of (Strings or Numbers) and keys made of (a literal value :key_two) and values of (Strings)" } expect.each do |input, expected| explain = YARD::Tags::TypesExplainer.explain(input)