From 71eee3bea12d3978ff7cceef531ef227e0d1929d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Sat, 13 Sep 2025 19:15:51 +0200 Subject: [PATCH 1/3] Add support for symbol and string literals in TypesExplainer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LITERALMATCH regex constant to match symbol (:symbol) and string ('string', "string") literals - Update TypesExplainer parser to recognize symbol and string literal types - Format literal values as "a literal value :symbol" for better readability - Add comprehensive test coverage for LITERALMATCH constant and literal type parsing - Fixes parsing inconsistency between YARD TypesExplainer and online parser Resolves #1627 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- lib/yard/code_objects/base.rb | 3 +++ lib/yard/tags/types_explainer.rb | 4 +++- spec/code_objects/constants_spec.rb | 20 ++++++++++++++++++++ spec/tags/types_explainer_spec.rb | 22 +++++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) 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..1aa9a50a9 100644 --- a/lib/yard/tags/types_explainer.rb +++ b/lib/yard/tags/types_explainer.rb @@ -33,6 +33,8 @@ def initialize(name) 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] == ":" || (name[0, 1] =~ /['"]/ && name[-1, 1] =~ /['"]/) + "a literal value #{name}" elsif name[0, 1] =~ /[A-Z]/ singular ? "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} " + name : "#{name}#{name[-1, 1] =~ /[A-Z]/ ? "'" : ''}s" else @@ -101,7 +103,7 @@ class Parser :collection_end => />/, :fixed_collection_start => /\(/, :fixed_collection_end => /\)/, - :type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|\w+/, + :type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|#{LITERALMATCH}|\w+/, :type_next => /[,;]/, :whitespace => /\s+/, :hash_collection_start => /\{/, 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..87d28005e 100644 --- a/spec/tags/types_explainer_spec.rb +++ b/spec/tags/types_explainer_spec.rb @@ -45,6 +45,14 @@ def parse_fail(types) expect(@t.to_s(false)).to eq name end end + + it "works for literal values" do + [':symbol', "'5'"].each do |name| + @t.name = name + expect(@t.to_s).to eq "a literal value #{name}" + expect(@t.to_s(false)).to eq "a literal value #{name}" + end + end end describe CollectionType, '#to_s' do @@ -131,6 +139,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 +211,8 @@ 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'" } expect.each do |input, expected| explain = YARD::Tags::TypesExplainer.explain(input) From 2d79fb8aef716a1b339495f9d16f41cd7b8e00a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Sun, 28 Sep 2025 16:27:18 +0200 Subject: [PATCH 2/3] Refactor: Extract Litral & Duck type classes This could be useful for solargraph gem --- lib/yard/tags/types_explainer.rb | 38 ++++++++++++++++++++++++------- spec/tags/types_explainer_spec.rb | 26 +++++++++++++-------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/lib/yard/tags/types_explainer.rb b/lib/yard/tags/types_explainer.rb index 1aa9a50a9..b6ce2f660 100644 --- a/lib/yard/tags/types_explainer.rb +++ b/lib/yard/tags/types_explainer.rb @@ -31,18 +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] == ":" || (name[0, 1] =~ /['"]/ && name[-1, 1] =~ /['"]/) - "a literal value #{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 @@ -56,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 @@ -137,7 +147,7 @@ def parse name = token when :type_next raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil? - type = Type.new(name) unless type + type = create_type(name) unless type types << type type = nil name = nil @@ -150,7 +160,7 @@ def parse 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 + type = create_type(name) unless type types << type return types end @@ -158,6 +168,18 @@ def parse raise SyntaxError, "invalid character at #{@scanner.peek(1)}" unless found 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 end diff --git a/spec/tags/types_explainer_spec.rb b/spec/tags/types_explainer_spec.rb index 87d28005e..4d76d3149 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 @@ -45,12 +41,22 @@ def parse_fail(types) expect(@t.to_s(false)).to eq name end 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| - @t.name = name - expect(@t.to_s).to eq "a literal value #{name}" - expect(@t.to_s(false)).to eq "a literal value #{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 @@ -93,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 From ddddd3440155c90f734121c348f9d7fcf528aba0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Sat, 27 Sep 2025 13:55:13 +0200 Subject: [PATCH 3/3] Add support for multiple & nested Hash keys definition --- lib/yard/tags/types_explainer.rb | 154 +++++++++++++++++++++++------- spec/tags/types_explainer_spec.rb | 5 +- 2 files changed, 124 insertions(+), 35 deletions(-) diff --git a/lib/yard/tags/types_explainer.rb b/lib/yard/tags/types_explainer.rb index b6ce2f660..64e3cedef 100644 --- a/lib/yard/tags/types_explainer.rb +++ b/lib/yard/tags/types_explainer.rb @@ -89,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 @@ -114,11 +152,13 @@ class Parser :fixed_collection_start => /\(/, :fixed_collection_end => /\)/, :type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|#{LITERALMATCH}|\w+/, - :type_next => /[,;]/, + :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 } @@ -130,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| @@ -141,32 +216,43 @@ 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 = create_type(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 = create_type(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 diff --git a/spec/tags/types_explainer_spec.rb b/spec/tags/types_explainer_spec.rb index 4d76d3149..cdab00b9b 100644 --- a/spec/tags/types_explainer_spec.rb +++ b/spec/tags/types_explainer_spec.rb @@ -218,7 +218,10 @@ def parse_fail(types) "#weird_method?, #<=>, #!=" => "an object that responds to #weird_method?; an object that responds to #<=>; an object that responds to #!=", - ":symbol, 'string'" => "a literal value :symbol; a literal value 'string'" + ":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)