rubycli 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,11 @@ module Rubycli
15
15
  trimmed = value.strip
16
16
  return value if trimmed.empty?
17
17
 
18
+ if symbol_literal?(trimmed)
19
+ symbol_value = trimmed.delete_prefix(':')
20
+ return symbol_value.to_sym unless symbol_value.empty?
21
+ end
22
+
18
23
  if literal_like?(trimmed)
19
24
  literal = try_literal_parse(value)
20
25
  return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
@@ -33,6 +38,12 @@ module Rubycli
33
38
 
34
39
  private
35
40
 
41
+ def symbol_literal?(value)
42
+ return false unless value
43
+
44
+ value.start_with?(':') && value.length > 1 && value[1..].match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
45
+ end
46
+
36
47
  def integer_string?(str)
37
48
  str =~ /\A-?\d+\z/
38
49
  end
data/lib/rubycli/cli.rb CHANGED
@@ -48,6 +48,9 @@ module Rubycli
48
48
  else
49
49
  execute_method(entry.method, command, args, cli_mode)
50
50
  end
51
+ rescue Rubycli::ArgumentError => e
52
+ warn "[ERROR] #{e.message}"
53
+ 1
51
54
  end
52
55
 
53
56
  def available_commands(target)
@@ -113,6 +116,7 @@ module Rubycli
113
116
  method = target.method(:call)
114
117
  pos_args, kw_args = @argument_parser.parse(args, method)
115
118
  Rubycli.apply_argument_coercions(pos_args, kw_args)
119
+ @argument_parser.validate_inputs(method, pos_args, kw_args)
116
120
  begin
117
121
  result = Rubycli.call_target(target, pos_args, kw_args)
118
122
  @result_emitter.emit(result)
@@ -151,11 +155,11 @@ module Rubycli
151
155
  def execute_method_with_params(method_obj, command, args, cli_mode)
152
156
  pos_args, kw_args = @argument_parser.parse(args, method_obj)
153
157
  Rubycli.apply_argument_coercions(pos_args, kw_args)
154
-
155
158
  if should_show_method_help?(pos_args, kw_args)
156
159
  puts usage_for_method(command, method_obj)
157
160
  return 0
158
161
  end
162
+ @argument_parser.validate_inputs(method_obj, pos_args, kw_args)
159
163
 
160
164
  begin
161
165
  result = Rubycli.call_target(method_obj, pos_args, kw_args)
@@ -3,7 +3,7 @@
3
3
  module Rubycli
4
4
  module CommandLine
5
5
  USAGE = <<~USAGE
6
- Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] <target-path> [<class-or-module>] [-- <cli-args>...]
6
+ Usage: rubycli [--new|-n] [--pre-script=<src>] [--json-args|-j | --eval-args|-e | --eval-lax|-E] [--strict] [--check|-c] <target-path> [<class-or-module>] [-- <cli-args>...]
7
7
 
8
8
  Examples:
9
9
  rubycli scripts/sample_runner.rb echo --message hello
@@ -17,6 +17,8 @@ module Rubycli
17
17
  --eval-args, -e Evaluate following arguments as Ruby code
18
18
  --eval-lax, -E Evaluate as Ruby but fall back to raw strings when parsing fails
19
19
  --auto-target, -a Auto-select the only callable constant when names don't match
20
+ --strict Enforce documented input types/choices (invalid values abort)
21
+ --check, -c Validate documentation/comments without executing commands
20
22
  (Note: --json-args cannot be combined with --eval-args or --eval-lax)
21
23
  (Note: Every option that accepts a value understands both --flag=value and --flag value forms.)
22
24
 
@@ -42,6 +44,7 @@ module Rubycli
42
44
  eval_mode = false
43
45
  eval_lax_mode = false
44
46
  constant_mode = nil
47
+ check_mode = false
45
48
  pre_script_sources = []
46
49
 
47
50
  loop do
@@ -64,7 +67,7 @@ module Rubycli
64
67
  flag = args.shift
65
68
  src = args.shift
66
69
  unless src
67
- warn "#{flag} requires a file path or inline Ruby code"
70
+ warn "[ERROR] #{flag} requires a file path or inline Ruby code"
68
71
  return 1
69
72
  end
70
73
  context = File.file?(src) ? File.expand_path(src) : "(inline #{flag})"
@@ -79,8 +82,19 @@ module Rubycli
79
82
  eval_mode = true
80
83
  eval_lax_mode = true
81
84
  args.shift
85
+ when '--strict'
86
+ Rubycli.environment.enable_strict_input!
87
+ args.shift
88
+ when '--check', '-c'
89
+ check_mode = true
90
+ Rubycli.environment.enable_doc_check!
91
+ args.shift
82
92
  when '--print-result'
83
- args.shift
93
+ args.shift
94
+ when '--debug'
95
+ args.shift
96
+ warn "[ERROR] --debug flag has been removed; set RUBYCLI_DEBUG=true instead."
97
+ return 1
84
98
  when '--auto-target', '-a'
85
99
  constant_mode = :auto
86
100
  args.shift
@@ -106,10 +120,32 @@ module Rubycli
106
120
  args.shift if args.first == '--'
107
121
 
108
122
  if json_mode && eval_mode
109
- warn '--json-args cannot be combined with --eval-args or --eval-lax'
123
+ warn '[ERROR] --json-args cannot be combined with --eval-args or --eval-lax'
124
+ return 1
125
+ end
126
+
127
+ if check_mode && (json_mode || eval_mode)
128
+ warn '[ERROR] --check cannot be combined with --json-args or --eval-args'
110
129
  return 1
111
130
  end
112
131
 
132
+ if check_mode && !args.empty?
133
+ warn '[ERROR] --check does not accept command arguments'
134
+ return 1
135
+ end
136
+
137
+ Rubycli.environment.clear_documentation_issues!
138
+
139
+ if check_mode
140
+ return Rubycli::Runner.check(
141
+ target_path,
142
+ class_or_module,
143
+ new: new_flag,
144
+ pre_scripts: pre_script_sources,
145
+ constant_mode: constant_mode
146
+ )
147
+ end
148
+
113
149
  Rubycli::Runner.execute(
114
150
  target_path,
115
151
  class_or_module,
@@ -124,10 +160,10 @@ module Rubycli
124
160
 
125
161
  0
126
162
  rescue Rubycli::Runner::PreScriptError => e
127
- warn e.message
163
+ warn "[ERROR] #{e.message}"
128
164
  1
129
165
  rescue Rubycli::Runner::Error => e
130
- warn e.message
166
+ warn "[ERROR] #{e.message}"
131
167
  1
132
168
  end
133
169
  end
@@ -122,7 +122,7 @@ module Rubycli
122
122
  file: source_file,
123
123
  line: line_number
124
124
  )
125
- return nil if @environment.strict_mode?
125
+ return nil if @environment.doc_check_mode?
126
126
  end
127
127
 
128
128
  pattern = /\A@param\s+([a-zA-Z0-9_]+)(?:\s+\[([^\]]+)\])?(?:\s+\(([^)]+)\))?(?:\s+(.*))?\z/
@@ -130,12 +130,14 @@ module Rubycli
130
130
  return nil unless match
131
131
 
132
132
  param_name = match[1]
133
+ param_symbol = param_name.to_sym
133
134
  type_str = match[2]
134
135
  option_tokens = combine_bracketed_tokens(match[3]&.split(/\s+/) || [])
135
136
  description = match[4]&.strip
136
137
  description = nil if description&.empty?
137
138
 
138
- types = parse_type_annotation(type_str)
139
+ raw_types = parse_type_annotation(type_str)
140
+ types, allowed_values = partition_type_tokens(raw_types)
139
141
 
140
142
  long_option = nil
141
143
  short_option = nil
@@ -179,23 +181,36 @@ module Rubycli
179
181
  end
180
182
 
181
183
  long_option ||= "--#{param_name.tr('_', '-')}"
184
+ role = parameter_role(method_obj, param_symbol)
185
+ if value_name.nil?
186
+ if role == :positional
187
+ value_name = default_placeholder_for(param_symbol)
188
+ elsif !types&.any? { |entry| boolean_type?(entry) }
189
+ # Most keywords expect a value; boolean flags should be documented with [Boolean].
190
+ value_name = default_placeholder_for(param_symbol)
191
+ end
192
+ end
182
193
 
183
- types = parse_type_annotation(type_token) if (types.nil? || types.empty?) && type_token
194
+ if (types.nil? || types.empty?) && type_token
195
+ inline_raw_types = parse_type_annotation(type_token)
196
+ inline_types, inline_allowed = partition_type_tokens(inline_raw_types)
197
+ types = inline_types
198
+ allowed_values = merge_allowed_values(allowed_values, inline_allowed)
199
+ end
184
200
 
201
+ # TODO: Derive primitive types from Ruby default values when explicit hints are absent.
185
202
  option_def = build_option_definition(
186
- param_name.to_sym,
203
+ param_symbol,
187
204
  long_option,
188
205
  short_option,
189
206
  value_name,
190
207
  types,
191
208
  description,
192
209
  inline_type_annotation: !type_token.nil?,
193
- doc_format: :tagged_param
210
+ doc_format: :tagged_param,
211
+ allowed_values: allowed_values
194
212
  )
195
213
 
196
- param_symbol = param_name.to_sym
197
- role = parameter_role(method_obj, param_symbol)
198
-
199
214
  if role == :positional
200
215
  placeholder = option_def.value_name || default_placeholder_for(option_def.keyword)
201
216
  return PositionalDefinition.new(
@@ -204,7 +219,8 @@ module Rubycli
204
219
  types: option_def.types,
205
220
  description: option_def.description,
206
221
  param_name: param_symbol,
207
- doc_format: option_def.doc_format
222
+ doc_format: option_def.doc_format,
223
+ allowed_values: option_def.allowed_values
208
224
  )
209
225
  elsif role == :keyword
210
226
  return option_def
@@ -218,7 +234,8 @@ module Rubycli
218
234
  types: option_def.types,
219
235
  description: option_def.description,
220
236
  param_name: param_symbol,
221
- doc_format: option_def.doc_format
237
+ doc_format: option_def.doc_format,
238
+ allowed_values: option_def.allowed_values
222
239
  )
223
240
  end
224
241
 
@@ -291,7 +308,8 @@ module Rubycli
291
308
 
292
309
  description = description_tokens.join(' ').strip
293
310
  description = nil if description.empty?
294
- types = parse_type_annotation(type_token)
311
+ raw_types = parse_type_annotation(type_token)
312
+ types, allowed_values = partition_type_tokens(raw_types)
295
313
 
296
314
  keyword = long_option.delete_prefix('--').tr('-', '_').to_sym
297
315
  return nil unless method_accepts_keyword?(method_obj, keyword)
@@ -304,7 +322,8 @@ module Rubycli
304
322
  types,
305
323
  description,
306
324
  inline_type_annotation: !type_token.nil?,
307
- doc_format: :rubycli
325
+ doc_format: :rubycli,
326
+ allowed_values: allowed_values
308
327
  )
309
328
  end
310
329
 
@@ -326,7 +345,8 @@ module Rubycli
326
345
  description = tokens.join(' ').strip
327
346
  description = nil if description.empty?
328
347
 
329
- types = parse_type_annotation(type_token)
348
+ raw_types = parse_type_annotation(type_token)
349
+ types, allowed_values = partition_type_tokens(raw_types)
330
350
  placeholder_info = analyze_placeholder(placeholder)
331
351
  inferred_types = infer_types_from_placeholder(
332
352
  normalize_type_list(types),
@@ -346,7 +366,8 @@ module Rubycli
346
366
  description: description,
347
367
  inline_type_annotation: inline_annotation,
348
368
  inline_type_text: inline_text,
349
- doc_format: :rubycli
369
+ doc_format: :rubycli,
370
+ allowed_values: allowed_values
350
371
  )
351
372
  end
352
373
 
@@ -493,16 +514,18 @@ module Rubycli
493
514
  file: source_file,
494
515
  line: line_for_comment
495
516
  )
496
- unless @environment.strict_mode?
517
+ unless @environment.doc_check_mode?
497
518
  fallback = PositionalDefinition.new(
498
519
  placeholder: name.to_s,
499
520
  label: name.to_s.upcase,
500
- types: [],
521
+ types: ['String'],
501
522
  description: nil,
502
523
  param_name: name,
503
524
  default_value: defaults[name],
504
525
  inline_type_annotation: false,
505
- inline_type_text: nil
526
+ inline_type_text: nil,
527
+ doc_format: :auto_generated,
528
+ allowed_values: []
506
529
  )
507
530
  metadata[:positionals] << fallback
508
531
  positional_map[name] = fallback
@@ -517,7 +540,7 @@ module Rubycli
517
540
  file: source_file,
518
541
  line: line_for_comment
519
542
  )
520
- unless @environment.strict_mode?
543
+ unless @environment.doc_check_mode?
521
544
  fallback_option = build_auto_option_definition(name)
522
545
  ordered_options << fallback_option if fallback_option
523
546
  end
@@ -558,6 +581,11 @@ module Rubycli
558
581
  opt.value_name = nil
559
582
  opt.types = ['Boolean']
560
583
  end
584
+ elsif opt.boolean_flag
585
+ opt.boolean_flag = false
586
+ opt.requires_value = true
587
+ opt.value_name ||= default_placeholder_for(opt.keyword)
588
+ opt.types = ['String'] if opt.types.nil? || opt.types.empty?
561
589
  end
562
590
  end
563
591
  end
@@ -616,6 +644,98 @@ module Rubycli
616
644
  cleaned.split(/[,|]/).map { |token| normalize_type_token(token) }.reject(&:empty?)
617
645
  end
618
646
 
647
+ def partition_type_tokens(tokens)
648
+ normalized = Array(tokens).dup
649
+ allowed = []
650
+
651
+ normalized.each do |token|
652
+ expand_annotation_token(token).each do |expanded|
653
+ literal_entry = literal_entry_from_token(expanded)
654
+ allowed << literal_entry if literal_entry
655
+ end
656
+ end
657
+
658
+ [normalized, allowed.compact.uniq]
659
+ end
660
+
661
+ def merge_allowed_values(primary, additional)
662
+ return Array(additional) if primary.nil? || primary.empty?
663
+ return Array(primary) if additional.nil? || additional.empty?
664
+
665
+ (primary + additional).uniq
666
+ end
667
+
668
+ def expand_annotation_token(token)
669
+ return [] unless token
670
+
671
+ stripped = token.strip
672
+ return [] if stripped.empty?
673
+
674
+ if stripped.start_with?('%i[') && stripped.end_with?(']')
675
+ inner = stripped[3..-2]
676
+ inner.split(/\s+/).map { |entry| ":#{entry}" }
677
+ elsif stripped.start_with?('%I[') && stripped.end_with?(']')
678
+ inner = stripped[3..-2]
679
+ inner.split(/\s+/).map { |entry| ":#{entry}" }
680
+ elsif stripped.start_with?('%w[') && stripped.end_with?(']')
681
+ inner = stripped[3..-2]
682
+ inner.split(/\s+/)
683
+ elsif stripped.start_with?('%W[') && stripped.end_with?(']')
684
+ inner = stripped[3..-2]
685
+ inner.split(/\s+/)
686
+ else
687
+ [stripped]
688
+ end
689
+ end
690
+
691
+ def literal_entry_from_token(token)
692
+ return nil unless token
693
+
694
+ stripped = token.strip
695
+ return nil if stripped.empty?
696
+ stripped = stripped[1..] if stripped.start_with?('[') && !stripped.end_with?(']')
697
+ if stripped.end_with?(']') && !stripped.include?('[')
698
+ stripped = stripped[0...-1]
699
+ end
700
+
701
+ lowered = stripped.downcase
702
+ return { kind: :literal, value: nil } if %w[nil null ~].include?(lowered)
703
+ return { kind: :literal, value: true } if lowered == 'true'
704
+ return { kind: :literal, value: false } if lowered == 'false'
705
+
706
+ if stripped.start_with?(':')
707
+ sym_name = stripped[1..]
708
+ return nil if sym_name.nil? || sym_name.empty?
709
+
710
+ return { kind: :literal, value: sym_name.to_sym }
711
+ end
712
+
713
+ if stripped.start_with?('"') && stripped.end_with?('"') && stripped.length >= 2
714
+ return { kind: :literal, value: stripped[1..-2] }
715
+ end
716
+
717
+ if stripped.start_with?("'") && stripped.end_with?("'") && stripped.length >= 2
718
+ return { kind: :literal, value: stripped[1..-2] }
719
+ end
720
+
721
+ if stripped.match?(/\A-?\d+\z/)
722
+ return { kind: :literal, value: Integer(stripped) }
723
+ end
724
+
725
+ if stripped.match?(/\A-?\d+\.\d+\z/)
726
+ return { kind: :literal, value: Float(stripped) }
727
+ end
728
+
729
+ if stripped.match?(/\A[a-z0-9._-]+\z/)
730
+ return { kind: :literal, value: stripped }
731
+ end
732
+
733
+ nil
734
+ rescue ArgumentError
735
+ nil
736
+ end
737
+
738
+
619
739
  def placeholder_token?(token)
620
740
  return false unless token
621
741
 
@@ -652,11 +772,15 @@ module Rubycli
652
772
  return false if stripped.empty?
653
773
 
654
774
  return true if stripped.start_with?('@')
775
+ return true if stripped.start_with?('%')
776
+ return true if stripped.include?('::')
777
+ return true if stripped.start_with?('(') && stripped.end_with?(')')
778
+ return true if stripped.include?('[') && stripped.include?(']')
655
779
 
656
- parsed = parse_type_annotation(stripped)
657
- return false if parsed.empty?
780
+ normalized = normalize_type_token(stripped)
781
+ return false if normalized.empty?
658
782
 
659
- parsed.all? { |entry| inline_type_hint?(entry) }
783
+ INLINE_TYPE_HINTS.include?(normalized)
660
784
  end
661
785
 
662
786
  def known_type_token?(token)
@@ -723,6 +847,9 @@ module Rubycli
723
847
  elsif token.start_with?('(') && !token.include?(')')
724
848
  buffer = token.dup
725
849
  closing = ')'
850
+ elsif token.start_with?('%') && token.include?('[') && !token.include?(']')
851
+ buffer = token.dup
852
+ closing = ']'
726
853
  else
727
854
  combined << token
728
855
  end
@@ -755,7 +882,8 @@ module Rubycli
755
882
  types,
756
883
  description,
757
884
  inline_type_annotation: false,
758
- doc_format: nil
885
+ doc_format: nil,
886
+ allowed_values: nil
759
887
  )
760
888
  normalized_long = normalize_long_option(long_option)
761
889
  normalized_short = normalize_short_option(short_option)
@@ -800,10 +928,15 @@ module Rubycli
800
928
  optional_value: optional_value,
801
929
  inline_type_annotation: inline_type_annotation,
802
930
  inline_type_text: inline_type_text,
803
- doc_format: doc_format
931
+ doc_format: doc_format,
932
+ allowed_values: normalize_allowed_values(allowed_values)
804
933
  )
805
934
  end
806
935
 
936
+ def normalize_allowed_values(values)
937
+ Array(values).compact.uniq
938
+ end
939
+
807
940
  def build_auto_option_definition(keyword)
808
941
  long_option = "--#{keyword.to_s.tr('_', '-')}"
809
942
  placeholder = default_placeholder_for(keyword)
@@ -815,7 +948,8 @@ module Rubycli
815
948
  [],
816
949
  nil,
817
950
  inline_type_annotation: false,
818
- doc_format: :auto_generated
951
+ doc_format: :auto_generated,
952
+ allowed_values: []
819
953
  )
820
954
  end
821
955
 
@@ -830,7 +964,8 @@ module Rubycli
830
964
  default_value: option.default_value,
831
965
  inline_type_annotation: option.inline_type_annotation,
832
966
  inline_type_text: option.inline_type_text,
833
- doc_format: option.doc_format
967
+ doc_format: option.doc_format,
968
+ allowed_values: option.allowed_values
834
969
  )
835
970
  end
836
971
  end
@@ -20,7 +20,7 @@ module Rubycli
20
20
  location = method_obj.source_location
21
21
  return empty_metadata unless location
22
22
 
23
- cache_key = [location[0], location[1], @environment.strict_mode?, @environment.allow_param_comments?]
23
+ cache_key = [location[0], location[1], @environment.doc_check_mode?, @environment.allow_param_comments?]
24
24
  return deep_dup(@metadata_cache[cache_key]) if @metadata_cache.key?(cache_key)
25
25
 
26
26
  comment_lines = @comment_extractor.extract(location[0], location[1])
@@ -6,6 +6,9 @@ module Rubycli
6
6
  @argv = argv
7
7
  @debug = env['RUBYCLI_DEBUG'] == 'true'
8
8
  @print_result = env['RUBYCLI_PRINT_RESULT'] == 'true'
9
+ @doc_check_mode = false
10
+ @strict_input = false
11
+ @documentation_issues = []
9
12
  scrub_argv_flags!
10
13
  end
11
14
 
@@ -17,16 +20,39 @@ module Rubycli
17
20
  @print_result
18
21
  end
19
22
 
20
- def strict_mode?
21
- value = fetch_env_value('RUBYCLI_STRICT', 'OFF')
22
- !%w[off 0 false].include?(value.downcase)
23
- end
24
-
25
23
  def allow_param_comments?
26
24
  value = fetch_env_value('RUBYCLI_ALLOW_PARAM_COMMENT', 'ON')
27
25
  %w[on 1 true].include?(value)
28
26
  end
29
27
 
28
+ def enable_doc_check!
29
+ @doc_check_mode = true
30
+ end
31
+
32
+ def doc_check_mode?
33
+ @doc_check_mode
34
+ end
35
+
36
+ def disable_doc_check!
37
+ @doc_check_mode = false
38
+ end
39
+
40
+ def enable_strict_input!
41
+ @strict_input = true
42
+ end
43
+
44
+ def strict_input?
45
+ @strict_input
46
+ end
47
+
48
+ def documentation_issues
49
+ @documentation_issues.dup
50
+ end
51
+
52
+ def clear_documentation_issues!
53
+ @documentation_issues.clear
54
+ end
55
+
30
56
  def constant_resolution_mode
31
57
  value = fetch_env_value('RUBYCLI_AUTO_TARGET', 'strict')
32
58
  return :auto if %w[auto on true yes 1].include?(value)
@@ -49,7 +75,17 @@ module Rubycli
49
75
  message
50
76
  end
51
77
 
52
- warn "[WARN] Rubycli documentation mismatch: #{formatted_message}" if strict_mode?
78
+ entry = { message: formatted_message, location: location }
79
+ @documentation_issues << entry
80
+ warn "[WARN] Rubycli documentation mismatch: #{formatted_message}" if doc_check_mode?
81
+ end
82
+
83
+ 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
53
89
  end
54
90
 
55
91
  def enable_print_result!
@@ -65,7 +101,6 @@ module Rubycli
65
101
  def scrub_argv_flags!
66
102
  return unless @argv
67
103
 
68
- remove_all_flags!(@argv, '--debug') { @debug = true }
69
104
  remove_all_flags!(@argv, '--print-result') { @print_result = true }
70
105
  end
71
106
 
@@ -47,7 +47,7 @@ module Rubycli
47
47
  EVAL_BINDING.eval(trimmed)
48
48
  rescue SyntaxError, NameError => e
49
49
  if eval_lax_mode?
50
- warn "[rubycli] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled."
50
+ warn "[WARN] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled."
51
51
  expression
52
52
  else
53
53
  raise