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