Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions lib/yard/code_objects/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
188 changes: 149 additions & 39 deletions lib/yard/tags/types_explainer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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
}

Expand All @@ -118,43 +170,101 @@ def initialize(string)
@scanner = StringScanner.new(string)
end

def parse
types = []
# @return [Array(Boolean, Array<Type>)] - 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<Type>]
def parse_with_handlers
loop do
found = false
TOKENS.each do |token_type, match|
# TODO: cleanup this code.
# 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<Type>]
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
Expand Down
20 changes: 20 additions & 0 deletions spec/code_objects/constants_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down
45 changes: 37 additions & 8 deletions spec/tags/types_explainer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) }

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<String>")
expect(type.first).to be_a(CollectionType)
Expand Down Expand Up @@ -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)
Expand Down
Loading