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.
- checksums.yaml +4 -4
- data/lib/soml/duration.rb +17 -39
- data/lib/soml/edit.rb +42 -23
- data/lib/soml/error.rb +27 -17
- data/lib/soml/format.rb +17 -4
- data/lib/soml/generator.rb +59 -29
- data/lib/soml/parser.rb +221 -215
- data/lib/soml/tree.rb +198 -64
- data/lib/soml/version.rb +1 -1
- data/lib/soml.rb +54 -44
- data/readme.md +123 -62
- data/sig/manifest.yaml +2 -0
- data/sig/soml.rbs +100 -4
- metadata +6 -3
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) {
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
44
|
-
INSTANT = /\A
|
|
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 = /[
|
|
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.
|
|
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) {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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 && (
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
834
|
-
finish
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
-
|
|
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 =
|
|
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 +=
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
1597
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1656
|
+
when 'null', 'nil', 'none', 'undefined'
|
|
1650
1657
|
"“#{word}” is not a value. Null is written null, in lowercase"
|
|
1651
|
-
|
|
1658
|
+
when 'nan'
|
|
1652
1659
|
'NaN is not representable. Use null for a missing value'
|
|
1653
|
-
|
|
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
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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
|
|
1731
|
-
return "Expected #{name} digits after
|
|
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
|
-
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
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}"
|