rubycli 0.1.4 → 0.1.6

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,3 +1,5 @@
1
+ require 'did_you_mean'
2
+
1
3
  require_relative 'type_utils'
2
4
  require_relative 'arguments/token_stream'
3
5
  require_relative 'arguments/value_converter'
@@ -61,6 +63,14 @@ module Rubycli
61
63
  [pos_args, kw_args]
62
64
  end
63
65
 
66
+ def validate_inputs(method_obj, positional_args, keyword_args)
67
+ return unless method_obj
68
+
69
+ metadata = @documentation_registry.metadata_for(method_obj)
70
+ validate_positional_arguments(method_obj, metadata, positional_args)
71
+ validate_keyword_arguments(metadata, keyword_args)
72
+ end
73
+
64
74
  private
65
75
 
66
76
  def debug_log(message)
@@ -196,6 +206,235 @@ module Rubycli
196
206
  @value_converter.convert(arg)
197
207
  end
198
208
 
209
+ def validate_positional_arguments(method_obj, metadata, positional_args)
210
+ return if positional_args.nil? || positional_args.empty?
211
+
212
+ positional_map = metadata[:positionals_map] || {}
213
+ ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) }
214
+
215
+ ordered_params.each_with_index do |(_, name), index|
216
+ definition = positional_map[name]
217
+ next unless definition
218
+ next if index >= positional_args.size
219
+
220
+ label = definition.label || definition.placeholder || name.to_s.upcase
221
+ enforce_value_against_definition(definition, positional_args[index], label)
222
+ end
223
+ end
224
+
225
+ def validate_keyword_arguments(metadata, keyword_args)
226
+ return if keyword_args.nil? || keyword_args.empty?
227
+
228
+ option_lookup = build_option_lookup(metadata[:options] || [])
229
+ keyword_args.each do |key, value|
230
+ definition = option_lookup[key.to_sym]
231
+ next unless definition
232
+
233
+ label = definition.long || "--#{key.to_s.tr('_', '-')}"
234
+ enforce_value_against_definition(definition, value, label)
235
+ end
236
+ end
237
+
238
+ def enforce_value_against_definition(definition, value, label)
239
+ return unless definition
240
+
241
+ return if type_allowed?(definition.types, value)
242
+
243
+ Array(value.is_a?(Array) ? value : [value]).each do |entry|
244
+ next if literal_allowed?(definition.allowed_values, entry)
245
+ next if type_allowed?(definition.types, entry)
246
+
247
+ message = build_invalid_value_message(definition, entry, label)
248
+ @environment.handle_input_violation(message)
249
+ end
250
+ end
251
+
252
+ def build_invalid_value_message(definition, entry, label)
253
+ description = allowed_value_description(definition)
254
+ formatted_value = format_literal_value(entry)
255
+
256
+ message = if description
257
+ "#{label} must be #{description} (received #{formatted_value})"
258
+ else
259
+ "Value #{formatted_value} for #{label} is not allowed"
260
+ end
261
+
262
+ suggestions = literal_suggestions(definition, entry)
263
+ return message if suggestions.empty?
264
+
265
+ suggestion_text = suggestions.size == 1 ? suggestions.first : suggestions.join(', ')
266
+ "#{message}. Did you mean #{suggestion_text}?"
267
+ end
268
+
269
+ def literal_allowed?(allowed_entries, value)
270
+ entries = Array(allowed_entries).compact
271
+ return false if entries.empty?
272
+
273
+ entries.any? { |entry| literal_match?(entry[:value], value) }
274
+ end
275
+
276
+ def literal_match?(candidate, value)
277
+ case candidate
278
+ when Symbol
279
+ value.is_a?(Symbol) && value == candidate
280
+ when String
281
+ value.is_a?(String) && value == candidate
282
+ when Integer
283
+ value.is_a?(Integer) && value == candidate
284
+ when Float
285
+ value.is_a?(Float) && value == candidate
286
+ when TrueClass, FalseClass
287
+ value == candidate
288
+ when NilClass
289
+ value.nil?
290
+ else
291
+ value == candidate
292
+ end
293
+ end
294
+
295
+ def type_allowed?(types, value)
296
+ tokens = Array(types).compact
297
+ return false if tokens.empty?
298
+
299
+ tokens.any? { |token| matches_type_token?(token, value) }
300
+ end
301
+
302
+ def allowed_value_description(definition)
303
+ literal_descriptions = Array(definition.allowed_values).map { |entry| format_literal_value(entry[:value]) }.reject(&:empty?)
304
+ type_descriptions = Array(definition.types)
305
+ .map { |token| token.to_s.strip }
306
+ .reject { |token| token.empty? || literal_hint_token?(token) }
307
+ combined = (literal_descriptions + type_descriptions).uniq.reject(&:empty?)
308
+ return nil if combined.empty?
309
+
310
+ return combined.first if combined.size == 1
311
+
312
+ "one of #{combined.join(', ')}"
313
+ end
314
+
315
+ def literal_suggestions(definition, entry)
316
+ literals = Array(definition.allowed_values).map { |allowed| allowed[:value] }.compact
317
+ return [] if literals.empty?
318
+ return [] unless entry.is_a?(String) || entry.is_a?(Symbol)
319
+
320
+ candidates = literals.select { |value| value.is_a?(String) || value.is_a?(Symbol) }
321
+ return [] if candidates.empty?
322
+
323
+ lookup = {}
324
+ dictionary = candidates.each_with_object([]) do |candidate, memo|
325
+ key = candidate.to_s
326
+ lookup[key] ||= candidate
327
+ memo << key unless memo.include?(key)
328
+ end
329
+
330
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: dictionary)
331
+ matches = spell_checker.correct(entry.to_s)
332
+ return [] if matches.empty?
333
+
334
+ matches.take(3).map { |match| format_literal_value(lookup[match]) }
335
+ rescue LoadError, NameError
336
+ []
337
+ end
338
+
339
+ def matches_type_token?(token, value)
340
+ normalized = token.to_s.strip
341
+ return true if normalized.empty?
342
+
343
+ if (inner = array_inner_type(normalized))
344
+ return false unless value.is_a?(Array)
345
+ return value.all? { |element| matches_type_token?(inner, element) }
346
+ end
347
+
348
+ case normalized
349
+ when 'Boolean'
350
+ value.is_a?(TrueClass) || value.is_a?(FalseClass)
351
+ when 'JSON'
352
+ value.is_a?(Hash) || value.is_a?(Array)
353
+ when 'nil', 'NilClass'
354
+ value.nil?
355
+ else
356
+ klass = constant_for_token(normalized)
357
+ return value.is_a?(klass) if klass
358
+
359
+ false
360
+ end
361
+ end
362
+
363
+ def array_inner_type(token)
364
+ if token.end_with?('[]')
365
+ token[0..-3]
366
+ elsif token.start_with?('Array<') && token.end_with?('>')
367
+ token[6..-2].strip
368
+ else
369
+ nil
370
+ end
371
+ end
372
+
373
+ def nil_type_token?(token)
374
+ token.to_s.strip.casecmp('nil').zero? || token.to_s.strip.casecmp('NilClass').zero?
375
+ end
376
+
377
+ def safe_constant_lookup(name)
378
+ parts = name.to_s.split('::').reject(&:empty?)
379
+ return nil if parts.empty?
380
+
381
+ context = Object
382
+ parts.each do |const_name|
383
+ return nil unless context.const_defined?(const_name, false)
384
+
385
+ context = context.const_get(const_name)
386
+ end
387
+ context
388
+ rescue NameError
389
+ nil
390
+ end
391
+
392
+ def constant_for_token(token)
393
+ normalized = token.to_s
394
+ case normalized
395
+ when 'Fixnum'
396
+ return Integer
397
+ when 'Date', 'DateTime'
398
+ require 'date'
399
+ when 'Time'
400
+ require 'time'
401
+ when 'BigDecimal', 'Decimal'
402
+ require 'bigdecimal'
403
+ when 'Pathname'
404
+ require 'pathname'
405
+ when 'Struct'
406
+ return Struct
407
+ end
408
+
409
+ safe_constant_lookup(normalized)
410
+ rescue LoadError
411
+ safe_constant_lookup(normalized)
412
+ end
413
+
414
+ def format_literal_value(value)
415
+ case value
416
+ when Symbol
417
+ ":#{value}"
418
+ when String
419
+ value.inspect
420
+ when Integer, Float
421
+ value.to_s
422
+ when TrueClass, FalseClass
423
+ value.to_s
424
+ when NilClass
425
+ 'nil'
426
+ else
427
+ value.inspect
428
+ end
429
+ end
430
+
431
+ def literal_hint_token?(token)
432
+ token = token.to_s.strip
433
+ return false if token.empty?
434
+
435
+ token.start_with?('%i[', '%I[', '%w[', '%W[')
436
+ end
437
+
199
438
 
200
439
  def build_cli_alias_map(option_defs)
201
440
  option_defs.each_with_object({}) do |opt, memo|
@@ -273,7 +512,15 @@ module Rubycli
273
512
  ->(value) { value.to_sym }
274
513
  when 'BigDecimal', 'Decimal'
275
514
  require 'bigdecimal'
276
- ->(value) { BigDecimal(value) }
515
+ ->(value) {
516
+ return value if value.is_a?(BigDecimal)
517
+
518
+ if value.is_a?(String)
519
+ BigDecimal(value)
520
+ else
521
+ BigDecimal(value.to_s)
522
+ end
523
+ }
277
524
  when 'Date'
278
525
  require 'date'
279
526
  ->(value) { Date.parse(value) }
@@ -282,6 +529,13 @@ module Rubycli
282
529
  ->(value) { Time.parse(value) }
283
530
  when 'JSON', 'Hash'
284
531
  ->(value) { JSON.parse(value) }
532
+ when 'Pathname'
533
+ require 'pathname'
534
+ ->(value) {
535
+ return value if value.is_a?(Pathname)
536
+
537
+ Pathname.new(value.to_s)
538
+ }
285
539
  else
286
540
  if normalized.start_with?('Array<') && normalized.end_with?('>')
287
541
  inner = normalized[6..-2].strip
@@ -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