rubycli 0.1.7 → 0.2.0

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.
@@ -32,11 +32,12 @@ module Rubycli
32
32
  summary_display_lines = []
33
33
  detail_lines = []
34
34
  summary_phase = true
35
+ deferred_positional = nil
35
36
 
36
37
  comment_lines.each do |content|
37
38
  stripped = content.strip
38
39
  if summary_phase && stripped.empty?
39
- summary_display_lines << ""
40
+ summary_display_lines << ''
40
41
  next
41
42
  end
42
43
 
@@ -69,7 +70,14 @@ module Rubycli
69
70
  end
70
71
 
71
72
  if (positional = parse_positional_line(stripped, method_obj))
72
- metadata[:positionals] << positional
73
+ # An untyped uppercase line opening a doc block ("JSON output
74
+ # formatter for reports.") reads exactly like a placeholder. Hold on
75
+ # to it and decide once the whole block is known.
76
+ if summary_phase && deferred_positional.nil? && !positional.inline_type_annotation
77
+ deferred_positional = { content: content, stripped: stripped, definition: positional }
78
+ else
79
+ metadata[:positionals] << positional
80
+ end
73
81
  summary_phase = false
74
82
  next
75
83
  end
@@ -81,6 +89,15 @@ module Rubycli
81
89
  end
82
90
  end
83
91
 
92
+ if deferred_positional
93
+ if positional_documentation_needed?(method_obj, metadata[:positionals].size)
94
+ metadata[:positionals].unshift(deferred_positional[:definition])
95
+ else
96
+ summary_display_lines << deferred_positional[:content].rstrip
97
+ summary_compact_lines << deferred_positional[:stripped]
98
+ end
99
+ end
100
+
84
101
  summary_text = summary_compact_lines.join(' ')
85
102
  summary_text = nil if summary_text.empty?
86
103
  metadata[:summary] = summary_text
@@ -93,6 +110,15 @@ module Rubycli
93
110
  metadata
94
111
  end
95
112
 
113
+ # True while the documented placeholders do not yet cover every positional
114
+ # parameter, which is what tells a real placeholder line from prose.
115
+ def positional_documentation_needed?(method_obj, documented_count)
116
+ return true unless method_obj.respond_to?(:parameters)
117
+
118
+ positional_count = method_obj.parameters.count { |type, _| %i[req opt rest].include?(type) }
119
+ documented_count < positional_count
120
+ end
121
+
96
122
  def trim_blank_edges(lines)
97
123
  return [] if lines.nil? || lines.empty?
98
124
 
@@ -108,15 +134,13 @@ module Rubycli
108
134
  def parse_tagged_param_line(line, method_obj)
109
135
  return nil unless line.start_with?('@param')
110
136
 
111
- source_file = nil
137
+ nil
112
138
  source_line = nil
113
- if method_obj.respond_to?(:source_location)
114
- source_file, source_line = method_obj.source_location
115
- end
139
+ _, source_line = method_obj.source_location if method_obj.respond_to?(:source_location)
116
140
  line_number = source_line ? [source_line - 1, 1].max : nil
117
141
 
118
142
  unless @environment.allow_param_comments?
119
- source_file, source_line = method_obj.source_location
143
+ source_file, = method_obj.source_location
120
144
  @environment.handle_documentation_issue(
121
145
  '@param notation is disabled. Enable it via ENV RUBYCLI_ALLOW_PARAM_COMMENT=ON.',
122
146
  file: source_file,
@@ -134,7 +158,7 @@ module Rubycli
134
158
  type_str = match[2]
135
159
  option_tokens = combine_bracketed_tokens(match[3]&.split(/\s+/) || [])
136
160
  description = match[4]&.strip
137
- description = nil if description&.empty?
161
+ description = nil if description && description.empty?
138
162
 
139
163
  raw_types = parse_type_annotation(type_str)
140
164
  audit_type_annotation_tokens(raw_types, method_obj)
@@ -248,13 +272,13 @@ module Rubycli
248
272
  return nil unless line.start_with?('--') || line.start_with?('-')
249
273
 
250
274
  raw_tokens = combine_bracketed_tokens(line.split(/\s+/))
251
- tokens = raw_tokens.flat_map { |token|
275
+ tokens = raw_tokens.flat_map do |token|
252
276
  if token.include?('/') && !token.start_with?('[')
253
277
  token.split('/')
254
278
  else
255
279
  [token]
256
280
  end
257
- }
281
+ end
258
282
 
259
283
  long_option = nil
260
284
  short_option = nil
@@ -341,9 +365,13 @@ module Rubycli
341
365
  return nil unless placeholder_token?(clean_placeholder)
342
366
 
343
367
  type_token = nil
344
- if tokens.first && type_token_candidate?(tokens.first)
345
- type_token = tokens.shift
346
- end
368
+ type_token = tokens.shift if tokens.first && type_token_candidate?(tokens.first)
369
+
370
+ # Summaries such as "A command that ..." or "I keep ..." start with an
371
+ # all-uppercase single letter and would otherwise take over the first
372
+ # positional argument. Require a type annotation to use one as a
373
+ # placeholder; "A [String] ..." keeps working.
374
+ return nil if type_token.nil? && clean_placeholder.match?(/\A[A-Z]\z/)
347
375
 
348
376
  description = tokens.join(' ').strip
349
377
  description = nil if description.empty?
@@ -392,14 +420,14 @@ module Rubycli
392
420
  return ReturnDefinition.new(types: types, description: description)
393
421
  end
394
422
 
395
- if line.start_with?('return ')
396
- stripped = line.sub(/\Areturn\s+/, '')
397
- type_token, description = stripped.split(/\s+/, 2)
398
- types = parse_type_annotation(type_token)
399
- audit_type_annotation_tokens(types, method_obj)
400
- description = description&.strip
401
- return ReturnDefinition.new(types: types, description: description)
402
- end
423
+ return unless line.start_with?('return ')
424
+
425
+ stripped = line.sub(/\Areturn\s+/, '')
426
+ type_token, description = stripped.split(/\s+/, 2)
427
+ types = parse_type_annotation(type_token)
428
+ audit_type_annotation_tokens(types, method_obj)
429
+ description = description&.strip
430
+ ReturnDefinition.new(types: types, description: description)
403
431
  end
404
432
 
405
433
  def doc_issue_location(method_obj)
@@ -437,11 +465,10 @@ module Rubycli
437
465
  end
438
466
 
439
467
  literal_entry = literal_entry_from_token(normalized)
440
- if literal_entry
441
- return
442
- end
468
+ return if literal_entry
443
469
 
444
- if literal_context && literal_token_candidate?(normalized, include_uppercase: true) && !known_type_token?(normalized)
470
+ if literal_context && literal_token_candidate?(normalized,
471
+ include_uppercase: true) && !known_type_token?(normalized)
445
472
  warn_unknown_allowed_value(normalized, source_file, line_number)
446
473
  return
447
474
  end
@@ -478,6 +505,7 @@ module Rubycli
478
505
  line = lines[index]
479
506
  signature << line
480
507
  break if balanced_signature?(signature)
508
+
481
509
  index += 1
482
510
  end
483
511
 
@@ -520,12 +548,13 @@ module Rubycli
520
548
 
521
549
  def extract_params_from_signature(signature)
522
550
  return nil unless (def_match = signature.match(/\bdef\b\s+[^(\s]+\s*(\((.*)\))?/m))
551
+
523
552
  if def_match[1]
524
- inner = def_match[1][1..-2]
525
- inner
553
+ def_match[1][1..-2]
554
+
526
555
  else
527
556
  signature_after_def = signature.sub(/.*\bdef\b\s+[^(\s]+\s*/m, '')
528
- signature_after_def.split(/\n/).first&.strip
557
+ signature_after_def.split("\n").first&.strip
529
558
  end
530
559
  end
531
560
 
@@ -564,18 +593,16 @@ module Rubycli
564
593
 
565
594
  source_file = nil
566
595
  source_line = nil
567
- if method_obj.respond_to?(:source_location)
568
- source_file, source_line = method_obj.source_location
569
- end
596
+ source_file, source_line = method_obj.source_location if method_obj.respond_to?(:source_location)
570
597
  line_for_comment = source_line ? [source_line - 1, 1].max : nil
571
598
 
572
599
  method_obj.parameters.each do |type, name|
573
600
  case type
574
- when :req, :opt
575
- doc = positional_defs.shift
601
+ when :req, :opt, :rest
602
+ doc = take_positional_definition(positional_defs, name)
576
603
  if doc
577
604
  doc.param_name = name
578
- doc.default_value = defaults[name]
605
+ doc.default_value = type == :rest ? [] : defaults[name]
579
606
  positional_map[name] = doc
580
607
  else
581
608
  @environment.handle_documentation_issue(
@@ -587,10 +614,10 @@ module Rubycli
587
614
  fallback = PositionalDefinition.new(
588
615
  placeholder: name.to_s,
589
616
  label: name.to_s.upcase,
590
- types: ['String'],
617
+ types: [type == :rest ? 'String[]' : 'String'],
591
618
  description: nil,
592
619
  param_name: name,
593
- default_value: defaults[name],
620
+ default_value: type == :rest ? [] : defaults[name],
594
621
  inline_type_annotation: false,
595
622
  inline_type_text: nil,
596
623
  doc_format: :auto_generated,
@@ -641,25 +668,46 @@ module Rubycli
641
668
  metadata[:positionals_map] = positional_map
642
669
 
643
670
  metadata[:options].each do |opt|
644
- if defaults.key?(opt.keyword)
645
- opt.default_value = defaults[opt.keyword]
646
- if TypeUtils.boolean_string?(opt.default_value)
647
- opt.boolean_flag = true
648
- opt.requires_value = false
649
- if opt.doc_format == :auto_generated
650
- opt.value_name = nil
651
- opt.types = ['Boolean']
652
- end
653
- elsif opt.boolean_flag
654
- opt.boolean_flag = false
655
- opt.requires_value = true
656
- opt.value_name ||= default_placeholder_for(opt.keyword)
657
- opt.types = ['String'] if opt.types.nil? || opt.types.empty?
671
+ next unless defaults.key?(opt.keyword)
672
+
673
+ opt.default_value = defaults[opt.keyword]
674
+ if boolean_default?(opt.default_value)
675
+ opt.boolean_flag = true
676
+ opt.requires_value = false
677
+ if opt.doc_format == :auto_generated
678
+ opt.value_name = nil
679
+ opt.types = ['Boolean']
658
680
  end
681
+ elsif opt.boolean_flag && !TypeUtils.boolean_string?(opt.default_value)
682
+ opt.boolean_flag = false
683
+ opt.requires_value = true
684
+ opt.value_name ||= default_placeholder_for(opt.keyword)
685
+ opt.types = ['String'] if opt.types.nil? || opt.types.empty?
659
686
  end
660
687
  end
661
688
  end
662
689
 
690
+ # Only a literal true/false default turns an option into a boolean flag.
691
+ # Values such as 0/1 are truthy for TypeUtils.boolean_string?, but they are
692
+ # ordinary defaults for numeric options, so they must keep accepting a value.
693
+ def boolean_default?(default_value)
694
+ return true if [true, false].include?(default_value)
695
+
696
+ %w[true false].include?(default_value.to_s.strip.downcase)
697
+ end
698
+
699
+ def take_positional_definition(positional_defs, parameter_name)
700
+ tagged_index = positional_defs.index do |definition|
701
+ definition.doc_format == :tagged_param && definition.param_name == parameter_name
702
+ end
703
+ return positional_defs.delete_at(tagged_index) if tagged_index
704
+
705
+ tagless_index = positional_defs.index { |definition| definition.doc_format != :tagged_param }
706
+ return positional_defs.delete_at(tagless_index) if tagless_index
707
+
708
+ nil
709
+ end
710
+
663
711
  def detail_line_for_extra_positional(doc)
664
712
  return nil unless doc
665
713
 
@@ -669,9 +717,7 @@ module Rubycli
669
717
  parts << placeholder unless placeholder.empty?
670
718
 
671
719
  type_text = doc.inline_type_text
672
- if (!type_text || type_text.empty?) && doc.types && !doc.types.empty?
673
- type_text = "[#{doc.types.join(', ')}]"
674
- end
720
+ type_text = "[#{doc.types.join(', ')}]" if (!type_text || type_text.empty?) && doc.types && !doc.types.empty?
675
721
  parts << type_text if type_text && !type_text.empty?
676
722
 
677
723
  description = doc.description.to_s.strip
@@ -762,10 +808,9 @@ module Rubycli
762
808
 
763
809
  stripped = token.strip
764
810
  return nil if stripped.empty?
811
+
765
812
  stripped = stripped[1..] if stripped.start_with?('[') && !stripped.end_with?(']')
766
- if stripped.end_with?(']') && !stripped.include?('[')
767
- stripped = stripped[0...-1]
768
- end
813
+ stripped = stripped[0...-1] if stripped.end_with?(']') && !stripped.include?('[')
769
814
 
770
815
  lowered = stripped.downcase
771
816
  return { kind: :literal, value: nil } if %w[nil null ~].include?(lowered)
@@ -787,17 +832,11 @@ module Rubycli
787
832
  return { kind: :literal, value: stripped[1..-2] }
788
833
  end
789
834
 
790
- if stripped.match?(/\A-?\d+\z/)
791
- return { kind: :literal, value: Integer(stripped) }
792
- end
835
+ return { kind: :literal, value: Integer(stripped) } if stripped.match?(/\A-?\d+\z/)
793
836
 
794
- if stripped.match?(/\A-?\d+\.\d+\z/)
795
- return { kind: :literal, value: Float(stripped) }
796
- end
837
+ return { kind: :literal, value: Float(stripped) } if stripped.match?(/\A-?\d+\.\d+\z/)
797
838
 
798
- if stripped.match?(/\A[a-z0-9._-]+\z/)
799
- return { kind: :literal, value: stripped }
800
- end
839
+ return { kind: :literal, value: stripped } if stripped.match?(/\A[a-z0-9._-]+\z/)
801
840
 
802
841
  nil
803
842
  rescue ArgumentError
@@ -897,7 +936,6 @@ module Rubycli
897
936
  @type_dictionary = nil
898
937
  end
899
938
 
900
-
901
939
  def placeholder_token?(token)
902
940
  return false unless token
903
941
 
@@ -908,7 +946,7 @@ module Rubycli
908
946
  candidate = candidate[1..-2].strip if optional
909
947
  return false if candidate.empty?
910
948
 
911
- candidate = candidate.gsub(/[,\|]/, '')
949
+ candidate = candidate.gsub(/[,|]/, '')
912
950
  return false if candidate.empty?
913
951
 
914
952
  ellipsis = candidate.end_with?('...')
@@ -990,8 +1028,6 @@ module Rubycli
990
1028
  stripped[0..-3]
991
1029
  elsif stripped.start_with?('Array<') && stripped.end_with?('>')
992
1030
  stripped[6..-2].strip
993
- else
994
- nil
995
1031
  end
996
1032
  end
997
1033
 
@@ -1105,16 +1141,14 @@ module Rubycli
1105
1141
  normalized_long = normalize_long_option(long_option)
1106
1142
  normalized_short = normalize_short_option(short_option)
1107
1143
  value_placeholder = value_name&.strip
1108
- value_placeholder = nil if value_placeholder&.empty?
1144
+ value_placeholder = nil if value_placeholder && value_placeholder.empty?
1109
1145
  description_text = description&.strip
1110
- description_text = nil if description_text&.empty?
1146
+ description_text = nil if description_text && description_text.empty?
1111
1147
 
1112
1148
  placeholder_info = analyze_placeholder(value_placeholder)
1113
1149
  normalized_types = normalize_type_list(types)
1114
1150
  inferred_types = infer_types_from_placeholder(normalized_types, placeholder_info)
1115
- if inferred_types.empty? && value_placeholder.nil?
1116
- inferred_types = ['Boolean']
1117
- end
1151
+ inferred_types = ['Boolean'] if inferred_types.empty? && value_placeholder.nil?
1118
1152
 
1119
1153
  optional_value = placeholder_info[:optional]
1120
1154
  boolean_flag = !optional_value && inferred_types.any? { |type| boolean_type?(type) }
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rubycli
2
4
  # Environment captures runtime flags and configuration derived from ENV / argv.
3
5
  class Environment
@@ -45,6 +47,10 @@ module Rubycli
45
47
  @strict_input
46
48
  end
47
49
 
50
+ def disable_strict_input!
51
+ @strict_input = false
52
+ end
53
+
48
54
  def documentation_issues
49
55
  @documentation_issues.dup
50
56
  end
@@ -70,10 +76,10 @@ module Rubycli
70
76
  end
71
77
 
72
78
  formatted_message = if location
73
- "#{location} #{message}"
74
- else
75
- message
76
- end
79
+ "#{location} #{message}"
80
+ else
81
+ message
82
+ end
77
83
 
78
84
  entry = { message: formatted_message, location: location }
79
85
  @documentation_issues << entry
@@ -81,17 +87,19 @@ module Rubycli
81
87
  end
82
88
 
83
89
  def handle_input_violation(message)
84
- if strict_input?
85
- raise Rubycli::ArgumentError, message
86
- else
87
- warn "[WARN] #{message} (use --strict to abort on invalid input)"
88
- end
90
+ raise Rubycli::ArgumentError, message if strict_input?
91
+
92
+ warn "[WARN] #{message} (use --strict to abort on invalid input)"
89
93
  end
90
94
 
91
95
  def enable_print_result!
92
96
  @print_result = true
93
97
  end
94
98
 
99
+ def disable_print_result!
100
+ @print_result = false
101
+ end
102
+
95
103
  private
96
104
 
97
105
  def fetch_env_value(key, default)
@@ -1,8 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Rubycli
2
4
  class EvalCoercer
3
5
  THREAD_KEY = :rubycli_eval_mode
4
6
  LAX_THREAD_KEY = :rubycli_eval_lax_mode
5
- EVAL_BINDING = Object.new.instance_eval { binding }
7
+ BINDING_THREAD_KEY = :rubycli_eval_binding
6
8
 
7
9
  def eval_mode?
8
10
  Thread.current[THREAD_KEY] == true
@@ -12,15 +14,28 @@ module Rubycli
12
14
  Thread.current[LAX_THREAD_KEY] == true
13
15
  end
14
16
 
15
- def with_eval_mode(enabled = true, lax: false)
17
+ def with_eval_mode(enabled = true, lax: false, reuse_binding: false)
16
18
  previous = Thread.current[THREAD_KEY]
17
19
  previous_lax = Thread.current[LAX_THREAD_KEY]
20
+ previous_binding = Thread.current[BINDING_THREAD_KEY]
18
21
  Thread.current[THREAD_KEY] = enabled
19
22
  Thread.current[LAX_THREAD_KEY] = enabled && lax
23
+ if enabled
24
+ Thread.current[BINDING_THREAD_KEY] = reuse_binding && previous_binding ? previous_binding : isolated_binding
25
+ end
20
26
  yield
21
27
  ensure
22
28
  Thread.current[THREAD_KEY] = previous
23
29
  Thread.current[LAX_THREAD_KEY] = previous_lax
30
+ Thread.current[BINDING_THREAD_KEY] = previous_binding
31
+ end
32
+
33
+ def with_eval_binding
34
+ previous_binding = Thread.current[BINDING_THREAD_KEY]
35
+ Thread.current[BINDING_THREAD_KEY] = isolated_binding
36
+ yield
37
+ ensure
38
+ Thread.current[BINDING_THREAD_KEY] = previous_binding
24
39
  end
25
40
 
26
41
  def coerce_eval_value(value)
@@ -34,7 +49,7 @@ module Rubycli
34
49
  else
35
50
  value
36
51
  end
37
- rescue StandardError => e
52
+ rescue SyntaxError, StandardError => e
38
53
  raise Rubycli::ArgumentError, "Failed to evaluate Ruby code: #{e.message}"
39
54
  end
40
55
 
@@ -44,14 +59,16 @@ module Rubycli
44
59
  trimmed = expression.strip
45
60
  return trimmed if trimmed.empty?
46
61
 
47
- EVAL_BINDING.eval(trimmed)
62
+ (Thread.current[BINDING_THREAD_KEY] || isolated_binding).eval(trimmed)
48
63
  rescue SyntaxError, NameError => e
49
- if eval_lax_mode?
50
- warn "[WARN] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled."
51
- expression
52
- else
53
- raise
54
- end
64
+ raise unless eval_lax_mode?
65
+
66
+ warn "[WARN] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled."
67
+ expression
68
+ end
69
+
70
+ def isolated_binding
71
+ Object.new.instance_eval { binding }
55
72
  end
56
73
  end
57
74
  end