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.
- checksums.yaml +7 -0
- data/lib/soml/duration.rb +167 -0
- data/lib/soml/edit.rb +468 -0
- data/lib/soml/error.rb +171 -0
- data/lib/soml/format.rb +331 -0
- data/lib/soml/generator.rb +364 -0
- data/lib/soml/parser.rb +1856 -0
- data/lib/soml/tree.rb +239 -0
- data/lib/soml/version.rb +6 -0
- data/lib/soml-lang.rb +4 -0
- data/lib/soml.rb +226 -0
- data/license +9 -0
- data/readme.md +552 -0
- data/sig/soml.rbs +143 -0
- metadata +58 -0
data/lib/soml/parser.rb
ADDED
|
@@ -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
|