soml-lang 0.0.1 → 0.0.3

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.
data/lib/soml/parser.rb CHANGED
@@ -24,15 +24,18 @@ module SOML
24
24
 
25
25
  DECIMAL_DIGIT = byte_table('0123456789')
26
26
 
27
+ # The first letters of the units, and of a day and a week.
28
+ DURATION_UNIT_START = byte_table('dhmnsuwDHMNSUW')
29
+
27
30
  # Exact as floats. The fast path divides by at most 10^14.
28
- POWERS_OF_TEN = Array.new(15) { |exponent| (10**exponent).to_f }.freeze
31
+ POWERS_OF_TEN = Array.new(15) { (10**it).to_f }.freeze
29
32
  LETTER = byte_table('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
30
33
 
31
- # The digits, the base, the name, its article, and the characters that an error does not name as an invalid digit. A lowercase hexadecimal digit is a digit in the wrong case, so it is not named. Shareable, so that a ractor can read it.
34
+ # The digits, the base, the name, its article, and a search for a character that an error names as an invalid digit, by the byte of the letter of the prefix. A lowercase hexadecimal digit is a digit in the wrong case, so it is not named. Shareable, so that a ractor can read it.
32
35
  RADIXES = Ractor.make_shareable({
33
- 'x' => [byte_table('0123456789ABCDEF'), 16, 'hexadecimal', 'A', /[0-9A-Fa-f_]/],
34
- 'o' => [byte_table('01234567'), 8, 'octal', 'An', /[0-7_]/],
35
- 'b' => [byte_table('01'), 2, 'binary', 'A', /[01_]/]
36
+ 0x78 => [byte_table('0123456789ABCDEF'), 16, 'hexadecimal', 'A', /[^0-9A-Fa-f_]/], # x
37
+ 0x6F => [byte_table('01234567'), 8, 'octal', 'An', /[^0-7_]/], # o
38
+ 0x62 => [byte_table('01'), 2, 'binary', 'A', /[^01_]/] # b
36
39
  })
37
40
 
38
41
  # The longest token the diagnostic regexes look at, so that a huge invalid token is described from its first part.
@@ -40,8 +43,8 @@ module SOML
40
43
 
41
44
  DURATION_UNIT_NAMES = DURATION_UNITS.keys.freeze
42
45
 
43
- INSTANT_PREFIX = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}/
44
- INSTANT = /\A([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.([0-9]+))?(Z|[+-]([0-9]{2}):([0-9]{2}))\z/
46
+ DATE_PREFIX = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}/
47
+ INSTANT = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:[0-9]{2})\z/
45
48
  LOCAL_DATE_TIME = /\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?\z/
46
49
  INSTANT_FORMAT = 'An instant is written as 2026-09-19T14:00:00Z, with an optional fraction of up to nine digits and an offset of Z or ±HH:MM'
47
50
  # Uppercase digits too, so that the value of such an escape is checked before its spelling.
@@ -49,7 +52,7 @@ module SOML
49
52
 
50
53
  # Every C0 control character except tab and line feed, and DEL. They are errors anywhere in a document. The set is for `String#count`, which looks up each byte in a table and is several times as fast as a regex search, so the regex only runs to find where an error is.
51
54
  CONTROL_CHARACTERS = "\x00-\x08\x0B-\x1F\x7F"
52
- CONTROL_CHARACTER = /[\x00-\x08\x0B-\x1F\x7F]/
55
+ CONTROL_CHARACTER = /[#{CONTROL_CHARACTERS}]/
53
56
 
54
57
  ESCAPED_STRING_SPECIAL = /[\n"\\]/
55
58
  KEY_QUOTING_HINT = '. A key that contains characters other than letters, digits, “_”, and “-” must be quoted'
@@ -151,7 +154,7 @@ module SOML
151
154
  # @param path [Array<String, Integer>]
152
155
  # @return [String]
153
156
  def self.describe_path(path)
154
- description = path.each_with_index.map do |segment, index|
157
+ description = path.map.with_index do |segment, index|
155
158
  if Integer === segment
156
159
  "[#{segment}]"
157
160
  else
@@ -159,29 +162,30 @@ module SOML
159
162
  end
160
163
  end.join
161
164
 
162
- abbreviate(description, 200).gsub(UNPRINTABLE) { |character| "\\u{#{character.ord.to_s(16)}}" }
165
+ abbreviate(description, 200).gsub(UNPRINTABLE) { "\\u{#{it.ord.to_s(16)}}" }
163
166
  end
164
167
 
165
168
  # Shorten text quoted in an error message, so a huge token does not make a huge message. The length is counted in UTF-16 code units, as the JS reference counts it, so that the two give the same message, and the cut never splits a character.
166
169
  def self.abbreviate(text, maximum_length = 40)
167
- # A character outside the BMP is two code units, and its UTF-8 lead byte is 0xF0 to 0xF4. `String#count` with that range of characters instead would build a table of a million entries.
168
- return text if text.length + text.b.count("\xF0-\xF4".b) <= maximum_length
170
+ return text if utf16_length(text) <= maximum_length
169
171
 
170
172
  length = 0
171
- kept = text.each_char.take_while { |character| (length += character.bytesize == 4 ? 2 : 1) <= maximum_length }
173
+ kept = text.each_char.take_while { (length += it.bytesize == 4 ? 2 : 1) <= maximum_length }
172
174
  "#{kept.join}…"
173
175
  end
174
176
 
177
+ # The length of `text` in UTF-16 code units, as JS counts it. A character outside the BMP is two code units, and its UTF-8 lead byte is 0xF0 to 0xF4. `String#count` with that range of characters instead would build a table of a million entries.
178
+ #
179
+ # Note: Not using UTF-16 encoding because it copies the text and is slower for short messages.
180
+ def self.utf16_length(text) = text.length + text.b.count("\xF0-\xF4".b)
181
+
175
182
  # The string as `JSON.stringify` writes it, as the JS reference shows a quoted key, so that a control character, such as a line feed, shows as its escape.
176
- def self.json_quote(text)
177
- escaped = text.gsub(/["\\\x00-\x1F]/) do |character|
178
- JSON_ESCAPES[character] || format('\\u%04x', character.ord)
179
- end
183
+ def self.json_quote(text) = "\"#{json_escape(text, /["\\\x00-\x1F]/)}\""
180
184
 
181
- "\"#{escaped}\""
182
- end
185
+ # `text` with the characters that `pattern` matches, which are control characters, `"`, or `\`, written as `JSON.stringify` writes them.
186
+ def self.json_escape(text, pattern) = text.gsub(pattern) { JSON_ESCAPES[it] || format('\\u%04x', it.ord) }
183
187
 
184
- # A value as Ruby names it in a conversion error, such as `nil` or `Integer` in "nil can't be coerced into Integer", in UTF-8. The class is read with `Kernel#class`, which a `BasicObject` does not have. {Duration} names a value the same way.
188
+ # A value as Ruby names it in a conversion error, such as `nil` or `Integer` in "nil can't be coerced into Integer", in UTF-8. The class is named with `Module#to_s`, as Ruby names it, because a class can define its own `to_s` that returns anything. {Duration} names a value the same way.
185
189
  #
186
190
  # @param value [Object]
187
191
  # @return [String]
@@ -190,10 +194,22 @@ module SOML
190
194
  when nil, true, false
191
195
  value.inspect
192
196
  else
193
- lossy_utf8(Kernel.instance_method(:class).bind_call(value).to_s)
197
+ Error.sanitize(lossy_utf8(Module.instance_method(:to_s).bind_call(class_of(value))))
194
198
  end
195
199
  end
196
200
 
201
+ # The class of a value, read with `Kernel#class`, which a `BasicObject` does not have.
202
+ #
203
+ # @param value [Object]
204
+ # @return [Class]
205
+ def self.class_of(value) = Kernel.instance_method(:class).bind_call(value)
206
+
207
+ # The name of a class, read with `Module#name`, because a class can define its own `name` that returns anything, or `nil` for an anonymous class.
208
+ #
209
+ # @param type [Module]
210
+ # @return [String, nil]
211
+ def self.module_name(type) = Module.instance_method(:name).bind_call(type)
212
+
197
213
  # Text in UTF-8, so that an error message can join it, such as a class name from a source file in Shift_JIS. Text that does not convert, such as a binary path from `ARGV` with `LANG=C`, or one in an encoding without a converter to UTF-8, has its bytes read as UTF-8, with what is not valid replaced.
198
214
  #
199
215
  # @param text [String]
@@ -214,7 +230,10 @@ module SOML
214
230
  # @return [String]
215
231
  def self.describe_argument(value)
216
232
  case value
217
- when String, Symbol, Integer, Float, Rational, nil, true, false
233
+ when String, Symbol
234
+ # `inspect` escapes a control character, but not a bidirectional control.
235
+ Error.sanitize(value.inspect)
236
+ when Integer, Float, Rational, nil, true, false
218
237
  value.inspect
219
238
  else
220
239
  conversion_name(value)
@@ -264,14 +283,8 @@ module SOML
264
283
  #
265
284
  # @return [SOML::Duration]
266
285
  def parse_duration_literal
267
- finish = 0
268
-
269
- while (byte = @source.getbyte(finish)) && NUMBER_BYTE[byte]
270
- finish += 1
271
- end
272
-
273
286
  # An empty source and an instant are not duration-like either.
274
- if finish < @length || !duration_like?(@source)
287
+ if number_end(0) < @length || !duration_like?(@source)
275
288
  fail_at("Expected a duration, such as 1h30m, but found #{@source.empty? ? 'nothing' : quote_found(abbreviate(@source))}", 0)
276
289
  end
277
290
 
@@ -322,10 +335,10 @@ module SOML
322
335
 
323
336
  # A sequence that the end of the input cuts short is reported as what it is.
324
337
  if truncated_utf8?(@source.byteslice(offset, @length - offset))
325
- raise ParseError.new('Incomplete UTF-8 sequence at the end of the input', valid, valid.bytesize, @path)
338
+ raise ParseError.new('Incomplete UTF-8 sequence at the end of the input', valid, offset, @path)
326
339
  end
327
340
 
328
- raise ParseError.new(format('Invalid UTF-8 byte 0x%02X', @source.getbyte(offset)), valid, valid.bytesize, @path)
341
+ raise ParseError.new(format('Invalid UTF-8 byte 0x%02X', @source.getbyte(offset)), valid, offset, @path)
329
342
  end
330
343
 
331
344
  # Whether some bytes could follow `rest` to make it one valid character.
@@ -348,13 +361,7 @@ module SOML
348
361
  end
349
362
  end
350
363
 
351
- def fail_at(reason, offset = @index)
352
- raise ParseError.new(reason, @source, offset, @path)
353
- end
354
-
355
- def fail_too_deep(offset = @index)
356
- fail_at("The document is nested more than #{MAX_DEPTH} levels deep", offset)
357
- end
364
+ def fail_at(reason, offset = @index) = raise(ParseError.new(reason, @source, offset, @path))
358
365
 
359
366
  # `bare-object = entry ( entry-sep entry )*`, where a separator is one or more line breaks.
360
367
  def parse_bare_object
@@ -512,7 +519,11 @@ module SOML
512
519
  # An entry whose value was left out, as in `a:` followed by `'b': 1` on the next line, reads the next key as the value. That failure is reported as what it is.
513
520
  def diagnose_missing_value(start, parent, key_start, comment_hint)
514
521
  # A YAML block sequence.
515
- fail_at('Expected a value, but found a “-” list. An array is written in brackets, as in [80, 443]', start) if @source.getbyte(start) == 0x2D && space?(@source.getbyte(start + 1))
522
+ first = @source.getbyte(start)
523
+ fail_at('Expected a value, but found a “-” list. An array is written in brackets, as in [80, 443]', start) if first == 0x2D && space?(@source.getbyte(start + 1))
524
+
525
+ # Only a key can be followed by its `:`. Reading anything else as a key would make an error, which counts the lines of the whole source, at every level of a nested value.
526
+ return unless first == 0x27 || first == 0x22 || (first && BARE_KEY_BYTE[first])
516
527
 
517
528
  @index = start
518
529
 
@@ -527,7 +538,6 @@ module SOML
527
538
 
528
539
  # Whitespace or the end follows the `:` of a bare key. A digit follows the `:` in an instant such as `2026-09-19T25:00:00Z`, whose own error is more precise. A quoted key cannot be part of a value, so anything may follow its `:`.
529
540
  following = @source.getbyte(@index + 1)
530
- first = @source.getbyte(start)
531
541
 
532
542
  return unless first == 0x27 || first == 0x22 || space_or_line_end?(following)
533
543
 
@@ -542,18 +552,12 @@ module SOML
542
552
  return '' if key_indentation.nil? || indentation.nil? || indentation <= key_indentation
543
553
 
544
554
  # The keys are written as in a document, so that the suggestion is valid, rather than as JSON strings like the rest of the message, whose escapes, such as `\b`, are not all valid.
545
- ". Indentation does not nest objects, so write #{abbreviate(format_key(parent), 200)}: {#{abbreviate(format_key(key), 200)}: …}"
546
- end
547
-
548
- # A key as a document writes it, bare when it can be, as {Generator} writes it.
549
- def format_key(key)
550
- SOML.bare_key?(key) ? key : Generator.new.generate_value(key, 0)
555
+ ". Indentation does not nest objects, so write #{abbreviate(Generator.format_key(parent), 200)}: {#{abbreviate(Generator.format_key(key), 200)}: …}"
551
556
  end
552
557
 
553
558
  def fail_missing_colon(key_start)
554
559
  source = @source
555
- following = @index
556
- following += 1 while space?(source.getbyte(following))
560
+ following = skip_spaces(@index)
557
561
  following_byte = source.getbyte(following)
558
562
 
559
563
  fail_at('Whitespace is not allowed between a key and its “:”') if following_byte == 0x3A && following > @index
@@ -562,11 +566,7 @@ module SOML
562
566
  if following_byte == 0x2F && source.getbyte(following + 1) == 0x2A
563
567
  comment_end = source.byteindex('*/', following + 2)
564
568
 
565
- if comment_end
566
- after_comment = comment_end + 2
567
- after_comment += 1 while space?(source.getbyte(after_comment))
568
- fail_at('A comment is not allowed between a key and its “:”', following) if source.getbyte(after_comment) == 0x3A
569
- end
569
+ fail_at('A comment is not allowed between a key and its “:”', following) if comment_end && source.getbyte(skip_spaces(comment_end + 2)) == 0x3A
570
570
  end
571
571
 
572
572
  colon = source.byteindex(':', following)
@@ -623,14 +623,12 @@ module SOML
623
623
  colon = @source.byteindex(':', @index)
624
624
  key = @source.byteslice(@index, colon - @index) if colon && colon < line_end(@index)
625
625
  # A longer key gets the hint without the example, as in JS, where the key and its `:` must fit in 1000 UTF-16 code units.
626
- key = nil if key && (key.length + key.b.count("\xF0-\xF4".b) >= MAX_DIAGNOSED_LENGTH || key.match?(NOT_QUOTABLE_KEY_CHARACTER))
626
+ key = nil if key && (Parser.utf16_length(key) >= MAX_DIAGNOSED_LENGTH || key.match?(NOT_QUOTABLE_KEY_CHARACTER))
627
627
  key.nil? ? KEY_QUOTING_HINT : "#{KEY_QUOTING_HINT}, as in '#{abbreviate(key)}'"
628
628
  end
629
629
 
630
630
  # `offset` is before the end of the source.
631
- def quotable_key_character?(offset)
632
- character_at(offset).match?(QUOTABLE_KEY_CHARACTER)
633
- end
631
+ def quotable_key_character?(offset) = character_at(offset).match?(QUOTABLE_KEY_CHARACTER)
634
632
 
635
633
  # A `.` after a key, as in `example.com: 1`. A key is never a path, so the `.` is meant either as part of the key or as nesting.
636
634
  def fail_dot_in_key(key_start)
@@ -652,7 +650,7 @@ module SOML
652
650
 
653
651
  nested = "#{words.last}: …"
654
652
 
655
- words[0...-1].reverse_each do |word|
653
+ words[...-1].reverse_each do |word|
656
654
  nested = "#{word}: {#{nested}}"
657
655
  end
658
656
 
@@ -660,9 +658,7 @@ module SOML
660
658
  end
661
659
 
662
660
  # Found text between curly quotes, as prose quotes it, with its control characters, such as a line feed, written as their JSON escapes, so that they show rather than become U+FFFD, and a backslash escaped, so that it does not read as one of them.
663
- def quote_found(text)
664
- "“#{text.gsub(/[\\\x00-\x1F]/) { JSON_ESCAPES[it] || format('\\u%04x', it.ord) }}”"
665
- end
661
+ def quote_found(text) = "“#{Parser.json_escape(text, /[\\\x00-\x1F]/)}”"
666
662
 
667
663
  def parse_object(depth)
668
664
  object = {}
@@ -678,7 +674,7 @@ module SOML
678
674
 
679
675
  # `{` or `[` at depth `depth`, then items, which the block reads, separated by a comma, a line break, or both, with an optional trailing comma, then the `closing` bracket. A comma must be on the line of the item before it.
680
676
  def parse_items(depth, closing, kind, item)
681
- fail_too_deep if depth > MAX_DEPTH
677
+ fail_at("The document is nested more than #{MAX_DEPTH} levels deep") if depth > MAX_DEPTH
682
678
 
683
679
  source = @source
684
680
  start = @index
@@ -733,9 +729,9 @@ module SOML
733
729
  when 0x69 # i
734
730
  keyword?('infinity') ? Float::INFINITY : fail_unexpected_value
735
731
  when 0x2D # -
736
- @source.getbyte(@index + 1) == 0x69 && keyword?('-infinity') ? -Float::INFINITY : (number = parse_number_or_instant)
732
+ @source.getbyte(@index + 1) == 0x69 && keyword?('-infinity') ? -Float::INFINITY : (number = parse_number_or_temporal)
737
733
  when 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39
738
- number = parse_number_or_instant
734
+ number = parse_number_or_temporal
739
735
  else
740
736
  fail_unexpected_value
741
737
  end
@@ -774,10 +770,12 @@ module SOML
774
770
  unit_end += 1
775
771
  end
776
772
 
773
+ return if unit_end == unit_start
774
+
777
775
  unit = source.byteslice(unit_start, unit_end - unit_start)
778
776
 
779
777
  # A keyword after a number is a missing comma.
780
- return if unit.empty? || !value_end?(skip_spaces(unit_end)) || %w[true false null infinity].include?(unit)
778
+ return if !value_end?(skip_spaces(unit_end)) || %w[true false null infinity].include?(unit)
781
779
 
782
780
  number = source.byteslice(start, @index - start)
783
781
  # The duration is only suggested when it is valid, which a number with an exponent or a radix prefix, a fraction of a nanosecond, a negative zero, or a value outside the 64-bit range is not. The string is always offered, because a unit such as `m` may mean meters rather than minutes.
@@ -830,23 +828,8 @@ module SOML
830
828
 
831
829
  # The text from `start` that was most likely meant as one unquoted string, such as `John Smith`: up to the end of the line, a comma, a closing bracket, or a comment after a space or a tab, without the spaces and tabs at its end. It is read up to 1000 UTF-16 code units, as in JS, and never ends inside a character.
832
830
  def unquoted_text(start = @index)
833
- source = @source
834
- finish = start
835
- length = 0
836
-
837
- while (byte = source.getbyte(finish))
838
- # A continuation byte is part of the character before it.
839
- unless byte.between?(0x80, 0xBF)
840
- break if length >= MAX_DIAGNOSED_LENGTH || unquoted_text_end?(finish)
841
-
842
- # A character outside the BMP is two UTF-16 code units, and its UTF-8 lead byte is 0xF0 to 0xF4.
843
- length += byte >= 0xF0 ? 2 : 1
844
- end
845
-
846
- finish += 1
847
- end
848
-
849
- source.byteslice(start, skip_spaces_back(finish) - start)
831
+ finish = code_unit_end(start) { unquoted_text_end?(it) }
832
+ @source.byteslice(start, skip_spaces_back(finish) - start)
850
833
  end
851
834
 
852
835
  def unquoted_text_end?(offset)
@@ -877,14 +860,10 @@ module SOML
877
860
  end
878
861
 
879
862
  # The hint for a `//` comment, as in JavaScript.
880
- def slash_comment_hint
881
- @source.getbyte(@index) == 0x2F && @source.getbyte(@index + 1) == 0x2F ? '. A comment starts with “#”' : ''
882
- end
863
+ def slash_comment_hint = @source.getbyte(@index) == 0x2F && @source.getbyte(@index + 1) == 0x2F ? '. A comment starts with “#”' : ''
883
864
 
884
865
  # The example in a message that a string must be quoted, which quotes `text` as a `'...'` string. That cannot hold a `'`, so then there is no simple example.
885
- def quoting_example(text)
886
- text.include?("'") ? '' : ", as in '#{abbreviate(text)}'"
887
- end
866
+ def quoting_example(text) = text.include?("'") ? '' : ", as in '#{abbreviate(text)}'"
888
867
 
889
868
  def describe_here
890
869
  return 'the end of the document' if @index >= @length
@@ -893,9 +872,7 @@ module SOML
893
872
  character == "\n" ? 'a line break' : describe_character(character)
894
873
  end
895
874
 
896
- def character_at(offset)
897
- @source.byteslice(offset, 4)[0]
898
- end
875
+ def character_at(offset) = @source.byteslice(offset, 4)[0]
899
876
 
900
877
  def parse_string
901
878
  source = @source
@@ -952,17 +929,19 @@ module SOML
952
929
  return value
953
930
  end
954
931
 
955
- text, chunk_start = parse_escape(special)
956
- value << text
932
+ chunk_start = parse_escape(special, value)
957
933
  end
958
934
  end
959
935
 
960
- # Decodes the escape at `offset`, which is a backslash, and returns its text and the offset after it.
961
- def parse_escape(offset)
936
+ # Decodes the escape at `offset`, which is a backslash, onto `value`, and returns the offset after it.
937
+ def parse_escape(offset, value)
962
938
  source = @source
963
939
  byte = source.getbyte(offset + 1)
964
940
  simple = SIMPLE_ESCAPES[byte]
965
- return [simple, offset + 2] if simple
941
+ if simple
942
+ value << simple
943
+ return offset + 2
944
+ end
966
945
 
967
946
  if byte == 0x75 # u
968
947
  # The slice may cut a character in two, which a regex refuses to read.
@@ -979,7 +958,8 @@ module SOML
979
958
  fail_at('A Unicode escape uses lowercase hexadecimal digits', offset) if hex.match?(/[A-F]/)
980
959
  fail_at("A Unicode escape may not have leading zeros; write \\u{#{hex.sub(/\A0+(?=.)/, '')}}", offset) if hex.length > 1 && hex.start_with?('0')
981
960
 
982
- return [code_point.chr(Encoding::UTF_8), offset + 1 + match[0].bytesize]
961
+ value << code_point
962
+ return offset + 1 + match[0].bytesize
983
963
  end
984
964
 
985
965
  # A backslash at the end of the document is the same mistake as one at the end of a line.
@@ -1073,8 +1053,7 @@ module SOML
1073
1053
  line_start = offset
1074
1054
 
1075
1055
  while line_start < @length
1076
- delimiter_start = line_start
1077
- delimiter_start += 1 while space?(source.getbyte(delimiter_start))
1056
+ delimiter_start = skip_spaces(line_start)
1078
1057
  run_length = 0
1079
1058
  run_length += 1 while source.getbyte(delimiter_start + run_length) == quote
1080
1059
  return [line_start, delimiter_start] if run_length == length
@@ -1094,30 +1073,27 @@ module SOML
1094
1073
 
1095
1074
  while (index = text.byteindex('\\', chunk_start))
1096
1075
  value << text.byteslice(chunk_start, index - chunk_start)
1097
- escaped, finish = parse_escape(offset + index)
1098
- value << escaped
1099
- chunk_start = finish - offset
1076
+ chunk_start = parse_escape(offset + index, value) - offset
1100
1077
  end
1101
1078
 
1102
1079
  value << text.byteslice(chunk_start, text.bytesize - chunk_start)
1103
1080
  end
1104
1081
 
1105
- def parse_number_or_instant
1082
+ def parse_number_or_temporal
1106
1083
  plain = parse_plain_number
1107
1084
  return plain unless plain.nil?
1108
1085
 
1109
- source = @source
1110
1086
  start = @index
1111
- finish = start + 1
1112
-
1113
- while (byte = source.getbyte(finish)) && NUMBER_BYTE[byte]
1114
- finish += 1
1115
- end
1116
-
1117
- text = source.byteslice(start, finish - start)
1087
+ finish = number_end(start)
1088
+ text = @source.byteslice(start, finish - start)
1118
1089
  @index = finish
1119
1090
 
1120
- return parse_instant(text, start) if text.match?(INSTANT_PREFIX)
1091
+ if text.match?(DATE_PREFIX)
1092
+ # A time after spaces or tabs still gets the instant separator diagnostic.
1093
+ return parse_date(text, start) if text.bytesize == 10 && !space_separated_time?
1094
+
1095
+ return parse_instant(text, start)
1096
+ end
1121
1097
 
1122
1098
  kind = classify_number(text)
1123
1099
  return parse_duration(text, start) if kind.nil? && duration_like?(text)
@@ -1228,7 +1204,7 @@ module SOML
1228
1204
 
1229
1205
  # Whether `text` is a decimal int, a radix int, or a float, following the grammar. A run of digits may have single underscores between digits.
1230
1206
  def classify_number(text)
1231
- radix = text.getbyte(0) == 0x30 && RADIXES[text[1]]
1207
+ radix = text.getbyte(0) == 0x30 && RADIXES[text.getbyte(1)]
1232
1208
 
1233
1209
  if radix
1234
1210
  finish = skip_digits(text, 2, radix[0])
@@ -1288,14 +1264,12 @@ module SOML
1288
1264
  end
1289
1265
 
1290
1266
  # Returns the index after an integer part, which is `0` or digits without a leading zero, or `index` when there is none. Anything after a leading `0`, such as the `5` in `05`, is left for the caller to reject.
1291
- def skip_integer_part(text, index)
1292
- text.getbyte(index) == 0x30 ? index + 1 : skip_digits(text, index, DECIMAL_DIGIT)
1293
- end
1267
+ def skip_integer_part(text, index) = text.getbyte(index) == 0x30 ? index + 1 : skip_digits(text, index, DECIMAL_DIGIT)
1294
1268
 
1295
1269
  # More than 64 significant digits is out of range in any radix, which is decided before a huge digit string is converted.
1296
1270
  def parse_integer(text, start)
1297
1271
  digits = text.delete('_')
1298
- radix = RADIXES[digits[1]] if digits.bytesize > 1
1272
+ radix = RADIXES[digits.getbyte(1)]
1299
1273
  significant = digits.getbyte(0) == 0x2D ? 1 : 0
1300
1274
 
1301
1275
  if radix
@@ -1320,7 +1294,7 @@ module SOML
1320
1294
  # Negative zero is the same value as zero.
1321
1295
  return 0.0 if first.nil?
1322
1296
 
1323
- significant = trim_trailing_zeros(digits.byteslice(first, digits.bytesize - first))
1297
+ significant = digits.byteslice(first, digits.bytesize - first).rstrip('0')
1324
1298
  # An exponent with more than 18 digits is far outside the float range either way, which is decided before converting a huge digit string, which takes quadratic time.
1325
1299
  exponent_text = exponent_text.start_with?('-') ? '-1000000000000000000' : '1000000000000000000' if exponent_text && exponent_text.delete_prefix('-').bytesize > 18
1326
1300
  exponent = exponent_text.to_i - (fraction&.bytesize || 0) + (digits.bytesize - first - significant.bytesize)
@@ -1349,6 +1323,9 @@ module SOML
1349
1323
  end
1350
1324
 
1351
1325
  # The float nearest to `significand × 10^exponent`, rounding a tie to even, as IEEE 754 does. It uses exact integer arithmetic rather than `String#to_f`, which warns when a value overflows or underflows.
1326
+ #
1327
+ # Note: Not using `Float()` because it warns at the limits.
1328
+ # Note: Not using `Rational#to_f` because it can round twice (Ruby bug #22246).
1352
1329
  def decimal_to_float(significand, exponent)
1353
1330
  numerator = exponent.negative? ? significand : significand * (10**exponent)
1354
1331
  denominator = exponent.negative? ? 10**-exponent : 1
@@ -1371,41 +1348,85 @@ module SOML
1371
1348
  Math.ldexp(quotient, shift)
1372
1349
  end
1373
1350
 
1374
- def parse_instant(text, start)
1375
- match = text.bytesize > MAX_DIAGNOSED_LENGTH ? nil : INSTANT.match(text)
1351
+ # Whether a time follows after spaces or tabs, as in `2026-11-01 12:00`, which an instant's error describes better than a date followed by another entry.
1352
+ def space_separated_time?
1353
+ time_start = skip_spaces(@index)
1354
+ time_start > @index && @source.byteslice(time_start, 5).b.match?(/\A[0-9]{1,2}:[0-9]{2}/)
1355
+ end
1356
+
1357
+ # A calendar date alone, validated as a whole ten-byte token without a time or timezone conversion.
1358
+ def parse_date(text, start)
1359
+ year = digits_value(text, 0, 4)
1360
+ month = digits_value(text, 5, 2)
1361
+ day = digits_value(text, 8, 2)
1362
+ reason = calendar_error(year, month, day)
1363
+ fail_at("Invalid date #{text}: #{reason}", start) if reason
1364
+
1365
+ Date.new(year, month, day, Date::GREGORIAN)
1366
+ end
1376
1367
 
1377
- if match.nil?
1378
- # A date, a space, and a time, as TOML and Python write a date and time.
1379
- time_start = @index + 1
1380
- finish = @source.getbyte(@index) == 0x20 && digit?(@source.getbyte(time_start)) ? number_end(time_start) : @index
1368
+ def parse_instant(text, start)
1369
+ unless text.bytesize <= MAX_DIAGNOSED_LENGTH && text.match?(INSTANT)
1370
+ # A date, spaces or tabs, and a time, as TOML and Python write a date and time.
1371
+ time_start = skip_spaces(@index)
1372
+ finish = time_start > @index && digit?(@source.getbyte(time_start)) ? number_end(time_start) : @index
1381
1373
  time = finish > time_start ? @source.byteslice(time_start, finish - time_start) : ''
1382
1374
  # More of the instant may follow, as in `14:00:00,5Z` or `14:00:00[Europe/Oslo]`, so it may have an offset.
1383
1375
  is_whole_value = value_end?(finish) && !(@source.getbyte(finish) == 0x2C && digit?(@source.getbyte(finish + 1)))
1384
1376
  fail_at(describe_bad_instant(text, time, is_whole_value, offset_after_space), start)
1385
1377
  end
1386
1378
 
1387
- year, month, day, hour, minute, second, fraction, offset, offset_hour, offset_minute = match.captures
1388
- check_instant(text, start, fraction.nil? || fraction.length <= 9, 'a fractional second has at most nine digits')
1389
- check_instant(text, start, year != '0000', 'the year must be 0001 to 9999')
1390
- check_instant(text, start, month.between?('01', '12'), 'the month must be 01 to 12')
1391
- last_day = days_in_month(year.to_i, month.to_i)
1392
- check_instant(text, start, day >= '01' && day.to_i <= last_day, "the day must be 01 to #{last_day} in that month")
1393
- check_instant(text, start, hour <= '23', 'the hour must be 00 to 23')
1394
- check_instant(text, start, minute <= '59', 'the minute must be 00 to 59')
1395
- check_instant(text, start, second <= '59', 'the second must be 00 to 59, and a leap second is not representable')
1396
-
1397
- seconds = (days_from_civil(year.to_i, month.to_i, day.to_i) * 86_400) + (hour.to_i * 3600) + (minute.to_i * 60) + second.to_i
1379
+ # The regex checked the layout, so each part is at a fixed offset, except the offset, which follows the optional fraction. Reading the digits as bytes makes no string for each part.
1380
+ year = digits_value(text, 0, 4)
1381
+ month = digits_value(text, 5, 2)
1382
+ day = digits_value(text, 8, 2)
1383
+ hour = digits_value(text, 11, 2)
1384
+ minute = digits_value(text, 14, 2)
1385
+ second = digits_value(text, 17, 2)
1386
+ # The offset, `Z` or `±HH:MM`, ends the instant, and a fraction is between the seconds and it.
1387
+ # `getbyte(-1)` made parsing a document of many instants a third slower, measured on Ruby 4.0 with YJIT.
1388
+ length = text.bytesize
1389
+ offset_start = text.getbyte(length - 1) == 0x5A ? length - 1 : length - 6
1390
+ fraction_length = offset_start == 19 ? 0 : offset_start - 20
1391
+
1392
+ fail_instant(text, start, 'a fractional second has at most nine digits') if fraction_length > 9
1393
+ reason = calendar_error(year, month, day)
1394
+ fail_instant(text, start, reason) if reason
1395
+ fail_instant(text, start, 'the hour must be 00 to 23') if hour > 23
1396
+ fail_instant(text, start, 'the minute must be 00 to 59') if minute > 59
1397
+ fail_instant(text, start, 'the second must be 00 to 59, and a leap second is not representable') if second > 59
1398
+
1399
+ seconds = (days_from_civil(year, month, day) * 86_400) + (hour * 3600) + (minute * 60) + second
1400
+ sign = text.getbyte(offset_start)
1401
+
1402
+ # Not `Z`, so `±HH:MM`.
1403
+ if sign != 0x5A
1404
+ offset_hour = digits_value(text, offset_start + 1, 2)
1405
+ offset_minute = digits_value(text, offset_start + 4, 2)
1406
+ fail_instant(text, start, 'the offset hour must be 00 to 23') if offset_hour > 23
1407
+ fail_instant(text, start, 'the offset minute must be 00 to 59') if offset_minute > 59
1408
+ fail_instant(text, start, '-00:00 means “offset unknown” in RFC 3339, which is not representable; use Z or +00:00') if sign == 0x2D && offset_hour.zero? && offset_minute.zero?
1409
+ offset_seconds = (offset_hour * 3600) + (offset_minute * 60)
1410
+ seconds += sign == 0x2B ? -offset_seconds : offset_seconds
1411
+ end
1412
+
1413
+ fail_instant(text, start, 'in UTC it falls outside the years 0001 to 9999') unless seconds.between?(MIN_INSTANT_SECONDS, MAX_INSTANT_SECONDS)
1414
+ Time.at(seconds, fraction_length.zero? ? 0 : digits_value(text, 20, fraction_length) * (10**(9 - fraction_length)), :nsec).utc
1415
+ end
1416
+
1417
+ # The value of the `count` decimal digits at `offset` in `text`, which the caller checked are digits.
1418
+ #
1419
+ # Note: Not using a slice and `to_i` because they are slower for these small fields.
1420
+ def digits_value(text, offset, count)
1421
+ value = 0
1422
+ finish = offset + count
1398
1423
 
1399
- if offset != 'Z'
1400
- check_instant(text, start, offset_hour <= '23', 'the offset hour must be 00 to 23')
1401
- check_instant(text, start, offset_minute <= '59', 'the offset minute must be 00 to 59')
1402
- check_instant(text, start, offset != '-00:00', '-00:00 means “offset unknown” in RFC 3339, which is not representable; use Z or +00:00')
1403
- offset_seconds = (offset_hour.to_i * 3600) + (offset_minute.to_i * 60)
1404
- seconds += offset.start_with?('+') ? -offset_seconds : offset_seconds
1424
+ while offset < finish
1425
+ value = (value * 10) + text.getbyte(offset) - 0x30
1426
+ offset += 1
1405
1427
  end
1406
1428
 
1407
- check_instant(text, start, seconds.between?(MIN_INSTANT_SECONDS, MAX_INSTANT_SECONDS), 'in UTC it falls outside the years 0001 to 9999')
1408
- Time.at(seconds, fraction.nil? ? 0 : fraction.ljust(9, '0').to_i, :nsec).utc
1429
+ value
1409
1430
  end
1410
1431
 
1411
1432
  # A scan of the parts in order, so that each error names the part it is about.
@@ -1441,7 +1462,7 @@ module SOML
1441
1462
  fail_duration(text, start, 'an underscore must be between two digits') if text.getbyte(unit_start) == 0x5F
1442
1463
 
1443
1464
  # Trailing zeros change nothing, and leaving them out keeps the arithmetic small however many there are.
1444
- fraction = trim_trailing_zeros(text.byteslice(digits_end + 1, unit_start - digits_end - 1).delete('_'))
1465
+ fraction = text.byteslice(digits_end + 1, unit_start - digits_end - 1).delete('_').rstrip('0')
1445
1466
  end
1446
1467
 
1447
1468
  unit_end = unit_start
@@ -1457,10 +1478,9 @@ module SOML
1457
1478
  fail_duration(text, start, 'only the last part may have a fraction') if following == 0x2E && digit?(text.getbyte(unit_end))
1458
1479
 
1459
1480
  size = DURATION_UNITS[unit]
1460
- digits = text.byteslice(index, digits_end - index).delete('_')
1461
1481
 
1462
- # More than 19 digits is out of range in any unit, which is decided before a huge digit string is converted.
1463
- total += digits.bytesize > 19 ? INT64_MAX * 2 : digits.to_i * size
1482
+ # More than 19 digits, which with the single underscores that may be between them is more than 37 bytes, is out of range in any unit, which is decided before a huge digit string is converted. `to_i` reads the underscores.
1483
+ total += digits_end - index > 37 ? INT64_MAX * 2 : text.byteslice(index, digits_end - index).to_i * size
1464
1484
  index = unit_end
1465
1485
  previous_rank = rank
1466
1486
  end
@@ -1471,6 +1491,7 @@ module SOML
1471
1491
  unless fraction.empty?
1472
1492
  scale = 10**fraction.bytesize
1473
1493
  fraction_nanoseconds = fraction.to_i * size
1494
+ # Note: Not using `divmod` because it allocates an array and is slower for ordinary short fractions.
1474
1495
  fail_duration(text, start, 'it is not a whole number of nanoseconds') unless (fraction_nanoseconds % scale).zero?
1475
1496
  total += fraction_nanoseconds / scale
1476
1497
  end
@@ -1481,13 +1502,9 @@ module SOML
1481
1502
  Duration.new(nanoseconds: is_negative ? -total : total)
1482
1503
  end
1483
1504
 
1484
- def check_instant(text, start, is_valid, reason)
1485
- fail_at("Invalid instant #{abbreviate(text)}: #{reason}", start) unless is_valid
1486
- end
1505
+ def fail_instant(text, start, reason) = fail_at("Invalid instant #{abbreviate(text)}: #{reason}", start)
1487
1506
 
1488
- def fail_duration(text, start, reason)
1489
- fail_at("Invalid duration #{abbreviate(text)}: #{reason}", start)
1490
- end
1507
+ def fail_duration(text, start, reason) = fail_at("Invalid duration #{abbreviate(text)}: #{reason}", start)
1491
1508
 
1492
1509
  # Whether a token that is not a number or an instant was meant as a duration: its first letter could begin a unit, or a day or a week. A radix prefix, an exponent, and other letters, such as the `T` in `20260919T140000Z` or the `x` in `1.5x`, are left to the number errors.
1493
1510
  def duration_like?(text)
@@ -1505,22 +1522,12 @@ module SOML
1505
1522
  return false if byte.nil?
1506
1523
 
1507
1524
  # A year unit, as in `1y` or `2years`, is only one when the token or another part follows it, so that a word such as `100yen` stays a string to quote. The longest unit and the byte after it fit in 6 bytes.
1508
- 'dhmnsuwDHMNSUW'.include?(byte.chr) || text.byteslice(index, 6).match?(/\A(?:y|yrs?|years?)(?:\z|[0-9])/i)
1525
+ DURATION_UNIT_START[byte] || text.byteslice(index, 6).match?(/\A(?:y|yrs?|years?)(?:\z|[0-9])/i)
1509
1526
  end
1510
1527
 
1511
- def trim_trailing_zeros(digits)
1512
- finish = digits.bytesize
1513
- finish -= 1 while finish.positive? && digits.getbyte(finish - 1) == 0x30
1514
- digits.byteslice(0, finish)
1515
- end
1516
-
1517
- def line_end(offset)
1518
- @source.byteindex("\n", offset) || @length
1519
- end
1528
+ def line_end(offset) = @source.byteindex("\n", offset) || @length
1520
1529
 
1521
- def line_start(offset)
1522
- offset.zero? ? 0 : (@source.byterindex("\n", offset - 1) || -1) + 1
1523
- end
1530
+ def line_start(offset) = offset.zero? ? 0 : (@source.byterindex("\n", offset - 1) || -1) + 1
1524
1531
 
1525
1532
  # The number of spaces and tabs before `offset` on its line, or `nil` when something else comes before it.
1526
1533
  def indentation_at(offset)
@@ -1528,9 +1535,9 @@ module SOML
1528
1535
  skip_spaces(start) == offset ? offset - start : nil
1529
1536
  end
1530
1537
 
1531
- # The end of the number, instant, or duration that starts at `start`.
1538
+ # The end of the number, date, instant, or duration that starts at `start`: the end of the run of the bytes that one can hold.
1532
1539
  def number_end(start)
1533
- finish = start + 1
1540
+ finish = start
1534
1541
 
1535
1542
  while (byte = @source.getbyte(finish)) && NUMBER_BYTE[byte]
1536
1543
  finish += 1
@@ -1539,15 +1546,15 @@ module SOML
1539
1546
  finish
1540
1547
  end
1541
1548
 
1542
- # The offset after `maximum` UTF-16 code units from `start`, as JS slices the source. A character outside the BMP that JS cuts in two at the limit is kept whole, so that, like the lone surrogate JS keeps, it is still there to stop a match at the end.
1543
- def code_unit_end(start, maximum = MAX_DIAGNOSED_LENGTH)
1549
+ # The offset after `maximum` UTF-16 code units from `start`, as JS slices the source, or before the first character for which the block returns true. A character outside the BMP that JS cuts in two at the limit is kept whole, so that, like the lone surrogate JS keeps, it is still there to stop a match at the end.
1550
+ def code_unit_end(start)
1544
1551
  finish = start
1545
1552
  length = 0
1546
1553
 
1547
1554
  while (byte = @source.getbyte(finish))
1548
1555
  # A continuation byte is part of the character before it.
1549
1556
  unless byte.between?(0x80, 0xBF)
1550
- break if length >= maximum
1557
+ break if length >= MAX_DIAGNOSED_LENGTH || (block_given? && yield(finish))
1551
1558
 
1552
1559
  # A character outside the BMP is two UTF-16 code units, and its UTF-8 lead byte is 0xF0 to 0xF4.
1553
1560
  length += byte >= 0xF0 ? 2 : 1
@@ -1565,43 +1572,42 @@ module SOML
1565
1572
  byte.nil? || (VALUE_END_BYTE[byte] && (byte != 0x2F || (following = @source.getbyte(offset + 1)) == 0x2A || following == 0x2F))
1566
1573
  end
1567
1574
 
1568
- def byte_before(offset)
1569
- offset.positive? ? @source.getbyte(offset - 1) : nil
1570
- end
1575
+ def byte_before(offset) = offset.positive? ? @source.getbyte(offset - 1) : nil
1571
1576
 
1572
1577
  def skip_spaces(offset)
1573
1578
  offset += 1 while space?(@source.getbyte(offset))
1574
1579
  offset
1575
1580
  end
1576
1581
 
1577
- def space?(byte)
1578
- byte == 0x20 || byte == 0x09
1579
- end
1582
+ def space?(byte) = byte == 0x20 || byte == 0x09
1580
1583
 
1581
1584
  # Whether a byte is a space, a tab, a line feed, or the end.
1582
- def space_or_line_end?(byte)
1583
- byte.nil? || byte == 0x0A || space?(byte)
1584
- end
1585
+ def space_or_line_end?(byte) = byte.nil? || byte == 0x0A || space?(byte)
1585
1586
 
1586
1587
  def skip_spaces_back(offset)
1587
1588
  offset -= 1 while offset.positive? && space?(@source.getbyte(offset - 1))
1588
1589
  offset
1589
1590
  end
1590
1591
 
1591
- def digit?(byte)
1592
- !byte.nil? && byte >= 0x30 && byte <= 0x39
1593
- end
1592
+ def digit?(byte) = !byte.nil? && byte >= 0x30 && byte <= 0x39
1594
1593
 
1595
- def digit_or_underscore?(byte)
1596
- byte == 0x5F || digit?(byte)
1597
- end
1594
+ def digit_or_underscore?(byte) = byte == 0x5F || digit?(byte)
1595
+
1596
+ # Note: Not using `String#count` because it scans the whole input even when its first character is invalid.
1597
+ def words_with_spaces?(text) = !text.match?(/[^A-Za-z0-9_ \t-]/)
1598
1598
 
1599
- def words_with_spaces?(text)
1600
- text.each_byte.all? { |byte| space?(byte) || BARE_KEY_BYTE[byte] }
1599
+ # Shared Gregorian validation for dates and the calendar portion of instants.
1600
+ def calendar_error(year, month, day)
1601
+ return 'the year must be 0001 to 9999' if year.zero?
1602
+ return 'the month must be 01 to 12' unless month.between?(1, 12)
1603
+
1604
+ last_day = days_in_month(year, month)
1605
+ "the day must be 01 to #{last_day} in that month" unless day.between?(1, last_day)
1601
1606
  end
1602
1607
 
1603
1608
  def days_in_month(year, month)
1604
1609
  if month == 2
1610
+ # Note: Not using `Date.gregorian_leap?` because it is slower.
1605
1611
  is_leap_year = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
1606
1612
  return is_leap_year ? 29 : 28
1607
1613
  end
@@ -1610,6 +1616,8 @@ module SOML
1610
1616
  end
1611
1617
 
1612
1618
  # Days since 1970-01-01 in the proleptic Gregorian calendar, from Howard Hinnant's `days_from_civil`.
1619
+ #
1620
+ # Note: Not using `Time.utc` because constructing it here is slower.
1613
1621
  def days_from_civil(year, month, day)
1614
1622
  year -= 1 if month <= 2
1615
1623
  era = year.div(400)
@@ -1621,9 +1629,7 @@ module SOML
1621
1629
 
1622
1630
  def abbreviate(...) = Parser.abbreviate(...)
1623
1631
 
1624
- def format_code_point(code_point)
1625
- format('U+%04X', code_point)
1626
- end
1632
+ def format_code_point(code_point) = format('U+%04X', code_point)
1627
1633
 
1628
1634
  # Describe a character for an error message, such as `“$”` or `U+00A0 (NO-BREAK SPACE)`.
1629
1635
  def describe_character(character)
@@ -1644,13 +1650,14 @@ module SOML
1644
1650
  # A keyword hint is only for the word on its own. In `Yes please` or `Nan Goldin`, following it would leave text behind or change the value.
1645
1651
  lowercase = text == word ? word.downcase : ''
1646
1652
 
1647
- if %w[true false yes no on off].include?(lowercase)
1653
+ case lowercase
1654
+ when 'true', 'false', 'yes', 'no', 'on', 'off'
1648
1655
  "“#{word}” is not a value. Booleans are written true and false, in lowercase"
1649
- elsif %w[null nil none undefined].include?(lowercase)
1656
+ when 'null', 'nil', 'none', 'undefined'
1650
1657
  "“#{word}” is not a value. Null is written null, in lowercase"
1651
- elsif lowercase == 'nan'
1658
+ when 'nan'
1652
1659
  'NaN is not representable. Use null for a missing value'
1653
- elsif %w[inf infinity].include?(lowercase)
1660
+ when 'inf', 'infinity'
1654
1661
  "“#{word}” is not a value. Infinity is written infinity, in lowercase"
1655
1662
  else
1656
1663
  "Unexpected “#{abbreviate(word)}”. A string value must be quoted#{quoting_example(text)}"
@@ -1723,22 +1730,28 @@ module SOML
1723
1730
  return 'A number prefix is lowercase: 0x, 0o, or 0b' if text.match?(/\A-?0[BOX]/)
1724
1731
 
1725
1732
  # The whole number, not the part cut at the diagnosed length, because a cut can end before the digit or the underscore that the message is about. Each check below takes linear time.
1726
- if (match = /\A(-?)0([box])(.*)\z/.match(full_text))
1727
- sign, radix, digits = match.captures
1728
- _, _, name, article, valid_character = RADIXES[radix]
1733
+ sign_length = full_text.getbyte(0) == 0x2D ? 1 : 0
1734
+ radix = full_text.getbyte(sign_length) == 0x30 && RADIXES[full_text.getbyte(sign_length + 1)]
1735
+
1736
+ if radix
1737
+ _, base, name, article, invalid_character = radix
1738
+ prefix_end = sign_length + 2
1739
+ digits = full_text.byteslice(prefix_end, full_text.bytesize - prefix_end)
1729
1740
 
1730
- return "#{article} #{name} integer cannot have a sign, because it states a bit pattern rather than a quantity" if sign == '-'
1731
- return "Expected #{name} digits after “0#{radix}”" if digits.empty?
1741
+ return "#{article} #{name} integer cannot have a sign, because it states a bit pattern rather than a quantity" if sign_length == 1
1742
+ return "Expected #{name} digits after “#{full_text.byteslice(0, 2)}”" if digits.empty?
1732
1743
 
1733
- if radix == 'x' && digits.match?(/[a-f]/) && !digits.match?(/[^0-9a-fA-F_]/)
1744
+ # A search for one character, because a repeat over a long number needs memory for every character.
1745
+ character = digits[invalid_character]
1746
+
1747
+ if base == 16 && character.nil? && digits.match?(/[a-f]/)
1734
1748
  # The uppercase spelling is only suggested when it is valid, so a misplaced underscore is reported first, and a value outside the 64-bit range gets no example.
1735
- return 'An underscore in a number must be between two digits' unless digits.match?(/\A[0-9a-fA-F]+(?:_[0-9a-fA-F]+)*\z/)
1749
+ return 'An underscore in a number must be between two digits' if digits.start_with?('_') || digits.end_with?('_') || digits.include?('__')
1736
1750
 
1737
1751
  uppercase = "0x#{digits.upcase}"
1738
1752
  return valid_value?(uppercase) ? "Hexadecimal digits are uppercase: #{abbreviate(uppercase)}" : 'Hexadecimal digits are uppercase'
1739
1753
  end
1740
1754
 
1741
- character = digits.each_char.find { |each_character| !each_character.match?(valid_character) }
1742
1755
  return character.nil? ? 'An underscore in a number must be between two digits' : "Invalid #{name} digit “#{character}”"
1743
1756
  end
1744
1757
 
@@ -1786,13 +1799,6 @@ module SOML
1786
1799
  text.match?(/[A-Za-z]/) ? "Invalid number “#{abbreviate(text)}”. A string value must be quoted#{quoting_example(unquoted_text)}" : "Invalid number “#{abbreviate(text)}”"
1787
1800
  end
1788
1801
 
1789
- # A date alone, which is not an instant. The instant it could be is only shown when the date exists, so that the example is valid.
1790
- def describe_date(date)
1791
- year, month, day = date.split('-').map(&:to_i)
1792
- is_existing = year >= 1 && month.between?(1, 12) && day.between?(1, days_in_month(year, month))
1793
- "#{date} is a date, not an instant. Write a date as a string, as in '#{date}'#{is_existing ? ". An instant needs a time and an offset, as in #{date}T00:00:00Z" : ''}"
1794
- end
1795
-
1796
1802
  # An offset on its own after spaces, as in `2026-09-19T14:00:00 Z`, which belongs directly after the time, or `nil`.
1797
1803
  def offset_after_space
1798
1804
  offset_start = skip_spaces(@index)
@@ -1810,7 +1816,7 @@ module SOML
1810
1816
  if text.bytesize > MAX_DIAGNOSED_LENGTH
1811
1817
  generic
1812
1818
  elsif text.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/)
1813
- time.match?(/\A[0-9]{2}:[0-9]{2}/) ? describe_space_separated_instant(text, time, is_whole_value) : describe_date(text)
1819
+ describe_space_separated_instant(text, time, is_whole_value)
1814
1820
  elsif text.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}t/)
1815
1821
  'The date and time separator in an instant is an uppercase “T”'
1816
1822
  elsif text.end_with?('z')
@@ -1835,11 +1841,11 @@ module SOML
1835
1841
  separator = 'The date and time separator in an instant is an uppercase “T”, not a space'
1836
1842
  instant = "#{date}T#{time}"
1837
1843
 
1838
- if instant.bytesize > MAX_DIAGNOSED_LENGTH
1839
- "#{separator}. #{INSTANT_FORMAT}"
1840
- elsif valid_value?(instant)
1844
+ is_short = instant.bytesize <= MAX_DIAGNOSED_LENGTH
1845
+
1846
+ if is_short && valid_value?(instant)
1841
1847
  "#{separator}, as in #{abbreviate(instant)}"
1842
- elsif is_whole_value && instant.match?(LOCAL_DATE_TIME) && valid_value?("#{instant}Z")
1848
+ elsif is_short && is_whole_value && instant.match?(LOCAL_DATE_TIME) && valid_value?("#{instant}Z")
1843
1849
  "#{separator}, and an instant needs the offset it was meant in, as in #{abbreviate(instant)}Z for UTC. A date and time without an offset is a local time, which is not an instant, so write it as a string, as in '#{abbreviate("#{date} #{time}")}'"
1844
1850
  else
1845
1851
  "#{separator}. #{INSTANT_FORMAT}"