Skip to content

Commit b0f014c

Browse files
committed
Add support for multiple & nested Hash keys definition
1 parent 2d79fb8 commit b0f014c

File tree

2 files changed

+125
-35
lines changed

2 files changed

+125
-35
lines changed

lib/yard/tags/types_explainer.rb

Lines changed: 121 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -89,18 +89,56 @@ def to_s(_singular = true)
8989

9090
# @private
9191
class HashCollectionType < Type
92-
attr_accessor :key_types, :value_types
92+
attr_accessor :key_value_pairs
9393

94-
def initialize(name, key_types, value_types)
94+
def initialize(name, key_types_or_pairs, value_types = nil)
9595
@name = name
96-
@key_types = key_types
97-
@value_types = value_types
96+
97+
if value_types.nil?
98+
# New signature: (name, key_value_pairs)
99+
@key_value_pairs = key_types_or_pairs || []
100+
else
101+
# Old signature: (name, key_types, value_types)
102+
@key_value_pairs = [[key_types_or_pairs, value_types]]
103+
end
104+
end
105+
106+
# Backward compatibility accessors
107+
def key_types
108+
return [] if @key_value_pairs.empty?
109+
@key_value_pairs.first[0] || []
110+
end
111+
112+
def key_types=(types)
113+
if @key_value_pairs.empty?
114+
@key_value_pairs = [[types, []]]
115+
else
116+
@key_value_pairs[0][0] = types
117+
end
118+
end
119+
120+
def value_types
121+
return [] if @key_value_pairs.empty?
122+
@key_value_pairs.first[1] || []
123+
end
124+
125+
def value_types=(types)
126+
if @key_value_pairs.empty?
127+
@key_value_pairs = [[[], types]]
128+
else
129+
@key_value_pairs[0][1] = types
130+
end
98131
end
99132

100133
def to_s(_singular = true)
101-
"a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name} with keys made of (" +
102-
list_join(key_types.map {|t| t.to_s(false) }) +
103-
") and values of (" + list_join(value_types.map {|t| t.to_s(false) }) + ")"
134+
return "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name}" if @key_value_pairs.empty?
135+
136+
result = "a#{name[0, 1] =~ /[aeiou]/i ? 'n' : ''} #{name} with "
137+
parts = @key_value_pairs.map do |keys, values|
138+
"keys made of (" + list_join(keys.map {|t| t.to_s(false) }) +
139+
") and values of (" + list_join(values.map {|t| t.to_s(false) }) + ")"
140+
end
141+
result + parts.join(" and ")
104142
end
105143
end
106144

@@ -114,11 +152,13 @@ class Parser
114152
:fixed_collection_start => /\(/,
115153
:fixed_collection_end => /\)/,
116154
:type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|#{LITERALMATCH}|\w+/,
117-
:type_next => /[,;]/,
155+
:type_next => /[,]/,
118156
:whitespace => /\s+/,
119157
:hash_collection_start => /\{/,
120-
:hash_collection_next => /=>/,
158+
:hash_collection_value => /=>/,
159+
:hash_collection_value_end => /;/,
121160
:hash_collection_end => /\}/,
161+
# :symbol_start => /:/,
122162
:parse_end => nil
123163
}
124164

@@ -130,43 +170,90 @@ def initialize(string)
130170
@scanner = StringScanner.new(string)
131171
end
132172

133-
def parse
134-
types = []
173+
# @return [Array(Boolean, Array<Type>)] - finished, types
174+
def parse(until_tokens: [:parse_end])
175+
current_parsed_types = []
135176
type = nil
136177
name = nil
178+
finished = false
179+
parse_with_handlers do |token_type, token|
180+
case token_type
181+
when *until_tokens
182+
raise SyntaxError, "expecting name, got '#{token}'" if name.nil?
183+
type = create_type(name) unless type
184+
current_parsed_types << type
185+
finished = true
186+
when :type_name
187+
raise SyntaxError, "expecting END, got name '#{token}'" if name
188+
name = token
189+
when :type_next
190+
raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil?
191+
type = create_type(name) unless type
192+
current_parsed_types << type
193+
name = nil
194+
type = nil
195+
when :fixed_collection_start, :collection_start
196+
name ||= "Array"
197+
klass = token_type == :collection_start ? CollectionType : FixedCollectionType
198+
type = klass.new(name, parse(until_tokens: [:fixed_collection_end, :collection_end, :parse_end]))
199+
when :hash_collection_start
200+
name ||= "Hash"
201+
type = parse_hash_collection(name)
202+
end
203+
204+
[finished, current_parsed_types]
205+
end
206+
end
207+
208+
private
209+
210+
# @return [Array<Type>]
211+
# @yield_return [Array<Ty[]]
212+
def parse_with_handlers
137213
loop do
138214
found = false
139215
TOKENS.each do |token_type, match|
140216
# TODO: cleanup this code.
141217
# rubocop:disable Lint/AssignmentInCondition
142218
next unless (match.nil? && @scanner.eos?) || (match && token = @scanner.scan(match))
143219
found = true
144-
case token_type
145-
when :type_name
146-
raise SyntaxError, "expecting END, got name '#{token}'" if name
147-
name = token
148-
when :type_next
149-
raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil?
150-
type = create_type(name) unless type
151-
types << type
152-
type = nil
153-
name = nil
154-
when :fixed_collection_start, :collection_start
155-
name ||= "Array"
156-
klass = token_type == :collection_start ? CollectionType : FixedCollectionType
157-
type = klass.new(name, parse)
158-
when :hash_collection_start
159-
name ||= "Hash"
160-
type = HashCollectionType.new(name, parse, parse)
161-
when :hash_collection_next, :hash_collection_end, :fixed_collection_end, :collection_end, :parse_end
162-
raise SyntaxError, "expecting name, got '#{token}'" if name.nil?
163-
type = create_type(name) unless type
164-
types << type
165-
return types
166-
end
220+
# @type [Array<Type>]
221+
finished, types = yield(token_type, token)
222+
return types if finished
223+
break
167224
end
168225
raise SyntaxError, "invalid character at #{@scanner.peek(1)}" unless found
169226
end
227+
nil
228+
end
229+
230+
def parse_hash_collection(name)
231+
key_value_pairs = []
232+
current_keys = []
233+
finished = false
234+
235+
parse_with_handlers do |token_type, token|
236+
case token_type
237+
when :type_name
238+
current_keys << create_type(token)
239+
when :type_next
240+
# Comma - continue collecting keys unless we just processed a value
241+
# In that case, start a new key group
242+
when :hash_collection_value
243+
# => - current keys map to the next value(s)
244+
raise SyntaxError, "no keys before =>" if current_keys.empty?
245+
values = parse(until_tokens: [:hash_collection_value_end, :parse_end])
246+
key_value_pairs << [current_keys, values]
247+
current_keys = []
248+
when :hash_collection_end, :parse_end
249+
# End of hash
250+
finished = true
251+
when :whitespace
252+
# Ignore whitespace
253+
end
254+
255+
[finished, HashCollectionType.new(name, key_value_pairs)]
256+
end
170257
end
171258

172259
private

spec/tags/types_explainer_spec.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,10 @@ def parse_fail(types)
218218
"#weird_method?, #<=>, #!=" => "an object that responds to #weird_method?;
219219
an object that responds to #<=>;
220220
an object that responds to #!=",
221-
":symbol, 'string'" => "a literal value :symbol; a literal value 'string'"
221+
":symbol, 'string'" => "a literal value :symbol; a literal value 'string'",
222+
"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)",
223+
"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))",
224+
"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)"
222225
}
223226
expect.each do |input, expected|
224227
explain = YARD::Tags::TypesExplainer.explain(input)

0 commit comments

Comments
 (0)