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