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.
@@ -1,4 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'did_you_mean'
4
+ require 'ripper'
2
5
 
3
6
  require_relative 'type_utils'
4
7
  require_relative 'arguments/token_stream'
@@ -8,6 +11,8 @@ module Rubycli
8
11
  class ArgumentParser
9
12
  include TypeUtils
10
13
 
14
+ OPTION_TOKEN_PATTERN = /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
15
+
11
16
  def initialize(environment:, documentation_registry:, json_coercer:, debug_logger:)
12
17
  @environment = environment
13
18
  @documentation_registry = documentation_registry
@@ -18,9 +23,11 @@ module Rubycli
18
23
 
19
24
  def parse(args, method = nil)
20
25
  pos_args = []
26
+ raw_pos_args = []
21
27
  kw_args = {}
22
28
 
23
29
  kw_param_names = extract_keyword_parameter_names(method)
30
+ required_kw_param_names = extract_required_keyword_parameter_names(method)
24
31
  debug_log "Available keyword parameters: #{kw_param_names.inspect}"
25
32
 
26
33
  metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil }
@@ -28,6 +35,9 @@ module Rubycli
28
35
  cli_aliases = build_cli_alias_map(option_defs)
29
36
  option_lookup = build_option_lookup(option_defs)
30
37
  type_converters = build_type_converter_map(option_defs)
38
+ known_option_names = (
39
+ kw_param_names + cli_aliases.keys + option_lookup.keys.map(&:to_s)
40
+ ).uniq
31
41
 
32
42
  stream = Arguments::TokenStream.new(args)
33
43
 
@@ -36,8 +46,9 @@ module Rubycli
36
46
 
37
47
  if token == '--'
38
48
  stream.advance
39
- rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) }
40
- pos_args.concat(rest_tokens)
49
+ rest_tokens = stream.consume_remaining
50
+ raw_pos_args.concat(rest_tokens)
51
+ pos_args.concat(rest_tokens.map { |value| convert_arg(value) })
41
52
  break
42
53
  elsif option_token?(token)
43
54
  stream.advance
@@ -48,18 +59,21 @@ module Rubycli
48
59
  kw_args,
49
60
  cli_aliases,
50
61
  option_lookup,
51
- type_converters
62
+ type_converters,
63
+ required_kw_param_names,
64
+ known_option_names
52
65
  )
53
- elsif assignment_token?(token)
66
+ elsif assignment_token_for_method?(token, method, kw_param_names)
54
67
  stream.advance
55
- process_assignment_token(token, kw_args)
68
+ process_assignment_token(token, kw_args, option_lookup, type_converters)
56
69
  else
70
+ raw_pos_args << token
57
71
  pos_args << convert_arg(token)
58
72
  stream.advance
59
73
  end
60
74
  end
61
75
 
62
- pos_args = convert_positional_arguments(pos_args, method, metadata)
76
+ pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
63
77
  debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
64
78
  [pos_args, kw_args]
65
79
  end
@@ -88,12 +102,27 @@ module Rubycli
88
102
  .map { |_, name| name.to_s }
89
103
  end
90
104
 
105
+ def extract_required_keyword_parameter_names(method)
106
+ return [] unless method
107
+
108
+ method.parameters
109
+ .select { |type, _| type == :keyreq }
110
+ .map { |_, name| name.to_s }
111
+ end
112
+
113
+ # Tokens such as -5, -1_000 and -0x10 match the option pattern, but no Ruby
114
+ # keyword can be named after a number, so they are values, not options.
91
115
  def option_token?(token)
92
- token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
116
+ token.match?(OPTION_TOKEN_PATTERN) && looks_like_option?(token)
93
117
  end
94
118
 
95
- def assignment_token?(token)
96
- !split_assignment_token(token).nil?
119
+ def assignment_token_for_method?(token, method, kw_param_names)
120
+ key, = split_assignment_token(token)
121
+ return false unless key
122
+ return true unless method
123
+ return true if kw_param_names.include?(key)
124
+
125
+ method.parameters.any? { |type, _| type == :keyrest }
97
126
  end
98
127
 
99
128
  def process_option_token(
@@ -103,9 +132,11 @@ module Rubycli
103
132
  kw_args,
104
133
  cli_aliases,
105
134
  option_lookup,
106
- type_converters
135
+ type_converters,
136
+ required_kw_param_names,
137
+ known_option_names
107
138
  )
108
- token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
139
+ token =~ OPTION_TOKEN_PATTERN
109
140
  cli_key = Regexp.last_match(1).tr('-', '_')
110
141
  embedded_value = Regexp.last_match(2)
111
142
 
@@ -117,7 +148,11 @@ module Rubycli
117
148
  final_key_sym = final_key.to_sym
118
149
 
119
150
  option_meta = option_lookup[final_key_sym]
120
- requires_value = option_meta ? option_meta[:requires_value] : nil
151
+ requires_value = if option_meta
152
+ option_meta[:requires_value]
153
+ else
154
+ required_kw_param_names.include?(final_key)
155
+ end
121
156
  option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
122
157
 
123
158
  value_capture = if embedded_value
@@ -126,17 +161,18 @@ module Rubycli
126
161
  capture_option_value(
127
162
  option_meta,
128
163
  stream,
129
- requires_value
164
+ requires_value,
165
+ known_option_names
130
166
  )
167
+ elsif requires_value
168
+ capture_required_option_value(option_label, stream, known_option_names)
131
169
  elsif (next_token = stream.current) && !looks_like_option?(next_token)
132
170
  stream.consume
133
171
  else
134
172
  'true'
135
173
  end
136
174
 
137
- if requires_value && (value_capture.nil? || value_capture == 'true')
138
- raise ArgumentError, "Option '#{option_label}' requires a value"
139
- end
175
+ raise ArgumentError, "Option '#{option_label}' requires a value" if requires_value && value_capture.nil?
140
176
 
141
177
  converted_value = convert_option_value(
142
178
  final_key_sym,
@@ -147,34 +183,62 @@ module Rubycli
147
183
 
148
184
  kw_args[final_key_sym] = converted_value
149
185
  end
150
- def capture_option_value(option_meta, stream, requires_value)
186
+
187
+ def capture_required_option_value(option_label, stream, known_option_names)
188
+ next_token = stream.current
189
+ option_like_value = looks_like_option?(next_token) &&
190
+ !valid_eval_option_value?(next_token, known_option_names)
191
+ raise ArgumentError, "Option '#{option_label}' requires a value" if next_token.nil? || option_like_value
192
+
193
+ stream.consume
194
+ end
195
+
196
+ def capture_option_value(option_meta, stream, requires_value, known_option_names)
151
197
  if option_meta[:boolean_flag]
152
198
  if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
153
199
  return stream.consume
154
200
  end
155
- return 'true'
201
+
202
+ 'true'
156
203
  elsif option_meta[:optional_value]
157
204
  if (next_token = stream.current) && !looks_like_option?(next_token)
158
205
  return stream.consume
159
206
  end
160
- return true
207
+
208
+ true
161
209
  elsif requires_value == false
162
- return 'true'
210
+ 'true'
163
211
  elsif requires_value
164
- next_token = stream.current
165
- raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token
166
-
167
- return stream.consume
212
+ capture_required_option_value(option_meta.long, stream, known_option_names)
168
213
  elsif (next_token = stream.current) && !looks_like_option?(next_token)
169
- return stream.consume
214
+ stream.consume
170
215
  else
171
- return 'true'
216
+ 'true'
172
217
  end
173
218
  end
174
219
 
175
- def process_assignment_token(token, kw_args)
220
+ def valid_eval_option_value?(token, known_option_names)
221
+ return false unless Rubycli.eval_mode?
222
+ return false if known_option_token?(token, known_option_names)
223
+
224
+ !Ripper.sexp(token).nil?
225
+ end
226
+
227
+ def known_option_token?(token, known_option_names)
228
+ match = token&.match(OPTION_TOKEN_PATTERN)
229
+ return false unless match
230
+
231
+ key = match[1].tr('-', '_')
232
+ return true if known_option_names.include?(key)
233
+
234
+ known_option_names.one? { |name| name.start_with?(key) }
235
+ end
236
+
237
+ def process_assignment_token(token, kw_args, option_lookup, type_converters)
176
238
  key, value = split_assignment_token(token)
177
- kw_args[key.to_sym] = convert_arg(value)
239
+ keyword = key.to_sym
240
+ option_meta = option_lookup[keyword]
241
+ kw_args[keyword] = convert_option_value(keyword, value, option_meta, type_converters)
178
242
  end
179
243
 
180
244
  def split_assignment_token(token)
@@ -187,33 +251,103 @@ module Rubycli
187
251
  [key, value]
188
252
  end
189
253
 
190
- def convert_positional_arguments(pos_args, method, metadata)
254
+ def convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
191
255
  return pos_args unless method
256
+ return pos_args if Rubycli.eval_mode? || Rubycli.json_mode?
192
257
 
193
258
  positional_map = metadata[:positionals_map] || {}
194
259
  return pos_args if positional_map.empty?
195
260
 
196
261
  converted = pos_args.dup
197
- method.parameters.each_with_index do |(type, name), index|
198
- next unless %i[req opt].include?(type)
262
+ positional_argument_bindings(method, converted.size).each do |type, name, indexes|
199
263
  definition = positional_map[name]
200
264
  next unless definition
201
- next if index >= converted.size
202
265
 
203
- converter = converter_for_definition(definition)
204
- next unless converter
266
+ case type
267
+ when :req, :opt
268
+ index = indexes.first
269
+ next unless index
270
+
271
+ converter = converter_for_definition(definition)
272
+ next unless converter
273
+
274
+ begin
275
+ converted[index] = converter.call(raw_pos_args[index])
276
+ rescue StandardError => e
277
+ label = definition.label || definition.placeholder || name.to_s.upcase
278
+ raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}"
279
+ end
280
+ when :rest
281
+ next if indexes.empty?
282
+
283
+ converter = converter_for_rest_definition(definition)
284
+ next unless converter
205
285
 
206
- begin
207
- converted[index] = converter.call(converted[index])
208
- rescue StandardError => e
209
286
  label = definition.label || definition.placeholder || name.to_s.upcase
210
- raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}"
287
+ indexes.each do |index|
288
+ value = raw_pos_args[index]
289
+ converted[index] = converter.call(value)
290
+ rescue StandardError => e
291
+ raise ArgumentError, "Value '#{value}' for #{label} is invalid: #{e.message}"
292
+ end
211
293
  end
212
294
  end
213
295
 
214
296
  converted
215
297
  end
216
298
 
299
+ def positional_argument_bindings(method_obj, argument_count)
300
+ parameters = method_obj.parameters.select { |type, _| %i[req opt rest].include?(type) }
301
+ first_flexible = parameters.index { |type, _| %i[opt rest].include?(type) }
302
+
303
+ unless first_flexible
304
+ return parameters.each_with_index.map do |(type, name), index|
305
+ [type, name, index < argument_count ? [index] : []]
306
+ end
307
+ end
308
+
309
+ last_flexible = parameters.rindex { |type, _| %i[opt rest].include?(type) }
310
+ bindings = []
311
+ cursor = 0
312
+
313
+ parameters[0...first_flexible].each do |type, name|
314
+ indexes = cursor < argument_count ? [cursor] : []
315
+ bindings << [type, name, indexes]
316
+ cursor += 1 unless indexes.empty?
317
+ end
318
+
319
+ trailing = parameters[(last_flexible + 1)..] || []
320
+ trailing_start = [argument_count - trailing.size, cursor].max
321
+ middle_end = [trailing_start, argument_count].min
322
+
323
+ parameters[first_flexible..last_flexible].each do |type, name|
324
+ indexes = if type == :rest
325
+ (cursor...middle_end).to_a
326
+ elsif cursor < middle_end
327
+ [cursor]
328
+ else
329
+ []
330
+ end
331
+ bindings << [type, name, indexes]
332
+ cursor += indexes.size
333
+ end
334
+
335
+ trailing.each_with_index do |(type, name), offset|
336
+ index = trailing_start + offset
337
+ bindings << [type, name, index < argument_count ? [index] : []]
338
+ end
339
+
340
+ bindings
341
+ end
342
+
343
+ def converter_for_rest_definition(definition)
344
+ scalar_types = Array(definition.types).compact.map do |type|
345
+ normalized = type.to_s.strip
346
+ array_inner_type(normalized) || normalized
347
+ end
348
+ build_converter_for_types(scalar_types)
349
+ end
350
+
217
351
  def converter_for_definition(definition)
218
352
  types = Array(definition.types).compact
219
353
  return nil if types.empty?
@@ -225,13 +359,29 @@ module Rubycli
225
359
  if normalized.start_with?('Array<') && normalized.end_with?('>')
226
360
  inner = normalized[6..-2].strip
227
361
  element_converter = converter_for_single_type(inner)
228
- return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
362
+ return lambda { |value|
363
+ list_items(value).map do |item|
364
+ if inner == 'String' && item.is_a?(String)
365
+ item
366
+ else
367
+ (element_converter ? element_converter.call(item) : item)
368
+ end
369
+ end
370
+ }
229
371
  elsif normalized.end_with?('[]')
230
372
  inner = normalized[0..-3]
231
373
  element_converter = converter_for_single_type(inner)
232
- return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
374
+ return lambda { |value|
375
+ list_items(value).map do |item|
376
+ if inner == 'String' && item.is_a?(String)
377
+ item
378
+ else
379
+ (element_converter ? element_converter.call(item) : item)
380
+ end
381
+ end
382
+ }
233
383
  elsif normalized.casecmp('Array').zero?
234
- return ->(value) { TypeUtils.parse_list(value) }
384
+ return ->(value) { list_items(value) }
235
385
  else
236
386
  single = converter_for_single_type(normalized)
237
387
  return single if single
@@ -265,15 +415,24 @@ module Rubycli
265
415
  return if positional_args.nil? || positional_args.empty?
266
416
 
267
417
  positional_map = metadata[:positionals_map] || {}
268
- ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) }
269
-
270
- ordered_params.each_with_index do |(_, name), index|
418
+ positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes|
271
419
  definition = positional_map[name]
272
420
  next unless definition
273
- next if index >= positional_args.size
274
421
 
275
422
  label = definition.label || definition.placeholder || name.to_s.upcase
276
- enforce_value_against_definition(definition, positional_args[index], label)
423
+
424
+ case type
425
+ when :req, :opt
426
+ index = indexes.first
427
+ next unless index
428
+
429
+ enforce_value_against_definition(definition, positional_args[index], label)
430
+ when :rest
431
+ next if indexes.empty?
432
+
433
+ values = indexes.map { |index| positional_args[index] }
434
+ enforce_value_against_definition(definition, values, label)
435
+ end
277
436
  end
278
437
  end
279
438
 
@@ -309,10 +468,10 @@ module Rubycli
309
468
  formatted_value = format_literal_value(entry)
310
469
 
311
470
  message = if description
312
- "#{label} must be #{description} (received #{formatted_value})"
313
- else
314
- "Value #{formatted_value} for #{label} is not allowed"
315
- end
471
+ "#{label} must be #{description} (received #{formatted_value})"
472
+ else
473
+ "Value #{formatted_value} for #{label} is not allowed"
474
+ end
316
475
 
317
476
  suggestions = literal_suggestions(definition, entry)
318
477
  return message if suggestions.empty?
@@ -355,7 +514,9 @@ module Rubycli
355
514
  end
356
515
 
357
516
  def allowed_value_description(definition)
358
- literal_descriptions = Array(definition.allowed_values).map { |entry| format_literal_value(entry[:value]) }.reject(&:empty?)
517
+ literal_descriptions = Array(definition.allowed_values).map do |entry|
518
+ format_literal_value(entry[:value])
519
+ end.reject(&:empty?)
359
520
  type_descriptions = Array(definition.types)
360
521
  .map { |token| token.to_s.strip }
361
522
  .reject { |token| token.empty? || literal_hint_token?(token) }
@@ -397,6 +558,7 @@ module Rubycli
397
558
 
398
559
  if (inner = array_inner_type(normalized))
399
560
  return false unless value.is_a?(Array)
561
+
400
562
  return value.all? { |element| matches_type_token?(inner, element) }
401
563
  end
402
564
 
@@ -420,8 +582,6 @@ module Rubycli
420
582
  token[0..-3]
421
583
  elsif token.start_with?('Array<') && token.end_with?('>')
422
584
  token[6..-2].strip
423
- else
424
- nil
425
585
  end
426
586
  end
427
587
 
@@ -466,6 +626,17 @@ module Rubycli
466
626
  safe_constant_lookup(normalized)
467
627
  end
468
628
 
629
+ # Type annotations may name a class from a library that is not installed
630
+ # (bigdecimal, for instance, is no longer a default gem). Report it as a
631
+ # user-facing error instead of letting the LoadError escape with a backtrace.
632
+ def require_optional_library(library, type_token)
633
+ require library
634
+ rescue LoadError
635
+ raise ArgumentError,
636
+ "Type '#{type_token}' needs the #{library} library, which is not installed. " \
637
+ "Install it with `gem install #{library}` (or add it to your Gemfile)."
638
+ end
639
+
469
640
  def format_literal_value(value)
470
641
  case value
471
642
  when Symbol
@@ -490,7 +661,6 @@ module Rubycli
490
661
  token.start_with?('%i[', '%I[', '%w[', '%W[')
491
662
  end
492
663
 
493
-
494
664
  def build_cli_alias_map(option_defs)
495
665
  option_defs.each_with_object({}) do |opt, memo|
496
666
  next unless opt.short
@@ -526,21 +696,17 @@ module Rubycli
526
696
  lambda do |value|
527
697
  return nil if value.nil? && allow_nil
528
698
 
529
- if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
530
- next nil
531
- end
699
+ next nil if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
532
700
 
533
701
  if converters.empty?
534
702
  value
535
703
  else
536
704
  last_error = nil
537
705
  converters.each do |converter|
538
- begin
539
- result = converter.call(value)
540
- return result
541
- rescue StandardError => e
542
- last_error = e
543
- end
706
+ result = converter.call(value)
707
+ return result
708
+ rescue StandardError => e
709
+ last_error = e
544
710
  end
545
711
  raise last_error || ArgumentError.new("Could not convert value '#{value}'")
546
712
  end
@@ -564,10 +730,13 @@ module Rubycli
564
730
  when 'Boolean', 'TrueClass', 'FalseClass'
565
731
  ->(value) { TypeUtils.convert_boolean(value) }
566
732
  when 'Symbol'
567
- ->(value) { value.to_sym }
733
+ lambda { |value|
734
+ converted_value = convert_arg(value)
735
+ converted_value.is_a?(Symbol) ? converted_value : value.to_sym
736
+ }
568
737
  when 'BigDecimal', 'Decimal'
569
- require 'bigdecimal'
570
- ->(value) {
738
+ require_optional_library('bigdecimal', normalized)
739
+ lambda { |value|
571
740
  return value if value.is_a?(BigDecimal)
572
741
 
573
742
  if value.is_a?(String)
@@ -577,20 +746,35 @@ module Rubycli
577
746
  end
578
747
  }
579
748
  when 'Date'
580
- require 'date'
749
+ require_optional_library('date', normalized)
581
750
  ->(value) { Date.parse(value) }
582
- when 'Time', 'DateTime'
583
- require 'time'
751
+ when 'Time'
752
+ require_optional_library('time', normalized)
584
753
  ->(value) { Time.parse(value) }
585
- when 'JSON', 'Hash'
586
- ->(value) {
587
- return value if value.is_a?(Hash)
754
+ when 'DateTime'
755
+ require_optional_library('date', normalized)
756
+ ->(value) { DateTime.parse(value) }
757
+ when 'JSON'
758
+ lambda { |value|
759
+ parsed_value = convert_arg(value)
760
+ parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array)
761
+ unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array)
762
+ raise ::ArgumentError, 'JSON value must be an object or array'
763
+ end
588
764
 
589
- JSON.parse(value)
765
+ parsed_value
766
+ }
767
+ when 'Hash'
768
+ lambda { |value|
769
+ parsed_value = convert_arg(value)
770
+ parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash)
771
+ raise ::ArgumentError, 'Hash value must be an object' unless parsed_value.is_a?(Hash)
772
+
773
+ parsed_value
590
774
  }
591
775
  when 'Pathname'
592
- require 'pathname'
593
- ->(value) {
776
+ require_optional_library('pathname', normalized)
777
+ lambda { |value|
594
778
  return value if value.is_a?(Pathname)
595
779
 
596
780
  Pathname.new(value.to_s)
@@ -599,39 +783,47 @@ module Rubycli
599
783
  if normalized.start_with?('Array<') && normalized.end_with?('>')
600
784
  inner = normalized[6..-2].strip
601
785
  element_converter = converter_for_single_type(inner)
602
- ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
786
+ lambda { |value|
787
+ list_items(value).map do |item|
788
+ if inner == 'String' && item.is_a?(String)
789
+ item
790
+ else
791
+ (element_converter ? element_converter.call(item) : item)
792
+ end
793
+ end
794
+ }
603
795
  elsif normalized.end_with?('[]')
604
796
  inner = normalized[0..-3]
605
797
  element_converter = converter_for_single_type(inner)
606
- ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } }
798
+ lambda { |value|
799
+ list_items(value).map do |item|
800
+ if inner == 'String' && item.is_a?(String)
801
+ item
802
+ else
803
+ (element_converter ? element_converter.call(item) : item)
804
+ end
805
+ end
806
+ }
607
807
  elsif normalized == 'Array'
608
- ->(value) { TypeUtils.parse_list(value) }
609
- else
610
- nil
808
+ ->(value) { list_items(value) }
611
809
  end
612
810
  end
613
811
  end
614
812
 
813
+ def list_items(value)
814
+ converted_value = convert_arg(value)
815
+ source = converted_value.is_a?(Array) ? converted_value : value
816
+ TypeUtils.parse_list(source)
817
+ end
818
+
615
819
  def convert_option_value(keyword, value, option_meta, type_converters)
616
- if Rubycli.eval_mode? || Rubycli.json_mode?
617
- return convert_arg(value)
618
- end
820
+ return convert_arg(value) if Rubycli.eval_mode? || Rubycli.json_mode?
619
821
 
620
822
  converter = type_converters[keyword]
621
823
  converted_value = convert_arg(value)
622
824
  return converted_value unless converter
623
825
 
624
- original_input = value if value.is_a?(String)
625
- expects_list = option_meta && option_meta.types.any? { |type|
626
- type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
627
- }
628
-
629
- value_for_converter = converted_value
630
- if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
631
- value_for_converter = original_input
632
- end
633
-
634
- converter.call(value_for_converter)
826
+ converter.call(value)
635
827
  rescue StandardError => e
636
828
  option_label = option_meta&.long || option_meta&.short || keyword
637
829
  raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
@@ -639,9 +831,16 @@ module Rubycli
639
831
 
640
832
  def looks_like_option?(token)
641
833
  return false unless token
642
- return false if token == '--'
834
+ return false if ['--', '-'].include?(token)
643
835
 
644
- token.start_with?('-') && !(token =~ /\A-?\d+(\.\d+)?\z/)
836
+ decimal_digits = '\d(?:_?\d)*'
837
+ negative_decimal = token.match?(
838
+ /\A-(?:#{decimal_digits}(?:\.(?:#{decimal_digits})?)?|\.#{decimal_digits})(?:[eE][+-]?#{decimal_digits})?\z/
839
+ )
840
+ negative_radix = token.match?(
841
+ /\A-0(?:[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+|[dD][0-9_]+)\z/
842
+ )
843
+ token.start_with?('-') && !negative_decimal && !negative_radix
645
844
  end
646
845
  end
647
846
  end