rubycli 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -0
- data/README.ja.md +82 -14
- data/README.md +82 -14
- data/lib/rubycli/argument_mode_controller.rb +69 -0
- data/lib/rubycli/argument_parser.rb +287 -102
- data/lib/rubycli/arguments/token_stream.rb +41 -0
- data/lib/rubycli/arguments/value_converter.rb +85 -0
- data/lib/rubycli/cli.rb +14 -7
- data/lib/rubycli/command_line.rb +58 -6
- data/lib/rubycli/constant_capture.rb +50 -0
- data/lib/rubycli/documentation/comment_extractor.rb +52 -0
- data/lib/rubycli/documentation/metadata_parser.rb +973 -0
- data/lib/rubycli/documentation_registry.rb +11 -853
- data/lib/rubycli/environment.rb +50 -8
- data/lib/rubycli/eval_coercer.rb +16 -1
- data/lib/rubycli/help_renderer.rb +64 -9
- data/lib/rubycli/types.rb +2 -2
- data/lib/rubycli/version.rb +1 -1
- data/lib/rubycli.rb +265 -121
- metadata +8 -2
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../types'
|
|
4
|
+
require_relative '../type_utils'
|
|
5
|
+
|
|
6
|
+
module Rubycli
|
|
7
|
+
module Documentation
|
|
8
|
+
class MetadataParser
|
|
9
|
+
include TypeUtils
|
|
10
|
+
|
|
11
|
+
def initialize(environment:)
|
|
12
|
+
@environment = environment
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def empty_metadata
|
|
16
|
+
{
|
|
17
|
+
options: [],
|
|
18
|
+
returns: [],
|
|
19
|
+
summary: nil,
|
|
20
|
+
summary_lines: [],
|
|
21
|
+
detail_lines: [],
|
|
22
|
+
positionals: [],
|
|
23
|
+
positionals_map: {}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parse(comment_lines, method_obj)
|
|
28
|
+
metadata = empty_metadata
|
|
29
|
+
return metadata if comment_lines.empty?
|
|
30
|
+
|
|
31
|
+
summary_compact_lines = []
|
|
32
|
+
summary_display_lines = []
|
|
33
|
+
detail_lines = []
|
|
34
|
+
summary_phase = true
|
|
35
|
+
|
|
36
|
+
comment_lines.each do |content|
|
|
37
|
+
stripped = content.strip
|
|
38
|
+
if summary_phase && stripped.empty?
|
|
39
|
+
summary_display_lines << ""
|
|
40
|
+
next
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if (option = parse_tagged_param_line(stripped, method_obj))
|
|
44
|
+
if option.is_a?(OptionDefinition)
|
|
45
|
+
if method_accepts_keyword?(method_obj, option.keyword)
|
|
46
|
+
metadata[:options].reject! { |existing| existing.keyword == option.keyword }
|
|
47
|
+
metadata[:options] << option
|
|
48
|
+
else
|
|
49
|
+
metadata[:positionals] << option_to_positional_definition(option)
|
|
50
|
+
end
|
|
51
|
+
elsif option.is_a?(PositionalDefinition)
|
|
52
|
+
metadata[:positionals] << option
|
|
53
|
+
end
|
|
54
|
+
summary_phase = false
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if (return_meta = parse_return_metadata(stripped))
|
|
59
|
+
metadata[:returns] << return_meta
|
|
60
|
+
summary_phase = false
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if (option = parse_tagless_option_line(stripped, method_obj))
|
|
65
|
+
metadata[:options].reject! { |existing| existing.keyword == option.keyword }
|
|
66
|
+
metadata[:options] << option
|
|
67
|
+
summary_phase = false
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if (positional = parse_positional_line(stripped))
|
|
72
|
+
metadata[:positionals] << positional
|
|
73
|
+
summary_phase = false
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
if summary_phase
|
|
77
|
+
summary_display_lines << content.rstrip
|
|
78
|
+
summary_compact_lines << stripped unless stripped.empty?
|
|
79
|
+
else
|
|
80
|
+
detail_lines << content.rstrip
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
summary_text = summary_compact_lines.join(' ')
|
|
85
|
+
summary_text = nil if summary_text.empty?
|
|
86
|
+
metadata[:summary] = summary_text
|
|
87
|
+
metadata[:summary_lines] = trim_blank_edges(summary_display_lines)
|
|
88
|
+
metadata[:detail_lines] = trim_blank_edges(detail_lines)
|
|
89
|
+
|
|
90
|
+
defaults = extract_parameter_defaults(method_obj)
|
|
91
|
+
align_and_validate_parameter_docs(method_obj, metadata, defaults)
|
|
92
|
+
|
|
93
|
+
metadata
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def trim_blank_edges(lines)
|
|
97
|
+
return [] if lines.nil? || lines.empty?
|
|
98
|
+
|
|
99
|
+
first = lines.index { |line| line && !line.strip.empty? }
|
|
100
|
+
return [] unless first
|
|
101
|
+
|
|
102
|
+
last = lines.rindex { |line| line && !line.strip.empty? }
|
|
103
|
+
return [] unless last
|
|
104
|
+
|
|
105
|
+
lines[first..last]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_tagged_param_line(line, method_obj)
|
|
109
|
+
return nil unless line.start_with?('@param')
|
|
110
|
+
|
|
111
|
+
source_file = nil
|
|
112
|
+
source_line = nil
|
|
113
|
+
if method_obj.respond_to?(:source_location)
|
|
114
|
+
source_file, source_line = method_obj.source_location
|
|
115
|
+
end
|
|
116
|
+
line_number = source_line ? [source_line - 1, 1].max : nil
|
|
117
|
+
|
|
118
|
+
unless @environment.allow_param_comments?
|
|
119
|
+
source_file, source_line = method_obj.source_location
|
|
120
|
+
@environment.handle_documentation_issue(
|
|
121
|
+
'@param notation is disabled. Enable it via ENV RUBYCLI_ALLOW_PARAM_COMMENT=ON.',
|
|
122
|
+
file: source_file,
|
|
123
|
+
line: line_number
|
|
124
|
+
)
|
|
125
|
+
return nil if @environment.doc_check_mode?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
pattern = /\A@param\s+([a-zA-Z0-9_]+)(?:\s+\[([^\]]+)\])?(?:\s+\(([^)]+)\))?(?:\s+(.*))?\z/
|
|
129
|
+
match = pattern.match(line)
|
|
130
|
+
return nil unless match
|
|
131
|
+
|
|
132
|
+
param_name = match[1]
|
|
133
|
+
param_symbol = param_name.to_sym
|
|
134
|
+
type_str = match[2]
|
|
135
|
+
option_tokens = combine_bracketed_tokens(match[3]&.split(/\s+/) || [])
|
|
136
|
+
description = match[4]&.strip
|
|
137
|
+
description = nil if description&.empty?
|
|
138
|
+
|
|
139
|
+
raw_types = parse_type_annotation(type_str)
|
|
140
|
+
types, allowed_values = partition_type_tokens(raw_types)
|
|
141
|
+
|
|
142
|
+
long_option = nil
|
|
143
|
+
short_option = nil
|
|
144
|
+
value_name = nil
|
|
145
|
+
type_token = nil
|
|
146
|
+
|
|
147
|
+
unless option_tokens.empty?
|
|
148
|
+
normalized = option_tokens.flat_map { |token| token.split('/') }
|
|
149
|
+
normalized.each do |token|
|
|
150
|
+
token_without_at = token.start_with?('@') ? token[1..] : token
|
|
151
|
+
if token.start_with?('--')
|
|
152
|
+
if (eq_index = token.index('='))
|
|
153
|
+
long_option = token[0...eq_index]
|
|
154
|
+
inline_value = token[(eq_index + 1)..]
|
|
155
|
+
if value_name.nil? && inline_value && !inline_value.strip.empty?
|
|
156
|
+
value_name = inline_value.strip
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
else
|
|
160
|
+
long_option = token
|
|
161
|
+
end
|
|
162
|
+
elsif token.start_with?('-')
|
|
163
|
+
if (eq_index = token.index('='))
|
|
164
|
+
short_option = token[0...eq_index]
|
|
165
|
+
inline_value = token[(eq_index + 1)..]
|
|
166
|
+
if value_name.nil? && inline_value && !inline_value.strip.empty?
|
|
167
|
+
value_name = inline_value.strip
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
short_option = token
|
|
172
|
+
end
|
|
173
|
+
elsif value_name.nil? && placeholder_token?(token_without_at)
|
|
174
|
+
value_name = token_without_at
|
|
175
|
+
elsif type_token.nil? && type_token_candidate?(token)
|
|
176
|
+
type_token = token
|
|
177
|
+
elsif value_name.nil?
|
|
178
|
+
value_name = token_without_at
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
long_option ||= "--#{param_name.tr('_', '-')}"
|
|
184
|
+
role = parameter_role(method_obj, param_symbol)
|
|
185
|
+
if value_name.nil?
|
|
186
|
+
if role == :positional
|
|
187
|
+
value_name = default_placeholder_for(param_symbol)
|
|
188
|
+
elsif !types&.any? { |entry| boolean_type?(entry) }
|
|
189
|
+
# Most keywords expect a value; boolean flags should be documented with [Boolean].
|
|
190
|
+
value_name = default_placeholder_for(param_symbol)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if (types.nil? || types.empty?) && type_token
|
|
195
|
+
inline_raw_types = parse_type_annotation(type_token)
|
|
196
|
+
inline_types, inline_allowed = partition_type_tokens(inline_raw_types)
|
|
197
|
+
types = inline_types
|
|
198
|
+
allowed_values = merge_allowed_values(allowed_values, inline_allowed)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# TODO: Derive primitive types from Ruby default values when explicit hints are absent.
|
|
202
|
+
option_def = build_option_definition(
|
|
203
|
+
param_symbol,
|
|
204
|
+
long_option,
|
|
205
|
+
short_option,
|
|
206
|
+
value_name,
|
|
207
|
+
types,
|
|
208
|
+
description,
|
|
209
|
+
inline_type_annotation: !type_token.nil?,
|
|
210
|
+
doc_format: :tagged_param,
|
|
211
|
+
allowed_values: allowed_values
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if role == :positional
|
|
215
|
+
placeholder = option_def.value_name || default_placeholder_for(option_def.keyword)
|
|
216
|
+
return PositionalDefinition.new(
|
|
217
|
+
placeholder: placeholder,
|
|
218
|
+
label: placeholder,
|
|
219
|
+
types: option_def.types,
|
|
220
|
+
description: option_def.description,
|
|
221
|
+
param_name: param_symbol,
|
|
222
|
+
doc_format: option_def.doc_format,
|
|
223
|
+
allowed_values: option_def.allowed_values
|
|
224
|
+
)
|
|
225
|
+
elsif role == :keyword
|
|
226
|
+
return option_def
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
unless method_accepts_keyword?(method_obj, param_symbol)
|
|
230
|
+
placeholder = option_def.value_name || default_placeholder_for(option_def.keyword)
|
|
231
|
+
return PositionalDefinition.new(
|
|
232
|
+
placeholder: placeholder,
|
|
233
|
+
label: placeholder,
|
|
234
|
+
types: option_def.types,
|
|
235
|
+
description: option_def.description,
|
|
236
|
+
param_name: param_symbol,
|
|
237
|
+
doc_format: option_def.doc_format,
|
|
238
|
+
allowed_values: option_def.allowed_values
|
|
239
|
+
)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
option_def
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def parse_tagless_option_line(line, method_obj)
|
|
246
|
+
return nil unless line.start_with?('--') || line.start_with?('-')
|
|
247
|
+
|
|
248
|
+
raw_tokens = combine_bracketed_tokens(line.split(/\s+/))
|
|
249
|
+
tokens = raw_tokens.flat_map { |token|
|
|
250
|
+
if token.include?('/') && !token.start_with?('[')
|
|
251
|
+
token.split('/')
|
|
252
|
+
else
|
|
253
|
+
[token]
|
|
254
|
+
end
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
long_option = nil
|
|
258
|
+
short_option = nil
|
|
259
|
+
inline_value_from_long = nil
|
|
260
|
+
inline_value_from_short = nil
|
|
261
|
+
remaining = []
|
|
262
|
+
|
|
263
|
+
tokens.each do |token|
|
|
264
|
+
if long_option.nil? && token.start_with?('--')
|
|
265
|
+
if (eq_index = token.index('='))
|
|
266
|
+
long_option = token[0...eq_index]
|
|
267
|
+
inline_value_from_long = token[(eq_index + 1)..]
|
|
268
|
+
else
|
|
269
|
+
long_option = token
|
|
270
|
+
end
|
|
271
|
+
next
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
if short_option.nil? && token.start_with?('-') && !token.start_with?('--')
|
|
275
|
+
if (eq_index = token.index('='))
|
|
276
|
+
short_option = token[0...eq_index]
|
|
277
|
+
inline_value_from_short = token[(eq_index + 1)..]
|
|
278
|
+
else
|
|
279
|
+
short_option = token
|
|
280
|
+
end
|
|
281
|
+
next
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
remaining << token
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return nil unless long_option
|
|
288
|
+
|
|
289
|
+
type_token = nil
|
|
290
|
+
value_name = [inline_value_from_long, inline_value_from_short].compact.map(&:strip).find { |val| !val.empty? }
|
|
291
|
+
description_tokens = []
|
|
292
|
+
|
|
293
|
+
remaining.each do |token|
|
|
294
|
+
token_without_at = token.start_with?('@') ? token[1..] : token
|
|
295
|
+
|
|
296
|
+
if value_name.nil? && placeholder_token?(token_without_at)
|
|
297
|
+
value_name = token_without_at
|
|
298
|
+
next
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
if type_token.nil? && type_token_candidate?(token)
|
|
302
|
+
type_token = token
|
|
303
|
+
next
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
description_tokens << token
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
description = description_tokens.join(' ').strip
|
|
310
|
+
description = nil if description.empty?
|
|
311
|
+
raw_types = parse_type_annotation(type_token)
|
|
312
|
+
types, allowed_values = partition_type_tokens(raw_types)
|
|
313
|
+
|
|
314
|
+
keyword = long_option.delete_prefix('--').tr('-', '_').to_sym
|
|
315
|
+
return nil unless method_accepts_keyword?(method_obj, keyword)
|
|
316
|
+
|
|
317
|
+
build_option_definition(
|
|
318
|
+
keyword,
|
|
319
|
+
long_option,
|
|
320
|
+
short_option,
|
|
321
|
+
value_name,
|
|
322
|
+
types,
|
|
323
|
+
description,
|
|
324
|
+
inline_type_annotation: !type_token.nil?,
|
|
325
|
+
doc_format: :rubycli,
|
|
326
|
+
allowed_values: allowed_values
|
|
327
|
+
)
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def parse_positional_line(line)
|
|
331
|
+
return nil if line.start_with?('--') || line.start_with?('-')
|
|
332
|
+
|
|
333
|
+
tokens = combine_bracketed_tokens(line.split(/\s+/))
|
|
334
|
+
placeholder = tokens.shift
|
|
335
|
+
return nil unless placeholder
|
|
336
|
+
|
|
337
|
+
clean_placeholder = placeholder.delete('[]')
|
|
338
|
+
return nil unless placeholder_token?(clean_placeholder)
|
|
339
|
+
|
|
340
|
+
type_token = nil
|
|
341
|
+
if tokens.first && type_token_candidate?(tokens.first)
|
|
342
|
+
type_token = tokens.shift
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
description = tokens.join(' ').strip
|
|
346
|
+
description = nil if description.empty?
|
|
347
|
+
|
|
348
|
+
raw_types = parse_type_annotation(type_token)
|
|
349
|
+
types, allowed_values = partition_type_tokens(raw_types)
|
|
350
|
+
placeholder_info = analyze_placeholder(placeholder)
|
|
351
|
+
inferred_types = infer_types_from_placeholder(
|
|
352
|
+
normalize_type_list(types),
|
|
353
|
+
placeholder_info,
|
|
354
|
+
include_optional_boolean: false
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
label = clean_placeholder
|
|
358
|
+
|
|
359
|
+
inline_annotation = !type_token.nil?
|
|
360
|
+
inline_text = inline_annotation ? format_inline_type_label(inferred_types) : nil
|
|
361
|
+
|
|
362
|
+
PositionalDefinition.new(
|
|
363
|
+
placeholder: placeholder,
|
|
364
|
+
label: label.empty? ? placeholder : label,
|
|
365
|
+
types: inferred_types,
|
|
366
|
+
description: description,
|
|
367
|
+
inline_type_annotation: inline_annotation,
|
|
368
|
+
inline_type_text: inline_text,
|
|
369
|
+
doc_format: :rubycli,
|
|
370
|
+
allowed_values: allowed_values
|
|
371
|
+
)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def parse_return_metadata(line)
|
|
375
|
+
yard_match = /\A@return\s+\[([^\]]+)\](?:\s+(.*))?\z/.match(line)
|
|
376
|
+
if yard_match
|
|
377
|
+
types = parse_type_annotation(yard_match[1])
|
|
378
|
+
description = yard_match[2]&.strip
|
|
379
|
+
return ReturnDefinition.new(types: types, description: description)
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
shorthand_match = /\A=>\s+(\[[^\]]+\]|[^\s]+)(?:\s+(.*))?\z/.match(line)
|
|
383
|
+
if shorthand_match
|
|
384
|
+
types = parse_type_annotation(shorthand_match[1])
|
|
385
|
+
description = shorthand_match[2]&.strip
|
|
386
|
+
return ReturnDefinition.new(types: types, description: description)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
if line.start_with?('return ')
|
|
390
|
+
stripped = line.sub(/\Areturn\s+/, '')
|
|
391
|
+
type_token, description = stripped.split(/\s+/, 2)
|
|
392
|
+
types = parse_type_annotation(type_token)
|
|
393
|
+
description = description&.strip
|
|
394
|
+
return ReturnDefinition.new(types: types, description: description)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def extract_parameter_defaults(method_obj)
|
|
399
|
+
location = method_obj.source_location
|
|
400
|
+
return {} unless location
|
|
401
|
+
|
|
402
|
+
file, line_no = location
|
|
403
|
+
return {} unless file && line_no
|
|
404
|
+
|
|
405
|
+
lines = File.readlines(file)
|
|
406
|
+
signature = String.new
|
|
407
|
+
index = line_no - 1
|
|
408
|
+
while index < lines.length
|
|
409
|
+
line = lines[index]
|
|
410
|
+
signature << line
|
|
411
|
+
break if balanced_signature?(signature)
|
|
412
|
+
index += 1
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
params_source = extract_params_from_signature(signature)
|
|
416
|
+
return {} unless params_source
|
|
417
|
+
|
|
418
|
+
split_parameters(params_source).each_with_object({}) do |param_token, memo|
|
|
419
|
+
case param_token
|
|
420
|
+
when /^\*\*/
|
|
421
|
+
next
|
|
422
|
+
when /^\*/
|
|
423
|
+
next
|
|
424
|
+
when /^&/
|
|
425
|
+
next
|
|
426
|
+
else
|
|
427
|
+
if (match = param_token.match(/\A([a-zA-Z0-9_]+)\s*=\s*(.+)\z/))
|
|
428
|
+
memo[match[1].to_sym] = match[2].strip
|
|
429
|
+
elsif (match = param_token.match(/\A([a-zA-Z0-9_]+):\s*(.+)\z/))
|
|
430
|
+
memo[match[1].to_sym] = match[2].strip
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
rescue Errno::ENOENT
|
|
435
|
+
{}
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def balanced_signature?(signature)
|
|
439
|
+
def_index = signature.index(/\bdef\b/)
|
|
440
|
+
return false unless def_index
|
|
441
|
+
|
|
442
|
+
open_parens = signature.count('(')
|
|
443
|
+
close_parens = signature.count(')')
|
|
444
|
+
|
|
445
|
+
if open_parens.zero?
|
|
446
|
+
!signature.strip.end_with?(',')
|
|
447
|
+
else
|
|
448
|
+
open_parens == close_parens && signature.rindex(')') > signature.index('(')
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def extract_params_from_signature(signature)
|
|
453
|
+
return nil unless (def_match = signature.match(/\bdef\b\s+[^(\s]+\s*(\((.*)\))?/m))
|
|
454
|
+
if def_match[1]
|
|
455
|
+
inner = def_match[1][1..-2]
|
|
456
|
+
inner
|
|
457
|
+
else
|
|
458
|
+
signature_after_def = signature.sub(/.*\bdef\b\s+[^(\s]+\s*/m, '')
|
|
459
|
+
signature_after_def.split(/\n/).first&.strip
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def split_parameters(param_string)
|
|
464
|
+
return [] unless param_string
|
|
465
|
+
|
|
466
|
+
tokens = []
|
|
467
|
+
current = String.new
|
|
468
|
+
depth = 0
|
|
469
|
+
param_string.each_char do |char|
|
|
470
|
+
case char
|
|
471
|
+
when '(', '[', '{'
|
|
472
|
+
depth += 1
|
|
473
|
+
when ')', ']', '}'
|
|
474
|
+
depth -= 1 if depth > 0
|
|
475
|
+
when ','
|
|
476
|
+
if depth.zero?
|
|
477
|
+
tokens << current.strip unless current.strip.empty?
|
|
478
|
+
current = String.new
|
|
479
|
+
next
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
current << char
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
tokens << current.strip unless current.strip.empty?
|
|
486
|
+
tokens
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def align_and_validate_parameter_docs(method_obj, metadata, defaults)
|
|
490
|
+
positional_defs = metadata[:positionals].dup
|
|
491
|
+
positional_map = {}
|
|
492
|
+
existing_options = metadata[:options].dup
|
|
493
|
+
options_by_keyword = existing_options.each_with_object({}) { |opt, memo| memo[opt.keyword] = opt }
|
|
494
|
+
ordered_options = []
|
|
495
|
+
|
|
496
|
+
source_file = nil
|
|
497
|
+
source_line = nil
|
|
498
|
+
if method_obj.respond_to?(:source_location)
|
|
499
|
+
source_file, source_line = method_obj.source_location
|
|
500
|
+
end
|
|
501
|
+
line_for_comment = source_line ? [source_line - 1, 1].max : nil
|
|
502
|
+
|
|
503
|
+
method_obj.parameters.each do |type, name|
|
|
504
|
+
case type
|
|
505
|
+
when :req, :opt
|
|
506
|
+
doc = positional_defs.shift
|
|
507
|
+
if doc
|
|
508
|
+
doc.param_name = name
|
|
509
|
+
doc.default_value = defaults[name]
|
|
510
|
+
positional_map[name] = doc
|
|
511
|
+
else
|
|
512
|
+
@environment.handle_documentation_issue(
|
|
513
|
+
"Documentation is missing for positional argument '#{name}'",
|
|
514
|
+
file: source_file,
|
|
515
|
+
line: line_for_comment
|
|
516
|
+
)
|
|
517
|
+
unless @environment.doc_check_mode?
|
|
518
|
+
fallback = PositionalDefinition.new(
|
|
519
|
+
placeholder: name.to_s,
|
|
520
|
+
label: name.to_s.upcase,
|
|
521
|
+
types: ['String'],
|
|
522
|
+
description: nil,
|
|
523
|
+
param_name: name,
|
|
524
|
+
default_value: defaults[name],
|
|
525
|
+
inline_type_annotation: false,
|
|
526
|
+
inline_type_text: nil,
|
|
527
|
+
doc_format: :auto_generated,
|
|
528
|
+
allowed_values: []
|
|
529
|
+
)
|
|
530
|
+
metadata[:positionals] << fallback
|
|
531
|
+
positional_map[name] = fallback
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
when :keyreq, :key
|
|
535
|
+
if (option = options_by_keyword[name])
|
|
536
|
+
ordered_options << option unless ordered_options.include?(option)
|
|
537
|
+
else
|
|
538
|
+
@environment.handle_documentation_issue(
|
|
539
|
+
"Documentation is missing for keyword argument ':#{name}'",
|
|
540
|
+
file: source_file,
|
|
541
|
+
line: line_for_comment
|
|
542
|
+
)
|
|
543
|
+
unless @environment.doc_check_mode?
|
|
544
|
+
fallback_option = build_auto_option_definition(name)
|
|
545
|
+
ordered_options << fallback_option if fallback_option
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
metadata[:options] = ordered_options + (existing_options - ordered_options)
|
|
552
|
+
|
|
553
|
+
unless positional_defs.empty?
|
|
554
|
+
extra = positional_defs.map(&:placeholder).join(', ')
|
|
555
|
+
@environment.handle_documentation_issue(
|
|
556
|
+
"Extra positional argument comments were found: #{extra}",
|
|
557
|
+
file: source_file,
|
|
558
|
+
line: line_for_comment
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
metadata[:positionals] -= positional_defs
|
|
562
|
+
|
|
563
|
+
positional_defs.each do |doc|
|
|
564
|
+
detail_line = detail_line_for_extra_positional(doc)
|
|
565
|
+
next unless detail_line
|
|
566
|
+
|
|
567
|
+
metadata[:detail_lines] ||= []
|
|
568
|
+
metadata[:detail_lines] << detail_line
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
metadata[:positionals_map] = positional_map
|
|
573
|
+
|
|
574
|
+
metadata[:options].each do |opt|
|
|
575
|
+
if defaults.key?(opt.keyword)
|
|
576
|
+
opt.default_value = defaults[opt.keyword]
|
|
577
|
+
if TypeUtils.boolean_string?(opt.default_value)
|
|
578
|
+
opt.boolean_flag = true
|
|
579
|
+
opt.requires_value = false
|
|
580
|
+
if opt.doc_format == :auto_generated
|
|
581
|
+
opt.value_name = nil
|
|
582
|
+
opt.types = ['Boolean']
|
|
583
|
+
end
|
|
584
|
+
elsif opt.boolean_flag
|
|
585
|
+
opt.boolean_flag = false
|
|
586
|
+
opt.requires_value = true
|
|
587
|
+
opt.value_name ||= default_placeholder_for(opt.keyword)
|
|
588
|
+
opt.types = ['String'] if opt.types.nil? || opt.types.empty?
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def detail_line_for_extra_positional(doc)
|
|
595
|
+
return nil unless doc
|
|
596
|
+
|
|
597
|
+
parts = []
|
|
598
|
+
placeholder = doc.placeholder || doc.label
|
|
599
|
+
placeholder = placeholder.to_s.strip
|
|
600
|
+
parts << placeholder unless placeholder.empty?
|
|
601
|
+
|
|
602
|
+
type_text = doc.inline_type_text
|
|
603
|
+
if (!type_text || type_text.empty?) && doc.types && !doc.types.empty?
|
|
604
|
+
type_text = "[#{doc.types.join(', ')}]"
|
|
605
|
+
end
|
|
606
|
+
parts << type_text if type_text && !type_text.empty?
|
|
607
|
+
|
|
608
|
+
description = doc.description.to_s.strip
|
|
609
|
+
parts << description unless description.empty?
|
|
610
|
+
|
|
611
|
+
text = parts.join(' ').strip
|
|
612
|
+
text.empty? ? nil : text
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
INLINE_TYPE_HINTS = %w[
|
|
616
|
+
String
|
|
617
|
+
Integer
|
|
618
|
+
Float
|
|
619
|
+
Numeric
|
|
620
|
+
Boolean
|
|
621
|
+
TrueClass
|
|
622
|
+
FalseClass
|
|
623
|
+
Symbol
|
|
624
|
+
Array
|
|
625
|
+
Hash
|
|
626
|
+
JSON
|
|
627
|
+
Time
|
|
628
|
+
Date
|
|
629
|
+
DateTime
|
|
630
|
+
BigDecimal
|
|
631
|
+
File
|
|
632
|
+
Pathname
|
|
633
|
+
nil
|
|
634
|
+
].freeze
|
|
635
|
+
|
|
636
|
+
def parse_type_annotation(type_str)
|
|
637
|
+
return [] unless type_str
|
|
638
|
+
|
|
639
|
+
cleaned = type_str.strip
|
|
640
|
+
cleaned = cleaned.delete_prefix('@')
|
|
641
|
+
cleaned = cleaned[1..-2].strip if cleaned.start_with?('(') && cleaned.end_with?(')')
|
|
642
|
+
cleaned = cleaned[1..-2] if cleaned.start_with?('[') && cleaned.end_with?(']')
|
|
643
|
+
cleaned = cleaned.sub(/\Atype\s*:\s*/i, '')
|
|
644
|
+
cleaned.split(/[,|]/).map { |token| normalize_type_token(token) }.reject(&:empty?)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def partition_type_tokens(tokens)
|
|
648
|
+
normalized = Array(tokens).dup
|
|
649
|
+
allowed = []
|
|
650
|
+
|
|
651
|
+
normalized.each do |token|
|
|
652
|
+
expand_annotation_token(token).each do |expanded|
|
|
653
|
+
literal_entry = literal_entry_from_token(expanded)
|
|
654
|
+
allowed << literal_entry if literal_entry
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
[normalized, allowed.compact.uniq]
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def merge_allowed_values(primary, additional)
|
|
662
|
+
return Array(additional) if primary.nil? || primary.empty?
|
|
663
|
+
return Array(primary) if additional.nil? || additional.empty?
|
|
664
|
+
|
|
665
|
+
(primary + additional).uniq
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def expand_annotation_token(token)
|
|
669
|
+
return [] unless token
|
|
670
|
+
|
|
671
|
+
stripped = token.strip
|
|
672
|
+
return [] if stripped.empty?
|
|
673
|
+
|
|
674
|
+
if stripped.start_with?('%i[') && stripped.end_with?(']')
|
|
675
|
+
inner = stripped[3..-2]
|
|
676
|
+
inner.split(/\s+/).map { |entry| ":#{entry}" }
|
|
677
|
+
elsif stripped.start_with?('%I[') && stripped.end_with?(']')
|
|
678
|
+
inner = stripped[3..-2]
|
|
679
|
+
inner.split(/\s+/).map { |entry| ":#{entry}" }
|
|
680
|
+
elsif stripped.start_with?('%w[') && stripped.end_with?(']')
|
|
681
|
+
inner = stripped[3..-2]
|
|
682
|
+
inner.split(/\s+/)
|
|
683
|
+
elsif stripped.start_with?('%W[') && stripped.end_with?(']')
|
|
684
|
+
inner = stripped[3..-2]
|
|
685
|
+
inner.split(/\s+/)
|
|
686
|
+
else
|
|
687
|
+
[stripped]
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
def literal_entry_from_token(token)
|
|
692
|
+
return nil unless token
|
|
693
|
+
|
|
694
|
+
stripped = token.strip
|
|
695
|
+
return nil if stripped.empty?
|
|
696
|
+
stripped = stripped[1..] if stripped.start_with?('[') && !stripped.end_with?(']')
|
|
697
|
+
if stripped.end_with?(']') && !stripped.include?('[')
|
|
698
|
+
stripped = stripped[0...-1]
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
lowered = stripped.downcase
|
|
702
|
+
return { kind: :literal, value: nil } if %w[nil null ~].include?(lowered)
|
|
703
|
+
return { kind: :literal, value: true } if lowered == 'true'
|
|
704
|
+
return { kind: :literal, value: false } if lowered == 'false'
|
|
705
|
+
|
|
706
|
+
if stripped.start_with?(':')
|
|
707
|
+
sym_name = stripped[1..]
|
|
708
|
+
return nil if sym_name.nil? || sym_name.empty?
|
|
709
|
+
|
|
710
|
+
return { kind: :literal, value: sym_name.to_sym }
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
if stripped.start_with?('"') && stripped.end_with?('"') && stripped.length >= 2
|
|
714
|
+
return { kind: :literal, value: stripped[1..-2] }
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
if stripped.start_with?("'") && stripped.end_with?("'") && stripped.length >= 2
|
|
718
|
+
return { kind: :literal, value: stripped[1..-2] }
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
if stripped.match?(/\A-?\d+\z/)
|
|
722
|
+
return { kind: :literal, value: Integer(stripped) }
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
if stripped.match?(/\A-?\d+\.\d+\z/)
|
|
726
|
+
return { kind: :literal, value: Float(stripped) }
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
if stripped.match?(/\A[a-z0-9._-]+\z/)
|
|
730
|
+
return { kind: :literal, value: stripped }
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
nil
|
|
734
|
+
rescue ArgumentError
|
|
735
|
+
nil
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def placeholder_token?(token)
|
|
740
|
+
return false unless token
|
|
741
|
+
|
|
742
|
+
candidate = token.strip.delete_prefix('@')
|
|
743
|
+
return false if candidate.empty?
|
|
744
|
+
|
|
745
|
+
optional = candidate.start_with?('[') && candidate.end_with?(']')
|
|
746
|
+
candidate = candidate[1..-2].strip if optional
|
|
747
|
+
return false if candidate.empty?
|
|
748
|
+
|
|
749
|
+
candidate = candidate.gsub(/[,\|]/, '')
|
|
750
|
+
return false if candidate.empty?
|
|
751
|
+
|
|
752
|
+
ellipsis = candidate.end_with?('...')
|
|
753
|
+
candidate = candidate[0..-4] if ellipsis
|
|
754
|
+
candidate = candidate.strip
|
|
755
|
+
return false if candidate.empty?
|
|
756
|
+
|
|
757
|
+
if candidate.start_with?('<') && candidate.end_with?('>')
|
|
758
|
+
inner = candidate[1..-2]
|
|
759
|
+
inner.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
|
|
760
|
+
else
|
|
761
|
+
cleaned = candidate.gsub(/[^A-Za-z0-9_]/, '')
|
|
762
|
+
return false if cleaned.empty?
|
|
763
|
+
|
|
764
|
+
cleaned == cleaned.upcase && cleaned.match?(/[A-Z]/)
|
|
765
|
+
end
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def type_token_candidate?(token)
|
|
769
|
+
return false unless token
|
|
770
|
+
|
|
771
|
+
stripped = token.strip
|
|
772
|
+
return false if stripped.empty?
|
|
773
|
+
|
|
774
|
+
return true if stripped.start_with?('@')
|
|
775
|
+
return true if stripped.start_with?('%')
|
|
776
|
+
return true if stripped.include?('::')
|
|
777
|
+
return true if stripped.start_with?('(') && stripped.end_with?(')')
|
|
778
|
+
return true if stripped.include?('[') && stripped.include?(']')
|
|
779
|
+
|
|
780
|
+
normalized = normalize_type_token(stripped)
|
|
781
|
+
return false if normalized.empty?
|
|
782
|
+
|
|
783
|
+
INLINE_TYPE_HINTS.include?(normalized)
|
|
784
|
+
end
|
|
785
|
+
|
|
786
|
+
def known_type_token?(token)
|
|
787
|
+
return false unless token
|
|
788
|
+
|
|
789
|
+
candidate = token.start_with?('@') ? token[1..] : token
|
|
790
|
+
candidate =~ /\A[A-Z][A-Za-z0-9_:<>\[\]]*\z/
|
|
791
|
+
end
|
|
792
|
+
|
|
793
|
+
def inline_type_hint?(token)
|
|
794
|
+
normalized = normalize_type_token(token)
|
|
795
|
+
return false if normalized.empty?
|
|
796
|
+
|
|
797
|
+
base = if normalized.include?('<') && normalized.end_with?('>')
|
|
798
|
+
normalized.split('<').first
|
|
799
|
+
elsif normalized.end_with?('[]')
|
|
800
|
+
normalized[0..-3]
|
|
801
|
+
else
|
|
802
|
+
normalized
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
INLINE_TYPE_HINTS.include?(base)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def parameter_role(method_obj, keyword)
|
|
809
|
+
return nil unless method_obj.respond_to?(:parameters)
|
|
810
|
+
|
|
811
|
+
symbol = keyword.to_sym
|
|
812
|
+
method_obj.parameters.each do |type, name|
|
|
813
|
+
next unless name == symbol
|
|
814
|
+
|
|
815
|
+
case type
|
|
816
|
+
when :req, :opt, :rest
|
|
817
|
+
return :positional
|
|
818
|
+
when :keyreq, :key
|
|
819
|
+
return :keyword
|
|
820
|
+
else
|
|
821
|
+
return nil
|
|
822
|
+
end
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
nil
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
def combine_bracketed_tokens(tokens)
|
|
829
|
+
combined = []
|
|
830
|
+
buffer = nil
|
|
831
|
+
closing = nil
|
|
832
|
+
|
|
833
|
+
tokens.each do |token|
|
|
834
|
+
next if token.nil?
|
|
835
|
+
|
|
836
|
+
if buffer
|
|
837
|
+
buffer << ' ' unless token.empty?
|
|
838
|
+
buffer << token
|
|
839
|
+
if closing && token.include?(closing)
|
|
840
|
+
combined << buffer
|
|
841
|
+
buffer = nil
|
|
842
|
+
closing = nil
|
|
843
|
+
end
|
|
844
|
+
elsif token.start_with?('[') && !token.include?(']')
|
|
845
|
+
buffer = token.dup
|
|
846
|
+
closing = ']'
|
|
847
|
+
elsif token.start_with?('(') && !token.include?(')')
|
|
848
|
+
buffer = token.dup
|
|
849
|
+
closing = ')'
|
|
850
|
+
elsif token.start_with?('%') && token.include?('[') && !token.include?(']')
|
|
851
|
+
buffer = token.dup
|
|
852
|
+
closing = ']'
|
|
853
|
+
else
|
|
854
|
+
combined << token
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
combined << buffer if buffer
|
|
859
|
+
combined
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def format_inline_type_label(types)
|
|
863
|
+
return nil if types.nil? || types.empty?
|
|
864
|
+
|
|
865
|
+
unique_types = types.reject(&:empty?).uniq
|
|
866
|
+
return nil if unique_types.empty?
|
|
867
|
+
|
|
868
|
+
"[#{unique_types.join(', ')}]"
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def method_accepts_keyword?(method_obj, keyword)
|
|
872
|
+
params = method_obj.parameters
|
|
873
|
+
keyword_names = params.select { |type, _| %i[key keyreq keyrest].include?(type) }.map { |_, name| name }
|
|
874
|
+
keyword_names.include?(keyword) || params.any? { |type, _| type == :keyrest }
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def build_option_definition(
|
|
878
|
+
keyword,
|
|
879
|
+
long_option,
|
|
880
|
+
short_option,
|
|
881
|
+
value_name,
|
|
882
|
+
types,
|
|
883
|
+
description,
|
|
884
|
+
inline_type_annotation: false,
|
|
885
|
+
doc_format: nil,
|
|
886
|
+
allowed_values: nil
|
|
887
|
+
)
|
|
888
|
+
normalized_long = normalize_long_option(long_option)
|
|
889
|
+
normalized_short = normalize_short_option(short_option)
|
|
890
|
+
value_placeholder = value_name&.strip
|
|
891
|
+
value_placeholder = nil if value_placeholder&.empty?
|
|
892
|
+
description_text = description&.strip
|
|
893
|
+
description_text = nil if description_text&.empty?
|
|
894
|
+
|
|
895
|
+
placeholder_info = analyze_placeholder(value_placeholder)
|
|
896
|
+
normalized_types = normalize_type_list(types)
|
|
897
|
+
inferred_types = infer_types_from_placeholder(normalized_types, placeholder_info)
|
|
898
|
+
if inferred_types.empty? && value_placeholder.nil?
|
|
899
|
+
inferred_types = ['Boolean']
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
optional_value = placeholder_info[:optional]
|
|
903
|
+
boolean_flag = !optional_value && inferred_types.any? { |type| boolean_type?(type) }
|
|
904
|
+
requires_value = determine_requires_value(
|
|
905
|
+
value_placeholder: value_placeholder,
|
|
906
|
+
types: inferred_types,
|
|
907
|
+
boolean_flag: boolean_flag,
|
|
908
|
+
optional_value: optional_value
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
if value_placeholder.nil? && !boolean_flag && requires_value
|
|
912
|
+
value_placeholder = default_placeholder_for(keyword)
|
|
913
|
+
placeholder_info = analyze_placeholder(value_placeholder)
|
|
914
|
+
optional_value = placeholder_info[:optional]
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
inline_type_text = inline_type_annotation ? format_inline_type_label(inferred_types) : nil
|
|
918
|
+
|
|
919
|
+
OptionDefinition.new(
|
|
920
|
+
keyword: keyword,
|
|
921
|
+
long: normalized_long,
|
|
922
|
+
short: normalized_short,
|
|
923
|
+
value_name: value_placeholder,
|
|
924
|
+
types: inferred_types,
|
|
925
|
+
description: description_text,
|
|
926
|
+
requires_value: requires_value,
|
|
927
|
+
boolean_flag: boolean_flag,
|
|
928
|
+
optional_value: optional_value,
|
|
929
|
+
inline_type_annotation: inline_type_annotation,
|
|
930
|
+
inline_type_text: inline_type_text,
|
|
931
|
+
doc_format: doc_format,
|
|
932
|
+
allowed_values: normalize_allowed_values(allowed_values)
|
|
933
|
+
)
|
|
934
|
+
end
|
|
935
|
+
|
|
936
|
+
def normalize_allowed_values(values)
|
|
937
|
+
Array(values).compact.uniq
|
|
938
|
+
end
|
|
939
|
+
|
|
940
|
+
def build_auto_option_definition(keyword)
|
|
941
|
+
long_option = "--#{keyword.to_s.tr('_', '-')}"
|
|
942
|
+
placeholder = default_placeholder_for(keyword)
|
|
943
|
+
build_option_definition(
|
|
944
|
+
keyword,
|
|
945
|
+
long_option,
|
|
946
|
+
nil,
|
|
947
|
+
placeholder,
|
|
948
|
+
[],
|
|
949
|
+
nil,
|
|
950
|
+
inline_type_annotation: false,
|
|
951
|
+
doc_format: :auto_generated,
|
|
952
|
+
allowed_values: []
|
|
953
|
+
)
|
|
954
|
+
end
|
|
955
|
+
|
|
956
|
+
def option_to_positional_definition(option)
|
|
957
|
+
placeholder = option.value_name || default_placeholder_for(option.keyword)
|
|
958
|
+
PositionalDefinition.new(
|
|
959
|
+
placeholder: placeholder,
|
|
960
|
+
label: placeholder,
|
|
961
|
+
types: option.types,
|
|
962
|
+
description: option.description,
|
|
963
|
+
param_name: option.keyword,
|
|
964
|
+
default_value: option.default_value,
|
|
965
|
+
inline_type_annotation: option.inline_type_annotation,
|
|
966
|
+
inline_type_text: option.inline_type_text,
|
|
967
|
+
doc_format: option.doc_format,
|
|
968
|
+
allowed_values: option.allowed_values
|
|
969
|
+
)
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
end
|