soml-lang 0.0.1

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.
@@ -0,0 +1,1856 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOML
4
+ # A recursive-descent reader over one UTF-8 string. Each method reads one grammar rule at `@index` and moves it past what it read.
5
+ #
6
+ # It is a close port of the JS reference's `parse.ts`, so that the two stay easy to compare and give the same error for every input. It works on byte offsets, because a character index is O(n) on a non-ASCII string, and reads bytes with `getbyte`, with C string searches for long runs. A repeated regex is never run over text of unbounded length, because Onigmo keeps a backtracking entry for every character it repeats over, which is about 50 bytes per character.
7
+ #
8
+ # @api private
9
+ class Parser
10
+ def self.byte_table(characters)
11
+ table = Array.new(256, false)
12
+
13
+ characters.each_byte do |byte|
14
+ table[byte] = true
15
+ end
16
+
17
+ table.freeze
18
+ end
19
+
20
+ private_class_method :byte_table
21
+
22
+ # What may directly follow a scalar value: whitespace, a separator, a closing bracket, a comment, or the end.
23
+ VALUE_END_BYTE = byte_table(" \t\n,]}#/")
24
+
25
+ DECIMAL_DIGIT = byte_table('0123456789')
26
+
27
+ # 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
29
+ LETTER = byte_table('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz')
30
+
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.
32
+ 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
+ })
37
+
38
+ # The longest token the diagnostic regexes look at, so that a huge invalid token is described from its first part.
39
+ MAX_DIAGNOSED_LENGTH = 1000
40
+
41
+ DURATION_UNIT_NAMES = DURATION_UNITS.keys.freeze
42
+
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/
45
+ 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
+ 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
+ # Uppercase digits too, so that the value of such an escape is checked before its spelling.
48
+ UNICODE_ESCAPE = /\Au\{([0-9A-Fa-f]{1,6})\}/
49
+
50
+ # 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
+ CONTROL_CHARACTERS = "\x00-\x08\x0B-\x1F\x7F"
52
+ CONTROL_CHARACTER = /[\x00-\x08\x0B-\x1F\x7F]/
53
+
54
+ ESCAPED_STRING_SPECIAL = /[\n"\\]/
55
+ KEY_QUOTING_HINT = '. A key that contains characters other than letters, digits, “_”, and “-” must be quoted'
56
+
57
+ # The characters that may be meant as part of a key but are not allowed in a bare key: visible ones that have no meaning in the grammar. A `=` is left out, because after a key, as in `name=foo`, it was most likely meant as the `:` of INI and TOML, so a key that holds a `=` gets no quoting hint.
58
+ # The classes overlap, so they are separate lookaheads, because Onigmo warns about a class with a range twice.
59
+ QUOTABLE_KEY_CHARACTER = /(?!\p{Default_Ignorable_Code_Point})(?!\p{White_Space})[^\p{C}"#'*,\/:=\[\]{}]/
60
+
61
+ # The overlap is split in two, for the same reason.
62
+ INVISIBLE_CHARACTER = /\p{Default_Ignorable_Code_Point}|[\p{C}\p{Z}]/
63
+
64
+ # Any other character.
65
+ NOT_QUOTABLE_KEY_CHARACTER = /\p{Default_Ignorable_Code_Point}|\p{White_Space}|[\p{C}"#'*,\/:=\[\]{}]/
66
+
67
+ SIMPLE_ESCAPES = {
68
+ 0x5C => '\\',
69
+ 0x22 => '"',
70
+ 0x6E => "\n",
71
+ 0x74 => "\t"
72
+ }.freeze
73
+
74
+ # What a backslash followed by one of these characters was probably meant to be.
75
+ ESCAPE_MISTAKES = {
76
+ 'r' => 'There is no \\r escape, because a carriage return cannot be represented',
77
+ "'" => 'A \' needs no escape inside "..."',
78
+ "\n" => 'A backslash must be followed by an escape character. Use \\\\ for a literal backslash, or a \'...\' string'
79
+ }.freeze
80
+
81
+ JSON_ESCAPES = {
82
+ '"' => '\\"',
83
+ '\\' => '\\\\',
84
+ "\b" => '\\b',
85
+ "\f" => '\\f',
86
+ "\n" => '\\n',
87
+ "\r" => '\\r',
88
+ "\t" => '\\t'
89
+ }.freeze
90
+
91
+ # Names for the characters that are invisible or easy to mistake, so an error can say what it found.
92
+ CHARACTER_NAMES = {
93
+ 0x061C => 'ARABIC LETTER MARK',
94
+ 0x85 => 'NEXT LINE',
95
+ 0xA0 => 'NO-BREAK SPACE',
96
+ 0xAD => 'SOFT HYPHEN',
97
+ 0x1680 => 'OGHAM SPACE MARK',
98
+ 0x2000 => 'EN QUAD',
99
+ 0x2001 => 'EM QUAD',
100
+ 0x2002 => 'EN SPACE',
101
+ 0x2003 => 'EM SPACE',
102
+ 0x2004 => 'THREE-PER-EM SPACE',
103
+ 0x2005 => 'FOUR-PER-EM SPACE',
104
+ 0x2006 => 'SIX-PER-EM SPACE',
105
+ 0x2007 => 'FIGURE SPACE',
106
+ 0x2008 => 'PUNCTUATION SPACE',
107
+ 0x2009 => 'THIN SPACE',
108
+ 0x200A => 'HAIR SPACE',
109
+ 0x200B => 'ZERO WIDTH SPACE',
110
+ 0x200C => 'ZERO WIDTH NON-JOINER',
111
+ 0x200D => 'ZERO WIDTH JOINER',
112
+ 0x200E => 'LEFT-TO-RIGHT MARK',
113
+ 0x200F => 'RIGHT-TO-LEFT MARK',
114
+ 0x2028 => 'LINE SEPARATOR',
115
+ 0x2029 => 'PARAGRAPH SEPARATOR',
116
+ 0x202A => 'LEFT-TO-RIGHT EMBEDDING',
117
+ 0x202B => 'RIGHT-TO-LEFT EMBEDDING',
118
+ 0x202C => 'POP DIRECTIONAL FORMATTING',
119
+ 0x202D => 'LEFT-TO-RIGHT OVERRIDE',
120
+ 0x202E => 'RIGHT-TO-LEFT OVERRIDE',
121
+ 0x202F => 'NARROW NO-BREAK SPACE',
122
+ 0x205F => 'MEDIUM MATHEMATICAL SPACE',
123
+ 0x2060 => 'WORD JOINER',
124
+ 0x2066 => 'LEFT-TO-RIGHT ISOLATE',
125
+ 0x2067 => 'RIGHT-TO-LEFT ISOLATE',
126
+ 0x2068 => 'FIRST STRONG ISOLATE',
127
+ 0x2069 => 'POP DIRECTIONAL ISOLATE',
128
+ 0x3000 => 'IDEOGRAPHIC SPACE',
129
+ 0xFEFF => 'ZERO WIDTH NO-BREAK SPACE'
130
+ }.freeze
131
+
132
+ # The instant range, checked in UTC, so that every instant has a canonical form that can be read back.
133
+ MIN_INSTANT_SECONDS = -62_135_596_800 # 0001-01-01T00:00:00Z
134
+ MAX_INSTANT_SECONDS = 253_402_300_799 # 9999-12-31T23:59:59Z
135
+
136
+ # A character that is invisible, or that a terminal acts on, which `json_quote` leaves as it is.
137
+ UNPRINTABLE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/
138
+
139
+ private_constant(*constants(false))
140
+
141
+ # A key as an error message shows it: bare when it can be, and otherwise quoted as `JSON.stringify` quotes it, and cut short when it is long. {Editor} shows the keys of a path the same way.
142
+ #
143
+ # @param key [String]
144
+ # @return [String]
145
+ def self.describe_key(key)
146
+ SOML.bare_key?(key) ? abbreviate(key) : json_quote(abbreviate(key))
147
+ end
148
+
149
+ # The keys and indexes of a path as an error message shows them, as in `servers[0].ratio`, with each key as {describe_key} shows it. The keys may come from the caller, perhaps from user input, or from untrusted data, so the path is cut short, and the characters that are invisible or that a terminal acts on become escapes. {Editor} and {GenerateError} show a path with it.
150
+ #
151
+ # @param path [Array<String, Integer>]
152
+ # @return [String]
153
+ def self.describe_path(path)
154
+ description = path.each_with_index.map do |segment, index|
155
+ if Integer === segment
156
+ "[#{segment}]"
157
+ else
158
+ index.zero? ? describe_key(segment) : ".#{describe_key(segment)}"
159
+ end
160
+ end.join
161
+
162
+ abbreviate(description, 200).gsub(UNPRINTABLE) { |character| "\\u{#{character.ord.to_s(16)}}" }
163
+ end
164
+
165
+ # 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
+ 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
169
+
170
+ length = 0
171
+ kept = text.each_char.take_while { |character| (length += character.bytesize == 4 ? 2 : 1) <= maximum_length }
172
+ "#{kept.join}…"
173
+ end
174
+
175
+ # 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
180
+
181
+ "\"#{escaped}\""
182
+ end
183
+
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.
185
+ #
186
+ # @param value [Object]
187
+ # @return [String]
188
+ def self.conversion_name(value)
189
+ case value
190
+ when nil, true, false
191
+ value.inspect
192
+ else
193
+ lossy_utf8(Kernel.instance_method(:class).bind_call(value).to_s)
194
+ end
195
+ end
196
+
197
+ # 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
+ #
199
+ # @param text [String]
200
+ # @return [String]
201
+ def self.lossy_utf8(text)
202
+ converted = begin
203
+ text.encode(Encoding::UTF_8)
204
+ rescue EncodingError
205
+ text.dup.force_encoding(Encoding::UTF_8)
206
+ end
207
+
208
+ converted.scrub
209
+ end
210
+
211
+ # A value as an argument error shows it: its `inspect` when it cannot nest, as `"1"` or `1.5`, and otherwise its class, as {conversion_name} names it. The `inspect` of a deep value, such as an array in an array a million levels deep, or a `Numeric` subclass that holds one, overflows the machine stack, which can crash the process rather than raise.
212
+ #
213
+ # @param value [Object]
214
+ # @return [String]
215
+ def self.describe_argument(value)
216
+ case value
217
+ when String, Symbol, Integer, Float, Rational, nil, true, false
218
+ value.inspect
219
+ else
220
+ conversion_name(value)
221
+ end
222
+ end
223
+
224
+ # @return [String] The source, in UTF-8.
225
+ attr_reader :source
226
+
227
+ # @param source [String]
228
+ # @param path [String, nil] The file the source is from, for the errors.
229
+ def initialize(source, symbolize_names: false, freeze: false, path: nil)
230
+ @path = path
231
+ @source = decode(source)
232
+ @length = @source.bytesize
233
+ @index = 0
234
+ @symbolize_names = symbolize_names
235
+ @freeze = freeze
236
+
237
+ # Where the document's collection starts, after the comments and whitespace before it.
238
+ @document_start = 0
239
+
240
+ check_characters
241
+ end
242
+
243
+ # @return [Hash, Array]
244
+ def parse
245
+ skip_trivia
246
+ @document_start = @index
247
+ fail_at('A document must contain an object or an array, but this one is empty') if @index >= @length
248
+
249
+ value = case @source.getbyte(@index)
250
+ when 0x7B # {
251
+ parse_object(1)
252
+ when 0x5B # [
253
+ parse_array(1)
254
+ else
255
+ parse_bare_object
256
+ end
257
+
258
+ skip_trivia
259
+ fail_at("Unexpected #{describe_here} after the end of the document") if @index < @length
260
+ @freeze ? Ractor.make_shareable(value) : value
261
+ end
262
+
263
+ # The whole source as one duration literal, for {SOML::Duration.parse}.
264
+ #
265
+ # @return [SOML::Duration]
266
+ def parse_duration_literal
267
+ finish = 0
268
+
269
+ while (byte = @source.getbyte(finish)) && NUMBER_BYTE[byte]
270
+ finish += 1
271
+ end
272
+
273
+ # An empty source and an instant are not duration-like either.
274
+ if finish < @length || !duration_like?(@source)
275
+ fail_at("Expected a duration, such as 1h30m, but found #{@source.empty? ? 'nothing' : quote_found(abbreviate(@source))}", 0)
276
+ end
277
+
278
+ parse_duration(@source, 0)
279
+ end
280
+
281
+ private
282
+
283
+ def decode(source)
284
+ string = String.try_convert(source)
285
+ raise TypeError, "no implicit conversion of #{Parser.conversion_name(source)} into String" if string.nil?
286
+
287
+ # Bytes in a binary or US-ASCII string, as from a file read in binary mode, are read as UTF-8, and `check_characters` reports invalid UTF-8 with its position. Another encoding is converted first, as `JSON.parse` does, which raises an `EncodingError` for a string that does not convert.
288
+ case string.encoding
289
+ when Encoding::UTF_8
290
+ string
291
+ when Encoding::BINARY, Encoding::US_ASCII
292
+ string.dup.force_encoding(Encoding::UTF_8)
293
+ else
294
+ string.encode(Encoding::UTF_8)
295
+ end
296
+ end
297
+
298
+ # Characters that are errors wherever they appear, so they are checked once, up front.
299
+ def check_characters
300
+ fail_invalid_utf8 unless @source.valid_encoding?
301
+ fail_at('A byte order mark (BOM) is not allowed', 0) if @source.start_with?("\uFEFF")
302
+
303
+ return if @source.count(CONTROL_CHARACTERS).zero?
304
+
305
+ index = @source.byteindex(CONTROL_CHARACTER)
306
+ code = @source.getbyte(index)
307
+ fail_at('A carriage return (U+000D) is not allowed anywhere. Use LF line endings', index) if code == 0x0D
308
+ fail_at("A raw control character (#{format_code_point(code)}) is not allowed anywhere, including in strings and comments. In a string, write it as the escape \\u{#{code.to_s(16)}} inside \"...\"", index)
309
+ end
310
+
311
+ # Only called on the error path, so it finds the first invalid character one character at a time.
312
+ def fail_invalid_utf8
313
+ offset = 0
314
+
315
+ @source.each_char do |character|
316
+ break unless character.valid_encoding?
317
+
318
+ offset += character.bytesize
319
+ end
320
+
321
+ valid = @source.byteslice(0, offset)
322
+
323
+ # A sequence that the end of the input cuts short is reported as what it is.
324
+ 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)
326
+ end
327
+
328
+ raise ParseError.new(format('Invalid UTF-8 byte 0x%02X', @source.getbyte(offset)), valid, valid.bytesize, @path)
329
+ end
330
+
331
+ # Whether some bytes could follow `rest` to make it one valid character.
332
+ def truncated_utf8?(rest)
333
+ lead = rest.getbyte(0)
334
+ needed = if lead >= 0xF0
335
+ 4
336
+ elsif lead >= 0xE0
337
+ 3
338
+ else
339
+ 2
340
+ end
341
+
342
+ return false if rest.bytesize >= needed
343
+
344
+ # The next byte is the one with the most constraints, and every byte after it may be any continuation byte.
345
+ (0x80..0xBF).any? do |next_byte|
346
+ candidate = rest.b << next_byte << ("\x80".b * (needed - rest.bytesize - 1))
347
+ candidate.force_encoding(Encoding::UTF_8).valid_encoding?
348
+ end
349
+ end
350
+
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
358
+
359
+ # `bare-object = entry ( entry-sep entry )*`, where a separator is one or more line breaks.
360
+ def parse_bare_object
361
+ object = {}
362
+ start = @index
363
+
364
+ begin
365
+ parse_entry(object, 1)
366
+ rescue ParseError => error
367
+ diagnose_bare_value(start)
368
+ raise error
369
+ end
370
+
371
+ has_line_break = skip_trivia
372
+
373
+ while @index < @length
374
+ fail_at('Top-level entries are separated by line breaks, not commas. Use braces for a one-line object') if @source.getbyte(@index) == 0x2C
375
+ fail_at("Expected a line break before the next entry, but found #{describe_here}#{slash_comment_hint}") unless has_line_break
376
+
377
+ parse_entry(object, 1)
378
+ has_line_break = skip_trivia
379
+ end
380
+
381
+ object
382
+ end
383
+
384
+ # A whole document that is one scalar, such as `5` or an instant, fails as an entry. That failure is reported as what it is.
385
+ def diagnose_bare_value(start)
386
+ @index = start
387
+
388
+ is_bare_value = begin
389
+ parse_value(1)
390
+ skip_trivia
391
+ @index >= @length
392
+ rescue ParseError
393
+ # Not a bare value either, so the original error stands.
394
+ false
395
+ end
396
+
397
+ fail_at('A bare value is not a document. A document is an object or an array, so write it as `key: value` or `[value]`', start) if is_bare_value
398
+ end
399
+
400
+ # Skips spaces, tabs, line breaks, and comments, and returns whether a line break was crossed. A line break inside a block comment does not count.
401
+ def skip_trivia
402
+ source = @source
403
+ index = @index
404
+ has_crossed_line_break = false
405
+
406
+ while (byte = source.getbyte(index))
407
+ case byte
408
+ when 0x20, 0x09
409
+ index += 1
410
+ when 0x0A
411
+ has_crossed_line_break = true
412
+ index += 1
413
+ when 0x23 # #
414
+ index = source.byteindex("\n", index) || @length
415
+ when 0x2F # /
416
+ break unless source.getbyte(index + 1) == 0x2A
417
+
418
+ index = skip_block_comment(index)
419
+ else
420
+ break
421
+ end
422
+ end
423
+
424
+ @index = index
425
+ has_crossed_line_break
426
+ end
427
+
428
+ # Returns the index after the block comment that starts at `start`.
429
+ def skip_block_comment(start)
430
+ finish = @source.byteindex('*/', start + 2)
431
+ fail_at('Unterminated block comment', start) if finish.nil?
432
+
433
+ nested = @source.byteindex('/*', start + 2)
434
+
435
+ # The body may not contain `/*`. An opening that overlaps the closing `*/`, as in `/*/`, is not inside the body.
436
+ fail_at('Block comments cannot be nested, and their body may not contain “/*”', nested) if nested && nested + 2 <= finish
437
+
438
+ finish + 2
439
+ end
440
+
441
+ # `entry = key ":" ws value`.
442
+ def parse_entry(object, depth)
443
+ key_start = @index
444
+ key = parse_key
445
+ byte = @source.getbyte(@index)
446
+ fail_dot_in_key(key_start) if byte == 0x2E
447
+ fail_missing_colon(key_start) unless byte == 0x3A
448
+ colon = @index
449
+ @index += 1
450
+ skip_trivia
451
+
452
+ begin
453
+ value = parse_value(depth + 1)
454
+ rescue ParseError => error
455
+ diagnose_bad_value(error, key, key_start, colon)
456
+ raise
457
+ end
458
+
459
+ member = @symbolize_names ? key.to_sym : key
460
+ fail_at("Duplicate key #{Parser.describe_key(key)}", key_start) if object.key?(member)
461
+ object[member] = value
462
+ end
463
+
464
+ # Reports a value that failed to parse as what the entry was meant to be, when that is clear.
465
+ def diagnose_bad_value(error, key, key_start, colon)
466
+ # A key that contains a `:`, as in `12:30: 'lunch'`, ends at the first `:`, and the rest is read as the value.
467
+ following = @source.getbyte(colon + 1)
468
+ diagnose_key_with_colon(key_start) if following && BARE_KEY_BYTE[following]
469
+
470
+ @index = colon + 1
471
+ is_value_on_next_line = skip_trivia
472
+ value_start = @index
473
+ comment_hint = describe_comment_as_value(colon)
474
+ diagnose_missing_value(value_start, key, key_start, comment_hint) if is_value_on_next_line
475
+
476
+ # A value that was left out at the end of the document or of an object. The offset of the error is in characters.
477
+ byte = @source.getbyte(value_start)
478
+
479
+ if !comment_hint.empty? && (byte.nil? || byte == 0x7D) && error.offset == @source.byteslice(0, value_start).length
480
+ fail_at("#{error.reason}#{comment_hint}", value_start)
481
+ end
482
+ end
483
+
484
+ # A bare key followed directly by `:` and more of a key, up to a `:` that ends the key, as in `a:b: 1`.
485
+ def diagnose_key_with_colon(key_start)
486
+ source = @source
487
+ index = key_start
488
+
489
+ while index < key_start + MAX_DIAGNOSED_LENGTH
490
+ byte = source.getbyte(index)
491
+
492
+ if byte == 0x3A
493
+ fail_at("A key that contains “:” must be quoted, as in '#{abbreviate(source.byteslice(key_start, index - key_start))}'", key_start) if space_or_line_end?(source.getbyte(index + 1))
494
+ elsif byte.nil? || !BARE_KEY_BYTE[byte]
495
+ return
496
+ end
497
+
498
+ index += 1
499
+ end
500
+ end
501
+
502
+ # The hint for a `#` directly after a `:`, as in `color: #FFF`, which starts a comment rather than a value.
503
+ def describe_comment_as_value(colon)
504
+ hash = skip_spaces(colon + 1)
505
+ return '' unless @source.getbyte(hash) == 0x23
506
+
507
+ # A `#` followed by a space, or by another `#`, starts an ordinary comment. A separator or a closing bracket after the value is not part of it. The regex only sees the start of a long comment, which is cut short in the message anyway.
508
+ match = @source.byteslice(hash, code_unit_end(hash) - hash)[/\A#[^\t\n #,\]}][^\t\n ,\]}]*/]
509
+ match.nil? ? '' : ". “#” starts a comment, so a value that starts with “#” must be quoted#{quoting_example(match)}"
510
+ end
511
+
512
+ # 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
+ def diagnose_missing_value(start, parent, key_start, comment_hint)
514
+ # 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))
516
+
517
+ @index = start
518
+
519
+ key = begin
520
+ parse_key
521
+ rescue ParseError
522
+ # Not a key either, so the original error stands.
523
+ nil
524
+ end
525
+
526
+ return unless key && @source.getbyte(@index) == 0x3A
527
+
528
+ # 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
+ following = @source.getbyte(@index + 1)
530
+ first = @source.getbyte(start)
531
+
532
+ return unless first == 0x27 || first == 0x22 || space_or_line_end?(following)
533
+
534
+ hint = comment_hint.empty? ? describe_indented_key(parent, key, key_start, start) : comment_hint
535
+ fail_at("Expected a value, but found the key #{abbreviate(Parser.describe_key(key), 200)}#{hint}", start)
536
+ end
537
+
538
+ # The hint for a key at `start` that is indented under the entry whose value is missing, as YAML nests an object.
539
+ def describe_indented_key(parent, key, key_start, start)
540
+ key_indentation = indentation_at(key_start)
541
+ indentation = indentation_at(start)
542
+ return '' if key_indentation.nil? || indentation.nil? || indentation <= key_indentation
543
+
544
+ # 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)
551
+ end
552
+
553
+ def fail_missing_colon(key_start)
554
+ source = @source
555
+ following = @index
556
+ following += 1 while space?(source.getbyte(following))
557
+ following_byte = source.getbyte(following)
558
+
559
+ fail_at('Whitespace is not allowed between a key and its “:”') if following_byte == 0x3A && following > @index
560
+
561
+ # A block comment followed by the ":" was meant to come before it.
562
+ if following_byte == 0x2F && source.getbyte(following + 1) == 0x2A
563
+ comment_end = source.byteindex('*/', following + 2)
564
+
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
570
+ end
571
+
572
+ colon = source.byteindex(':', following)
573
+
574
+ # A key with a space in it, such as `the name: 1`. The hint is only given for plain words, because quoting a quoted key would change what it means, and for a `:` that whitespace or the end follows, because quoting the words before the `:` in `server localhost:8080` would give a valid document with another meaning. So `the name:1` gets no hint.
575
+ if colon && following > @index && following_byte && BARE_KEY_BYTE[following_byte] && colon < line_end(following) && space_or_line_end?(source.getbyte(colon + 1))
576
+ # Only spaces and tabs are trimmed, because other Unicode whitespace, such as U+00A0, is not whitespace in SOML.
577
+ key = source.byteslice(key_start, skip_spaces_back(colon) - key_start)
578
+ fail_at("A bare key cannot contain spaces. Quote it, as in '#{abbreviate(key)}'", key_start) if words_with_spaces?(key)
579
+ end
580
+
581
+ fail_at('Expected “:” after the key') if @index >= @length || source.getbyte(@index) == 0x0A
582
+
583
+ # A character directly after a bare key is most likely meant to be part of it.
584
+ previous = source.getbyte(@index - 1)
585
+ is_after_bare_key = previous != 0x27 && previous != 0x22 && following == @index
586
+ hint = is_after_bare_key && quotable_key_character?(following) ? KEY_QUOTING_HINT : ''
587
+ @index = following
588
+ fail_at("Expected “:” after the key, but found #{describe_here}#{hint}")
589
+ end
590
+
591
+ def parse_key
592
+ source = @source
593
+ start = @index
594
+ byte = source.getbyte(start)
595
+
596
+ if byte == 0x27 || byte == 0x22
597
+ fail_at('A block string cannot be a key') if source.getbyte(start + 1) == byte && source.getbyte(start + 2) == byte
598
+
599
+ # A key is a string, so it may hold anything a string can, including a line feed written as an escape.
600
+ return byte == 0x27 ? parse_literal_string : parse_escaped_string
601
+ end
602
+
603
+ finish = start
604
+
605
+ while (byte = source.getbyte(finish)) && BARE_KEY_BYTE[byte]
606
+ finish += 1
607
+ end
608
+
609
+ if finish == start
610
+ diagnose_table_header if byte == 0x5B
611
+ fail_at(@index >= @length ? 'Expected a key' : "Expected a key, but found #{describe_here}#{key_quoting_hint}#{slash_comment_hint}")
612
+ end
613
+
614
+ @index = finish
615
+ source.byteslice(start, finish - start)
616
+ end
617
+
618
+ # The hint for a key that starts with a character a bare key cannot hold, such as the `$` in `$schema`, or nothing for a character with another meaning, such as `}`.
619
+ def key_quoting_hint
620
+ return '' unless quotable_key_character?(@index)
621
+
622
+ # The key runs to its `:`, and every character before it must be one a quoted key is for. Two searches, because a repeat over a long line needs memory for every character.
623
+ colon = @source.byteindex(':', @index)
624
+ key = @source.byteslice(@index, colon - @index) if colon && colon < line_end(@index)
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))
627
+ key.nil? ? KEY_QUOTING_HINT : "#{KEY_QUOTING_HINT}, as in '#{abbreviate(key)}'"
628
+ end
629
+
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
634
+
635
+ # 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
+ def fail_dot_in_key(key_start)
637
+ source = @source
638
+ finish = @index
639
+
640
+ while finish < key_start + MAX_DIAGNOSED_LENGTH && (byte = source.getbyte(finish)) && (BARE_KEY_BYTE[byte] || byte == 0x2E)
641
+ finish += 1
642
+ end
643
+
644
+ key = source.byteslice(key_start, finish - key_start)
645
+ # A `-1` limit keeps the empty word after a trailing dot.
646
+ words = key.split('.', -1)
647
+
648
+ # The suggestions are only given for a bare key whose dots each have a word on both sides, so that both are valid.
649
+ if source.getbyte(finish) != 0x3A || !BARE_KEY_BYTE[source.getbyte(key_start)] || words.include?('')
650
+ fail_at('A key cannot contain “.” unless it is quoted. Quote the whole key, or use braces to nest, as in a: {b: …}')
651
+ end
652
+
653
+ nested = "#{words.last}: …"
654
+
655
+ words[0...-1].reverse_each do |word|
656
+ nested = "#{word}: {#{nested}}"
657
+ end
658
+
659
+ fail_at("A bare key cannot contain “.”. Quote it, as in '#{abbreviate(key)}', or use braces to nest, as in #{abbreviate(nested)}")
660
+ end
661
+
662
+ # 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
666
+
667
+ def parse_object(depth)
668
+ object = {}
669
+ parse_items(depth, 0x7D, 'object', 'an object member') { parse_entry(object, depth) }
670
+ object
671
+ end
672
+
673
+ def parse_array(depth)
674
+ array = []
675
+ parse_items(depth, 0x5D, 'array', 'an array item') { array << parse_value(depth + 1) }
676
+ array
677
+ end
678
+
679
+ # `{` 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
+ def parse_items(depth, closing, kind, item)
681
+ fail_too_deep if depth > MAX_DEPTH
682
+
683
+ source = @source
684
+ start = @index
685
+ @index += 1
686
+ skip_trivia
687
+
688
+ until (byte = source.getbyte(@index)) == closing
689
+ has_line_break = false
690
+
691
+ if byte
692
+ yield
693
+ item_end = @index
694
+ has_line_break = skip_trivia
695
+ byte = source.getbyte(@index)
696
+ end
697
+
698
+ fail_at("Unterminated #{kind}: expected “#{closing.chr}”", start) if byte.nil?
699
+
700
+ if byte == 0x2C # ,
701
+ fail_at('A comma must be on the same line as the item before it. The line break already separates the items, so remove the comma') if has_line_break
702
+
703
+ @index += 1
704
+ skip_trivia
705
+ elsif !has_line_break && byte != closing
706
+ # Without a line break that separates, a line break in the gap is inside a block comment.
707
+ line_break = source.byteindex("\n", item_end)
708
+ hint = line_break && line_break < @index ? '. A line break inside a block comment does not separate items' : slash_comment_hint
709
+ fail_at("Expected “,”, a line break, or “#{closing.chr}” after #{item}, but found #{describe_here}#{hint}")
710
+ end
711
+ end
712
+
713
+ @index += 1
714
+ end
715
+
716
+ def parse_value(depth)
717
+ start = @index
718
+
719
+ # A container returns directly, because its closing bracket ends it, and the checks below are for what follows a scalar.
720
+ value = case @source.getbyte(@index)
721
+ when 0x7B # {
722
+ return parse_object(depth)
723
+ when 0x5B # [
724
+ return parse_array(depth)
725
+ when 0x27, 0x22 # ' "
726
+ parse_string
727
+ when 0x74 # t
728
+ keyword?('true') ? true : fail_unexpected_value
729
+ when 0x66 # f
730
+ keyword?('false') ? false : fail_unexpected_value
731
+ when 0x6E # n
732
+ keyword?('null') ? nil : fail_unexpected_value
733
+ when 0x69 # i
734
+ keyword?('infinity') ? Float::INFINITY : fail_unexpected_value
735
+ when 0x2D # -
736
+ @source.getbyte(@index + 1) == 0x69 && keyword?('-infinity') ? -Float::INFINITY : (number = parse_number_or_instant)
737
+ when 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39
738
+ number = parse_number_or_instant
739
+ else
740
+ fail_unexpected_value
741
+ end
742
+
743
+ # An int or a float written with digits.
744
+ is_number = Integer === number || Float === number
745
+ byte = @source.getbyte(@index)
746
+ fail_after_value(start, is_number) if byte && !VALUE_END_BYTE[byte]
747
+ diagnose_unit_after_space(start) if is_number && space?(byte)
748
+ value
749
+ end
750
+
751
+ # A character that cannot follow the value that starts at `start`.
752
+ def fail_after_value(start, is_number)
753
+ byte = @source.getbyte(@index)
754
+
755
+ # Go writes microseconds as `µs`, with the micro sign or the Greek letter mu. The duration is only suggested when it is valid, which a number with an exponent or a radix prefix, for example, is not.
756
+ if is_number && (byte == 0xC2 || byte == 0xCE) && ["\u00B5", "\u03BC"].include?(character_at(@index)) && @source.getbyte(@index + 2) == 0x73
757
+ number = @source.byteslice(start, @index - start)
758
+ fail_at("The unit for microseconds is written us#{valid_value?("#{number}us") ? ", as in #{abbreviate(number)}us" : ''}")
759
+ end
760
+
761
+ # A `''` inside a '...' string, as SQL and YAML escape a quote, ends the string.
762
+ fail_at('There is no \'\' escape in a \'...\' string. Write a string that contains \' as "...", as in "it\'s"') if byte == 0x27 && @source.getbyte(start) == 0x27
763
+
764
+ fail_at("Unexpected #{describe_here} after a value")
765
+ end
766
+
767
+ # A number followed by a space and a word, such as `512 MiB` or `10 seconds`, which is an error anyway. A word followed by more than spaces, a separator, or a comment is left to the general errors, because it may be a key, as in `a: 1 b: 2`, or a sentence.
768
+ def diagnose_unit_after_space(start)
769
+ source = @source
770
+ unit_start = skip_spaces(@index)
771
+ unit_end = unit_start
772
+
773
+ while (byte = source.getbyte(unit_end)) && LETTER[byte]
774
+ unit_end += 1
775
+ end
776
+
777
+ unit = source.byteslice(unit_start, unit_end - unit_start)
778
+
779
+ # 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)
781
+
782
+ number = source.byteslice(start, @index - start)
783
+ # 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.
784
+ duration = DURATION_UNITS.key?(unit) && valid_value?("#{number}#{unit}") ? "#{abbreviate(number)}#{unit}" : '10s'
785
+ fail_at("A unit cannot follow a number after a space. Write a duration without the space, as in #{duration}, and anything else, such as a size, as a string, as in '#{abbreviate("#{number} #{unit}")}'", unit_start)
786
+ end
787
+
788
+ def keyword?(word)
789
+ length = word.bytesize
790
+
791
+ # A search that finds the word here, which allocates nothing. It only runs where the word is the one valid value, so a search past here is on the way to an error.
792
+ return false unless @source.byteindex(word, @index) == @index
793
+
794
+ # A longer word, such as `nullable`, is not the keyword.
795
+ following = @source.getbyte(@index + length)
796
+ return false if following && BARE_KEY_BYTE[following]
797
+
798
+ @index += length
799
+ true
800
+ end
801
+
802
+ def fail_unexpected_value
803
+ fail_at('Expected a value, but reached the end of the document') if @index >= @length
804
+
805
+ byte = @source.getbyte(@index)
806
+ fail_at('A “+” sign is not allowed. A number without a sign is positive') if byte == 0x2B
807
+
808
+ # A `.` that no digit follows begins a string, such as `.env` or `./foo`, rather than a number.
809
+ fail_at(digit?(@source.getbyte(@index + 1)) ? 'A number cannot begin with “.”; write a digit before it, as in 0.5' : describe_unknown_word('.', unquoted_text)) if byte == 0x2E
810
+
811
+ word_end = @index
812
+
813
+ while (byte = @source.getbyte(word_end)) && BARE_KEY_BYTE[byte]
814
+ word_end += 1
815
+ end
816
+
817
+ if word_end > @index
818
+ word = @source.byteslice(@index, word_end - @index)
819
+
820
+ # A key where a value should be, as when the value of an entry is left out and the next line has a key, or as in `[a: 1]`, is reported as a key. A word after the `:` of a member and a space, as in `msg: Error: file not found` or `url: https://example.com`, is that member's value instead, an unquoted string. Without the space, as in `a:b: 1`, the first `:` was most likely meant as part of the key.
821
+ spaces_start = skip_spaces_back(@index)
822
+ is_member_value = spaces_start < @index && @source.getbyte(spaces_start - 1) == 0x3A
823
+ fail_at("Expected a value, but found the key #{abbreviate(word)}") if !is_member_value && @source.getbyte(word_end) == 0x3A
824
+ diagnose_table_header(true)
825
+ fail_at(describe_unknown_word(word, unquoted_text))
826
+ end
827
+
828
+ fail_at("Expected a value, but found #{describe_here}#{block_scalar_indicator? ? '. Write a multiline string as a block string, between \'\'\' lines' : slash_comment_hint}")
829
+ end
830
+
831
+ # 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
+ 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)
850
+ end
851
+
852
+ def unquoted_text_end?(offset)
853
+ byte = @source.getbyte(offset)
854
+ is_comment = byte == 0x23 || (byte == 0x2F && @source.getbyte(offset + 1) == 0x2A)
855
+ byte == 0x0A || byte == 0x2C || byte == 0x5D || byte == 0x7D || (is_comment && space?(@source.getbyte(offset - 1)))
856
+ end
857
+
858
+ # A TOML table header, as in `[server]` or `[[servers]]` on a line of its own, reads as an array that holds a word, or as a key that starts with "[". Where a value is expected, `is_value`, it is only a table header when its bracket opens the document, because a table cannot be written as an item of an array inside it.
859
+ def diagnose_table_header(is_value = false)
860
+ start = line_start(@index)
861
+ return if is_value && skip_spaces(start) != @document_start
862
+
863
+ line = @source.byteslice(start, [line_end(@index), code_unit_end(start)].min - start)
864
+ # The name is a valid key, so that the suggestion is valid.
865
+ name = line[/\A[\t ]*\[\[?([A-Z_a-z][0-9A-Z_a-z-]*)\]\]?[\t ]*\z/, 1]
866
+ fail_at("There are no table headers. Write the table as an object, as in #{abbreviate(name)}: {…}") if name
867
+ end
868
+
869
+ # A YAML literal block scalar indicator, as in `key: |` or `key: |-`, at the end of its line.
870
+ def block_scalar_indicator?
871
+ # A folded block scalar, `>`, joins its lines, which a block string does not, so only `|` gets the hint.
872
+ return false unless @source.getbyte(@index) == 0x7C
873
+
874
+ chomping = @source.getbyte(@index + 1)
875
+ following = @source.getbyte(skip_spaces(chomping == 0x2D || chomping == 0x2B ? @index + 2 : @index + 1))
876
+ following.nil? || following == 0x0A
877
+ end
878
+
879
+ # 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
883
+
884
+ # 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
888
+
889
+ def describe_here
890
+ return 'the end of the document' if @index >= @length
891
+
892
+ character = character_at(@index)
893
+ character == "\n" ? 'a line break' : describe_character(character)
894
+ end
895
+
896
+ def character_at(offset)
897
+ @source.byteslice(offset, 4)[0]
898
+ end
899
+
900
+ def parse_string
901
+ source = @source
902
+ quote = source.getbyte(@index)
903
+ return parse_block_string(quote) if source.getbyte(@index + 1) == quote && source.getbyte(@index + 2) == quote
904
+
905
+ quote == 0x27 ? parse_literal_string : parse_escaped_string
906
+ end
907
+
908
+ # `'...'` has no escapes, so its value is the source text between the quotes.
909
+ def parse_literal_string
910
+ start = @index
911
+ close = @source.byteindex("'", start + 1)
912
+ value = close && @source.byteslice(start + 1, close - start - 1)
913
+
914
+ # The search stops at the first quote, so a one-line document with many strings stays linear.
915
+ if value.nil? || value.include?("\n")
916
+ fail_at("Unterminated string. A '...' string must end on the line it starts on; use a block string (''') for multiple lines", start)
917
+ end
918
+
919
+ @index = close + 1
920
+ value
921
+ end
922
+
923
+ def parse_escaped_string
924
+ source = @source
925
+ start = @index
926
+ close = source.byteindex('"', start + 1)
927
+
928
+ # The common case, a string without escapes.
929
+ if close
930
+ value = source.byteslice(start + 1, close - start - 1)
931
+
932
+ unless value.include?('\\') || value.include?("\n")
933
+ @index = close + 1
934
+ return value
935
+ end
936
+ end
937
+
938
+ value = +''
939
+ chunk_start = start + 1
940
+
941
+ loop do
942
+ special = source.byteindex(ESCAPED_STRING_SPECIAL, chunk_start)
943
+
944
+ if special.nil? || source.getbyte(special) == 0x0A
945
+ fail_at('Unterminated string. A "..." string must end on the line it starts on; use a block string (""") for multiple lines', start)
946
+ end
947
+
948
+ value << source.byteslice(chunk_start, special - chunk_start)
949
+
950
+ if source.getbyte(special) == 0x22
951
+ @index = special + 1
952
+ return value
953
+ end
954
+
955
+ text, chunk_start = parse_escape(special)
956
+ value << text
957
+ end
958
+ end
959
+
960
+ # Decodes the escape at `offset`, which is a backslash, and returns its text and the offset after it.
961
+ def parse_escape(offset)
962
+ source = @source
963
+ byte = source.getbyte(offset + 1)
964
+ simple = SIMPLE_ESCAPES[byte]
965
+ return [simple, offset + 2] if simple
966
+
967
+ if byte == 0x75 # u
968
+ # The slice may cut a character in two, which a regex refuses to read.
969
+ match = UNICODE_ESCAPE.match(source.byteslice(offset + 1, 10).scrub)
970
+ fail_at(describe_bad_unicode_escape(source.byteslice(offset + 1, 48).scrub('')[0, 12]), offset) if match.nil?
971
+
972
+ hex = match[1]
973
+ code_point = hex.to_i(16)
974
+
975
+ # The value is checked before the spelling, so that the uppercase and leading zeros errors never point to an escape that is not allowed either.
976
+ fail_at("A carriage return (U+000D) cannot be represented, so \\u{#{hex}} is not allowed", offset) if code_point == 0x0D
977
+ fail_at("\\u{#{hex}} is a surrogate, which is not a Unicode scalar value", offset) if code_point.between?(0xD800, 0xDFFF)
978
+ fail_at("\\u{#{hex}} is above U+10FFFF, the largest Unicode scalar value", offset) if code_point > 0x10FFFF
979
+ fail_at('A Unicode escape uses lowercase hexadecimal digits', offset) if hex.match?(/[A-F]/)
980
+ 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
+
982
+ return [code_point.chr(Encoding::UTF_8), offset + 1 + match[0].bytesize]
983
+ end
984
+
985
+ # A backslash at the end of the document is the same mistake as one at the end of a line.
986
+ character = character_at(offset + 1)
987
+ reason = ESCAPE_MISTAKES[character || "\n"] || "Unknown escape “\\#{character}”. The escapes are \\\\, \\\", \\n, \\t, and \\u{…}; use a '...' string for literal backslashes"
988
+ fail_at(reason, offset)
989
+ end
990
+
991
+ # A block string opens with a run of three or more quotes and a line break, and closes at the first line whose first non-whitespace content is a run of exactly that many quotes. The closing line's indentation is removed from every content line, except a blank one, which holds only spaces and tabs and becomes an empty line.
992
+ def parse_block_string(quote)
993
+ source = @source
994
+ start = @index
995
+ delimiter_length = 0
996
+ delimiter_length += 1 while source.getbyte(start + delimiter_length) == quote
997
+ @index = start + delimiter_length
998
+
999
+ fail_at('Unterminated block string', start) if @index >= @length
1000
+
1001
+ # As in Swift, the opening delimiter is followed directly by a line break, not even by trailing whitespace.
1002
+ fail_at("A block string's opening delimiter must be followed directly by a line break, and its content starts on the next line") unless source.getbyte(@index) == 0x0A
1003
+
1004
+ content_start = @index + 1
1005
+ closing_line_start, delimiter_start = find_block_string_end(content_start, quote, delimiter_length)
1006
+ fail_at("Unterminated block string#{describe_inline_closing_delimiter(content_start, quote, delimiter_length)}", start) if closing_line_start.nil?
1007
+
1008
+ indentation = source.byteslice(closing_line_start, delimiter_start - closing_line_start)
1009
+ @index = delimiter_start + delimiter_length
1010
+ lines = []
1011
+ line_start = content_start
1012
+
1013
+ while line_start < closing_line_start
1014
+ finish = line_end(line_start)
1015
+ line = source.byteslice(line_start, finish - line_start)
1016
+
1017
+ # A blank line, which is empty or holds only spaces and tabs, may leave out the indentation, and it becomes an empty line. Swift is stricter here: there only a completely empty line may leave it out.
1018
+ if !line.match?(NOT_BLANK_CHARACTER)
1019
+ lines << ['', line_start]
1020
+ elsif line.start_with?(indentation)
1021
+ lines << [line.byteslice(indentation.bytesize, line.bytesize - indentation.bytesize), line_start + indentation.bytesize]
1022
+ else
1023
+ fail_at("This line does not start with the indentation of its block string's closing delimiter. Every line except a blank one must start with exactly the same spaces and tabs", line_start)
1024
+ end
1025
+
1026
+ line_start = finish + 1
1027
+ end
1028
+
1029
+ # Blank lines directly after the opening delimiter and directly before the closing one are not content. Every blank line is empty by now.
1030
+ first = 0
1031
+ last = lines.length
1032
+ first += 1 while first < last && lines[first][0].empty?
1033
+ last -= 1 while last > first && lines[last - 1][0].empty?
1034
+ kept = lines[first...last]
1035
+
1036
+ # `join` of nothing is a US-ASCII string.
1037
+ if kept.empty?
1038
+ +''
1039
+ elsif quote == 0x27
1040
+ kept.map(&:first).join("\n")
1041
+ else
1042
+ kept.map { |text, offset| unescape_line(text, offset) }.join("\n")
1043
+ end
1044
+ end
1045
+
1046
+ # The hint for a content line that ends with the delimiter, as TOML allows. Adding a closing line after it would keep the delimiter as content.
1047
+ def describe_inline_closing_delimiter(content_start, quote, delimiter_length)
1048
+ source = @source
1049
+ quote_character = quote.chr
1050
+ delimiter = quote_character * delimiter_length
1051
+ line = source.byteslice(0, content_start).count("\n") + 1
1052
+ line_start = content_start
1053
+
1054
+ while line_start < @length
1055
+ finish = line_end(line_start)
1056
+ text_start = skip_spaces(line_start)
1057
+ text_end = skip_spaces_back(finish)
1058
+ text = text_end > text_start ? source.byteslice(text_start, text_end - text_start) : ''
1059
+
1060
+ # A line of only quotes, longer than the delimiter, is content.
1061
+ return ". Its closing delimiter must start a line, so move the #{delimiter} at the end of line #{line} to a new line" if text.end_with?(delimiter) && text.count(quote_character) != text.bytesize
1062
+
1063
+ line += 1
1064
+ line_start = finish + 1
1065
+ end
1066
+
1067
+ ''
1068
+ end
1069
+
1070
+ # Find the closing delimiter of a block string: the first line from `offset` on that holds, after zero or more spaces and tabs, a run of exactly `length` of the `quote` character. A longer run is content. Returns where that line and its delimiter start, or `nil` when no line closes the block.
1071
+ def find_block_string_end(offset, quote, length)
1072
+ source = @source
1073
+ line_start = offset
1074
+
1075
+ while line_start < @length
1076
+ delimiter_start = line_start
1077
+ delimiter_start += 1 while space?(source.getbyte(delimiter_start))
1078
+ run_length = 0
1079
+ run_length += 1 while source.getbyte(delimiter_start + run_length) == quote
1080
+ return [line_start, delimiter_start] if run_length == length
1081
+
1082
+ line_start = line_end(line_start) + 1
1083
+ end
1084
+
1085
+ # Past the last line, either because the source ended with a line break or because the last line was content.
1086
+ nil
1087
+ end
1088
+
1089
+ def unescape_line(text, offset)
1090
+ return text unless text.include?('\\')
1091
+
1092
+ value = +''
1093
+ chunk_start = 0
1094
+
1095
+ while (index = text.byteindex('\\', chunk_start))
1096
+ value << text.byteslice(chunk_start, index - chunk_start)
1097
+ escaped, finish = parse_escape(offset + index)
1098
+ value << escaped
1099
+ chunk_start = finish - offset
1100
+ end
1101
+
1102
+ value << text.byteslice(chunk_start, text.bytesize - chunk_start)
1103
+ end
1104
+
1105
+ def parse_number_or_instant
1106
+ plain = parse_plain_number
1107
+ return plain unless plain.nil?
1108
+
1109
+ source = @source
1110
+ 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)
1118
+ @index = finish
1119
+
1120
+ return parse_instant(text, start) if text.match?(INSTANT_PREFIX)
1121
+
1122
+ kind = classify_number(text)
1123
+ return parse_duration(text, start) if kind.nil? && duration_like?(text)
1124
+
1125
+ case kind
1126
+ when :decimal, :radix
1127
+ fail_at('“-0” is not allowed, because zero has one spelling: 0', start) if text == '-0'
1128
+ parse_integer(text, start)
1129
+ when :float
1130
+ parse_float(text, start)
1131
+ else
1132
+ fail_at(describe_thousands_separator(text, start) || describe_bad_number(text, unquoted_text(start)), start)
1133
+ end
1134
+ end
1135
+
1136
+ # A comma used as a thousands separator, as in `[1,000]`, makes the group after it a separate item, which fails only when it has a leading zero. Writing that item in octal, as the leading zero message suggests, would parse to the wrong items.
1137
+ def describe_thousands_separator(text, start)
1138
+ return unless text.match?(/\A0[0-9]{2}\z/) && byte_before(start) == 0x2C
1139
+
1140
+ group_end = start - 1
1141
+ number_start = group_start(group_end)
1142
+
1143
+ # Earlier groups of three digits, as in `12,345,000`.
1144
+ while group_end - number_start == 3 && byte_before(number_start) == 0x2C && digit?(byte_before(number_start - 1))
1145
+ group_end = number_start - 1
1146
+ number_start = group_start(group_end)
1147
+ end
1148
+
1149
+ before = byte_before(number_start)
1150
+
1151
+ # The first group has one to three digits, which are not the end of a float, of a number with underscores, or of a number with a radix prefix.
1152
+ return if number_start == group_end || before == 0x2E || before == 0x5F || number_start < group_end - 3 || (before && LETTER[before])
1153
+
1154
+ # The sign belongs to the number, as in `-1,000`.
1155
+ number_start -= 1 if before == 0x2D
1156
+
1157
+ groups_start = start + text.bytesize
1158
+ groups = @source.byteslice(groups_start, code_unit_end(groups_start) - groups_start)[/\A(?:,[0-9]{3}(?![0-9]))*/]
1159
+ number = @source.byteslice(number_start, groups_start + groups.bytesize - number_start)
1160
+ reason = "Leading zeros are not allowed in a decimal number. A comma separates items, so #{abbreviate(number)} is not one number"
1161
+ # Both spellings are the same int, so they are suggested only when it is in range.
1162
+ with_underscores = number.tr(',', '_')
1163
+ valid_value?(with_underscores) ? "#{reason}. Write it as #{abbreviate(with_underscores)} or #{abbreviate(number.delete(','))}" : reason
1164
+ end
1165
+
1166
+ # Where the run of digits that ends at `group_end` starts.
1167
+ def group_start(group_end)
1168
+ start = group_end
1169
+ start -= 1 while digit?(byte_before(start))
1170
+ start
1171
+ end
1172
+
1173
+ # The common case, a short decimal int or float such as `8080`, `-3`, or `30.5`, without the general path's maximal-run scan and classification. Anything else, including every error, returns `nil` and is left to the general path.
1174
+ def parse_plain_number
1175
+ source = @source
1176
+ start = @index
1177
+ index = source.getbyte(start) == 0x2D ? start + 1 : start
1178
+ integer_start = index
1179
+ value = 0
1180
+
1181
+ # The digits are summed as they are read, so a short number allocates nothing. Only the first 18 are, because the sum is only used for a number that short, and summing a long run in a Bignum would take quadratic time.
1182
+ while (byte = source.getbyte(index)) && byte >= 0x30 && byte <= 0x39
1183
+ value = (value * 10) + byte - 0x30 if index - integer_start < 18
1184
+ index += 1
1185
+ end
1186
+
1187
+ integer_length = index - integer_start
1188
+
1189
+ # No digits, or a leading zero followed by more digits, which the general path reports as an error or reads as an instant before the year 1000.
1190
+ return if integer_length.zero? || (integer_length > 1 && source.getbyte(integer_start) == 0x30)
1191
+
1192
+ fraction_start = nil
1193
+
1194
+ if byte == 0x2E # .
1195
+ fraction_start = index + 1
1196
+ index = fraction_start
1197
+
1198
+ while (byte = source.getbyte(index)) && byte >= 0x30 && byte <= 0x39
1199
+ value = (value * 10) + byte - 0x30 if index - integer_start < 19
1200
+ index += 1
1201
+ end
1202
+
1203
+ return if index == fraction_start
1204
+ end
1205
+
1206
+ # A character that may continue a number, such as `e`, `_`, or `-`, needs the general path. So does an int with more than 18 digits, which may be outside the 64-bit range, and a float with more than 60 digits, which `String#to_f` does not round correctly.
1207
+ length = index - integer_start
1208
+ return if (byte && !VALUE_END_BYTE[byte]) || length > (fraction_start ? 61 : 18)
1209
+
1210
+ is_negative = integer_start > start
1211
+
1212
+ if fraction_start
1213
+ @index = index
1214
+
1215
+ # With at most 15 digits, the digits and the power of ten are exact as floats, so the quotient is rounded once, which is correct rounding. Without an exponent and with at most 60 digits, a value is far from the float limits, so `to_f` rounds it correctly and never warns.
1216
+ value = length <= 16 ? value.to_f / POWERS_OF_TEN[index - fraction_start] : source.byteslice(integer_start, length).to_f
1217
+
1218
+ # Negative zero is the same value as zero.
1219
+ return is_negative && !value.zero? ? -value : value
1220
+ end
1221
+
1222
+ # `-0` is an error, which the general path reports.
1223
+ return if is_negative && value.zero?
1224
+
1225
+ @index = index
1226
+ is_negative ? -value : value
1227
+ end
1228
+
1229
+ # 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
+ def classify_number(text)
1231
+ radix = text.getbyte(0) == 0x30 && RADIXES[text[1]]
1232
+
1233
+ if radix
1234
+ finish = skip_digits(text, 2, radix[0])
1235
+ return finish > 2 && finish == text.bytesize ? :radix : nil
1236
+ end
1237
+
1238
+ index = text.getbyte(0) == 0x2D ? 1 : 0
1239
+
1240
+ # The integer part is `0` or has no leading zero.
1241
+ integer_end = skip_integer_part(text, index)
1242
+ return if integer_end == index
1243
+
1244
+ index = integer_end
1245
+ kind = :decimal
1246
+
1247
+ if text.getbyte(index) == 0x2E # .
1248
+ finish = skip_digits(text, index + 1, DECIMAL_DIGIT)
1249
+ return if finish == index + 1
1250
+
1251
+ index = finish
1252
+ kind = :float
1253
+ end
1254
+
1255
+ if text.getbyte(index) == 0x65 # e
1256
+ is_negative = text.getbyte(index + 1) == 0x2D
1257
+ exponent_start = index + (is_negative ? 2 : 1)
1258
+
1259
+ # The exponent follows the same leading-zero rule as the integer part, and its zero has one spelling, `e0`, so `e-0` is not an exponent.
1260
+ finish = is_negative && text.getbyte(exponent_start) == 0x30 ? exponent_start : skip_integer_part(text, exponent_start)
1261
+ return if finish == exponent_start
1262
+
1263
+ index = finish
1264
+ kind = :float
1265
+ end
1266
+
1267
+ index == text.bytesize ? kind : nil
1268
+ end
1269
+
1270
+ # Returns the index after a run of digits that may have single underscores between them, or `index` when there is no digit there.
1271
+ def skip_digits(text, index, digits)
1272
+ byte = text.getbyte(index)
1273
+ return index unless byte && digits[byte]
1274
+
1275
+ index += 1
1276
+
1277
+ while (byte = text.getbyte(index))
1278
+ if digits[byte]
1279
+ index += 1
1280
+ elsif byte == 0x5F && (following = text.getbyte(index + 1)) && digits[following]
1281
+ index += 2
1282
+ else
1283
+ break
1284
+ end
1285
+ end
1286
+
1287
+ index
1288
+ end
1289
+
1290
+ # 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
1294
+
1295
+ # More than 64 significant digits is out of range in any radix, which is decided before a huge digit string is converted.
1296
+ def parse_integer(text, start)
1297
+ digits = text.delete('_')
1298
+ radix = RADIXES[digits[1]] if digits.bytesize > 1
1299
+ significant = digits.getbyte(0) == 0x2D ? 1 : 0
1300
+
1301
+ if radix
1302
+ significant = 2
1303
+ significant += 1 while digits.getbyte(significant) == 0x30
1304
+ end
1305
+
1306
+ if digits.bytesize - significant <= 64
1307
+ value = radix ? digits.byteslice(significant, digits.bytesize - significant).to_i(radix[1]) : digits.to_i
1308
+ return value if value.between?(INT64_MIN, INT64_MAX)
1309
+ end
1310
+
1311
+ fail_at("The integer #{abbreviate(text)} is outside the 64-bit range (-9223372036854775808 to 9223372036854775807)", start)
1312
+ end
1313
+
1314
+ def parse_float(text, start)
1315
+ mantissa, exponent_text = text.delete('_').split('e')
1316
+ whole, fraction = mantissa.delete_prefix('-').split('.')
1317
+ digits = "#{whole}#{fraction}"
1318
+ first = digits.index(/[1-9]/)
1319
+
1320
+ # Negative zero is the same value as zero.
1321
+ return 0.0 if first.nil?
1322
+
1323
+ significant = trim_trailing_zeros(digits.byteslice(first, digits.bytesize - first))
1324
+ # 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
+ exponent_text = exponent_text.start_with?('-') ? '-1000000000000000000' : '1000000000000000000' if exponent_text && exponent_text.delete_prefix('-').bytesize > 18
1326
+ exponent = exponent_text.to_i - (fraction&.bytesize || 0) + (digits.bytesize - first - significant.bytesize)
1327
+
1328
+ # A float has at most 767 significant decimal digits, so the digits after the 800th only matter by whether one of them is not zero, which a final 1 stands for. One is, because the trailing zeros are gone.
1329
+ if significant.bytesize > 800
1330
+ exponent += significant.bytesize - 801
1331
+ significant = "#{significant.byteslice(0, 800)}1"
1332
+ end
1333
+
1334
+ # The value is at least 10^(magnitude - 1) and below 10^magnitude, which decides the far cases before any big number is made.
1335
+ magnitude = exponent + significant.bytesize
1336
+ value = if magnitude > 310
1337
+ Float::INFINITY
1338
+ elsif magnitude < -324
1339
+ 0.0
1340
+ else
1341
+ decimal_to_float(significant.to_i, exponent)
1342
+ end
1343
+
1344
+ is_negative = mantissa.start_with?('-')
1345
+ fail_at("#{abbreviate(text)} is too large to be a finite float. Use #{is_negative ? '-infinity' : 'infinity'} if you mean it", start) if value.infinite?
1346
+ fail_at("#{abbreviate(text)} is too small to be told apart from zero. Write 0.0 if you mean zero", start) if value.zero?
1347
+
1348
+ is_negative ? -value : value
1349
+ end
1350
+
1351
+ # 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.
1352
+ def decimal_to_float(significand, exponent)
1353
+ numerator = exponent.negative? ? significand : significand * (10**exponent)
1354
+ denominator = exponent.negative? ? 10**-exponent : 1
1355
+
1356
+ # The binary exponent that gives a 53-bit quotient, or the subnormal one, which gives fewer bits.
1357
+ shift = [numerator.bit_length - denominator.bit_length - 53, -1074].max
1358
+ quotient, remainder = shift.negative? ? (numerator << -shift).divmod(denominator) : numerator.divmod(denominator << shift)
1359
+
1360
+ # The bit lengths estimate the shift to within one.
1361
+ if quotient.bit_length > 53
1362
+ shift += 1
1363
+ quotient, remainder = shift.negative? ? (numerator << -shift).divmod(denominator) : numerator.divmod(denominator << shift)
1364
+ end
1365
+
1366
+ divisor = shift.negative? ? denominator : denominator << shift
1367
+ comparison = (remainder * 2) <=> divisor
1368
+ quotient += 1 if comparison.positive? || (comparison.zero? && quotient.odd?)
1369
+
1370
+ # A quotient of 2^53 and a shift past the largest exponent give infinity.
1371
+ Math.ldexp(quotient, shift)
1372
+ end
1373
+
1374
+ def parse_instant(text, start)
1375
+ match = text.bytesize > MAX_DIAGNOSED_LENGTH ? nil : INSTANT.match(text)
1376
+
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
1381
+ time = finish > time_start ? @source.byteslice(time_start, finish - time_start) : ''
1382
+ # 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
+ is_whole_value = value_end?(finish) && !(@source.getbyte(finish) == 0x2C && digit?(@source.getbyte(finish + 1)))
1384
+ fail_at(describe_bad_instant(text, time, is_whole_value, offset_after_space), start)
1385
+ end
1386
+
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
1398
+
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
1405
+ end
1406
+
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
1409
+ end
1410
+
1411
+ # A scan of the parts in order, so that each error names the part it is about.
1412
+ def parse_duration(text, start)
1413
+ is_negative = text.getbyte(0) == 0x2D
1414
+ index = is_negative ? 1 : 0
1415
+ previous_rank = -1
1416
+ total = 0
1417
+
1418
+ # The fraction of the last part, and that part's unit size. Only the last part may have one, so the value checks wait until every part is read.
1419
+ fraction = ''
1420
+ size = 0
1421
+
1422
+ while index < text.bytesize
1423
+ digits_end = skip_integer_part(text, index)
1424
+ following = text.getbyte(digits_end)
1425
+
1426
+ if digits_end == index
1427
+ byte = text.getbyte(index)
1428
+ fail_duration(text, start, 'only the whole duration takes a sign, as in -1h30m') if byte == 0x2D && digit?(text.getbyte(index + 1))
1429
+ fail_duration(text, start, byte == 0x2B ? 'a “+” sign is not allowed' : "expected a number at “#{abbreviate(text.byteslice(index, text.bytesize - index), 10)}”")
1430
+ end
1431
+
1432
+ fail_duration(text, start, 'leading zeros are not allowed') if text.getbyte(index) == 0x30 && (following == 0x5F || digit?(following))
1433
+ fail_duration(text, start, 'an underscore must be between two digits') if following == 0x5F
1434
+
1435
+ fraction = ''
1436
+ unit_start = digits_end
1437
+
1438
+ if following == 0x2E # .
1439
+ unit_start = skip_digits(text, digits_end + 1, DECIMAL_DIGIT)
1440
+ fail_duration(text, start, 'a “.” must be followed by a digit') if unit_start == digits_end + 1
1441
+ fail_duration(text, start, 'an underscore must be between two digits') if text.getbyte(unit_start) == 0x5F
1442
+
1443
+ # 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('_'))
1445
+ end
1446
+
1447
+ unit_end = unit_start
1448
+
1449
+ while (byte = text.getbyte(unit_end)) && LETTER[byte]
1450
+ unit_end += 1
1451
+ end
1452
+
1453
+ unit = text.byteslice(unit_start, unit_end - unit_start)
1454
+ rank = DURATION_UNIT_NAMES.index(unit)
1455
+ fail_duration(text, start, describe_bad_duration_unit(unit, text)) if rank.nil?
1456
+ fail_duration(text, start, 'the units are in the order h, m, s, ms, us, ns, and each appears at most once') if rank <= previous_rank
1457
+ fail_duration(text, start, 'only the last part may have a fraction') if following == 0x2E && digit?(text.getbyte(unit_end))
1458
+
1459
+ size = DURATION_UNITS[unit]
1460
+ digits = text.byteslice(index, digits_end - index).delete('_')
1461
+
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
1464
+ index = unit_end
1465
+ previous_rank = rank
1466
+ end
1467
+
1468
+ # More than 13 fraction digits without a trailing zero is finer than a nanosecond in any unit, which is decided before a huge digit string is converted.
1469
+ fail_duration(text, start, 'it is not a whole number of nanoseconds') if fraction.bytesize > 13
1470
+
1471
+ unless fraction.empty?
1472
+ scale = 10**fraction.bytesize
1473
+ fraction_nanoseconds = fraction.to_i * size
1474
+ fail_duration(text, start, 'it is not a whole number of nanoseconds') unless (fraction_nanoseconds % scale).zero?
1475
+ total += fraction_nanoseconds / scale
1476
+ end
1477
+
1478
+ fail_duration(text, start, '“-” is not allowed before zero, because zero has one spelling: 0s') if is_negative && total.zero?
1479
+ fail_duration(text, start, 'it is outside the 64-bit range of nanoseconds, about 292 years either way') if total > (is_negative ? -INT64_MIN : INT64_MAX)
1480
+
1481
+ Duration.new(nanoseconds: is_negative ? -total : total)
1482
+ end
1483
+
1484
+ def check_instant(text, start, is_valid, reason)
1485
+ fail_at("Invalid instant #{abbreviate(text)}: #{reason}", start) unless is_valid
1486
+ end
1487
+
1488
+ def fail_duration(text, start, reason)
1489
+ fail_at("Invalid duration #{abbreviate(text)}: #{reason}", start)
1490
+ end
1491
+
1492
+ # 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
+ def duration_like?(text)
1494
+ index = text.getbyte(0) == 0x2D ? 1 : 0
1495
+ return false unless digit?(text.getbyte(index))
1496
+
1497
+ index += 1 while digit_or_underscore?(text.getbyte(index))
1498
+
1499
+ if text.getbyte(index) == 0x2E
1500
+ index += 1
1501
+ index += 1 while digit_or_underscore?(text.getbyte(index))
1502
+ end
1503
+
1504
+ byte = text.getbyte(index)
1505
+ return false if byte.nil?
1506
+
1507
+ # 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)
1509
+ end
1510
+
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
1520
+
1521
+ def line_start(offset)
1522
+ offset.zero? ? 0 : (@source.byterindex("\n", offset - 1) || -1) + 1
1523
+ end
1524
+
1525
+ # The number of spaces and tabs before `offset` on its line, or `nil` when something else comes before it.
1526
+ def indentation_at(offset)
1527
+ start = line_start(offset)
1528
+ skip_spaces(start) == offset ? offset - start : nil
1529
+ end
1530
+
1531
+ # The end of the number, instant, or duration that starts at `start`.
1532
+ def number_end(start)
1533
+ finish = start + 1
1534
+
1535
+ while (byte = @source.getbyte(finish)) && NUMBER_BYTE[byte]
1536
+ finish += 1
1537
+ end
1538
+
1539
+ finish
1540
+ end
1541
+
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)
1544
+ finish = start
1545
+ length = 0
1546
+
1547
+ while (byte = @source.getbyte(finish))
1548
+ # A continuation byte is part of the character before it.
1549
+ unless byte.between?(0x80, 0xBF)
1550
+ break if length >= maximum
1551
+
1552
+ # A character outside the BMP is two UTF-16 code units, and its UTF-8 lead byte is 0xF0 to 0xF4.
1553
+ length += byte >= 0xF0 ? 2 : 1
1554
+ end
1555
+
1556
+ finish += 1
1557
+ end
1558
+
1559
+ finish
1560
+ end
1561
+
1562
+ # Whether a value ends at `offset`, so that a suggestion that ends there leaves nothing out: at the end, whitespace, a separator, a closing bracket, or a comment. A `/` only starts a comment with a `*` after it, or a `//` comment of another language, which the error for what follows names, so in `km/h` the text goes on.
1563
+ def value_end?(offset)
1564
+ byte = @source.getbyte(offset)
1565
+ byte.nil? || (VALUE_END_BYTE[byte] && (byte != 0x2F || (following = @source.getbyte(offset + 1)) == 0x2A || following == 0x2F))
1566
+ end
1567
+
1568
+ def byte_before(offset)
1569
+ offset.positive? ? @source.getbyte(offset - 1) : nil
1570
+ end
1571
+
1572
+ def skip_spaces(offset)
1573
+ offset += 1 while space?(@source.getbyte(offset))
1574
+ offset
1575
+ end
1576
+
1577
+ def space?(byte)
1578
+ byte == 0x20 || byte == 0x09
1579
+ end
1580
+
1581
+ # 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
+
1586
+ def skip_spaces_back(offset)
1587
+ offset -= 1 while offset.positive? && space?(@source.getbyte(offset - 1))
1588
+ offset
1589
+ end
1590
+
1591
+ def digit?(byte)
1592
+ !byte.nil? && byte >= 0x30 && byte <= 0x39
1593
+ end
1594
+
1595
+ def digit_or_underscore?(byte)
1596
+ byte == 0x5F || digit?(byte)
1597
+ end
1598
+
1599
+ def words_with_spaces?(text)
1600
+ text.each_byte.all? { |byte| space?(byte) || BARE_KEY_BYTE[byte] }
1601
+ end
1602
+
1603
+ def days_in_month(year, month)
1604
+ if month == 2
1605
+ is_leap_year = (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
1606
+ return is_leap_year ? 29 : 28
1607
+ end
1608
+
1609
+ [4, 6, 9, 11].include?(month) ? 30 : 31
1610
+ end
1611
+
1612
+ # Days since 1970-01-01 in the proleptic Gregorian calendar, from Howard Hinnant's `days_from_civil`.
1613
+ def days_from_civil(year, month, day)
1614
+ year -= 1 if month <= 2
1615
+ era = year.div(400)
1616
+ year_of_era = year - (era * 400)
1617
+ day_of_year = ((153 * (month + (month > 2 ? -3 : 9))) + 2) / 5 + day - 1
1618
+ day_of_era = (year_of_era * 365) + (year_of_era / 4) - (year_of_era / 100) + day_of_year
1619
+ (era * 146_097) + day_of_era - 719_468
1620
+ end
1621
+
1622
+ def abbreviate(...) = Parser.abbreviate(...)
1623
+
1624
+ def format_code_point(code_point)
1625
+ format('U+%04X', code_point)
1626
+ end
1627
+
1628
+ # Describe a character for an error message, such as `“$”` or `U+00A0 (NO-BREAK SPACE)`.
1629
+ def describe_character(character)
1630
+ code_point = character.ord
1631
+ name = CHARACTER_NAMES[code_point]
1632
+
1633
+ if name
1634
+ is_whitespace = character.match?(/[\p{White_Space}\u200B\uFEFF]/)
1635
+ return "#{format_code_point(code_point)} (#{name}#{is_whitespace ? '; only space, tab, and line feed are whitespace' : ''})"
1636
+ end
1637
+
1638
+ # Control, format, private-use, unassigned, surrogate, separator, and default-ignorable characters are invisible or ambiguous, so they are shown by code point.
1639
+ code_point != 0x20 && character.match?(INVISIBLE_CHARACTER) ? format_code_point(code_point) : "“#{character}”"
1640
+ end
1641
+
1642
+ # `text` is the unquoted string that `word` begins, which the suggestion quotes.
1643
+ def describe_unknown_word(word, text)
1644
+ # 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
+ lowercase = text == word ? word.downcase : ''
1646
+
1647
+ if %w[true false yes no on off].include?(lowercase)
1648
+ "“#{word}” is not a value. Booleans are written true and false, in lowercase"
1649
+ elsif %w[null nil none undefined].include?(lowercase)
1650
+ "“#{word}” is not a value. Null is written null, in lowercase"
1651
+ elsif lowercase == 'nan'
1652
+ 'NaN is not representable. Use null for a missing value'
1653
+ elsif %w[inf infinity].include?(lowercase)
1654
+ "“#{word}” is not a value. Infinity is written infinity, in lowercase"
1655
+ else
1656
+ "Unexpected “#{abbreviate(word)}”. A string value must be quoted#{quoting_example(text)}"
1657
+ end
1658
+ end
1659
+
1660
+ def describe_bad_unicode_escape(rest)
1661
+ if rest.match?(/\Au[0-9A-Fa-f]{4}/)
1662
+ describe_four_digit_escape(rest)
1663
+ elsif rest.match?(/\Au\{[0-9A-Fa-f]*[A-F]/)
1664
+ 'A Unicode escape uses lowercase hexadecimal digits'
1665
+ elsif rest.start_with?('u{}')
1666
+ 'A Unicode escape needs one to six hexadecimal digits'
1667
+ elsif rest.match?(/\Au\{[0-9a-f]{7}/)
1668
+ 'A Unicode escape has at most six hexadecimal digits'
1669
+ else
1670
+ 'A Unicode escape is written \\u{…} with one to six lowercase hexadecimal digits'
1671
+ end
1672
+ end
1673
+
1674
+ # The JSON form `\uXXXX`, from its `u`, with the escape to write instead. JSON writes a character above U+FFFF as two of them, a surrogate pair, which is one `\u{…}` escape here. Here, a lone surrogate and a carriage return have no escape.
1675
+ def describe_four_digit_escape(rest)
1676
+ code = rest[1, 4].to_i(16)
1677
+ low = rest[5..].match?(/\A\\u[0-9A-Fa-f]{4}/) ? rest[7, 4] : nil
1678
+ low_code = low&.to_i(16)
1679
+
1680
+ if code.between?(0xD800, 0xDBFF) && low_code&.between?(0xDC00, 0xDFFF)
1681
+ code_point = 0x10000 + ((code - 0xD800) * 0x400) + (low_code - 0xDC00)
1682
+ return "The four-digit \\#{rest[0, 5]}\\u#{low} form is not an escape. Write \\u{#{code_point.to_s(16)}}"
1683
+ end
1684
+
1685
+ form = "The four-digit \\#{rest[0, 5]} form is not an escape"
1686
+
1687
+ if code.between?(0xD800, 0xDFFF)
1688
+ "#{form}, and a lone surrogate is not a Unicode scalar value. Write the character it is half of as one \\u{…} escape"
1689
+ elsif code == 0x0D
1690
+ "#{form}, and a carriage return (U+000D) cannot be represented"
1691
+ else
1692
+ "#{form}. Write \\u{#{code.to_s(16)}}"
1693
+ end
1694
+ end
1695
+
1696
+ def describe_bad_duration_unit(unit, text)
1697
+ if unit.empty?
1698
+ 'every number needs a unit: h, m, s, ms, us, or ns'
1699
+ elsif unit.match?(/\Ad(?:ays?)?\z/)
1700
+ 'there is no day unit, because a day is not a fixed length. Write 24h for a fixed 24 hours'
1701
+ elsif unit.match?(/\Aw(?:eeks?)?\z/)
1702
+ 'there is no week unit, because a day is not a fixed length. Write 168h for a fixed 168 hours'
1703
+ elsif unit.match?(/\A(?:y|yrs?|years?)\z/i)
1704
+ 'there is no year unit, because a year is not a fixed length. Write 8760h for a fixed 365 days'
1705
+ elsif unit == 'M'
1706
+ # A number with `M` alone is more often a size, as in `memory: 512M`, and `512m` would read as minutes.
1707
+ # Whether the text is digits and underscores before the `M`, found with a search for one other character, because a repeat over a long token needs memory for every character.
1708
+ is_size = text.bytesize <= MAX_DIAGNOSED_LENGTH && digit?(text.getbyte(0)) && !text.byteslice(0, text.bytesize - 1).match?(/[^0-9_]/)
1709
+ size_hint = is_size ? ". A size, such as 512M, is a string: '#{abbreviate(text)}'" : ''
1710
+ "there is no month unit, because a month is not a fixed length#{size_hint}"
1711
+ elsif DURATION_UNITS.key?(unit.downcase)
1712
+ "the units are lowercase: #{unit.downcase}"
1713
+ else
1714
+ "“#{abbreviate(unit, 10)}” is not a unit. The units are h, m, s, ms, us, and ns, and a string must be quoted"
1715
+ end
1716
+ end
1717
+
1718
+ # `unquoted_text` is the unquoted string that `full_text` begins, for the suggestion to quote it.
1719
+ def describe_bad_number(full_text, unquoted_text)
1720
+ text = full_text.byteslice(0, MAX_DIAGNOSED_LENGTH)
1721
+
1722
+ return 'A “+” sign is not allowed in a number, including in an exponent' if text.include?('+')
1723
+ return 'A number prefix is lowercase: 0x, 0o, or 0b' if text.match?(/\A-?0[BOX]/)
1724
+
1725
+ # 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]
1729
+
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?
1732
+
1733
+ if radix == 'x' && digits.match?(/[a-f]/) && !digits.match?(/[^0-9a-fA-F_]/)
1734
+ # 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/)
1736
+
1737
+ uppercase = "0x#{digits.upcase}"
1738
+ return valid_value?(uppercase) ? "Hexadecimal digits are uppercase: #{abbreviate(uppercase)}" : 'Hexadecimal digits are uppercase'
1739
+ end
1740
+
1741
+ character = digits.each_char.find { |each_character| !each_character.match?(valid_character) }
1742
+ return character.nil? ? 'An underscore in a number must be between two digits' : "Invalid #{name} digit “#{character}”"
1743
+ end
1744
+
1745
+ # Only when the “E” is where an exponent marker goes, with digits, a `-`, or nothing after it. In `5EUR` or `10EB`, it is part of a word, so lowercasing it would not help.
1746
+ return 'An exponent marker is a lowercase “e”' if text.match?(/\A-?[0-9][0-9_]*(?:\.[0-9_]+)?E-?[0-9_]*\z/)
1747
+ return 'A decimal point must be followed by a digit' if text.match?(/\A-?[0-9_]+\.(?:\z|[^0-9_])/)
1748
+ return 'A number cannot begin with “.”; write a digit before it, as in -0.5' if text.start_with?('-.')
1749
+
1750
+ if text.match?(/\A[0-9]+(?::[0-9]+)+\z/)
1751
+ return text.match?(/\A[0-9]{1,2}:[0-9]{2}(?::[0-9]{2}(?:\.[0-9]+)?)?\z/) ? 'A time of day is a string, so it must be quoted' : "Invalid number “#{abbreviate(text)}”. A value that contains “:” must be quoted, as in '#{abbreviate(text)}'"
1752
+ end
1753
+
1754
+ # A value with a `:` that starts with a zero, such as the MAC address `00:1A:2B`, is not a number with a leading zero, because removing the zero would not make it valid.
1755
+ if text.match?(/\A-?0[0-9_]/) && !text.include?(':')
1756
+ # Removing the zero gives a valid number with a different meaning, so the message says what the zero usually meant.
1757
+ # A `'...'` string cannot hold a `'`, so then there is no example.
1758
+ identifier = "an identifier, such as a ZIP code, as a string#{unquoted_text.include?("'") ? '' : ": '#{abbreviate(unquoted_text)}'"}"
1759
+
1760
+ # The octal suggestion is only for the number on its own, not for one that more text follows, as in `0412 345 678`, and only when it is in range. It is decided on the whole number, because a cut can end before a digit that is not octal or that changes the value.
1761
+ digits = full_text.lstrip('0')
1762
+ octal = "0o#{digits.empty? ? '0' : digits}"
1763
+
1764
+ if unquoted_text == full_text && full_text.match?(/\A0[0-7]+\z/) && valid_value?(octal)
1765
+ return "Leading zeros are not allowed in a decimal number. Write an octal number, such as a file mode, as #{abbreviate(octal)}, and #{identifier}"
1766
+ end
1767
+
1768
+ return text.match?(/\A0[0-9]+\z/) ? "Leading zeros are not allowed in a decimal number. Write #{identifier}" : 'Leading zeros are not allowed in a decimal number'
1769
+ end
1770
+
1771
+ # Only in a number: an underscore next to a letter other than the exponent marker is part of a word, such as `4k_video`.
1772
+ return 'An underscore in a number must be between two digits' if text.match?(/_(?:\z|[^0-9])|(?:\A|[^0-9])_/) && !text.match?(/[^-0-9_.e]/)
1773
+ return 'Leading zeros are not allowed in an exponent' if text.match?(/\A-?[0-9][0-9_]*(?:\.[0-9_]+)?e-?0_?[0-9]/)
1774
+ return '“e-0” is not allowed, because an exponent of zero has one spelling: e0' if text.match?(/\A-?[0-9][0-9_]*(?:\.[0-9_]+)?e-0\z/)
1775
+ # Only a number ends with its exponent marker. A word that ends with an “e”, such as `1byte` or `-verbose`, has no exponent.
1776
+ return 'Expected digits after the exponent marker “e”' if text.match?(/\A-?[0-9][0-9_]*(?:\.[0-9_]+)?e-?\z/)
1777
+ return "Invalid number “#{abbreviate(text)}”. A value with several dots, such as a version number, must be quoted" if text.count('.') > 1
1778
+
1779
+ if text.match?(/\A-(?:[^0-9]|\z)/)
1780
+ # The whole word, as for one without the “-”, so that a word such as `-nano` or `-info` is not taken for NaN or infinity.
1781
+ return 'NaN is not representable. Use null for a missing value' if text.match?(/\A-nan\z/i)
1782
+
1783
+ return text.match?(/\A-inf(?:inity)?\z/i) ? "“#{abbreviate(text)}” is not a value. Negative infinity is written -infinity" : 'Expected a digit or “infinity” after “-”'
1784
+ end
1785
+
1786
+ text.match?(/[A-Za-z]/) ? "Invalid number “#{abbreviate(text)}”. A string value must be quoted#{quoting_example(unquoted_text)}" : "Invalid number “#{abbreviate(text)}”"
1787
+ end
1788
+
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
+ # An offset on its own after spaces, as in `2026-09-19T14:00:00 Z`, which belongs directly after the time, or `nil`.
1797
+ def offset_after_space
1798
+ offset_start = skip_spaces(@index)
1799
+ return if offset_start == @index
1800
+
1801
+ # The bytes, because the 6 of them can end inside a character, which a regex refuses to read. An offset is ASCII.
1802
+ offset = @source.byteslice(offset_start, 6).b[/\A(?:Z|[+-][0-9]{2}:[0-9]{2})/]
1803
+ offset if offset && value_end?(offset_start + offset.bytesize)
1804
+ end
1805
+
1806
+ # `time` is the token after a space that follows `text`, or an empty string. `is_whole_value` is whether nothing that may be part of the instant follows `text`. `offset` is an offset after a space that follows `text`, or `nil`.
1807
+ def describe_bad_instant(text, time, is_whole_value, offset)
1808
+ generic = "Invalid instant “#{abbreviate(text)}”. #{INSTANT_FORMAT}"
1809
+
1810
+ if text.bytesize > MAX_DIAGNOSED_LENGTH
1811
+ generic
1812
+ 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)
1814
+ elsif text.match?(/\A[0-9]{4}-[0-9]{2}-[0-9]{2}t/)
1815
+ 'The date and time separator in an instant is an uppercase “T”'
1816
+ elsif text.end_with?('z')
1817
+ 'The UTC offset in an instant is an uppercase “Z”'
1818
+ elsif text.match?(/[+-][0-9]{4}\z/)
1819
+ "An instant's offset is written with a colon, as in +07:00"
1820
+ elsif !text.match?(LOCAL_DATE_TIME)
1821
+ generic
1822
+ elsif offset
1823
+ # The instant has the offset it was meant in, so it is not a local time to write as a string.
1824
+ reason = 'An instant\'s offset follows its time directly, without a space'
1825
+ valid_value?("#{text}#{offset}") ? "#{reason}, as in #{abbreviate("#{text}#{offset}")}" : reason
1826
+ elsif is_whole_value
1827
+ "An instant needs an offset: Z or ±HH:MM. 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(text)}', or add the offset it was meant in"
1828
+ else
1829
+ 'An instant needs an offset: Z or ±HH:MM'
1830
+ end
1831
+ end
1832
+
1833
+ # A date and a time with a space between them, where an instant has a "T". Following the example must not turn a local time into UTC silently, so a time without an offset is described as what it is. An instant is only shown when it is valid, so a date that does not exist, a time out of range, or an instant outside the years 0001 to 9999 in UTC gets the general format instead.
1834
+ def describe_space_separated_instant(date, time, is_whole_value)
1835
+ separator = 'The date and time separator in an instant is an uppercase “T”, not a space'
1836
+ instant = "#{date}T#{time}"
1837
+
1838
+ if instant.bytesize > MAX_DIAGNOSED_LENGTH
1839
+ "#{separator}. #{INSTANT_FORMAT}"
1840
+ elsif valid_value?(instant)
1841
+ "#{separator}, as in #{abbreviate(instant)}"
1842
+ elsif is_whole_value && instant.match?(LOCAL_DATE_TIME) && valid_value?("#{instant}Z")
1843
+ "#{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
+ else
1845
+ "#{separator}. #{INSTANT_FORMAT}"
1846
+ end
1847
+ end
1848
+
1849
+ # Whether `text` is one valid value, so that an error message only suggests a fix that works.
1850
+ def valid_value?(text)
1851
+ Parser.new("[#{text}]").parse.length == 1
1852
+ rescue ParseError
1853
+ false
1854
+ end
1855
+ end
1856
+ end