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.
@@ -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