rubycli 0.1.6 → 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,17 +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
 
76
+ pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
62
77
  debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}"
63
78
  [pos_args, kw_args]
64
79
  end
@@ -87,12 +102,27 @@ module Rubycli
87
102
  .map { |_, name| name.to_s }
88
103
  end
89
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.
90
115
  def option_token?(token)
91
- token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
116
+ token.match?(OPTION_TOKEN_PATTERN) && looks_like_option?(token)
92
117
  end
93
118
 
94
- def assignment_token?(token)
95
- !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 }
96
126
  end
97
127
 
98
128
  def process_option_token(
@@ -102,9 +132,11 @@ module Rubycli
102
132
  kw_args,
103
133
  cli_aliases,
104
134
  option_lookup,
105
- type_converters
135
+ type_converters,
136
+ required_kw_param_names,
137
+ known_option_names
106
138
  )
107
- token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/
139
+ token =~ OPTION_TOKEN_PATTERN
108
140
  cli_key = Regexp.last_match(1).tr('-', '_')
109
141
  embedded_value = Regexp.last_match(2)
110
142
 
@@ -116,7 +148,11 @@ module Rubycli
116
148
  final_key_sym = final_key.to_sym
117
149
 
118
150
  option_meta = option_lookup[final_key_sym]
119
- 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
120
156
  option_label = option_meta&.long || "--#{final_key.tr('_', '-')}"
121
157
 
122
158
  value_capture = if embedded_value
@@ -125,17 +161,18 @@ module Rubycli
125
161
  capture_option_value(
126
162
  option_meta,
127
163
  stream,
128
- requires_value
164
+ requires_value,
165
+ known_option_names
129
166
  )
167
+ elsif requires_value
168
+ capture_required_option_value(option_label, stream, known_option_names)
130
169
  elsif (next_token = stream.current) && !looks_like_option?(next_token)
131
170
  stream.consume
132
171
  else
133
172
  'true'
134
173
  end
135
174
 
136
- if requires_value && (value_capture.nil? || value_capture == 'true')
137
- raise ArgumentError, "Option '#{option_label}' requires a value"
138
- end
175
+ raise ArgumentError, "Option '#{option_label}' requires a value" if requires_value && value_capture.nil?
139
176
 
140
177
  converted_value = convert_option_value(
141
178
  final_key_sym,
@@ -146,34 +183,62 @@ module Rubycli
146
183
 
147
184
  kw_args[final_key_sym] = converted_value
148
185
  end
149
- 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)
150
197
  if option_meta[:boolean_flag]
151
198
  if (next_token = stream.current) && TypeUtils.boolean_string?(next_token)
152
199
  return stream.consume
153
200
  end
154
- return 'true'
201
+
202
+ 'true'
155
203
  elsif option_meta[:optional_value]
156
204
  if (next_token = stream.current) && !looks_like_option?(next_token)
157
205
  return stream.consume
158
206
  end
159
- return true
207
+
208
+ true
160
209
  elsif requires_value == false
161
- return 'true'
210
+ 'true'
162
211
  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
212
+ capture_required_option_value(option_meta.long, stream, known_option_names)
167
213
  elsif (next_token = stream.current) && !looks_like_option?(next_token)
168
- return stream.consume
214
+ stream.consume
169
215
  else
170
- return 'true'
216
+ 'true'
171
217
  end
172
218
  end
173
219
 
174
- 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)
175
238
  key, value = split_assignment_token(token)
176
- 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)
177
242
  end
178
243
 
179
244
  def split_assignment_token(token)
@@ -186,6 +251,146 @@ module Rubycli
186
251
  [key, value]
187
252
  end
188
253
 
254
+ def convert_positional_arguments(pos_args, raw_pos_args, method, metadata)
255
+ return pos_args unless method
256
+ return pos_args if Rubycli.eval_mode? || Rubycli.json_mode?
257
+
258
+ positional_map = metadata[:positionals_map] || {}
259
+ return pos_args if positional_map.empty?
260
+
261
+ converted = pos_args.dup
262
+ positional_argument_bindings(method, converted.size).each do |type, name, indexes|
263
+ definition = positional_map[name]
264
+ next unless definition
265
+
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
285
+
286
+ label = definition.label || definition.placeholder || name.to_s.upcase
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
293
+ end
294
+ end
295
+
296
+ converted
297
+ end
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
+
351
+ def converter_for_definition(definition)
352
+ types = Array(definition.types).compact
353
+ return nil if types.empty?
354
+
355
+ types.each do |type|
356
+ normalized = type.to_s.strip
357
+ next if normalized.empty?
358
+
359
+ if normalized.start_with?('Array<') && normalized.end_with?('>')
360
+ inner = normalized[6..-2].strip
361
+ element_converter = converter_for_single_type(inner)
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
+ }
371
+ elsif normalized.end_with?('[]')
372
+ inner = normalized[0..-3]
373
+ element_converter = converter_for_single_type(inner)
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
+ }
383
+ elsif normalized.casecmp('Array').zero?
384
+ return ->(value) { list_items(value) }
385
+ else
386
+ single = converter_for_single_type(normalized)
387
+ return single if single
388
+ end
389
+ end
390
+
391
+ nil
392
+ end
393
+
189
394
  def resolve_keyword_parameter(cli_key, kw_param_names)
190
395
  exact_match = kw_param_names.find { |name| name == cli_key }
191
396
  return exact_match if exact_match
@@ -210,15 +415,24 @@ module Rubycli
210
415
  return if positional_args.nil? || positional_args.empty?
211
416
 
212
417
  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|
418
+ positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes|
216
419
  definition = positional_map[name]
217
420
  next unless definition
218
- next if index >= positional_args.size
219
421
 
220
422
  label = definition.label || definition.placeholder || name.to_s.upcase
221
- 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
222
436
  end
223
437
  end
224
438
 
@@ -254,10 +468,10 @@ module Rubycli
254
468
  formatted_value = format_literal_value(entry)
255
469
 
256
470
  message = if description
257
- "#{label} must be #{description} (received #{formatted_value})"
258
- else
259
- "Value #{formatted_value} for #{label} is not allowed"
260
- end
471
+ "#{label} must be #{description} (received #{formatted_value})"
472
+ else
473
+ "Value #{formatted_value} for #{label} is not allowed"
474
+ end
261
475
 
262
476
  suggestions = literal_suggestions(definition, entry)
263
477
  return message if suggestions.empty?
@@ -300,7 +514,9 @@ module Rubycli
300
514
  end
301
515
 
302
516
  def allowed_value_description(definition)
303
- 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?)
304
520
  type_descriptions = Array(definition.types)
305
521
  .map { |token| token.to_s.strip }
306
522
  .reject { |token| token.empty? || literal_hint_token?(token) }
@@ -342,6 +558,7 @@ module Rubycli
342
558
 
343
559
  if (inner = array_inner_type(normalized))
344
560
  return false unless value.is_a?(Array)
561
+
345
562
  return value.all? { |element| matches_type_token?(inner, element) }
346
563
  end
347
564
 
@@ -365,8 +582,6 @@ module Rubycli
365
582
  token[0..-3]
366
583
  elsif token.start_with?('Array<') && token.end_with?('>')
367
584
  token[6..-2].strip
368
- else
369
- nil
370
585
  end
371
586
  end
372
587
 
@@ -411,6 +626,17 @@ module Rubycli
411
626
  safe_constant_lookup(normalized)
412
627
  end
413
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
+
414
640
  def format_literal_value(value)
415
641
  case value
416
642
  when Symbol
@@ -435,7 +661,6 @@ module Rubycli
435
661
  token.start_with?('%i[', '%I[', '%w[', '%W[')
436
662
  end
437
663
 
438
-
439
664
  def build_cli_alias_map(option_defs)
440
665
  option_defs.each_with_object({}) do |opt, memo|
441
666
  next unless opt.short
@@ -471,21 +696,17 @@ module Rubycli
471
696
  lambda do |value|
472
697
  return nil if value.nil? && allow_nil
473
698
 
474
- if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
475
- next nil
476
- end
699
+ next nil if allow_nil && value.is_a?(String) && value.strip.casecmp('nil').zero?
477
700
 
478
701
  if converters.empty?
479
702
  value
480
703
  else
481
704
  last_error = nil
482
705
  converters.each do |converter|
483
- begin
484
- result = converter.call(value)
485
- return result
486
- rescue StandardError => e
487
- last_error = e
488
- end
706
+ result = converter.call(value)
707
+ return result
708
+ rescue StandardError => e
709
+ last_error = e
489
710
  end
490
711
  raise last_error || ArgumentError.new("Could not convert value '#{value}'")
491
712
  end
@@ -509,10 +730,13 @@ module Rubycli
509
730
  when 'Boolean', 'TrueClass', 'FalseClass'
510
731
  ->(value) { TypeUtils.convert_boolean(value) }
511
732
  when 'Symbol'
512
- ->(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
+ }
513
737
  when 'BigDecimal', 'Decimal'
514
- require 'bigdecimal'
515
- ->(value) {
738
+ require_optional_library('bigdecimal', normalized)
739
+ lambda { |value|
516
740
  return value if value.is_a?(BigDecimal)
517
741
 
518
742
  if value.is_a?(String)
@@ -522,16 +746,35 @@ module Rubycli
522
746
  end
523
747
  }
524
748
  when 'Date'
525
- require 'date'
749
+ require_optional_library('date', normalized)
526
750
  ->(value) { Date.parse(value) }
527
- when 'Time', 'DateTime'
528
- require 'time'
751
+ when 'Time'
752
+ require_optional_library('time', normalized)
529
753
  ->(value) { Time.parse(value) }
530
- when 'JSON', 'Hash'
531
- ->(value) { JSON.parse(value) }
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
764
+
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
774
+ }
532
775
  when 'Pathname'
533
- require 'pathname'
534
- ->(value) {
776
+ require_optional_library('pathname', normalized)
777
+ lambda { |value|
535
778
  return value if value.is_a?(Pathname)
536
779
 
537
780
  Pathname.new(value.to_s)
@@ -540,39 +783,47 @@ module Rubycli
540
783
  if normalized.start_with?('Array<') && normalized.end_with?('>')
541
784
  inner = normalized[6..-2].strip
542
785
  element_converter = converter_for_single_type(inner)
543
- ->(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
+ }
544
795
  elsif normalized.end_with?('[]')
545
796
  inner = normalized[0..-3]
546
797
  element_converter = converter_for_single_type(inner)
547
- ->(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
+ }
548
807
  elsif normalized == 'Array'
549
- ->(value) { TypeUtils.parse_list(value) }
550
- else
551
- nil
808
+ ->(value) { list_items(value) }
552
809
  end
553
810
  end
554
811
  end
555
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
+
556
819
  def convert_option_value(keyword, value, option_meta, type_converters)
557
- if Rubycli.eval_mode? || Rubycli.json_mode?
558
- return convert_arg(value)
559
- end
820
+ return convert_arg(value) if Rubycli.eval_mode? || Rubycli.json_mode?
560
821
 
561
822
  converter = type_converters[keyword]
562
823
  converted_value = convert_arg(value)
563
824
  return converted_value unless converter
564
825
 
565
- original_input = value if value.is_a?(String)
566
- expects_list = option_meta && option_meta.types.any? { |type|
567
- type.to_s.end_with?('[]') || type.to_s.start_with?('Array<')
568
- }
569
-
570
- value_for_converter = converted_value
571
- if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',')
572
- value_for_converter = original_input
573
- end
574
-
575
- converter.call(value_for_converter)
826
+ converter.call(value)
576
827
  rescue StandardError => e
577
828
  option_label = option_meta&.long || option_meta&.short || keyword
578
829
  raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}"
@@ -580,9 +831,16 @@ module Rubycli
580
831
 
581
832
  def looks_like_option?(token)
582
833
  return false unless token
583
- return false if token == '--'
834
+ return false if ['--', '-'].include?(token)
584
835
 
585
- 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
586
844
  end
587
845
  end
588
846
  end