rubycli 0.1.2 → 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.
@@ -1,5 +1,8 @@
1
- require 'psych'
1
+ require 'did_you_mean'
2
+
2
3
  require_relative 'type_utils'
4
+ require_relative 'arguments/token_stream'
5
+ require_relative 'arguments/value_converter'
3
6
 
4
7
  module Rubycli
5
8
  class ArgumentParser
@@ -10,6 +13,7 @@ module Rubycli
10
13
  @documentation_registry = documentation_registry
11
14
  @json_coercer = json_coercer
12
15
  @debug_logger = debug_logger
16
+ @value_converter = Arguments::ValueConverter.new
13
17
  end
14
18
 
15
19
  def parse(args, method = nil)
@@ -25,21 +29,21 @@ module Rubycli
25
29
  option_lookup = build_option_lookup(option_defs)
26
30
  type_converters = build_type_converter_map(option_defs)
27
31
 
28
- i = 0
29
- while i < args.size
30
- token = args[i]
32
+ stream = Arguments::TokenStream.new(args)
33
+
34
+ until stream.finished?
35
+ token = stream.current
31
36
 
32
37
  if token == '--'
33
- rest_tokens = (args[(i + 1)..-1] || []).map { |value| convert_arg(value) }
38
+ stream.advance
39
+ rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) }
34
40
  pos_args.concat(rest_tokens)
35
41
  break
36
- end
37
-
38
- if option_token?(token)
39
- i = process_option_token(
42
+ elsif option_token?(token)
43
+ stream.advance
44
+ process_option_token(
40
45
  token,
41
- args,
42
- i,
46
+ stream,
43
47
  kw_param_names,
44
48
  kw_args,
45
49
  cli_aliases,
@@ -47,18 +51,26 @@ module Rubycli
47
51
  type_converters
48
52
  )
49
53
  elsif assignment_token?(token)
54
+ stream.advance
50
55
  process_assignment_token(token, kw_args)
51
56
  else
52
57
  pos_args << convert_arg(token)
58
+ stream.advance
53
59
  end
54
-
55
- i += 1
56
60
  end
57
61
 
58
62
  debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
59
63
  [pos_args, kw_args]
60
64
  end
61
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
+
62
74
  private
63
75
 
64
76
  def debug_log(message)
@@ -85,8 +97,7 @@ module Rubycli
85
97
 
86
98
  def process_option_token(
87
99
  token,
88
- args,
89
- current_index,
100
+ stream,
90
101
  kw_param_names,
91
102
  kw_args,
92
103
  cli_aliases,
@@ -108,21 +119,19 @@ module Rubycli
108
119
  requires_value = option_meta ? option_meta[:requires_value] : nil
109
120
  option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
110
121
 
111
- value_capture, current_index = if embedded_value
112
- [embedded_value, current_index]
113
- elsif option_meta
114
- capture_option_value(
115
- option_meta,
116
- args,
117
- current_index,
118
- requires_value
119
- )
120
- elsif current_index + 1 < args.size && !looks_like_option?(args[current_index + 1])
121
- current_index += 1
122
- [args[current_index], current_index]
123
- else
124
- ['true', current_index]
125
- end
122
+ value_capture = if embedded_value
123
+ embedded_value
124
+ elsif option_meta
125
+ capture_option_value(
126
+ option_meta,
127
+ stream,
128
+ requires_value
129
+ )
130
+ elsif (next_token = stream.current) && !looks_like_option?(next_token)
131
+ stream.consume
132
+ else
133
+ 'true'
134
+ end
126
135
 
127
136
  if requires_value && (value_capture.nil? || value_capture == 'true')
128
137
  raise ArgumentError, "Option '#{option_label}' requires a value"
@@ -136,40 +145,30 @@ module Rubycli
136
145
  )
137
146
 
138
147
  kw_args[final_key_sym] = converted_value
139
- current_index
140
- end
141
-
142
- def capture_option_value(option_meta, args, current_index, requires_value)
143
- new_index = current_index
144
- value = if option_meta[:boolean_flag]
145
- if new_index + 1 < args.size && TypeUtils.boolean_string?(args[new_index + 1])
146
- new_index += 1
147
- args[new_index]
148
- else
149
- 'true'
150
- end
151
- elsif option_meta[:optional_value]
152
- if new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
153
- new_index += 1
154
- args[new_index]
155
- else
156
- true
157
- end
158
- elsif requires_value == false
159
- 'true'
160
- elsif requires_value
161
- if new_index + 1 >= args.size
162
- raise ArgumentError, "Option '#{option_meta.long}' requires a value"
163
- end
164
- new_index += 1
165
- args[new_index]
166
- elsif new_index + 1 < args.size && !looks_like_option?(args[new_index + 1])
167
- new_index += 1
168
- args[new_index]
169
- else
170
- 'true'
171
- end
172
- [value, new_index]
148
+ end
149
+ def capture_option_value(option_meta, stream, requires_value)
150
+ if option_meta[:boolean_flag]
151
+ if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
152
+ return stream.consume
153
+ end
154
+ return 'true'
155
+ elsif option_meta[:optional_value]
156
+ if (next_token = stream.current) && !looks_like_option?(next_token)
157
+ return stream.consume
158
+ end
159
+ return true
160
+ elsif requires_value == false
161
+ return 'true'
162
+ elsif requires_value
163
+ next_token = stream.current
164
+ raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
165
+
166
+ return stream.consume
167
+ elsif (next_token = stream.current) && !looks_like_option?(next_token)
168
+ return stream.consume
169
+ else
170
+ return 'true'
171
+ end
173
172
  end
174
173
 
175
174
  def process_assignment_token(token, kw_args)
@@ -204,65 +203,236 @@ module Rubycli
204
203
  end
205
204
 
206
205
  def convert_arg(arg)
207
- return arg if Rubycli.eval_mode? || Rubycli.json_mode?
208
- return arg unless arg.is_a?(String)
206
+ @value_converter.convert(arg)
207
+ end
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
209
268
 
210
- trimmed = arg.strip
211
- return arg if trimmed.empty?
269
+ def literal_allowed?(allowed_entries, value)
270
+ entries = Array(allowed_entries).compact
271
+ return false if entries.empty?
212
272
 
213
- if literal_like?(trimmed)
214
- literal = try_literal_parse(arg)
215
- return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
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
216
292
  end
293
+ end
294
+
295
+ def type_allowed?(types, value)
296
+ tokens = Array(types).compact
297
+ return false if tokens.empty?
217
298
 
218
- return nil if null_literal?(trimmed)
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?
219
309
 
220
- lower = trimmed.downcase
221
- return true if lower == 'true'
222
- return false if lower == 'false'
223
- return arg.to_i if integer_string?(trimmed)
224
- return arg.to_f if float_string?(trimmed)
310
+ return combined.first if combined.size == 1
225
311
 
226
- arg
312
+ "one of #{combined.join(', ')}"
227
313
  end
228
314
 
229
- def integer_string?(str)
230
- str =~ /\A-?\d+\z/
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
+ []
231
337
  end
232
338
 
233
- def float_string?(str)
234
- str =~ /\A-?\d+\.\d+\z/
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
235
361
  end
236
362
 
237
- LITERAL_PARSE_FAILURE = Object.new
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
238
372
 
239
- def try_literal_parse(value)
240
- return LITERAL_PARSE_FAILURE unless value.is_a?(String)
373
+ def nil_type_token?(token)
374
+ token.to_s.strip.casecmp('nil').zero? || token.to_s.strip.casecmp('NilClass').zero?
375
+ end
241
376
 
242
- trimmed = value.strip
243
- return value if trimmed.empty?
377
+ def safe_constant_lookup(name)
378
+ parts = name.to_s.split('::').reject(&:empty?)
379
+ return nil if parts.empty?
244
380
 
245
- literal = Psych.safe_load(trimmed, aliases: false)
246
- return literal unless literal.nil? && !null_literal?(trimmed)
381
+ context = Object
382
+ parts.each do |const_name|
383
+ return nil unless context.const_defined?(const_name, false)
247
384
 
248
- LITERAL_PARSE_FAILURE
249
- rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
250
- LITERAL_PARSE_FAILURE
385
+ context = context.const_get(const_name)
386
+ end
387
+ context
388
+ rescue NameError
389
+ nil
251
390
  end
252
391
 
253
- def null_literal?(value)
254
- return false unless value
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
255
408
 
256
- %w[null ~].include?(value.downcase)
409
+ safe_constant_lookup(normalized)
410
+ rescue LoadError
411
+ safe_constant_lookup(normalized)
257
412
  end
258
413
 
259
- def literal_like?(value)
260
- return false unless value
261
- return true if value.start_with?('[', '{', '"', "'")
262
- return true if value.start_with?('---')
263
- return true if value.match?(/\A(?:true|false|null|nil)\z/i)
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
264
430
 
265
- false
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[')
266
436
  end
267
437
 
268
438
 
@@ -342,7 +512,15 @@ module Rubycli
342
512
  ->(value) { value.to_sym }
343
513
  when 'BigDecimal', 'Decimal'
344
514
  require 'bigdecimal'
345
- ->(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
+ }
346
524
  when 'Date'
347
525
  require 'date'
348
526
  ->(value) { Date.parse(value) }
@@ -351,6 +529,13 @@ module Rubycli
351
529
  ->(value) { Time.parse(value) }
352
530
  when 'JSON', 'Hash'
353
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
+ }
354
539
  else
355
540
  if normalized.start_with?('Array<') && normalized.end_with?('>')
356
541
  inner = normalized[6..-2].strip
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubycli
4
+ module Arguments
5
+ # Lightweight mutable cursor over CLI tokens.
6
+ class TokenStream
7
+ def initialize(tokens)
8
+ @tokens = Array(tokens).dup
9
+ @index = 0
10
+ end
11
+
12
+ def current
13
+ @tokens[@index]
14
+ end
15
+
16
+ def peek(offset = 1)
17
+ @tokens[@index + offset]
18
+ end
19
+
20
+ def advance(count = 1)
21
+ @index += count
22
+ end
23
+
24
+ def consume
25
+ value = current
26
+ advance
27
+ value
28
+ end
29
+
30
+ def consume_remaining
31
+ remaining = @tokens[@index..] || []
32
+ @index = @tokens.length
33
+ remaining
34
+ end
35
+
36
+ def finished?
37
+ @index >= @tokens.length
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'psych'
4
+
5
+ module Rubycli
6
+ module Arguments
7
+ # Converts raw CLI tokens into Ruby primitives when safe to do so.
8
+ class ValueConverter
9
+ LITERAL_PARSE_FAILURE = Object.new
10
+
11
+ def convert(value)
12
+ return value if Rubycli.eval_mode? || Rubycli.json_mode?
13
+ return value unless value.is_a?(String)
14
+
15
+ trimmed = value.strip
16
+ return value if trimmed.empty?
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
+
23
+ if literal_like?(trimmed)
24
+ literal = try_literal_parse(value)
25
+ return literal unless literal.equal?(LITERAL_PARSE_FAILURE)
26
+ end
27
+
28
+ return nil if null_literal?(trimmed)
29
+
30
+ lower = trimmed.downcase
31
+ return true if lower == 'true'
32
+ return false if lower == 'false'
33
+ return value.to_i if integer_string?(trimmed)
34
+ return value.to_f if float_string?(trimmed)
35
+
36
+ value
37
+ end
38
+
39
+ private
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
+
47
+ def integer_string?(str)
48
+ str =~ /\A-?\d+\z/
49
+ end
50
+
51
+ def float_string?(str)
52
+ str =~ /\A-?\d+\.\d+\z/
53
+ end
54
+
55
+ def try_literal_parse(value)
56
+ return LITERAL_PARSE_FAILURE unless value.is_a?(String)
57
+
58
+ trimmed = value.strip
59
+ return value if trimmed.empty?
60
+
61
+ literal = Psych.safe_load(trimmed, aliases: false)
62
+ return literal unless literal.nil? && !null_literal?(trimmed)
63
+
64
+ LITERAL_PARSE_FAILURE
65
+ rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::Exception
66
+ LITERAL_PARSE_FAILURE
67
+ end
68
+
69
+ def null_literal?(value)
70
+ return false unless value
71
+
72
+ %w[null ~].include?(value.downcase)
73
+ end
74
+
75
+ def literal_like?(value)
76
+ return false unless value
77
+ return true if value.start_with?('[', '{', '"', "'")
78
+ return true if value.start_with?('---')
79
+ return true if value.match?(/\A(?:true|false|null|nil)\z/i)
80
+
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end