rubycli 0.1.1 → 0.1.4

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,838 @@
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.strict_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
+ type_str = match[2]
134
+ option_tokens = combine_bracketed_tokens(match[3]&.split(/\s+/) || [])
135
+ description = match[4]&.strip
136
+ description = nil if description&.empty?
137
+
138
+ types = parse_type_annotation(type_str)
139
+
140
+ long_option = nil
141
+ short_option = nil
142
+ value_name = nil
143
+ type_token = nil
144
+
145
+ unless option_tokens.empty?
146
+ normalized = option_tokens.flat_map { |token| token.split('/') }
147
+ normalized.each do |token|
148
+ token_without_at = token.start_with?('@') ? token[1..] : token
149
+ if token.start_with?('--')
150
+ if (eq_index = token.index('='))
151
+ long_option = token[0...eq_index]
152
+ inline_value = token[(eq_index + 1)..]
153
+ if value_name.nil? && inline_value && !inline_value.strip.empty?
154
+ value_name = inline_value.strip
155
+ next
156
+ end
157
+ else
158
+ long_option = token
159
+ end
160
+ elsif token.start_with?('-')
161
+ if (eq_index = token.index('='))
162
+ short_option = token[0...eq_index]
163
+ inline_value = token[(eq_index + 1)..]
164
+ if value_name.nil? && inline_value && !inline_value.strip.empty?
165
+ value_name = inline_value.strip
166
+ next
167
+ end
168
+ else
169
+ short_option = token
170
+ end
171
+ elsif value_name.nil? && placeholder_token?(token_without_at)
172
+ value_name = token_without_at
173
+ elsif type_token.nil? && type_token_candidate?(token)
174
+ type_token = token
175
+ elsif value_name.nil?
176
+ value_name = token_without_at
177
+ end
178
+ end
179
+ end
180
+
181
+ long_option ||= "--#{param_name.tr('_', '-')}"
182
+
183
+ types = parse_type_annotation(type_token) if (types.nil? || types.empty?) && type_token
184
+
185
+ option_def = build_option_definition(
186
+ param_name.to_sym,
187
+ long_option,
188
+ short_option,
189
+ value_name,
190
+ types,
191
+ description,
192
+ inline_type_annotation: !type_token.nil?,
193
+ doc_format: :tagged_param
194
+ )
195
+
196
+ param_symbol = param_name.to_sym
197
+ role = parameter_role(method_obj, param_symbol)
198
+
199
+ if role == :positional
200
+ placeholder = option_def.value_name || default_placeholder_for(option_def.keyword)
201
+ return PositionalDefinition.new(
202
+ placeholder: placeholder,
203
+ label: placeholder,
204
+ types: option_def.types,
205
+ description: option_def.description,
206
+ param_name: param_symbol,
207
+ doc_format: option_def.doc_format
208
+ )
209
+ elsif role == :keyword
210
+ return option_def
211
+ end
212
+
213
+ unless method_accepts_keyword?(method_obj, param_symbol)
214
+ placeholder = option_def.value_name || default_placeholder_for(option_def.keyword)
215
+ return PositionalDefinition.new(
216
+ placeholder: placeholder,
217
+ label: placeholder,
218
+ types: option_def.types,
219
+ description: option_def.description,
220
+ param_name: param_symbol,
221
+ doc_format: option_def.doc_format
222
+ )
223
+ end
224
+
225
+ option_def
226
+ end
227
+
228
+ def parse_tagless_option_line(line, method_obj)
229
+ return nil unless line.start_with?('--') || line.start_with?('-')
230
+
231
+ raw_tokens = combine_bracketed_tokens(line.split(/\s+/))
232
+ tokens = raw_tokens.flat_map { |token|
233
+ if token.include?('/') && !token.start_with?('[')
234
+ token.split('/')
235
+ else
236
+ [token]
237
+ end
238
+ }
239
+
240
+ long_option = nil
241
+ short_option = nil
242
+ inline_value_from_long = nil
243
+ inline_value_from_short = nil
244
+ remaining = []
245
+
246
+ tokens.each do |token|
247
+ if long_option.nil? && token.start_with?('--')
248
+ if (eq_index = token.index('='))
249
+ long_option = token[0...eq_index]
250
+ inline_value_from_long = token[(eq_index + 1)..]
251
+ else
252
+ long_option = token
253
+ end
254
+ next
255
+ end
256
+
257
+ if short_option.nil? && token.start_with?('-') && !token.start_with?('--')
258
+ if (eq_index = token.index('='))
259
+ short_option = token[0...eq_index]
260
+ inline_value_from_short = token[(eq_index + 1)..]
261
+ else
262
+ short_option = token
263
+ end
264
+ next
265
+ end
266
+
267
+ remaining << token
268
+ end
269
+
270
+ return nil unless long_option
271
+
272
+ type_token = nil
273
+ value_name = [inline_value_from_long, inline_value_from_short].compact.map(&:strip).find { |val| !val.empty? }
274
+ description_tokens = []
275
+
276
+ remaining.each do |token|
277
+ token_without_at = token.start_with?('@') ? token[1..] : token
278
+
279
+ if value_name.nil? && placeholder_token?(token_without_at)
280
+ value_name = token_without_at
281
+ next
282
+ end
283
+
284
+ if type_token.nil? && type_token_candidate?(token)
285
+ type_token = token
286
+ next
287
+ end
288
+
289
+ description_tokens << token
290
+ end
291
+
292
+ description = description_tokens.join(' ').strip
293
+ description = nil if description.empty?
294
+ types = parse_type_annotation(type_token)
295
+
296
+ keyword = long_option.delete_prefix('--').tr('-', '_').to_sym
297
+ return nil unless method_accepts_keyword?(method_obj, keyword)
298
+
299
+ build_option_definition(
300
+ keyword,
301
+ long_option,
302
+ short_option,
303
+ value_name,
304
+ types,
305
+ description,
306
+ inline_type_annotation: !type_token.nil?,
307
+ doc_format: :rubycli
308
+ )
309
+ end
310
+
311
+ def parse_positional_line(line)
312
+ return nil if line.start_with?('--') || line.start_with?('-')
313
+
314
+ tokens = combine_bracketed_tokens(line.split(/\s+/))
315
+ placeholder = tokens.shift
316
+ return nil unless placeholder
317
+
318
+ clean_placeholder = placeholder.delete('[]')
319
+ return nil unless placeholder_token?(clean_placeholder)
320
+
321
+ type_token = nil
322
+ if tokens.first && type_token_candidate?(tokens.first)
323
+ type_token = tokens.shift
324
+ end
325
+
326
+ description = tokens.join(' ').strip
327
+ description = nil if description.empty?
328
+
329
+ types = parse_type_annotation(type_token)
330
+ placeholder_info = analyze_placeholder(placeholder)
331
+ inferred_types = infer_types_from_placeholder(
332
+ normalize_type_list(types),
333
+ placeholder_info,
334
+ include_optional_boolean: false
335
+ )
336
+
337
+ label = clean_placeholder
338
+
339
+ inline_annotation = !type_token.nil?
340
+ inline_text = inline_annotation ? format_inline_type_label(inferred_types) : nil
341
+
342
+ PositionalDefinition.new(
343
+ placeholder: placeholder,
344
+ label: label.empty? ? placeholder : label,
345
+ types: inferred_types,
346
+ description: description,
347
+ inline_type_annotation: inline_annotation,
348
+ inline_type_text: inline_text,
349
+ doc_format: :rubycli
350
+ )
351
+ end
352
+
353
+ def parse_return_metadata(line)
354
+ yard_match = /\A@return\s+\[([^\]]+)\](?:\s+(.*))?\z/.match(line)
355
+ if yard_match
356
+ types = parse_type_annotation(yard_match[1])
357
+ description = yard_match[2]&.strip
358
+ return ReturnDefinition.new(types: types, description: description)
359
+ end
360
+
361
+ shorthand_match = /\A=>\s+(\[[^\]]+\]|[^\s]+)(?:\s+(.*))?\z/.match(line)
362
+ if shorthand_match
363
+ types = parse_type_annotation(shorthand_match[1])
364
+ description = shorthand_match[2]&.strip
365
+ return ReturnDefinition.new(types: types, description: description)
366
+ end
367
+
368
+ if line.start_with?('return ')
369
+ stripped = line.sub(/\Areturn\s+/, '')
370
+ type_token, description = stripped.split(/\s+/, 2)
371
+ types = parse_type_annotation(type_token)
372
+ description = description&.strip
373
+ return ReturnDefinition.new(types: types, description: description)
374
+ end
375
+ end
376
+
377
+ def extract_parameter_defaults(method_obj)
378
+ location = method_obj.source_location
379
+ return {} unless location
380
+
381
+ file, line_no = location
382
+ return {} unless file && line_no
383
+
384
+ lines = File.readlines(file)
385
+ signature = String.new
386
+ index = line_no - 1
387
+ while index < lines.length
388
+ line = lines[index]
389
+ signature << line
390
+ break if balanced_signature?(signature)
391
+ index += 1
392
+ end
393
+
394
+ params_source = extract_params_from_signature(signature)
395
+ return {} unless params_source
396
+
397
+ split_parameters(params_source).each_with_object({}) do |param_token, memo|
398
+ case param_token
399
+ when /^\*\*/
400
+ next
401
+ when /^\*/
402
+ next
403
+ when /^&/
404
+ next
405
+ else
406
+ if (match = param_token.match(/\A([a-zA-Z0-9_]+)\s*=\s*(.+)\z/))
407
+ memo[match[1].to_sym] = match[2].strip
408
+ elsif (match = param_token.match(/\A([a-zA-Z0-9_]+):\s*(.+)\z/))
409
+ memo[match[1].to_sym] = match[2].strip
410
+ end
411
+ end
412
+ end
413
+ rescue Errno::ENOENT
414
+ {}
415
+ end
416
+
417
+ def balanced_signature?(signature)
418
+ def_index = signature.index(/\bdef\b/)
419
+ return false unless def_index
420
+
421
+ open_parens = signature.count('(')
422
+ close_parens = signature.count(')')
423
+
424
+ if open_parens.zero?
425
+ !signature.strip.end_with?(',')
426
+ else
427
+ open_parens == close_parens && signature.rindex(')') > signature.index('(')
428
+ end
429
+ end
430
+
431
+ def extract_params_from_signature(signature)
432
+ return nil unless (def_match = signature.match(/\bdef\b\s+[^(\s]+\s*(\((.*)\))?/m))
433
+ if def_match[1]
434
+ inner = def_match[1][1..-2]
435
+ inner
436
+ else
437
+ signature_after_def = signature.sub(/.*\bdef\b\s+[^(\s]+\s*/m, '')
438
+ signature_after_def.split(/\n/).first&.strip
439
+ end
440
+ end
441
+
442
+ def split_parameters(param_string)
443
+ return [] unless param_string
444
+
445
+ tokens = []
446
+ current = String.new
447
+ depth = 0
448
+ param_string.each_char do |char|
449
+ case char
450
+ when '(', '[', '{'
451
+ depth += 1
452
+ when ')', ']', '}'
453
+ depth -= 1 if depth > 0
454
+ when ','
455
+ if depth.zero?
456
+ tokens << current.strip unless current.strip.empty?
457
+ current = String.new
458
+ next
459
+ end
460
+ end
461
+ current << char
462
+ end
463
+
464
+ tokens << current.strip unless current.strip.empty?
465
+ tokens
466
+ end
467
+
468
+ def align_and_validate_parameter_docs(method_obj, metadata, defaults)
469
+ positional_defs = metadata[:positionals].dup
470
+ positional_map = {}
471
+ existing_options = metadata[:options].dup
472
+ options_by_keyword = existing_options.each_with_object({}) { |opt, memo| memo[opt.keyword] = opt }
473
+ ordered_options = []
474
+
475
+ source_file = nil
476
+ source_line = nil
477
+ if method_obj.respond_to?(:source_location)
478
+ source_file, source_line = method_obj.source_location
479
+ end
480
+ line_for_comment = source_line ? [source_line - 1, 1].max : nil
481
+
482
+ method_obj.parameters.each do |type, name|
483
+ case type
484
+ when :req, :opt
485
+ doc = positional_defs.shift
486
+ if doc
487
+ doc.param_name = name
488
+ doc.default_value = defaults[name]
489
+ positional_map[name] = doc
490
+ else
491
+ @environment.handle_documentation_issue(
492
+ "Documentation is missing for positional argument '#{name}'",
493
+ file: source_file,
494
+ line: line_for_comment
495
+ )
496
+ unless @environment.strict_mode?
497
+ fallback = PositionalDefinition.new(
498
+ placeholder: name.to_s,
499
+ label: name.to_s.upcase,
500
+ types: [],
501
+ description: nil,
502
+ param_name: name,
503
+ default_value: defaults[name],
504
+ inline_type_annotation: false,
505
+ inline_type_text: nil
506
+ )
507
+ metadata[:positionals] << fallback
508
+ positional_map[name] = fallback
509
+ end
510
+ end
511
+ when :keyreq, :key
512
+ if (option = options_by_keyword[name])
513
+ ordered_options << option unless ordered_options.include?(option)
514
+ else
515
+ @environment.handle_documentation_issue(
516
+ "Documentation is missing for keyword argument ':#{name}'",
517
+ file: source_file,
518
+ line: line_for_comment
519
+ )
520
+ unless @environment.strict_mode?
521
+ fallback_option = build_auto_option_definition(name)
522
+ ordered_options << fallback_option if fallback_option
523
+ end
524
+ end
525
+ end
526
+ end
527
+
528
+ metadata[:options] = ordered_options + (existing_options - ordered_options)
529
+
530
+ unless positional_defs.empty?
531
+ extra = positional_defs.map(&:placeholder).join(', ')
532
+ @environment.handle_documentation_issue(
533
+ "Extra positional argument comments were found: #{extra}",
534
+ file: source_file,
535
+ line: line_for_comment
536
+ )
537
+
538
+ metadata[:positionals] -= positional_defs
539
+
540
+ positional_defs.each do |doc|
541
+ detail_line = detail_line_for_extra_positional(doc)
542
+ next unless detail_line
543
+
544
+ metadata[:detail_lines] ||= []
545
+ metadata[:detail_lines] << detail_line
546
+ end
547
+ end
548
+
549
+ metadata[:positionals_map] = positional_map
550
+
551
+ metadata[:options].each do |opt|
552
+ if defaults.key?(opt.keyword)
553
+ opt.default_value = defaults[opt.keyword]
554
+ if TypeUtils.boolean_string?(opt.default_value)
555
+ opt.boolean_flag = true
556
+ opt.requires_value = false
557
+ if opt.doc_format == :auto_generated
558
+ opt.value_name = nil
559
+ opt.types = ['Boolean']
560
+ end
561
+ end
562
+ end
563
+ end
564
+ end
565
+
566
+ def detail_line_for_extra_positional(doc)
567
+ return nil unless doc
568
+
569
+ parts = []
570
+ placeholder = doc.placeholder || doc.label
571
+ placeholder = placeholder.to_s.strip
572
+ parts << placeholder unless placeholder.empty?
573
+
574
+ type_text = doc.inline_type_text
575
+ if (!type_text || type_text.empty?) && doc.types && !doc.types.empty?
576
+ type_text = "[#{doc.types.join(', ')}]"
577
+ end
578
+ parts << type_text if type_text && !type_text.empty?
579
+
580
+ description = doc.description.to_s.strip
581
+ parts << description unless description.empty?
582
+
583
+ text = parts.join(' ').strip
584
+ text.empty? ? nil : text
585
+ end
586
+
587
+ INLINE_TYPE_HINTS = %w[
588
+ String
589
+ Integer
590
+ Float
591
+ Numeric
592
+ Boolean
593
+ TrueClass
594
+ FalseClass
595
+ Symbol
596
+ Array
597
+ Hash
598
+ JSON
599
+ Time
600
+ Date
601
+ DateTime
602
+ BigDecimal
603
+ File
604
+ Pathname
605
+ nil
606
+ ].freeze
607
+
608
+ def parse_type_annotation(type_str)
609
+ return [] unless type_str
610
+
611
+ cleaned = type_str.strip
612
+ cleaned = cleaned.delete_prefix('@')
613
+ cleaned = cleaned[1..-2].strip if cleaned.start_with?('(') && cleaned.end_with?(')')
614
+ cleaned = cleaned[1..-2] if cleaned.start_with?('[') && cleaned.end_with?(']')
615
+ cleaned = cleaned.sub(/\Atype\s*:\s*/i, '')
616
+ cleaned.split(/[,|]/).map { |token| normalize_type_token(token) }.reject(&:empty?)
617
+ end
618
+
619
+ def placeholder_token?(token)
620
+ return false unless token
621
+
622
+ candidate = token.strip.delete_prefix('@')
623
+ return false if candidate.empty?
624
+
625
+ optional = candidate.start_with?('[') && candidate.end_with?(']')
626
+ candidate = candidate[1..-2].strip if optional
627
+ return false if candidate.empty?
628
+
629
+ candidate = candidate.gsub(/[,\|]/, '')
630
+ return false if candidate.empty?
631
+
632
+ ellipsis = candidate.end_with?('...')
633
+ candidate = candidate[0..-4] if ellipsis
634
+ candidate = candidate.strip
635
+ return false if candidate.empty?
636
+
637
+ if candidate.start_with?('<') && candidate.end_with?('>')
638
+ inner = candidate[1..-2]
639
+ inner.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
640
+ else
641
+ cleaned = candidate.gsub(/[^A-Za-z0-9_]/, '')
642
+ return false if cleaned.empty?
643
+
644
+ cleaned == cleaned.upcase && cleaned.match?(/[A-Z]/)
645
+ end
646
+ end
647
+
648
+ def type_token_candidate?(token)
649
+ return false unless token
650
+
651
+ stripped = token.strip
652
+ return false if stripped.empty?
653
+
654
+ return true if stripped.start_with?('@')
655
+
656
+ parsed = parse_type_annotation(stripped)
657
+ return false if parsed.empty?
658
+
659
+ parsed.all? { |entry| inline_type_hint?(entry) }
660
+ end
661
+
662
+ def known_type_token?(token)
663
+ return false unless token
664
+
665
+ candidate = token.start_with?('@') ? token[1..] : token
666
+ candidate =~ /\A[A-Z][A-Za-z0-9_:<>\[\]]*\z/
667
+ end
668
+
669
+ def inline_type_hint?(token)
670
+ normalized = normalize_type_token(token)
671
+ return false if normalized.empty?
672
+
673
+ base = if normalized.include?('<') && normalized.end_with?('>')
674
+ normalized.split('<').first
675
+ elsif normalized.end_with?('[]')
676
+ normalized[0..-3]
677
+ else
678
+ normalized
679
+ end
680
+
681
+ INLINE_TYPE_HINTS.include?(base)
682
+ end
683
+
684
+ def parameter_role(method_obj, keyword)
685
+ return nil unless method_obj.respond_to?(:parameters)
686
+
687
+ symbol = keyword.to_sym
688
+ method_obj.parameters.each do |type, name|
689
+ next unless name == symbol
690
+
691
+ case type
692
+ when :req, :opt, :rest
693
+ return :positional
694
+ when :keyreq, :key
695
+ return :keyword
696
+ else
697
+ return nil
698
+ end
699
+ end
700
+
701
+ nil
702
+ end
703
+
704
+ def combine_bracketed_tokens(tokens)
705
+ combined = []
706
+ buffer = nil
707
+ closing = nil
708
+
709
+ tokens.each do |token|
710
+ next if token.nil?
711
+
712
+ if buffer
713
+ buffer << ' ' unless token.empty?
714
+ buffer << token
715
+ if closing && token.include?(closing)
716
+ combined << buffer
717
+ buffer = nil
718
+ closing = nil
719
+ end
720
+ elsif token.start_with?('[') && !token.include?(']')
721
+ buffer = token.dup
722
+ closing = ']'
723
+ elsif token.start_with?('(') && !token.include?(')')
724
+ buffer = token.dup
725
+ closing = ')'
726
+ else
727
+ combined << token
728
+ end
729
+ end
730
+
731
+ combined << buffer if buffer
732
+ combined
733
+ end
734
+
735
+ def format_inline_type_label(types)
736
+ return nil if types.nil? || types.empty?
737
+
738
+ unique_types = types.reject(&:empty?).uniq
739
+ return nil if unique_types.empty?
740
+
741
+ "[#{unique_types.join(', ')}]"
742
+ end
743
+
744
+ def method_accepts_keyword?(method_obj, keyword)
745
+ params = method_obj.parameters
746
+ keyword_names = params.select { |type, _| %i[key keyreq keyrest].include?(type) }.map { |_, name| name }
747
+ keyword_names.include?(keyword) || params.any? { |type, _| type == :keyrest }
748
+ end
749
+
750
+ def build_option_definition(
751
+ keyword,
752
+ long_option,
753
+ short_option,
754
+ value_name,
755
+ types,
756
+ description,
757
+ inline_type_annotation: false,
758
+ doc_format: nil
759
+ )
760
+ normalized_long = normalize_long_option(long_option)
761
+ normalized_short = normalize_short_option(short_option)
762
+ value_placeholder = value_name&.strip
763
+ value_placeholder = nil if value_placeholder&.empty?
764
+ description_text = description&.strip
765
+ description_text = nil if description_text&.empty?
766
+
767
+ placeholder_info = analyze_placeholder(value_placeholder)
768
+ normalized_types = normalize_type_list(types)
769
+ inferred_types = infer_types_from_placeholder(normalized_types, placeholder_info)
770
+ if inferred_types.empty? && value_placeholder.nil?
771
+ inferred_types = ['Boolean']
772
+ end
773
+
774
+ optional_value = placeholder_info[:optional]
775
+ boolean_flag = !optional_value && inferred_types.any? { |type| boolean_type?(type) }
776
+ requires_value = determine_requires_value(
777
+ value_placeholder: value_placeholder,
778
+ types: inferred_types,
779
+ boolean_flag: boolean_flag,
780
+ optional_value: optional_value
781
+ )
782
+
783
+ if value_placeholder.nil? && !boolean_flag && requires_value
784
+ value_placeholder = default_placeholder_for(keyword)
785
+ placeholder_info = analyze_placeholder(value_placeholder)
786
+ optional_value = placeholder_info[:optional]
787
+ end
788
+
789
+ inline_type_text = inline_type_annotation ? format_inline_type_label(inferred_types) : nil
790
+
791
+ OptionDefinition.new(
792
+ keyword: keyword,
793
+ long: normalized_long,
794
+ short: normalized_short,
795
+ value_name: value_placeholder,
796
+ types: inferred_types,
797
+ description: description_text,
798
+ requires_value: requires_value,
799
+ boolean_flag: boolean_flag,
800
+ optional_value: optional_value,
801
+ inline_type_annotation: inline_type_annotation,
802
+ inline_type_text: inline_type_text,
803
+ doc_format: doc_format
804
+ )
805
+ end
806
+
807
+ def build_auto_option_definition(keyword)
808
+ long_option = "--#{keyword.to_s.tr('_', '-')}"
809
+ placeholder = default_placeholder_for(keyword)
810
+ build_option_definition(
811
+ keyword,
812
+ long_option,
813
+ nil,
814
+ placeholder,
815
+ [],
816
+ nil,
817
+ inline_type_annotation: false,
818
+ doc_format: :auto_generated
819
+ )
820
+ end
821
+
822
+ def option_to_positional_definition(option)
823
+ placeholder = option.value_name || default_placeholder_for(option.keyword)
824
+ PositionalDefinition.new(
825
+ placeholder: placeholder,
826
+ label: placeholder,
827
+ types: option.types,
828
+ description: option.description,
829
+ param_name: option.keyword,
830
+ default_value: option.default_value,
831
+ inline_type_annotation: option.inline_type_annotation,
832
+ inline_type_text: option.inline_type_text,
833
+ doc_format: option.doc_format
834
+ )
835
+ end
836
+ end
837
+ end
838
+ end