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.
data/lib/tomlrb/parser.rb CHANGED
@@ -1,21 +1,796 @@
1
- # frozen-string-literal: true
1
+ # frozen_string_literal: true
2
2
 
3
- require 'tomlrb/generated_parser'
3
+ require "strscan"
4
4
 
5
- class Tomlrb::Parser < Tomlrb::GeneratedParser
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
- def initialize(tokenizer, **options)
8
- @tokenizer = tokenizer
9
- @handler = Tomlrb::Handler.new(**options)
10
- super()
11
- end
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
- def next_token
14
- @tokenizer.next_token
15
- end
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
- def parse
18
- do_parse
19
- @handler
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