tomlrb 2.0.5 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +103 -0
- data/README.md +85 -0
- data/lib/tomlrb/local_date.rb +24 -13
- data/lib/tomlrb/local_date_time.rb +33 -20
- data/lib/tomlrb/local_time.rb +36 -16
- data/lib/tomlrb/parser.rb +789 -14
- data/lib/tomlrb/version.rb +2 -2
- data/lib/tomlrb.rb +18 -32
- data/sig/tomlrb.rbs +75 -0
- metadata +15 -24
- data/lib/tomlrb/generated_parser.rb +0 -808
- data/lib/tomlrb/handler.rb +0 -279
- data/lib/tomlrb/scanner.rb +0 -92
- data/lib/tomlrb/string_utils.rb +0 -40
data/lib/tomlrb/parser.rb
CHANGED
|
@@ -1,21 +1,796 @@
|
|
|
1
|
-
#
|
|
1
|
+
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "strscan"
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
module Tomlrb
|
|
6
|
+
# Single-pass TOML 1.1 / 1.0 parser over a StringScanner.
|
|
7
|
+
#
|
|
8
|
+
# Costs that shape the code (CRuby 3.3, instructions): a regex call has about
|
|
9
|
+
# 1000 fixed overhead, an anchored `[class]*` loop then costs ~80 per byte while
|
|
10
|
+
# an unanchored search for a character class costs ~11 per byte, a capture
|
|
11
|
+
# 600-800, a `getbyte` ~190 (~40 under YJIT). So the parser dispatches on bytes
|
|
12
|
+
# through lookup tables, fuses the commonest shapes into one regex (`key =`,
|
|
13
|
+
# a plain string or integer plus the rest of its line and any blank lines after
|
|
14
|
+
# it, an array element or inline table value plus its comma), reads date and
|
|
15
|
+
# time fields as bytes, and skips comments by searching for the character that
|
|
16
|
+
# ends them. Arrays and inline tables use an explicit stack, so nesting depth is
|
|
17
|
+
# bounded by memory rather than the VM stack. Every regex is linear-time:
|
|
18
|
+
# alternatives start with disjoint characters and repeated groups are atomic or
|
|
19
|
+
# cannot overlap.
|
|
20
|
+
class Parser
|
|
21
|
+
# Kinds of the tables the document creates, tracked by identity in @kinds.
|
|
22
|
+
# Hashes and Arrays missing from @kinds (inline tables, array values) are final.
|
|
23
|
+
IMPLICIT = 1 # created as a header prefix (`a` in `[a.b]`); its own header may still define it
|
|
24
|
+
HEADER = 2 # defined by `[header]`, or an element of an array of tables
|
|
25
|
+
DOTTED = 3 # defined by a dotted key (`a` in `a.b = 1`); only dotted keys may extend it
|
|
26
|
+
ARRAY_OF_TABLES = 4
|
|
6
27
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
28
|
+
CTRL = '\x00-\x08\x0a-\x1f\x7f' # control characters, tab excluded
|
|
29
|
+
EOL_SRC = "[ \\t]*(?:#[^#{CTRL}]*)?(?:(?:\\r?\\n[ \\t]*)+|\\z)"
|
|
30
|
+
BARE_KEY_SRC = "[A-Za-z0-9_-]+"
|
|
31
|
+
TABLE_NAME_SRC = "[ \\t]*(#{BARE_KEY_SRC}(?:\\.#{BARE_KEY_SRC})*)[ \\t]*"
|
|
32
|
+
PLAIN_BASIC_STRING_SRC = %("([^"\\\\#{CTRL}]*)") # no escapes; captures the content
|
|
33
|
+
PLAIN_LITERAL_STRING_SRC = "'([^'#{CTRL}]*)'"
|
|
34
|
+
INTEGER_SRC = "[+-]?(?:0|[1-9](?:_?[0-9])*)"
|
|
12
35
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
36
|
+
EOL = /#{EOL_SRC}/
|
|
37
|
+
WHITESPACE = /(?:[ \t\n]|\r\n)+/
|
|
38
|
+
SPACES = /[ \t]+/
|
|
39
|
+
EQUALS = /[ \t]*=[ \t]*/
|
|
40
|
+
# Searched for, not matched: where a block of comment lines ends. It stops at a
|
|
41
|
+
# line break not followed by another comment line, or at a character no comment
|
|
42
|
+
# may contain, so consecutive comment lines cost one call.
|
|
43
|
+
COMMENT_BLOCK_END = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\r(?!\n)|\r?\n(?![ \t]*#)/
|
|
44
|
+
|
|
45
|
+
BARE_KEY = /#{BARE_KEY_SRC}/
|
|
46
|
+
KEY_EQUALS = /(#{BARE_KEY_SRC})[ \t]*=[ \t]*/
|
|
47
|
+
INLINE_KEY_EQUALS = /[ \t]*(#{BARE_KEY_SRC})[ \t]*=[ \t]*/
|
|
48
|
+
TABLE_HEADER = /\[#{TABLE_NAME_SRC}\]#{EOL_SRC}/
|
|
49
|
+
ARRAY_TABLE_HEADER = /\[\[#{TABLE_NAME_SRC}\]\]#{EOL_SRC}/
|
|
50
|
+
TABLE_CLOSE = /\]#{EOL_SRC}/
|
|
51
|
+
ARRAY_TABLE_CLOSE = /\]\]#{EOL_SRC}/
|
|
52
|
+
|
|
53
|
+
SIMPLE_BASIC_STRING_EOL = /#{PLAIN_BASIC_STRING_SRC}#{EOL_SRC}/
|
|
54
|
+
# A plain string array element with its comma and the whitespace after it, or
|
|
55
|
+
# the last element before `]` (closing after a value or after a trailing comma
|
|
56
|
+
# is the same thing, so the loop needs no record of which it was). The leading
|
|
57
|
+
# whitespace is atomic: giving it back a character at a time would rerun the
|
|
58
|
+
# lookahead over the rest of it, quadratic in the run's length.
|
|
59
|
+
ELEMENT_END = '(?>[ \t]*)(?:,[ \t]*(?:\r?\n[ \t]*)?|(?=[ \t\r\n]*\]))'
|
|
60
|
+
STRING_ELEMENT = /#{PLAIN_BASIC_STRING_SRC}#{ELEMENT_END}/
|
|
61
|
+
# The same for an integer; the separator rules out a float or date continuing it.
|
|
62
|
+
INTEGER_ELEMENT = /(#{INTEGER_SRC})#{ELEMENT_END}/
|
|
63
|
+
STRING_SEGMENT = /[^"\\#{CTRL}]*/
|
|
64
|
+
# Searched for, not matched: what ends a multi-line basic string's plain run
|
|
65
|
+
# (three quotes, a backslash, a control character or a lone CR). Single and
|
|
66
|
+
# double quotes stay inside the run.
|
|
67
|
+
MULTILINE_STRING_STOP = /"""|[\\\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\r(?!\n)/
|
|
68
|
+
UNICODE_ESCAPE = /\\(?:u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8}))/
|
|
69
|
+
# A backslash ending a line drops the line break and all whitespace after it.
|
|
70
|
+
LINE_ENDING_BACKSLASH = /\\[ \t]*\r?\n(?:[ \t\n]|\r\n)*/
|
|
71
|
+
LITERAL_STRING = /#{PLAIN_LITERAL_STRING_SRC}/
|
|
72
|
+
LITERAL_STRING_EOL = /#{PLAIN_LITERAL_STRING_SRC}#{EOL_SRC}/
|
|
73
|
+
# The same for a multi-line literal string, which has no escapes.
|
|
74
|
+
MULTILINE_LITERAL_STRING_STOP = /'''|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\r(?!\n)/
|
|
75
|
+
|
|
76
|
+
# What follows a float's integer part: a fraction, an exponent, or both.
|
|
77
|
+
FLOAT_TAIL_SRC = "(?:\\.[0-9](?:_?[0-9])*(?:[eE][+-]?[0-9](?:_?[0-9])*)?|[eE][+-]?[0-9](?:_?[0-9])*)"
|
|
78
|
+
FLOAT_SRC = "#{INTEGER_SRC}#{FLOAT_TAIL_SRC}"
|
|
79
|
+
INTEGER = /#{INTEGER_SRC}/
|
|
80
|
+
INTEGER_EOL = /(#{INTEGER_SRC})#{EOL_SRC}/
|
|
81
|
+
FLOAT_TAIL = /#{FLOAT_TAIL_SRC}/
|
|
82
|
+
FLOAT_EOL = /(#{FLOAT_SRC})#{EOL_SRC}/
|
|
83
|
+
# A plain string, integer or boolean inline table value together with its
|
|
84
|
+
# comma, or up to the `}` after it; the caller tells which by the last byte
|
|
85
|
+
# consumed. A float, date or anything else continuing the value fails it.
|
|
86
|
+
INLINE_VALUE_END = '(?>[ \t]*)(?:,|(?=\}))'
|
|
87
|
+
INLINE_STRING = /#{PLAIN_BASIC_STRING_SRC}#{INLINE_VALUE_END}/
|
|
88
|
+
INLINE_INTEGER = /(#{INTEGER_SRC})#{INLINE_VALUE_END}/
|
|
89
|
+
INLINE_TRUE = /true#{INLINE_VALUE_END}/
|
|
90
|
+
INLINE_FALSE = /false#{INLINE_VALUE_END}/
|
|
91
|
+
NON_DECIMAL_INTEGER = /0(?:x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|o[0-7](?:_?[0-7])*|b[01](?:_?[01])*)/
|
|
92
|
+
FLOAT_KEYWORD = /[+-]?(?:inf|nan)/
|
|
93
|
+
TRUE_KEYWORD = /true/
|
|
94
|
+
FALSE_KEYWORD = /false/
|
|
95
|
+
TRUE_EOL = /true#{EOL_SRC}/
|
|
96
|
+
FALSE_EOL = /false#{EOL_SRC}/
|
|
97
|
+
# No captures: the fields sit at fixed offsets from the match start and are
|
|
98
|
+
# read as bytes (see #datetime), saving a String and 600-800 instructions each.
|
|
99
|
+
TIME_SRC_V1_0 = '\d{2}:\d{2}:\d{2}(?:\.\d+)?'
|
|
100
|
+
TIME_SRC_V1_1 = '\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?' # seconds optional
|
|
101
|
+
LOCAL_TIME_V1_0 = /#{TIME_SRC_V1_0}/
|
|
102
|
+
LOCAL_TIME_V1_1 = /#{TIME_SRC_V1_1}/
|
|
103
|
+
DATETIME_V1_0 = /\d{4}-\d{2}-\d{2}(?:[Tt ]#{TIME_SRC_V1_0}(?:[Zz]|[-+]\d{2}:\d{2})?)?/
|
|
104
|
+
DATETIME_V1_1 = /\d{4}-\d{2}-\d{2}(?:[Tt ]#{TIME_SRC_V1_1}(?:[Zz]|[-+]\d{2}:\d{2})?)?/
|
|
105
|
+
|
|
106
|
+
def self.byte_table(classes)
|
|
107
|
+
table = Array.new(256)
|
|
108
|
+
classes.each { |chars, klass| chars.each_byte { |b| table[b] = klass } }
|
|
109
|
+
table.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Escape character byte -> its value.
|
|
113
|
+
ESCAPES_V1_0 = byte_table("b" => "\b", "t" => "\t", "n" => "\n", "f" => "\f", "r" => "\r", '"' => '"', "\\" => "\\")
|
|
114
|
+
ESCAPES_V1_1 = ESCAPES_V1_0.dup.tap { |table| table["e".ord] = "\e" }.freeze
|
|
115
|
+
HEX_ESCAPE = /\\x([0-9A-Fa-f]{2})/
|
|
116
|
+
|
|
117
|
+
# What differs between the TOML versions read. 1.1 only relaxes 1.0: a valid
|
|
118
|
+
# 1.0 document means the same under either, 1.0 just rejects more.
|
|
119
|
+
Spec = Data.define(
|
|
120
|
+
:local_time, # seconds required in 1.0, optional in 1.1
|
|
121
|
+
:datetime, # likewise
|
|
122
|
+
:escapes, # escape character byte -> its value (1.1 adds \e)
|
|
123
|
+
:hex_escapes, # \xHH (1.1)
|
|
124
|
+
:inline_newlines # newlines, comments and a trailing comma in inline tables (1.1)
|
|
125
|
+
)
|
|
126
|
+
SPECS = {
|
|
127
|
+
"1.0" => Spec.new(local_time: LOCAL_TIME_V1_0, datetime: DATETIME_V1_0, escapes: ESCAPES_V1_0,
|
|
128
|
+
hex_escapes: false, inline_newlines: false),
|
|
129
|
+
"1.1" => Spec.new(local_time: LOCAL_TIME_V1_1, datetime: DATETIME_V1_1, escapes: ESCAPES_V1_1,
|
|
130
|
+
hex_escapes: true, inline_newlines: true)
|
|
131
|
+
}.freeze
|
|
132
|
+
|
|
133
|
+
LBRACKET = "[".ord
|
|
134
|
+
RBRACKET = "]".ord
|
|
135
|
+
LBRACE = "{".ord
|
|
136
|
+
RBRACE = "}".ord
|
|
137
|
+
COMMA = ",".ord
|
|
138
|
+
DOT = ".".ord
|
|
139
|
+
COLON = ":".ord
|
|
140
|
+
DASH = "-".ord
|
|
141
|
+
ZERO = "0".ord
|
|
142
|
+
HASH = "#".ord
|
|
143
|
+
BACKSLASH = "\\".ord
|
|
144
|
+
NEWLINE = "\n".ord
|
|
145
|
+
CR = "\r".ord
|
|
146
|
+
SPACE = " ".ord
|
|
147
|
+
TAB = "\t".ord
|
|
148
|
+
DQUOTE = '"'.ord
|
|
149
|
+
SQUOTE = "'".ord
|
|
150
|
+
LOWER_E = "e".ord
|
|
151
|
+
PLUS = "+".ord
|
|
152
|
+
UPPER_Z = "Z".ord
|
|
153
|
+
LOWER_Z = "z".ord
|
|
154
|
+
UPPER_E = "E".ord
|
|
155
|
+
LOWER_I = "i".ord
|
|
156
|
+
LOWER_N = "n".ord
|
|
157
|
+
LOWER_T = "t".ord
|
|
158
|
+
LOWER_F = "f".ord
|
|
159
|
+
NON_DECIMAL_BASES = {"x".ord => 16, "o".ord => 8, "b".ord => 2}.freeze
|
|
160
|
+
|
|
161
|
+
GAP_START = byte_table(" \t\n\r#" => true)
|
|
162
|
+
LINE_BREAK_OR_COMMENT = byte_table("\n\r#" => true)
|
|
163
|
+
# Regex for an inline table value that can be fused with its separator.
|
|
164
|
+
INLINE_SCALAR = byte_table('"' => INLINE_STRING, "0123456789+-" => INLINE_INTEGER, "t" => INLINE_TRUE,
|
|
165
|
+
"f" => INLINE_FALSE)
|
|
166
|
+
NUMBER_START = byte_table("0123456789+-" => true)
|
|
167
|
+
VALUE_START = byte_table(
|
|
168
|
+
'"' => :basic_string, "'" => :literal_string, "0123456789" => :digit, "+-" => :sign,
|
|
169
|
+
"t" => :true_keyword, "f" => :false_keyword, "in" => :inf_or_nan, "[" => :array, "{" => :inline_table
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# @param version ["1.1", "1.0"] the TOML spec to follow; "1.0" also rejects what 1.1 added
|
|
173
|
+
def initialize(src, version: "1.1", symbolize_keys: false, **)
|
|
174
|
+
spec = SPECS.fetch(version) do
|
|
175
|
+
raise ArgumentError, "Unsupported TOML version #{version.inspect} (supported: #{SPECS.keys.join(", ")})"
|
|
176
|
+
end
|
|
177
|
+
if src.encoding != Encoding::UTF_8
|
|
178
|
+
src = (src.encoding == Encoding::BINARY) ? src.dup.force_encoding(Encoding::UTF_8) : src.encode(Encoding::UTF_8)
|
|
179
|
+
end
|
|
180
|
+
raise ParseError, "Invalid UTF-8 in document" unless src.valid_encoding?
|
|
181
|
+
|
|
182
|
+
@src = src
|
|
183
|
+
@ss = StringScanner.new(src)
|
|
184
|
+
@symbolize_keys = symbolize_keys ? true : false
|
|
185
|
+
@local_time = spec.local_time
|
|
186
|
+
@datetime = spec.datetime
|
|
187
|
+
@escapes = spec.escapes
|
|
188
|
+
@hex_escapes = spec.hex_escapes
|
|
189
|
+
@inline_newlines = spec.inline_newlines
|
|
190
|
+
@root = {}
|
|
191
|
+
@current = @root
|
|
192
|
+
@kinds = {}.compare_by_identity
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse
|
|
196
|
+
ss = @ss
|
|
197
|
+
src = @src
|
|
198
|
+
while (byte = src.getbyte(ss.pos))
|
|
199
|
+
if GAP_START[byte]
|
|
200
|
+
byte = skip_gap
|
|
201
|
+
break unless byte
|
|
202
|
+
end
|
|
203
|
+
(byte == LBRACKET) ? header : key_value
|
|
204
|
+
end
|
|
205
|
+
@root
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
# Statements
|
|
211
|
+
|
|
212
|
+
# Keys are frozen before insertion: Hash#[]= would otherwise intern each
|
|
213
|
+
# unfrozen String key through the fstring table, hashing it twice.
|
|
214
|
+
def key_value
|
|
215
|
+
ss = @ss
|
|
216
|
+
if ss.skip(KEY_EQUALS)
|
|
217
|
+
key = ss[1]
|
|
218
|
+
key = @symbolize_keys ? key.to_sym : key.freeze
|
|
219
|
+
value = line_value
|
|
220
|
+
table = @current
|
|
221
|
+
conflict([key]) if table.key?(key)
|
|
222
|
+
table[key] = value
|
|
223
|
+
else
|
|
224
|
+
keys = key_path
|
|
225
|
+
error("Expected '=' after key") unless ss.skip(EQUALS)
|
|
226
|
+
define(@current, keys, line_value)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A value that must be followed by the end of its line; the common scalars
|
|
231
|
+
# are matched together with that line end in one regex.
|
|
232
|
+
def line_value
|
|
233
|
+
ss = @ss
|
|
234
|
+
byte = @src.getbyte(ss.pos)
|
|
235
|
+
case VALUE_START[byte || 0]
|
|
236
|
+
when :basic_string
|
|
237
|
+
return ss[1] if ss.skip(SIMPLE_BASIC_STRING_EOL)
|
|
238
|
+
when :literal_string
|
|
239
|
+
return ss[1] if ss.skip(LITERAL_STRING_EOL)
|
|
240
|
+
when :digit
|
|
241
|
+
unless date_or_time_ahead?
|
|
242
|
+
return ss[1].to_i if ss.skip(INTEGER_EOL)
|
|
243
|
+
return ss[1].to_f if ss.skip(FLOAT_EOL)
|
|
244
|
+
end
|
|
245
|
+
when :sign
|
|
246
|
+
# `+inf` and `-nan` fail both and fall through to `scalar`.
|
|
247
|
+
return ss[1].to_i if ss.skip(INTEGER_EOL)
|
|
248
|
+
return ss[1].to_f if ss.skip(FLOAT_EOL)
|
|
249
|
+
when :true_keyword
|
|
250
|
+
return true if ss.skip(TRUE_EOL)
|
|
251
|
+
when :false_keyword
|
|
252
|
+
return false if ss.skip(FALSE_EOL)
|
|
253
|
+
when :array, :inline_table
|
|
254
|
+
value = container(byte)
|
|
255
|
+
error("Expected end of line after value") unless ss.skip(EOL)
|
|
256
|
+
return value
|
|
257
|
+
end
|
|
258
|
+
value = scalar(byte)
|
|
259
|
+
error("Expected end of line after value") unless ss.skip(EOL)
|
|
260
|
+
value
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def header
|
|
264
|
+
ss = @ss
|
|
265
|
+
array = @src.getbyte(ss.pos + 1) == LBRACKET
|
|
266
|
+
if ss.skip(array ? ARRAY_TABLE_HEADER : TABLE_HEADER)
|
|
267
|
+
# Frozen only where a table is created: most header keys just look up an existing one.
|
|
268
|
+
name = ss[1]
|
|
269
|
+
keys = name.include?(".") ? name.split(".") : [name]
|
|
270
|
+
keys.map!(&:to_sym) if @symbolize_keys
|
|
271
|
+
else
|
|
272
|
+
ss.pos += array ? 2 : 1
|
|
273
|
+
skip_spaces
|
|
274
|
+
keys = key_path
|
|
275
|
+
error(array ? "Expected ']]' after array of tables name" : "Expected ']' after table name") unless
|
|
276
|
+
ss.skip(array ? ARRAY_TABLE_CLOSE : TABLE_CLOSE)
|
|
277
|
+
end
|
|
278
|
+
array ? open_array_of_tables(keys) : open_table(keys)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def key_path
|
|
282
|
+
ss = @ss
|
|
283
|
+
src = @src
|
|
284
|
+
keys = []
|
|
285
|
+
while true
|
|
286
|
+
key =
|
|
287
|
+
case src.getbyte(ss.pos)
|
|
288
|
+
when DQUOTE then single_line_basic_string
|
|
289
|
+
when SQUOTE then ss.skip(LITERAL_STRING) ? ss[1] : error("Invalid quoted key")
|
|
290
|
+
else ss.scan(BARE_KEY) || error("Expected a key")
|
|
291
|
+
end
|
|
292
|
+
keys << (@symbolize_keys ? key.to_sym : key.freeze)
|
|
293
|
+
return keys unless skip_spaces == DOT
|
|
294
|
+
|
|
295
|
+
ss.pos += 1
|
|
296
|
+
skip_spaces
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Whitespace
|
|
301
|
+
|
|
302
|
+
# Skips spaces and tabs; returns the next byte.
|
|
303
|
+
def skip_spaces
|
|
304
|
+
ss = @ss
|
|
305
|
+
src = @src
|
|
306
|
+
pos = ss.pos
|
|
307
|
+
byte = src.getbyte(pos)
|
|
308
|
+
return byte unless byte == SPACE || byte == TAB
|
|
309
|
+
|
|
310
|
+
# A single space (`a = 1, b = 2`) is far more common than a run; skip it without a regex.
|
|
311
|
+
byte = src.getbyte(pos + 1)
|
|
312
|
+
if byte == SPACE || byte == TAB
|
|
313
|
+
ss.skip(SPACES)
|
|
314
|
+
src.getbyte(ss.pos)
|
|
315
|
+
else
|
|
316
|
+
ss.pos = pos + 1
|
|
317
|
+
byte
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Skips whitespace, newlines and comments; returns the next byte.
|
|
322
|
+
def skip_gap
|
|
323
|
+
ss = @ss
|
|
324
|
+
src = @src
|
|
325
|
+
while (byte = src.getbyte(ss.pos))
|
|
326
|
+
if byte == HASH
|
|
327
|
+
skip_comment
|
|
328
|
+
elsif byte == SPACE && !GAP_START[src.getbyte(ss.pos + 1) || 0]
|
|
329
|
+
ss.pos += 1
|
|
330
|
+
elsif GAP_START[byte]
|
|
331
|
+
ss.skip(WHITESPACE) || error("Carriage return without line feed")
|
|
332
|
+
else
|
|
333
|
+
return byte
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def skip_comment
|
|
340
|
+
ss = @ss
|
|
341
|
+
unless ss.skip_until(COMMENT_BLOCK_END)
|
|
342
|
+
ss.terminate
|
|
343
|
+
return
|
|
344
|
+
end
|
|
345
|
+
return if @src.getbyte(ss.pos - 1) == NEWLINE
|
|
346
|
+
|
|
347
|
+
ss.pos -= 1
|
|
348
|
+
error("Control character in comment")
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Values
|
|
352
|
+
|
|
353
|
+
def scalar(byte)
|
|
354
|
+
ss = @ss
|
|
355
|
+
case VALUE_START[byte || 0]
|
|
356
|
+
when :basic_string then basic_string
|
|
357
|
+
when :literal_string then literal_string
|
|
358
|
+
when :digit then digit_value
|
|
359
|
+
when :sign
|
|
360
|
+
next_byte = @src.getbyte(ss.pos + 1)
|
|
361
|
+
(next_byte == LOWER_I || next_byte == LOWER_N) ? float_keyword : number
|
|
362
|
+
when :true_keyword then ss.skip(TRUE_KEYWORD) ? true : error("Invalid value")
|
|
363
|
+
when :false_keyword then ss.skip(FALSE_KEYWORD) ? false : error("Invalid value")
|
|
364
|
+
when :inf_or_nan then float_keyword
|
|
365
|
+
else error(byte ? "Invalid value" : "Expected a value")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Parses the array or inline table opening at the current position. Enclosing
|
|
370
|
+
# containers are kept on `stack` as (container, pending keys) pairs, keys nil
|
|
371
|
+
# for an array; it stays nil for a flat container, the common case.
|
|
372
|
+
# `expecting_value` is true right after an opening bracket or a comma. In TOML 1.0
|
|
373
|
+
# an inline table may only close there when it is still empty (no trailing comma).
|
|
374
|
+
def container(byte)
|
|
375
|
+
ss = @ss
|
|
376
|
+
src = @src
|
|
377
|
+
stack = nil
|
|
378
|
+
ss.pos += 1
|
|
379
|
+
in_array = byte == LBRACKET
|
|
380
|
+
current = in_array ? [] : {}
|
|
381
|
+
keys = nil
|
|
382
|
+
expecting_value = true
|
|
383
|
+
|
|
384
|
+
while true
|
|
385
|
+
if in_array
|
|
386
|
+
byte = src.getbyte(ss.pos)
|
|
387
|
+
byte = skip_gap if byte && GAP_START[byte]
|
|
388
|
+
if expecting_value
|
|
389
|
+
if byte == DQUOTE
|
|
390
|
+
if ss.skip(STRING_ELEMENT)
|
|
391
|
+
current << ss[1]
|
|
392
|
+
next
|
|
393
|
+
end
|
|
394
|
+
elsif NUMBER_START[byte || 0] && ss.skip(INTEGER_ELEMENT)
|
|
395
|
+
current << ss[1].to_i
|
|
396
|
+
next
|
|
397
|
+
end
|
|
398
|
+
if byte != RBRACKET
|
|
399
|
+
if byte == LBRACKET || byte == LBRACE
|
|
400
|
+
(stack ||= []).push(current, nil)
|
|
401
|
+
ss.pos += 1
|
|
402
|
+
in_array = byte == LBRACKET
|
|
403
|
+
current = in_array ? [] : {}
|
|
404
|
+
next
|
|
405
|
+
end
|
|
406
|
+
current << scalar(byte)
|
|
407
|
+
expecting_value = false
|
|
408
|
+
next
|
|
409
|
+
end
|
|
410
|
+
elsif byte == COMMA
|
|
411
|
+
ss.pos += 1
|
|
412
|
+
expecting_value = true
|
|
413
|
+
next
|
|
414
|
+
elsif byte != RBRACKET
|
|
415
|
+
error("Expected , or ] in array")
|
|
416
|
+
end
|
|
417
|
+
else
|
|
418
|
+
# TOML 1.1 lets inline tables span lines. Plain spaces are left to the
|
|
419
|
+
# cheaper paths below; skip_gap only runs before a line break or comment.
|
|
420
|
+
if @inline_newlines
|
|
421
|
+
byte = src.getbyte(ss.pos)
|
|
422
|
+
skip_gap if byte && LINE_BREAK_OR_COMMENT[byte]
|
|
423
|
+
end
|
|
424
|
+
if expecting_value && ss.skip(INLINE_KEY_EQUALS)
|
|
425
|
+
key = ss[1]
|
|
426
|
+
key = @symbolize_keys ? key.to_sym : key.freeze
|
|
427
|
+
byte = src.getbyte(ss.pos)
|
|
428
|
+
if byte == LBRACKET || byte == LBRACE
|
|
429
|
+
(stack ||= []).push(current, [key])
|
|
430
|
+
ss.pos += 1
|
|
431
|
+
in_array = byte == LBRACKET
|
|
432
|
+
current = in_array ? [] : {}
|
|
433
|
+
next
|
|
434
|
+
end
|
|
435
|
+
conflict([key]) if current.key?(key)
|
|
436
|
+
if (fused = INLINE_SCALAR[byte || 0]) && ss.skip(fused)
|
|
437
|
+
current[key] =
|
|
438
|
+
case byte
|
|
439
|
+
when DQUOTE then ss[1]
|
|
440
|
+
when LOWER_T then true
|
|
441
|
+
when LOWER_F then false
|
|
442
|
+
else ss[1].to_i
|
|
443
|
+
end
|
|
444
|
+
# The separator either took a comma or stopped before `}`.
|
|
445
|
+
expecting_value = src.getbyte(ss.pos - 1) == COMMA
|
|
446
|
+
else
|
|
447
|
+
current[key] = scalar(byte)
|
|
448
|
+
expecting_value = false
|
|
449
|
+
end
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
byte = skip_spaces
|
|
453
|
+
byte = skip_gap if @inline_newlines && byte && LINE_BREAK_OR_COMMENT[byte]
|
|
454
|
+
if expecting_value
|
|
455
|
+
if byte != RBRACE || !(@inline_newlines || current.empty?)
|
|
456
|
+
keys = key_path
|
|
457
|
+
error("Expected '=' in inline table") unless ss.skip(EQUALS)
|
|
458
|
+
byte = src.getbyte(ss.pos)
|
|
459
|
+
if byte == LBRACKET || byte == LBRACE
|
|
460
|
+
(stack ||= []).push(current, keys)
|
|
461
|
+
ss.pos += 1
|
|
462
|
+
in_array = byte == LBRACKET
|
|
463
|
+
current = in_array ? [] : {}
|
|
464
|
+
next
|
|
465
|
+
end
|
|
466
|
+
define(current, keys, scalar(byte))
|
|
467
|
+
expecting_value = false
|
|
468
|
+
next
|
|
469
|
+
end
|
|
470
|
+
elsif byte == COMMA
|
|
471
|
+
ss.pos += 1
|
|
472
|
+
expecting_value = true
|
|
473
|
+
next
|
|
474
|
+
elsif byte != RBRACE
|
|
475
|
+
error("Expected , or } in inline table")
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Close the current container and hand it to its parent.
|
|
480
|
+
ss.pos += 1
|
|
481
|
+
value = current
|
|
482
|
+
return value if stack.nil? || stack.empty?
|
|
483
|
+
|
|
484
|
+
keys = stack.pop
|
|
485
|
+
current = stack.pop
|
|
486
|
+
if (in_array = keys.nil?)
|
|
487
|
+
current << value
|
|
488
|
+
else
|
|
489
|
+
define(current, keys, value)
|
|
490
|
+
end
|
|
491
|
+
expecting_value = false
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def basic_string
|
|
496
|
+
triple_quote?(DQUOTE) ? multiline_string(DQUOTE, MULTILINE_STRING_STOP) : single_line_basic_string
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Reads plain runs and escapes straight off the scanner: one match per run and
|
|
500
|
+
# a table lookup per simple escape, instead of matching the whole string and
|
|
501
|
+
# rescanning it with gsub.
|
|
502
|
+
def single_line_basic_string
|
|
503
|
+
ss = @ss
|
|
504
|
+
src = @src
|
|
505
|
+
ss.pos += 1
|
|
506
|
+
out = nil
|
|
507
|
+
while true
|
|
508
|
+
segment = ss.scan(STRING_SEGMENT)
|
|
509
|
+
out = out ? out << segment : segment
|
|
510
|
+
byte = src.getbyte(ss.pos)
|
|
511
|
+
if byte == DQUOTE
|
|
512
|
+
ss.pos += 1
|
|
513
|
+
return out
|
|
514
|
+
end
|
|
515
|
+
error("Unterminated string") unless byte == BACKSLASH
|
|
516
|
+
|
|
517
|
+
append_escape(out, false)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The multi-line string, basic (`quote` is DQUOTE) or literal, opening at the
|
|
522
|
+
# current position. Plain runs, quotes short of three included, are found by
|
|
523
|
+
# searching for what ends them: unlike an anchored match, the search neither
|
|
524
|
+
# walks every byte through the regex VM nor grows Onigmo's stack with the run
|
|
525
|
+
# (48 MB for a 1 MB string). Up to two quotes before the closing three are
|
|
526
|
+
# content (`'''a'''''` is `a''`).
|
|
527
|
+
def multiline_string(quote, stop)
|
|
528
|
+
ss = @ss
|
|
529
|
+
src = @src
|
|
530
|
+
pos = ss.pos + 3
|
|
531
|
+
byte = src.getbyte(pos)
|
|
532
|
+
if byte == NEWLINE
|
|
533
|
+
pos += 1
|
|
534
|
+
elsif byte == CR && src.getbyte(pos + 1) == NEWLINE
|
|
535
|
+
pos += 2
|
|
536
|
+
end
|
|
537
|
+
ss.pos = pos
|
|
538
|
+
out = nil
|
|
539
|
+
while true
|
|
540
|
+
start = ss.pos
|
|
541
|
+
unless ss.skip_until(stop)
|
|
542
|
+
ss.terminate
|
|
543
|
+
unterminated_multiline_string(quote)
|
|
544
|
+
end
|
|
545
|
+
pos = ss.pos - ss.matched_size
|
|
546
|
+
byte = src.getbyte(pos)
|
|
547
|
+
if byte == quote
|
|
548
|
+
pos += 1 if src.getbyte(pos + 3) == quote
|
|
549
|
+
pos += 1 if src.getbyte(pos + 3) == quote
|
|
550
|
+
end
|
|
551
|
+
segment = src.byteslice(start, pos - start)
|
|
552
|
+
out = out ? out << segment : segment
|
|
553
|
+
if byte == quote
|
|
554
|
+
ss.pos = pos + 3
|
|
555
|
+
return out
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
ss.pos = pos
|
|
559
|
+
unterminated_multiline_string(quote) unless byte == BACKSLASH
|
|
560
|
+
append_escape(out, true)
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def unterminated_multiline_string(quote)
|
|
565
|
+
error((quote == DQUOTE) ? "Unterminated multi-line string" : "Unterminated multi-line literal string")
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# Appends the escape sequence at the current backslash to `out`.
|
|
569
|
+
def append_escape(out, multiline)
|
|
570
|
+
ss = @ss
|
|
571
|
+
byte = @src.getbyte(ss.pos + 1)
|
|
572
|
+
if byte && (char = @escapes[byte])
|
|
573
|
+
ss.pos += 2
|
|
574
|
+
out << char
|
|
575
|
+
elsif ss.skip(UNICODE_ESCAPE)
|
|
576
|
+
out << (ss[1] || ss[2]).to_i(16).chr(Encoding::UTF_8)
|
|
577
|
+
elsif @hex_escapes && ss.skip(HEX_ESCAPE)
|
|
578
|
+
out << ss[1].to_i(16).chr(Encoding::UTF_8)
|
|
579
|
+
elsif !(multiline && ss.skip(LINE_ENDING_BACKSLASH))
|
|
580
|
+
error("Invalid escape sequence")
|
|
581
|
+
end
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
def literal_string
|
|
585
|
+
ss = @ss
|
|
586
|
+
if triple_quote?(SQUOTE)
|
|
587
|
+
multiline_string(SQUOTE, MULTILINE_LITERAL_STRING_STOP)
|
|
588
|
+
elsif ss.skip(LITERAL_STRING)
|
|
589
|
+
ss[1]
|
|
590
|
+
else
|
|
591
|
+
error("Invalid or unterminated literal string")
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def triple_quote?(quote)
|
|
596
|
+
pos = @ss.pos
|
|
597
|
+
@src.getbyte(pos + 1) == quote && @src.getbyte(pos + 2) == quote
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# LOCAL_TIME needs `HH:` and DATETIME `YYYY-`; the separator byte decides
|
|
601
|
+
# which (if any) applies without a failing regex match.
|
|
602
|
+
def date_or_time_ahead?
|
|
603
|
+
pos = @ss.pos
|
|
604
|
+
@src.getbyte(pos + 2) == COLON || @src.getbyte(pos + 4) == DASH
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# The separator guards can fire on a short number followed by more lines
|
|
608
|
+
# (`3\n\n[-]`), so a failed date/time match falls back to a number.
|
|
609
|
+
def digit_value
|
|
610
|
+
ss = @ss
|
|
611
|
+
pos = ss.pos
|
|
612
|
+
if @src.getbyte(pos + 2) == COLON && ss.skip(@local_time)
|
|
613
|
+
LocalTime.new(two_digits(pos), two_digits(pos + 3), seconds(pos + 5, ss.pos))
|
|
614
|
+
elsif @src.getbyte(pos + 4) == DASH && ss.skip(@datetime)
|
|
615
|
+
datetime(pos, ss.pos)
|
|
616
|
+
else
|
|
617
|
+
number
|
|
618
|
+
end
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
# INTEGER runs first because most numbers are integers; a following `.`, `e`
|
|
622
|
+
# or `E` means it only matched the integer part of a float, whose tail is then
|
|
623
|
+
# scanned from there. Nothing is materialized until the kind is known.
|
|
624
|
+
def number
|
|
625
|
+
ss = @ss
|
|
626
|
+
src = @src
|
|
627
|
+
pos = ss.pos
|
|
628
|
+
if src.getbyte(pos) == ZERO && (base = NON_DECIMAL_BASES[src.getbyte(pos + 1)])
|
|
629
|
+
text = ss.scan(NON_DECIMAL_INTEGER) || error("Invalid integer")
|
|
630
|
+
return text.to_i(base)
|
|
631
|
+
end
|
|
632
|
+
error("Invalid number") unless ss.skip(INTEGER)
|
|
633
|
+
next_byte = src.getbyte(ss.pos)
|
|
634
|
+
return ss.matched.to_i unless next_byte == DOT || next_byte == LOWER_E || next_byte == UPPER_E
|
|
635
|
+
|
|
636
|
+
unless ss.skip(FLOAT_TAIL)
|
|
637
|
+
ss.pos = pos
|
|
638
|
+
error("Invalid float")
|
|
639
|
+
end
|
|
640
|
+
src.byteslice(pos, ss.pos - pos).to_f
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def float_keyword
|
|
644
|
+
text = @ss.scan(FLOAT_KEYWORD) || error("Invalid value")
|
|
645
|
+
if text.end_with?("nan")
|
|
646
|
+
Float::NAN
|
|
647
|
+
else
|
|
648
|
+
text.start_with?("-") ? -Float::INFINITY : Float::INFINITY
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# The date-time matched from `pos` to `stop`. Fields are range-checked as
|
|
653
|
+
# Integers before Time.new, which would otherwise silently roll an
|
|
654
|
+
# out-of-range day or 24:00:00 over.
|
|
655
|
+
def datetime(pos, stop)
|
|
656
|
+
src = @src
|
|
657
|
+
year = two_digits(pos) * 100 + two_digits(pos + 2)
|
|
658
|
+
month = two_digits(pos + 5)
|
|
659
|
+
day = two_digits(pos + 8)
|
|
660
|
+
return LocalDate.new(year, month, day) if stop == pos + 10
|
|
661
|
+
|
|
662
|
+
hour = two_digits(pos + 11)
|
|
663
|
+
min = two_digits(pos + 14)
|
|
664
|
+
last = src.getbyte(stop - 1)
|
|
665
|
+
if last == UPPER_Z || last == LOWER_Z
|
|
666
|
+
time_end = stop - 1
|
|
667
|
+
offset = 0 # the same Time as "+00:00", without parsing it
|
|
668
|
+
elsif (sign = src.getbyte(stop - 6)) == PLUS || sign == DASH
|
|
669
|
+
time_end = stop - 6
|
|
670
|
+
offset = src.byteslice(time_end, 6)
|
|
671
|
+
else
|
|
672
|
+
return LocalDateTime.new(year, month, day, hour, min, seconds(pos + 16, stop))
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
sec = seconds(pos + 16, time_end)
|
|
676
|
+
unless LocalDate.valid_date?(year, month, day) && hour < 24 && min < 60 && sec < 60
|
|
677
|
+
raise ArgumentError, "Invalid Offset Date-Time: #{src.byteslice(pos, stop - pos)}"
|
|
678
|
+
end
|
|
679
|
+
|
|
680
|
+
Time.new(year, month, day, hour, min, sec, offset)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def two_digits(pos)
|
|
684
|
+
src = @src
|
|
685
|
+
src.getbyte(pos) * 10 + src.getbyte(pos + 1) - 528 # "00" is 48 * 10 + 48
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Seconds of a time whose minutes end at `pos` and which ends at `stop`: none
|
|
689
|
+
# (TOML 1.1 `hh:mm`), `:ss`, or `:ss.fraction`. Fractions are exact (`56.123`
|
|
690
|
+
# stays 56123/1000; a Float would make it 56.122999...); whole seconds stay
|
|
691
|
+
# Integers, which Time handles fastest.
|
|
692
|
+
def seconds(pos, stop)
|
|
693
|
+
if stop == pos
|
|
694
|
+
0
|
|
695
|
+
elsif stop == pos + 3
|
|
696
|
+
two_digits(pos + 1)
|
|
697
|
+
else
|
|
698
|
+
Rational(@src.byteslice(pos + 1, stop - pos - 1))
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Tables
|
|
703
|
+
|
|
704
|
+
def open_table(keys)
|
|
705
|
+
parent = header_parent(keys)
|
|
706
|
+
key = keys.last
|
|
707
|
+
table = parent[key]
|
|
708
|
+
if table.nil?
|
|
709
|
+
table = parent[key.freeze] = {}
|
|
710
|
+
@kinds[table] = HEADER
|
|
711
|
+
elsif @kinds[table] == IMPLICIT
|
|
712
|
+
@kinds[table] = HEADER
|
|
713
|
+
else
|
|
714
|
+
conflict(keys)
|
|
715
|
+
end
|
|
716
|
+
@current = table
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def open_array_of_tables(keys)
|
|
720
|
+
parent = header_parent(keys)
|
|
721
|
+
key = keys.last
|
|
722
|
+
tables = parent[key]
|
|
723
|
+
element = {}
|
|
724
|
+
@kinds[element] = HEADER
|
|
725
|
+
if tables.nil?
|
|
726
|
+
tables = parent[key.freeze] = [element]
|
|
727
|
+
@kinds[tables] = ARRAY_OF_TABLES
|
|
728
|
+
elsif @kinds[tables] == ARRAY_OF_TABLES
|
|
729
|
+
tables << element
|
|
730
|
+
else
|
|
731
|
+
conflict(keys)
|
|
732
|
+
end
|
|
733
|
+
@current = element
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
# Walks `[a.b.c]` up to `b`, creating implicit tables and descending into the
|
|
737
|
+
# latest element of arrays of tables.
|
|
738
|
+
def header_parent(keys)
|
|
739
|
+
table = @root
|
|
740
|
+
last = keys.length - 1
|
|
741
|
+
i = 0
|
|
742
|
+
while i < last
|
|
743
|
+
key = keys[i]
|
|
744
|
+
child = table[key]
|
|
745
|
+
if child.nil?
|
|
746
|
+
child = table[key.freeze] = {}
|
|
747
|
+
@kinds[child] = IMPLICIT
|
|
748
|
+
else
|
|
749
|
+
case @kinds[child]
|
|
750
|
+
when ARRAY_OF_TABLES then child = child.last
|
|
751
|
+
when nil then conflict(keys)
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
table = child
|
|
755
|
+
i += 1
|
|
756
|
+
end
|
|
757
|
+
table
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def define(table, keys, value)
|
|
761
|
+
last = keys.length - 1
|
|
762
|
+
i = 0
|
|
763
|
+
while i < last
|
|
764
|
+
key = keys[i]
|
|
765
|
+
child = table[key]
|
|
766
|
+
if child.nil?
|
|
767
|
+
child = table[key] = {}
|
|
768
|
+
@kinds[child] = DOTTED
|
|
769
|
+
elsif @kinds[child] != DOTTED
|
|
770
|
+
conflict(keys)
|
|
771
|
+
end
|
|
772
|
+
table = child
|
|
773
|
+
i += 1
|
|
774
|
+
end
|
|
775
|
+
conflict(keys) if table.key?(keys[last])
|
|
776
|
+
|
|
777
|
+
table[keys[last]] = value
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# Errors
|
|
781
|
+
|
|
782
|
+
def conflict(keys)
|
|
783
|
+
raise ParseError, "Key #{keys.join(".")} is already defined"
|
|
784
|
+
end
|
|
16
785
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
786
|
+
# Columns count characters; the scanner position is a byte offset.
|
|
787
|
+
def error(message)
|
|
788
|
+
pos = @ss.pos
|
|
789
|
+
before = @src.byteslice(0, pos)
|
|
790
|
+
line_start = before.byterindex("\n")
|
|
791
|
+
line_start = line_start ? line_start + 1 : 0
|
|
792
|
+
column = before.byteslice(line_start, pos - line_start).length + 1
|
|
793
|
+
raise ParseError, "#{message} at line #{before.count("\n") + 1}, column #{column}"
|
|
794
|
+
end
|
|
20
795
|
end
|
|
21
796
|
end
|