@decimalturn/toml-patch 0.6.0 → 0.7.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.
- package/README.md +6 -12
- package/dist/toml-patch.cjs.min.js +2 -2
- package/dist/toml-patch.cjs.min.js.map +1 -1
- package/dist/toml-patch.d.ts +1 -1
- package/dist/toml-patch.es.js +1118 -186
- package/dist/toml-patch.umd.min.js +2 -2
- package/dist/toml-patch.umd.min.js.map +1 -1
- package/package.json +2 -2
package/dist/toml-patch.es.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//! @decimalturn/toml-patch v0.
|
|
1
|
+
//! @decimalturn/toml-patch v0.7.0 - https://github.com/DecimalTurn/toml-patch - @license: MIT
|
|
2
2
|
var NodeType;
|
|
3
3
|
(function (NodeType) {
|
|
4
4
|
NodeType["Document"] = "Document";
|
|
@@ -82,6 +82,9 @@ function isBlock(node) {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function iterator(value) {
|
|
85
|
+
if (typeof value === 'string') {
|
|
86
|
+
return utf16Iterator(value);
|
|
87
|
+
}
|
|
85
88
|
return value[Symbol.iterator]();
|
|
86
89
|
}
|
|
87
90
|
/**
|
|
@@ -139,6 +142,18 @@ class Cursor {
|
|
|
139
142
|
function done() {
|
|
140
143
|
return { value: undefined, done: true };
|
|
141
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Creates a UTF-16 code unit iterator for a string.
|
|
147
|
+
* This is necessary because cursor.index is used to access string positions
|
|
148
|
+
* via input[cursor.index] and input.slice() throughout the tokenizer.
|
|
149
|
+
* While Symbol.iterator yields code points (better for humans), we need
|
|
150
|
+
* UTF-16 indices for correct string access in JavaScript.
|
|
151
|
+
*/
|
|
152
|
+
function* utf16Iterator(str) {
|
|
153
|
+
for (let i = 0; i < str.length; i++) {
|
|
154
|
+
yield str[i];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
142
157
|
|
|
143
158
|
function getSpan(location) {
|
|
144
159
|
return {
|
|
@@ -174,19 +189,29 @@ function findPosition(input, index) {
|
|
|
174
189
|
}
|
|
175
190
|
function getLine$1(input, position) {
|
|
176
191
|
const lines = findLines(input);
|
|
177
|
-
const start = lines[position.line - 2]
|
|
192
|
+
const start = lines[position.line - 2] !== undefined ? lines[position.line - 2] + 1 : 0;
|
|
178
193
|
const end = lines[position.line - 1] || input.length;
|
|
179
194
|
return input.substring(start, end);
|
|
180
195
|
}
|
|
181
196
|
function findLines(input) {
|
|
182
|
-
// exec is stateful, so create new regexp each time
|
|
183
|
-
const BY_NEW_LINE = /\r\n|\n/g;
|
|
184
197
|
const indexes = [];
|
|
185
|
-
let
|
|
186
|
-
|
|
187
|
-
|
|
198
|
+
for (let i = 0; i < input.length; i++) {
|
|
199
|
+
const char = input[i];
|
|
200
|
+
if (char === '\n') {
|
|
201
|
+
indexes.push(i);
|
|
202
|
+
}
|
|
203
|
+
else if (char === '\r') {
|
|
204
|
+
// Handle \r\n as a single line break
|
|
205
|
+
if (input[i + 1] === '\n') {
|
|
206
|
+
indexes.push(i + 1); // Position after \r\n
|
|
207
|
+
i++; // Skip the \n
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
indexes.push(i);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
188
213
|
}
|
|
189
|
-
indexes.push(input.length
|
|
214
|
+
indexes.push(input.length);
|
|
190
215
|
return indexes;
|
|
191
216
|
}
|
|
192
217
|
function clonePosition(position) {
|
|
@@ -235,7 +260,6 @@ var TokenType;
|
|
|
235
260
|
TokenType["Literal"] = "Literal";
|
|
236
261
|
})(TokenType || (TokenType = {}));
|
|
237
262
|
const IS_WHITESPACE = /\s/;
|
|
238
|
-
const IS_NEW_LINE = /(\r\n|\n)/;
|
|
239
263
|
const DOUBLE_QUOTE = `"`;
|
|
240
264
|
const SINGLE_QUOTE = `'`;
|
|
241
265
|
const SPACE = ' ';
|
|
@@ -246,6 +270,19 @@ function* tokenize(input) {
|
|
|
246
270
|
cursor.next();
|
|
247
271
|
const locate = createLocate(input);
|
|
248
272
|
while (!cursor.done) {
|
|
273
|
+
const code = cursor.value.charCodeAt(0);
|
|
274
|
+
// TOML does not allow ASCII control characters other than HT (TAB), LF, and CR.
|
|
275
|
+
// CR is only allowed as part of CRLF and is validated below.
|
|
276
|
+
if ((code <= 0x1f || code === 0x7f) && code !== 0x09 && code !== 0x0d && code !== 0x0a) {
|
|
277
|
+
throw new ParseError(input, findPosition(input, cursor.index), `Control character 0x${code.toString(16).toUpperCase().padStart(2, '0')} is not allowed in TOML`);
|
|
278
|
+
}
|
|
279
|
+
// CR (0x0D) is only allowed as part of CRLF
|
|
280
|
+
if (cursor.value === '\r') {
|
|
281
|
+
const next = cursor.peek();
|
|
282
|
+
if (next.done || next.value !== '\n') {
|
|
283
|
+
throw new ParseError(input, findPosition(input, cursor.index), 'Invalid standalone CR (\\r); CR must be part of a CRLF sequence');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
249
286
|
if (IS_WHITESPACE.test(cursor.value)) ;
|
|
250
287
|
else if (cursor.value === '[' || cursor.value === ']') {
|
|
251
288
|
// Handle special characters: [, ], {, }, =, comma
|
|
@@ -265,7 +302,7 @@ function* tokenize(input) {
|
|
|
265
302
|
}
|
|
266
303
|
else if (cursor.value === '#') {
|
|
267
304
|
// Handle comments = # -> EOL
|
|
268
|
-
yield comment$1(cursor, locate);
|
|
305
|
+
yield comment$1(cursor, locate, input);
|
|
269
306
|
}
|
|
270
307
|
else {
|
|
271
308
|
const multiline_char = checkThree(input, cursor.index, SINGLE_QUOTE) ||
|
|
@@ -284,11 +321,19 @@ function* tokenize(input) {
|
|
|
284
321
|
function specialCharacter(cursor, locate, type) {
|
|
285
322
|
return { type, raw: cursor.value, loc: locate(cursor.index, cursor.index + 1) };
|
|
286
323
|
}
|
|
287
|
-
function comment$1(cursor, locate) {
|
|
324
|
+
function comment$1(cursor, locate, input) {
|
|
288
325
|
const start = cursor.index;
|
|
289
326
|
let raw = cursor.value;
|
|
290
|
-
|
|
327
|
+
// TOML comment ends at CR or LF.
|
|
328
|
+
while (!cursor.peek().done &&
|
|
329
|
+
cursor.peek().value !== '\n' &&
|
|
330
|
+
cursor.peek().value !== '\r') {
|
|
291
331
|
cursor.next();
|
|
332
|
+
const code = cursor.value.charCodeAt(0);
|
|
333
|
+
// Disallow ASCII control characters in comments (except HT / TAB).
|
|
334
|
+
if ((code <= 0x1f || code === 0x7f) && code !== 0x09) {
|
|
335
|
+
throw new ParseError(input, findPosition(input, cursor.index), `Control character 0x${code.toString(16).toUpperCase().padStart(2, '0')} is not allowed in TOML`);
|
|
336
|
+
}
|
|
292
337
|
raw += cursor.value;
|
|
293
338
|
}
|
|
294
339
|
// Early exit is ok for comment, no closing conditions
|
|
@@ -300,19 +345,76 @@ function comment$1(cursor, locate) {
|
|
|
300
345
|
}
|
|
301
346
|
function multiline(cursor, locate, multiline_char, input) {
|
|
302
347
|
const start = cursor.index;
|
|
303
|
-
|
|
348
|
+
const quotes = multiline_char + multiline_char + multiline_char;
|
|
304
349
|
let raw = quotes;
|
|
305
|
-
// Skip over quotes
|
|
350
|
+
// Skip over opening quotes
|
|
306
351
|
cursor.next();
|
|
307
352
|
cursor.next();
|
|
308
353
|
cursor.next();
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
354
|
+
// Multiline strings close on the first unescaped """ / '''.
|
|
355
|
+
// A run of 4 or 5 quote characters at the end is allowed to include 1 or 2 quotes
|
|
356
|
+
// immediately before the closing delimiter, but 6+ consecutive quotes is invalid.
|
|
357
|
+
while (!cursor.done) {
|
|
358
|
+
const found = checkThree(input, cursor.index, multiline_char);
|
|
359
|
+
if (found) {
|
|
360
|
+
let runLength = 3;
|
|
361
|
+
while (input[cursor.index + runLength] === multiline_char) {
|
|
362
|
+
runLength++;
|
|
363
|
+
}
|
|
364
|
+
if (runLength >= 6) {
|
|
365
|
+
throw new ParseError(input, findPosition(input, cursor.index), `Invalid multiline string: ${runLength} consecutive ${multiline_char} characters`);
|
|
366
|
+
}
|
|
367
|
+
if (runLength === 3) {
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
// runLength is 4 or 5: keep the leading 1 or 2 quote chars as content,
|
|
371
|
+
// and close on the last 3.
|
|
372
|
+
raw += multiline_char.repeat(runLength - 3);
|
|
373
|
+
for (let i = 0; i < runLength - 3; i++) {
|
|
374
|
+
cursor.next();
|
|
375
|
+
}
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
if (cursor.value === '\r') {
|
|
379
|
+
const next = cursor.peek();
|
|
380
|
+
if (next.done || next.value !== '\n') {
|
|
381
|
+
throw new ParseError(input, findPosition(input, cursor.index), 'Invalid standalone CR (\\r) in multiline string (must be part of CRLF sequence)');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Validate control characters in multiline strings
|
|
385
|
+
const code = cursor.value.charCodeAt(0);
|
|
386
|
+
// In multiline strings, control characters are not allowed except tab (0x09), LF (0x0A), and CR (0x0D as part of CRLF)
|
|
387
|
+
// DEL (0x7F) is also not allowed
|
|
388
|
+
if ((code <= 0x1f || code === 0x7f) && code !== 0x09 && code !== 0x0a && code !== 0x0d) {
|
|
389
|
+
const stringType = multiline_char === DOUBLE_QUOTE ? 'multiline basic strings' : 'multiline literal strings';
|
|
390
|
+
const hexCode = `0x${code.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
391
|
+
// Provide friendly names for common control characters
|
|
392
|
+
let charName = '';
|
|
393
|
+
if (code === 0x00) {
|
|
394
|
+
charName = 'Null';
|
|
395
|
+
}
|
|
396
|
+
else if (code === 0x7f) {
|
|
397
|
+
charName = 'DEL';
|
|
398
|
+
}
|
|
399
|
+
const message = charName
|
|
400
|
+
? `${charName} (control character ${hexCode}) is not allowed in ${stringType}`
|
|
401
|
+
: `Control character ${hexCode} is not allowed in ${stringType}`;
|
|
402
|
+
throw new ParseError(input, findPosition(input, cursor.index), message);
|
|
403
|
+
}
|
|
312
404
|
raw += cursor.value;
|
|
313
405
|
cursor.next();
|
|
314
406
|
}
|
|
315
407
|
if (cursor.done) {
|
|
408
|
+
// Check if the issue might be caused by escape sequences preventing proper closure
|
|
409
|
+
// For multiline basic strings ("""), check if there are backslashes near the end that might be escaping quotes
|
|
410
|
+
if (multiline_char === DOUBLE_QUOTE) {
|
|
411
|
+
// Check the last few characters before EOF for patterns like \""" or \"
|
|
412
|
+
const precedingText = input.slice(0, cursor.index);
|
|
413
|
+
const hasEscapedQuotes = /\\"+$/.test(precedingText);
|
|
414
|
+
if (hasEscapedQuotes) {
|
|
415
|
+
throw new ParseError(input, findPosition(input, cursor.index), `Expected close of multiline string with ${quotes}, reached end of file. Check for escape sequences (\\) that may be preventing proper string closure`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
316
418
|
throw new ParseError(input, findPosition(input, cursor.index), `Expected close of multiline string with ${quotes}, reached end of file`);
|
|
317
419
|
}
|
|
318
420
|
raw += quotes;
|
|
@@ -367,6 +469,35 @@ function string$1(cursor, locate, input) {
|
|
|
367
469
|
};
|
|
368
470
|
while (!cursor.done && !isFinished(cursor)) {
|
|
369
471
|
cursor.next();
|
|
472
|
+
// Validate control characters in quoted strings
|
|
473
|
+
if (double_quoted || single_quoted) {
|
|
474
|
+
const code = cursor.value.charCodeAt(0);
|
|
475
|
+
// In basic strings (double-quoted) and literal strings (single-quoted),
|
|
476
|
+
// control characters are not allowed except tab (0x09)
|
|
477
|
+
// DEL (0x7F) is also not allowed
|
|
478
|
+
if ((code <= 0x1f || code === 0x7f) && code !== 0x09) {
|
|
479
|
+
const stringType = double_quoted ? 'basic strings' : 'literal strings';
|
|
480
|
+
const hexCode = `0x${code.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
481
|
+
// Provide friendly names for common control characters
|
|
482
|
+
let charName = '';
|
|
483
|
+
if (code === 0x0a) {
|
|
484
|
+
charName = 'Newline';
|
|
485
|
+
}
|
|
486
|
+
else if (code === 0x0d) {
|
|
487
|
+
charName = 'Carriage return';
|
|
488
|
+
}
|
|
489
|
+
else if (code === 0x00) {
|
|
490
|
+
charName = 'Null';
|
|
491
|
+
}
|
|
492
|
+
else if (code === 0x7f) {
|
|
493
|
+
charName = 'DEL';
|
|
494
|
+
}
|
|
495
|
+
const message = charName
|
|
496
|
+
? `${charName} (control character ${hexCode}) is not allowed in ${stringType}`
|
|
497
|
+
: `Control character ${hexCode} is not allowed in ${stringType}`;
|
|
498
|
+
throw new ParseError(input, findPosition(input, cursor.index), message);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
370
501
|
if (cursor.value === DOUBLE_QUOTE)
|
|
371
502
|
double_quoted = !double_quoted;
|
|
372
503
|
if (cursor.value === SINGLE_QUOTE && !double_quoted)
|
|
@@ -430,15 +561,6 @@ function checkThree(input, current, check) {
|
|
|
430
561
|
const isEscaped = backslashes[0].length % 2 !== 0; // Odd number of backslashes means escaped
|
|
431
562
|
return isEscaped ? false : check; // Return `check` if not escaped, otherwise `false`
|
|
432
563
|
}
|
|
433
|
-
function CheckMoreThanThree(input, current, check) {
|
|
434
|
-
if (!check) {
|
|
435
|
-
return false;
|
|
436
|
-
}
|
|
437
|
-
return (input[current] === check &&
|
|
438
|
-
input[current + 1] === check &&
|
|
439
|
-
input[current + 2] === check &&
|
|
440
|
-
input[current + 3] === check);
|
|
441
|
-
}
|
|
442
564
|
|
|
443
565
|
function last(values) {
|
|
444
566
|
return values[values.length - 1];
|
|
@@ -577,6 +699,40 @@ function isBackslashEscaped(source, backslashOffset) {
|
|
|
577
699
|
return precedingBackslashes % 2 !== 0;
|
|
578
700
|
}
|
|
579
701
|
function unescapeLargeUnicode(escaped) {
|
|
702
|
+
// First, validate all escape sequences are valid TOML escapes
|
|
703
|
+
// Valid TOML escape sequences: \b \t \n \f \r \" \\ \uXXXX \UXXXXXXXX \xHH (1.1.0) \e (1.1.0)
|
|
704
|
+
const ESCAPE_VALIDATION = /\\(.)/g;
|
|
705
|
+
let match;
|
|
706
|
+
while ((match = ESCAPE_VALIDATION.exec(escaped)) !== null) {
|
|
707
|
+
const offset = match.index;
|
|
708
|
+
if (isBackslashEscaped(escaped, offset)) {
|
|
709
|
+
continue; // This backslash is itself escaped, so skip
|
|
710
|
+
}
|
|
711
|
+
const escapeChar = match[1];
|
|
712
|
+
// Valid single-char escapes: b, t, n, f, r, ", \, e
|
|
713
|
+
// Valid multi-char escapes: u (followed by 4 hex), U (followed by 8 hex), x (followed by 2 hex)
|
|
714
|
+
const validEscapes = ['b', 't', 'n', 'f', 'r', '"', '\\', 'e', 'u', 'U', 'x'];
|
|
715
|
+
if (!validEscapes.includes(escapeChar)) {
|
|
716
|
+
throw new Error(`Invalid escape sequence: \\${escapeChar}`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// Validate \uXXXX sequences don't use surrogate codepoints (0xD800-0xDFFF)
|
|
720
|
+
const SMALL_UNICODE = /\\u([a-fA-F0-9]{4})/g;
|
|
721
|
+
const smallUnicodeSource = escaped;
|
|
722
|
+
while ((match = SMALL_UNICODE.exec(smallUnicodeSource)) !== null) {
|
|
723
|
+
const offset = match.index;
|
|
724
|
+
if (isBackslashEscaped(smallUnicodeSource, offset)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const hex = match[1];
|
|
728
|
+
const codePoint = parseInt(hex, 16);
|
|
729
|
+
// Surrogate pair range: 0xD800-0xDFFF
|
|
730
|
+
// High surrogates: 0xD800-0xDBFF
|
|
731
|
+
// Low surrogates: 0xDC00-0xDFFF
|
|
732
|
+
if (codePoint >= 0xD800 && codePoint <= 0xDFFF) {
|
|
733
|
+
throw new Error(`Invalid Unicode escape: \\u${hex} (surrogate codepoints are not allowed)`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
580
736
|
// TOML 1.1.0: Handle \xHH hex escapes (for codepoints < 255)
|
|
581
737
|
const HEX_ESCAPE = /\\x([a-fA-F0-9]{2})/g;
|
|
582
738
|
const hexEscapeSource = escaped;
|
|
@@ -825,13 +981,14 @@ class DateFormatHelper {
|
|
|
825
981
|
// Patterns for different date/time formats
|
|
826
982
|
DateFormatHelper.IS_DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
827
983
|
DateFormatHelper.IS_TIME_ONLY = /^\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
|
|
828
|
-
DateFormatHelper.IS_LOCAL_DATETIME_T = /^\d{4}-\d{2}-\d{2}
|
|
984
|
+
DateFormatHelper.IS_LOCAL_DATETIME_T = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
|
|
829
985
|
DateFormatHelper.IS_LOCAL_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
|
|
830
|
-
DateFormatHelper.IS_OFFSET_DATETIME_T = /^\d{4}-\d{2}-\d{2}
|
|
986
|
+
DateFormatHelper.IS_OFFSET_DATETIME_T = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
831
987
|
DateFormatHelper.IS_OFFSET_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
|
|
832
988
|
// Legacy patterns from parse-toml.ts (for compatibility)
|
|
833
|
-
|
|
834
|
-
DateFormatHelper.
|
|
989
|
+
// Made more permissive to catch malformed dates (e.g., 1987-7-05) for validation
|
|
990
|
+
DateFormatHelper.IS_FULL_DATE = /(\d{4})-(\d+)-(\d+)/;
|
|
991
|
+
DateFormatHelper.IS_FULL_TIME = /(\d+):(\d+)(?::(\d+))?/;
|
|
835
992
|
/**
|
|
836
993
|
* Custom Date class for local dates (date-only).
|
|
837
994
|
* Format: 1979-05-27
|
|
@@ -1004,16 +1161,19 @@ const TRUE = 'true';
|
|
|
1004
1161
|
const FALSE = 'false';
|
|
1005
1162
|
const HAS_E = /e/i;
|
|
1006
1163
|
const IS_DIVIDER = /\_/g;
|
|
1007
|
-
const IS_INF =
|
|
1008
|
-
const IS_NAN =
|
|
1009
|
-
const IS_HEX = /^0x
|
|
1010
|
-
const IS_OCTAL = /^0o
|
|
1011
|
-
const IS_BINARY = /^0b
|
|
1164
|
+
const IS_INF = /^[+\-]?inf$/;
|
|
1165
|
+
const IS_NAN = /^[+\-]?nan$/;
|
|
1166
|
+
const IS_HEX = /^[+\-]?0x/i;
|
|
1167
|
+
const IS_OCTAL = /^[+\-]?0o/i;
|
|
1168
|
+
const IS_BINARY = /^[+\-]?0b/i;
|
|
1012
1169
|
function* parseTOML(input) {
|
|
1013
|
-
|
|
1014
|
-
const cursor = new Cursor(
|
|
1170
|
+
// Use non-generator parsing to avoid stack overflow on deeply nested structures
|
|
1171
|
+
const cursor = new Cursor(tokenize(input));
|
|
1015
1172
|
while (!cursor.next().done) {
|
|
1016
|
-
|
|
1173
|
+
const blocks = walkBlock(cursor, input);
|
|
1174
|
+
for (const block of blocks) {
|
|
1175
|
+
yield block;
|
|
1176
|
+
}
|
|
1017
1177
|
}
|
|
1018
1178
|
}
|
|
1019
1179
|
/**
|
|
@@ -1028,59 +1188,14 @@ function* continueParsingTOML(existingAst, remainingString) {
|
|
|
1028
1188
|
for (const item of existingAst) {
|
|
1029
1189
|
yield item;
|
|
1030
1190
|
}
|
|
1031
|
-
// Parse and yield all items from the remaining string
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
if (cursor.value.type === TokenType.Comment) {
|
|
1038
|
-
yield comment(cursor);
|
|
1039
|
-
}
|
|
1040
|
-
else if (cursor.value.type === TokenType.Bracket) {
|
|
1041
|
-
yield table(cursor, input);
|
|
1042
|
-
}
|
|
1043
|
-
else if (cursor.value.type === TokenType.Literal) {
|
|
1044
|
-
yield* keyValue(cursor, input);
|
|
1045
|
-
}
|
|
1046
|
-
else {
|
|
1047
|
-
throw new ParseError(input, cursor.value.loc.start, `Unexpected token "${cursor.value.type}". Expected Comment, Bracket, or String`);
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
function* walkValue$1(cursor, input) {
|
|
1051
|
-
if (cursor.value.type === TokenType.Literal) {
|
|
1052
|
-
if (cursor.value.raw[0] === DOUBLE_QUOTE || cursor.value.raw[0] === SINGLE_QUOTE) {
|
|
1053
|
-
yield string(cursor);
|
|
1054
|
-
}
|
|
1055
|
-
else if (cursor.value.raw === TRUE || cursor.value.raw === FALSE) {
|
|
1056
|
-
yield boolean(cursor);
|
|
1057
|
-
}
|
|
1058
|
-
else if (dateFormatHelper.IS_FULL_DATE.test(cursor.value.raw) || dateFormatHelper.IS_FULL_TIME.test(cursor.value.raw)) {
|
|
1059
|
-
yield datetime(cursor, input);
|
|
1060
|
-
}
|
|
1061
|
-
else if ((!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) ||
|
|
1062
|
-
IS_INF.test(cursor.value.raw) ||
|
|
1063
|
-
IS_NAN.test(cursor.value.raw) ||
|
|
1064
|
-
(HAS_E.test(cursor.value.raw) && !IS_HEX.test(cursor.value.raw))) {
|
|
1065
|
-
yield float(cursor, input);
|
|
1066
|
-
}
|
|
1067
|
-
else {
|
|
1068
|
-
yield integer(cursor);
|
|
1191
|
+
// Parse and yield all items from the remaining string using non-generator path
|
|
1192
|
+
const cursor = new Cursor(tokenize(remainingString));
|
|
1193
|
+
while (!cursor.next().done) {
|
|
1194
|
+
const blocks = walkBlock(cursor, remainingString);
|
|
1195
|
+
for (const block of blocks) {
|
|
1196
|
+
yield block;
|
|
1069
1197
|
}
|
|
1070
1198
|
}
|
|
1071
|
-
else if (cursor.value.type === TokenType.Curly) {
|
|
1072
|
-
const [inline_table, comments] = inlineTable(cursor, input);
|
|
1073
|
-
yield inline_table;
|
|
1074
|
-
yield* comments;
|
|
1075
|
-
}
|
|
1076
|
-
else if (cursor.value.type === TokenType.Bracket) {
|
|
1077
|
-
const [inline_array, comments] = inlineArray(cursor, input);
|
|
1078
|
-
yield inline_array;
|
|
1079
|
-
yield* comments;
|
|
1080
|
-
}
|
|
1081
|
-
else {
|
|
1082
|
-
throw new ParseError(input, cursor.value.loc.start, `Unrecognized token type "${cursor.value.type}". Expected String, Curly, or Bracket`);
|
|
1083
|
-
}
|
|
1084
1199
|
}
|
|
1085
1200
|
function comment(cursor) {
|
|
1086
1201
|
// # line comment
|
|
@@ -1114,8 +1229,22 @@ function table(cursor, input) {
|
|
|
1114
1229
|
if (is_table && cursor.value.raw !== '[') {
|
|
1115
1230
|
throw new ParseError(input, cursor.value.loc.start, `Expected table opening "[", found ${cursor.value.raw}`);
|
|
1116
1231
|
}
|
|
1117
|
-
if (!is_table
|
|
1118
|
-
|
|
1232
|
+
if (!is_table) {
|
|
1233
|
+
const next = cursor.peek();
|
|
1234
|
+
if (next.done) {
|
|
1235
|
+
throw new ParseError(input, cursor.value.loc.start, 'Expected second "[" for array of tables opening, found end of input');
|
|
1236
|
+
}
|
|
1237
|
+
if (cursor.value.raw !== '[' || next.value.raw !== '[') {
|
|
1238
|
+
throw new ParseError(input, cursor.value.loc.start, `Expected array of tables opening "[[", found ${cursor.value.raw + next.value.raw}`);
|
|
1239
|
+
}
|
|
1240
|
+
// Validate that table array brackets are immediately adjacent (no whitespace)
|
|
1241
|
+
const firstBracket = cursor.value;
|
|
1242
|
+
const secondBracket = next.value;
|
|
1243
|
+
// Check if brackets are on the same line and adjacent columns
|
|
1244
|
+
if (firstBracket.loc.end.line !== secondBracket.loc.start.line ||
|
|
1245
|
+
firstBracket.loc.end.column !== secondBracket.loc.start.column) {
|
|
1246
|
+
throw new ParseError(input, firstBracket.loc.start, 'Array of tables opening brackets must be immediately adjacent with no whitespace: [[table]]');
|
|
1247
|
+
}
|
|
1119
1248
|
}
|
|
1120
1249
|
// Set start location from opening tag
|
|
1121
1250
|
const key = is_table
|
|
@@ -1134,23 +1263,84 @@ function table(cursor, input) {
|
|
|
1134
1263
|
if (cursor.done) {
|
|
1135
1264
|
throw new ParseError(input, key.loc.start, `Expected table key, reached end of file`);
|
|
1136
1265
|
}
|
|
1266
|
+
// Check if the table/array name is empty (e.g., [[]] or [])
|
|
1267
|
+
if (cursor.value.type === TokenType.Bracket && cursor.value.raw === ']') {
|
|
1268
|
+
throw new ParseError(input, cursor.value.loc.start, type === NodeType.TableArray
|
|
1269
|
+
? 'Array of tables header [[]] requires a table name'
|
|
1270
|
+
: 'Table header [] requires a table name');
|
|
1271
|
+
}
|
|
1272
|
+
// Validate that multiline strings are not used as table keys
|
|
1273
|
+
const raw = cursor.value.raw;
|
|
1274
|
+
{
|
|
1275
|
+
if (raw.startsWith('"""') || raw.startsWith("'''")) {
|
|
1276
|
+
throw new ParseError(input, cursor.value.loc.start, 'Multiline strings (""" or \'\'\') cannot be used as keys');
|
|
1277
|
+
}
|
|
1278
|
+
// Validate bare key characters (TOML 1.1.0: A-Za-z0-9_- only)
|
|
1279
|
+
const isQuoted = raw.startsWith('"') || raw.startsWith("'");
|
|
1280
|
+
if (!isQuoted) {
|
|
1281
|
+
for (let i = 0; i < raw.length; i++) {
|
|
1282
|
+
const char = raw[i];
|
|
1283
|
+
if (!/[A-Za-z0-9_-]/.test(char)) {
|
|
1284
|
+
throw new ParseError(input, { line: cursor.value.loc.start.line, column: cursor.value.loc.start.column + i }, `Invalid character '${char}' in bare key. Bare keys can only contain A-Z, a-z, 0-9, _, and -`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
let keyValue;
|
|
1290
|
+
try {
|
|
1291
|
+
keyValue = [parseString(cursor.value.raw)];
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
const e = err;
|
|
1295
|
+
throw new ParseError(input, cursor.value.loc.start, e.message);
|
|
1296
|
+
}
|
|
1137
1297
|
key.item = {
|
|
1138
1298
|
type: NodeType.Key,
|
|
1139
1299
|
loc: cloneLocation(cursor.value.loc),
|
|
1140
1300
|
raw: cursor.value.raw,
|
|
1141
|
-
value:
|
|
1301
|
+
value: keyValue
|
|
1142
1302
|
};
|
|
1143
1303
|
while (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
|
|
1144
1304
|
cursor.next();
|
|
1145
1305
|
const dot = cursor.value;
|
|
1146
1306
|
cursor.next();
|
|
1307
|
+
// Validate each part of a dotted table key
|
|
1308
|
+
const partRaw = cursor.value.raw;
|
|
1309
|
+
const partIsQuoted = partRaw.startsWith('"') || partRaw.startsWith("'");
|
|
1310
|
+
if (!partIsQuoted) {
|
|
1311
|
+
for (let i = 0; i < partRaw.length; i++) {
|
|
1312
|
+
const char = partRaw[i];
|
|
1313
|
+
if (!/[A-Za-z0-9_-]/.test(char)) {
|
|
1314
|
+
throw new ParseError(input, { line: cursor.value.loc.start.line, column: cursor.value.loc.start.column + i }, `Invalid character '${char}' in bare key. Bare keys can only contain A-Z, a-z, 0-9, _, and -`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1147
1318
|
const before = ' '.repeat(dot.loc.start.column - key.item.loc.end.column);
|
|
1148
1319
|
const after = ' '.repeat(cursor.value.loc.start.column - dot.loc.end.column);
|
|
1149
1320
|
key.item.loc.end = cursor.value.loc.end;
|
|
1150
1321
|
key.item.raw += `${before}.${after}${cursor.value.raw}`;
|
|
1151
|
-
|
|
1322
|
+
try {
|
|
1323
|
+
key.item.value.push(parseString(cursor.value.raw));
|
|
1324
|
+
}
|
|
1325
|
+
catch (err) {
|
|
1326
|
+
const e = err;
|
|
1327
|
+
throw new ParseError(input, cursor.value.loc.start, e.message);
|
|
1328
|
+
}
|
|
1152
1329
|
}
|
|
1153
1330
|
cursor.next();
|
|
1331
|
+
// Table headers must not contain newlines - all parts must be on the same line
|
|
1332
|
+
// Example invalid TOML (table/newline-01): [tbl\n]
|
|
1333
|
+
// Example invalid TOML (table/newline-03): ["tbl"\n]
|
|
1334
|
+
if (!cursor.done) {
|
|
1335
|
+
const headerStartLine = is_table
|
|
1336
|
+
? key.loc.start.line
|
|
1337
|
+
: key.loc.start.line; // Both use the opening bracket line
|
|
1338
|
+
if (cursor.value.loc.start.line !== headerStartLine) {
|
|
1339
|
+
throw new ParseError(input, cursor.value.loc.start, is_table
|
|
1340
|
+
? `Table header must not contain newlines. Expected closing ']' on line ${headerStartLine}, found on line ${cursor.value.loc.start.line}`
|
|
1341
|
+
: `Unclosed array of tables header: expected closing ']]' on line ${headerStartLine}, found newline`);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1154
1344
|
if (is_table && (cursor.done || cursor.value.raw !== ']')) {
|
|
1155
1345
|
throw new ParseError(input, cursor.done ? key.item.loc.end : cursor.value.loc.start, `Expected table closing "]", found ${cursor.done ? 'end of file' : cursor.value.raw}`);
|
|
1156
1346
|
}
|
|
@@ -1163,15 +1353,37 @@ function table(cursor, input) {
|
|
|
1163
1353
|
? 'end of file'
|
|
1164
1354
|
: cursor.value.raw + cursor.peek().value.raw}`);
|
|
1165
1355
|
}
|
|
1356
|
+
// Validate that table array closing brackets are immediately adjacent (no whitespace)
|
|
1357
|
+
if (!is_table) {
|
|
1358
|
+
const firstBracket = cursor.value;
|
|
1359
|
+
const secondBracket = cursor.peek().value;
|
|
1360
|
+
// Check if brackets are on the same line and adjacent columns
|
|
1361
|
+
if (firstBracket.loc.end.line !== secondBracket.loc.start.line ||
|
|
1362
|
+
firstBracket.loc.end.column !== secondBracket.loc.start.column) {
|
|
1363
|
+
throw new ParseError(input, firstBracket.loc.start, 'Array of tables closing brackets must be immediately adjacent with no whitespace: ]]');
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1166
1366
|
// Set end location from closing tag
|
|
1167
1367
|
if (!is_table)
|
|
1168
1368
|
cursor.next();
|
|
1169
1369
|
key.loc.end = cursor.value.loc.end;
|
|
1370
|
+
// Table/array headers must be alone on their line - nothing can follow the closing bracket(s)
|
|
1371
|
+
// Example invalid TOML (key/after-table): [error] this = "should not be here"
|
|
1372
|
+
// Example invalid TOML (key/after-array): [[agencies]] owner = "S Cjelli"
|
|
1373
|
+
if (!cursor.peek().done) {
|
|
1374
|
+
const nextToken = cursor.peek().value;
|
|
1375
|
+
// Check if there's content on the same line after the closing bracket
|
|
1376
|
+
// Comments are the only thing allowed on the same line
|
|
1377
|
+
if (nextToken.loc.start.line === key.loc.end.line &&
|
|
1378
|
+
nextToken.type !== TokenType.Comment) {
|
|
1379
|
+
throw new ParseError(input, nextToken.loc.start, `Unexpected content after ${is_table ? 'table' : 'array of tables'} header`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1170
1382
|
// Add child items
|
|
1171
1383
|
let items = [];
|
|
1172
1384
|
while (!cursor.peek().done && cursor.peek().value.type !== TokenType.Bracket) {
|
|
1173
1385
|
cursor.next();
|
|
1174
|
-
merge(items,
|
|
1386
|
+
merge(items, walkBlock(cursor, input));
|
|
1175
1387
|
}
|
|
1176
1388
|
return {
|
|
1177
1389
|
type: is_table ? NodeType.Table : NodeType.TableArray,
|
|
@@ -1185,56 +1397,20 @@ function table(cursor, input) {
|
|
|
1185
1397
|
items
|
|
1186
1398
|
};
|
|
1187
1399
|
}
|
|
1188
|
-
function
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
// ^-^ key
|
|
1193
|
-
// ^ equals
|
|
1194
|
-
// ^---^ value
|
|
1195
|
-
const key = {
|
|
1196
|
-
type: NodeType.Key,
|
|
1197
|
-
loc: cloneLocation(cursor.value.loc),
|
|
1198
|
-
raw: cursor.value.raw,
|
|
1199
|
-
value: [parseString(cursor.value.raw)]
|
|
1200
|
-
};
|
|
1201
|
-
while (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
|
|
1202
|
-
cursor.next();
|
|
1203
|
-
cursor.next();
|
|
1204
|
-
key.loc.end = cursor.value.loc.end;
|
|
1205
|
-
key.raw += `.${cursor.value.raw}`;
|
|
1206
|
-
key.value.push(parseString(cursor.value.raw));
|
|
1207
|
-
}
|
|
1208
|
-
cursor.next();
|
|
1209
|
-
if (cursor.done || cursor.value.type !== TokenType.Equal) {
|
|
1210
|
-
throw new ParseError(input, cursor.done ? key.loc.end : cursor.value.loc.start, `Expected "=" for key-value, found ${cursor.done ? 'end of file' : cursor.value.raw}`);
|
|
1400
|
+
function string(cursor, input) {
|
|
1401
|
+
let value;
|
|
1402
|
+
try {
|
|
1403
|
+
value = parseString(cursor.value.raw);
|
|
1211
1404
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
throw new ParseError(input, key.loc.start, `Expected value for key-value, reached end of file`);
|
|
1405
|
+
catch (err) {
|
|
1406
|
+
const e = err;
|
|
1407
|
+
throw new ParseError(input, cursor.value.loc.start, e.message);
|
|
1216
1408
|
}
|
|
1217
|
-
const [value, ...comments] = walkValue$1(cursor, input);
|
|
1218
|
-
return [
|
|
1219
|
-
{
|
|
1220
|
-
type: NodeType.KeyValue,
|
|
1221
|
-
key,
|
|
1222
|
-
value: value,
|
|
1223
|
-
loc: {
|
|
1224
|
-
start: clonePosition(key.loc.start),
|
|
1225
|
-
end: clonePosition(value.loc.end)
|
|
1226
|
-
},
|
|
1227
|
-
equals
|
|
1228
|
-
},
|
|
1229
|
-
...comments
|
|
1230
|
-
];
|
|
1231
|
-
}
|
|
1232
|
-
function string(cursor) {
|
|
1233
1409
|
return {
|
|
1234
1410
|
type: NodeType.String,
|
|
1235
1411
|
loc: cursor.value.loc,
|
|
1236
1412
|
raw: cursor.value.raw,
|
|
1237
|
-
value
|
|
1413
|
+
value
|
|
1238
1414
|
};
|
|
1239
1415
|
}
|
|
1240
1416
|
function boolean(cursor) {
|
|
@@ -1287,6 +1463,10 @@ function datetime(cursor, input) {
|
|
|
1287
1463
|
loc = { start, end: cursor.value.loc.end };
|
|
1288
1464
|
raw += `.${cursor.value.raw}`;
|
|
1289
1465
|
}
|
|
1466
|
+
// Validate datetime format
|
|
1467
|
+
{
|
|
1468
|
+
validateDateTimeFormat(raw, input, loc.start);
|
|
1469
|
+
}
|
|
1290
1470
|
if (!dateFormatHelper.IS_FULL_DATE.test(raw)) {
|
|
1291
1471
|
// Local time only (e.g., "07:32:00" or "07:32:00.999")
|
|
1292
1472
|
if (dateFormatHelper.IS_TIME_ONLY.test(raw)) {
|
|
@@ -1329,18 +1509,227 @@ function datetime(cursor, input) {
|
|
|
1329
1509
|
value
|
|
1330
1510
|
};
|
|
1331
1511
|
}
|
|
1512
|
+
// Helper function to calculate days in a month for any year (including 0-99)
|
|
1513
|
+
// JavaScript's Date constructor treats years 0-99 as 1900-1999, so we need manual calculation
|
|
1514
|
+
function getDaysInMonth(year, month) {
|
|
1515
|
+
// Month is 1-12
|
|
1516
|
+
const daysPerMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
1517
|
+
if (month === 2) {
|
|
1518
|
+
// Check if it's a leap year
|
|
1519
|
+
// Leap year rules: divisible by 4, except century years (divisible by 100) unless also divisible by 400
|
|
1520
|
+
const isLeapYear = (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0);
|
|
1521
|
+
return isLeapYear ? 29 : 28;
|
|
1522
|
+
}
|
|
1523
|
+
return daysPerMonth[month - 1];
|
|
1524
|
+
}
|
|
1525
|
+
// Helper function to validate datetime format
|
|
1526
|
+
function validateDateTimeFormat(raw, input, loc) {
|
|
1527
|
+
// Group 9: fractional seconds and timezone offset validation.
|
|
1528
|
+
// Reject fractional seconds with no digits after the dot (e.g. "...:09.Z" or "...:09.+01:00").
|
|
1529
|
+
if (/\.([Zz]|[+-])/.test(raw)) {
|
|
1530
|
+
throw new ParseError(input, loc, `Invalid datetime "${raw}": fractional seconds must have at least one digit after decimal point`);
|
|
1531
|
+
}
|
|
1532
|
+
// Reject trailing +/- without hour/minute (e.g., "...+" or "...-")
|
|
1533
|
+
if (/[+-]$/.test(raw)) {
|
|
1534
|
+
throw new ParseError(input, loc, `Invalid datetime "${raw}": timezone offset requires hour and minute components`);
|
|
1535
|
+
}
|
|
1536
|
+
// If an offset is present, it must be [+-]HH:MM and only after a time component.
|
|
1537
|
+
// (Avoid accidentally matching date hyphens by requiring a time first.)
|
|
1538
|
+
const hasTime = /\d{2}:\d{2}/.test(raw);
|
|
1539
|
+
const offsetMatch = hasTime ? raw.match(/([+-])(\d+)(:?)(\d*)$/) : null;
|
|
1540
|
+
if (offsetMatch) {
|
|
1541
|
+
const fullOffset = offsetMatch[0];
|
|
1542
|
+
const hours = offsetMatch[2];
|
|
1543
|
+
const colon = offsetMatch[3];
|
|
1544
|
+
const minutes = offsetMatch[4];
|
|
1545
|
+
if (colon !== ':') {
|
|
1546
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": must use colon separator (e.g., +09:09)`);
|
|
1547
|
+
}
|
|
1548
|
+
if (hours.length !== 2) {
|
|
1549
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": hour must be exactly 2 digits`);
|
|
1550
|
+
}
|
|
1551
|
+
if (!minutes || minutes.length === 0) {
|
|
1552
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": minute component is required`);
|
|
1553
|
+
}
|
|
1554
|
+
if (minutes.length !== 2) {
|
|
1555
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": minute must be exactly 2 digits`);
|
|
1556
|
+
}
|
|
1557
|
+
const hourNum = parseInt(hours, 10);
|
|
1558
|
+
if (hourNum < 0 || hourNum > 23) {
|
|
1559
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": hour must be between 00 and 23, found ${hours}`);
|
|
1560
|
+
}
|
|
1561
|
+
const minuteNum = parseInt(minutes, 10);
|
|
1562
|
+
if (minuteNum < 0 || minuteNum > 59) {
|
|
1563
|
+
throw new ParseError(input, loc, `Invalid timezone offset "${fullOffset}": minute must be between 00 and 59, found ${minutes}`);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
// First, ensure the overall shape is valid (anchors matter).
|
|
1567
|
+
// This catches cases where regexes below might partially match a prefix and ignore trailing junk.
|
|
1568
|
+
const validDateTimePattern = /^\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?$/;
|
|
1569
|
+
const validTimePattern = /^\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
|
|
1570
|
+
if (!validDateTimePattern.test(raw) && !validTimePattern.test(raw)) {
|
|
1571
|
+
// Date cannot end with trailing T without a time component
|
|
1572
|
+
if (/^\d{4}-\d{2}-\d{2}T$/.test(raw)) {
|
|
1573
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": date cannot end with 'T' without a time component`);
|
|
1574
|
+
}
|
|
1575
|
+
// Any unexpected character immediately after a date-only value.
|
|
1576
|
+
// Exclude T/t (date-time separators) from this check.
|
|
1577
|
+
if (/^\d{4}-\d{2}-\d{2}[a-su-zA-SU-Z]/.test(raw)) {
|
|
1578
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": unexpected character after date`);
|
|
1579
|
+
}
|
|
1580
|
+
// Missing separator between date and time
|
|
1581
|
+
if (/^\d{4}-\d{2}-\d{2}\d{2}:\d{2}/.test(raw)) {
|
|
1582
|
+
throw new ParseError(input, loc, `Invalid datetime "${raw}": missing separator 'T' or space between date and time`);
|
|
1583
|
+
}
|
|
1584
|
+
throw new ParseError(input, loc, `Invalid datetime "${raw}"`);
|
|
1585
|
+
}
|
|
1586
|
+
// Check for year with wrong number of digits (must be exactly 4)
|
|
1587
|
+
const yearMatch = raw.match(/^(\d+)-/);
|
|
1588
|
+
if (yearMatch && yearMatch[1].length !== 4) {
|
|
1589
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": year must be exactly 4 digits, found ${yearMatch[1].length}`);
|
|
1590
|
+
}
|
|
1591
|
+
// Check for date with wrong number of digits for month/day BEFORE extracting components
|
|
1592
|
+
// Pattern should be YYYY-MM-DD (exactly 4, 2, 2 digits)
|
|
1593
|
+
const datePattern = /^(\d+)-(\d+)-(\d+)/;
|
|
1594
|
+
const dateMatch = raw.match(datePattern);
|
|
1595
|
+
if (dateMatch) {
|
|
1596
|
+
const [, , month, day] = dateMatch;
|
|
1597
|
+
if (month.length !== 2) {
|
|
1598
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": month must be exactly 2 digits, found ${month.length}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (day.length !== 2) {
|
|
1601
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": day must be exactly 2 digits, found ${day.length}`);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
// Check for time with wrong number of digits for hour/minute/second
|
|
1605
|
+
const timePattern = /[T ](\d+):(\d+)(?::(\d+))?/;
|
|
1606
|
+
const timeMatch = raw.match(timePattern);
|
|
1607
|
+
if (timeMatch) {
|
|
1608
|
+
const [, hour, minute, second] = timeMatch;
|
|
1609
|
+
if (hour.length !== 2) {
|
|
1610
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": hour must be exactly 2 digits, found ${hour.length}`);
|
|
1611
|
+
}
|
|
1612
|
+
if (minute.length !== 2) {
|
|
1613
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": minute must be exactly 2 digits, found ${minute.length}`);
|
|
1614
|
+
}
|
|
1615
|
+
if (second && second.length !== 2) {
|
|
1616
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": second must be exactly 2 digits, found ${second.length}`);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
// Check for standalone time (no date prefix)
|
|
1620
|
+
const timeOnlyPattern = /^(\d+):(\d+)(?::(\d+))?/;
|
|
1621
|
+
const timeOnlyMatch = raw.match(timeOnlyPattern);
|
|
1622
|
+
if (timeOnlyMatch && !dateMatch) {
|
|
1623
|
+
const [, hour, minute, second] = timeOnlyMatch;
|
|
1624
|
+
if (hour.length !== 2) {
|
|
1625
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": hour must be exactly 2 digits, found ${hour.length}`);
|
|
1626
|
+
}
|
|
1627
|
+
if (minute.length !== 2) {
|
|
1628
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": minute must be exactly 2 digits, found ${minute.length}`);
|
|
1629
|
+
}
|
|
1630
|
+
if (second && second.length !== 2) {
|
|
1631
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": second must be exactly 2 digits, found ${second.length}`);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
// Extract components for range validation (now we know they have the right length)
|
|
1635
|
+
const dateTimeMatch = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?/);
|
|
1636
|
+
const timeOnlyMatchExact = raw.match(/^(\d{2}):(\d{2})(?::(\d{2}))?/);
|
|
1637
|
+
if (dateTimeMatch) {
|
|
1638
|
+
const [, year, month, day, hour, minute, second] = dateTimeMatch;
|
|
1639
|
+
// Validate month range (01-12)
|
|
1640
|
+
const monthNum = parseInt(month, 10);
|
|
1641
|
+
if (monthNum < 1 || monthNum > 12) {
|
|
1642
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": month must be between 01 and 12`);
|
|
1643
|
+
}
|
|
1644
|
+
// Validate day range (01-31 depending on month)
|
|
1645
|
+
const dayNum = parseInt(day, 10);
|
|
1646
|
+
if (dayNum < 1 || dayNum > 31) {
|
|
1647
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": day must be between 01 and 31`);
|
|
1648
|
+
}
|
|
1649
|
+
// Check if day is valid for the specific month
|
|
1650
|
+
const yearNum = parseInt(year, 10);
|
|
1651
|
+
const daysInMonth = getDaysInMonth(yearNum, monthNum);
|
|
1652
|
+
if (dayNum > daysInMonth) {
|
|
1653
|
+
throw new ParseError(input, loc, `Invalid date "${raw}": day ${day} is invalid for month ${month} in year ${year}`);
|
|
1654
|
+
}
|
|
1655
|
+
// Validate time component ranges if present
|
|
1656
|
+
if (hour !== undefined) {
|
|
1657
|
+
const hourNum = parseInt(hour, 10);
|
|
1658
|
+
if (hourNum < 0 || hourNum > 23) {
|
|
1659
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": hour must be between 00 and 23`);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
if (minute !== undefined) {
|
|
1663
|
+
const minuteNum = parseInt(minute, 10);
|
|
1664
|
+
if (minuteNum < 0 || minuteNum > 59) {
|
|
1665
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": minute must be between 00 and 59`);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
if (second !== undefined) {
|
|
1669
|
+
const secondNum = parseInt(second, 10);
|
|
1670
|
+
if (secondNum < 0 || secondNum > 60) {
|
|
1671
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": second must be between 00 and 60`);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
else if (timeOnlyMatchExact) {
|
|
1676
|
+
const [, hour, minute, second] = timeOnlyMatchExact;
|
|
1677
|
+
const hourNum = parseInt(hour, 10);
|
|
1678
|
+
if (hourNum < 0 || hourNum > 23) {
|
|
1679
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": hour must be between 00 and 23`);
|
|
1680
|
+
}
|
|
1681
|
+
const minuteNum = parseInt(minute, 10);
|
|
1682
|
+
if (minuteNum < 0 || minuteNum > 59) {
|
|
1683
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": minute must be between 00 and 59`);
|
|
1684
|
+
}
|
|
1685
|
+
if (second !== undefined) {
|
|
1686
|
+
const secondNum = parseInt(second, 10);
|
|
1687
|
+
if (secondNum < 0 || secondNum > 60) {
|
|
1688
|
+
throw new ParseError(input, loc, `Invalid time "${raw}": second must be between 00 and 60`);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1332
1693
|
function float(cursor, input) {
|
|
1333
1694
|
let loc = cursor.value.loc;
|
|
1334
1695
|
let raw = cursor.value.raw;
|
|
1335
1696
|
let value;
|
|
1336
1697
|
if (IS_INF.test(raw)) {
|
|
1337
|
-
value = raw
|
|
1698
|
+
value = raw.startsWith('-') ? -Infinity : Infinity;
|
|
1338
1699
|
}
|
|
1339
1700
|
else if (IS_NAN.test(raw)) {
|
|
1340
|
-
value =
|
|
1701
|
+
value = NaN;
|
|
1341
1702
|
}
|
|
1342
1703
|
else if (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
|
|
1343
1704
|
const start = loc.start;
|
|
1705
|
+
{
|
|
1706
|
+
// Validate that we don't already have an exponent (e.g., 1e2 cannot have a fractional part after it)
|
|
1707
|
+
if (HAS_E.test(raw) && !IS_HEX.test(raw)) {
|
|
1708
|
+
throw new ParseError(input, loc.start, `Invalid float "${raw}": cannot have decimal point after exponent`);
|
|
1709
|
+
}
|
|
1710
|
+
// Validate integer part before decimal point
|
|
1711
|
+
const intPart = raw;
|
|
1712
|
+
// Validate no leading zeros in integer part (after optional sign)
|
|
1713
|
+
// e.g. 00.1, +00.1, 0_0.1 are invalid
|
|
1714
|
+
const withoutUnderscores = intPart.replace(IS_DIVIDER, '');
|
|
1715
|
+
if (/^[+\-]?0\d/.test(withoutUnderscores) && !IS_HEX.test(intPart) && !IS_OCTAL.test(intPart) && !IS_BINARY.test(intPart)) {
|
|
1716
|
+
throw new ParseError(input, loc.start, 'Leading zeros are not allowed in the integer part of a float');
|
|
1717
|
+
}
|
|
1718
|
+
// Validate no leading dot (must have at least one digit before the dot)
|
|
1719
|
+
const withoutSign = intPart.replace(/^[+\-]/, '');
|
|
1720
|
+
if (withoutSign === '' || withoutSign === '_') {
|
|
1721
|
+
throw new ParseError(input, loc.start, `Invalid float: decimal point must be preceded by at least one digit`);
|
|
1722
|
+
}
|
|
1723
|
+
if (/_$/.test(intPart)) {
|
|
1724
|
+
throw new ParseError(input, loc.start, 'Underscore before decimal point is not allowed');
|
|
1725
|
+
}
|
|
1726
|
+
if (/^[+\-]?_/.test(intPart)) {
|
|
1727
|
+
throw new ParseError(input, loc.start, 'Leading underscore is not allowed');
|
|
1728
|
+
}
|
|
1729
|
+
if (/__/.test(intPart)) {
|
|
1730
|
+
throw new ParseError(input, loc.start, 'Consecutive underscores are not allowed');
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1344
1733
|
// From spec:
|
|
1345
1734
|
// | A fractional part is a decimal point followed by one or more digits.
|
|
1346
1735
|
//
|
|
@@ -1352,47 +1741,425 @@ function float(cursor, input) {
|
|
|
1352
1741
|
cursor.next();
|
|
1353
1742
|
raw += `.${cursor.value.raw}`;
|
|
1354
1743
|
loc = { start, end: cursor.value.loc.end };
|
|
1744
|
+
{
|
|
1745
|
+
// Validate underscore placement in fractional part
|
|
1746
|
+
const fracPart = cursor.value.raw;
|
|
1747
|
+
// Validate that fractional part starts with a digit (not 'e')
|
|
1748
|
+
if (!/^\d/.test(fracPart)) {
|
|
1749
|
+
throw new ParseError(input, cursor.value.loc.start, `Invalid float: fractional part must start with a digit, found "${fracPart}"`);
|
|
1750
|
+
}
|
|
1751
|
+
if (/^_/.test(fracPart)) {
|
|
1752
|
+
throw new ParseError(input, cursor.value.loc.start, 'Underscore after decimal point is not allowed');
|
|
1753
|
+
}
|
|
1754
|
+
if (/_$/.test(fracPart)) {
|
|
1755
|
+
throw new ParseError(input, cursor.value.loc.start, 'Trailing underscore in fractional part is not allowed');
|
|
1756
|
+
}
|
|
1757
|
+
// Validate underscore before exponent in fractional part
|
|
1758
|
+
if (/_[eE]/.test(fracPart)) {
|
|
1759
|
+
throw new ParseError(input, cursor.value.loc.start, 'Underscore before exponent is not allowed');
|
|
1760
|
+
}
|
|
1761
|
+
// Validate underscore at start of exponent in fractional part
|
|
1762
|
+
if (/[eE][+\-]?_/.test(fracPart)) {
|
|
1763
|
+
throw new ParseError(input, cursor.value.loc.start, 'Underscore at start of exponent is not allowed');
|
|
1764
|
+
}
|
|
1765
|
+
// Validate incomplete exponent in fractional part (just E with nothing or just sign after)
|
|
1766
|
+
if (/[eE][+\-]?$/.test(fracPart)) {
|
|
1767
|
+
throw new ParseError(input, cursor.value.loc.start, `Invalid float "${raw}": incomplete exponent`);
|
|
1768
|
+
}
|
|
1769
|
+
// Validate no decimal point in exponent in fractional part
|
|
1770
|
+
if (/[eE][+\-]?.*\./.test(fracPart)) {
|
|
1771
|
+
throw new ParseError(input, cursor.value.loc.start, `Invalid float "${raw}": decimal point not allowed in exponent`);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1355
1774
|
value = Number(raw.replace(IS_DIVIDER, ''));
|
|
1356
1775
|
}
|
|
1357
1776
|
else {
|
|
1777
|
+
// Validate underscore placement in integer part (exponent-only floats like 1e5)
|
|
1778
|
+
if (/_$/.test(raw)) {
|
|
1779
|
+
throw new ParseError(input, loc.start, 'Underscore before decimal point is not allowed');
|
|
1780
|
+
}
|
|
1781
|
+
if (/^[+\-]?_/.test(raw)) {
|
|
1782
|
+
throw new ParseError(input, loc.start, 'Leading underscore is not allowed');
|
|
1783
|
+
}
|
|
1784
|
+
if (/__/.test(raw)) {
|
|
1785
|
+
throw new ParseError(input, loc.start, 'Consecutive underscores are not allowed');
|
|
1786
|
+
}
|
|
1787
|
+
// Validate incomplete exponent (just E with nothing or just sign after)
|
|
1788
|
+
if (/[eE][+\-]?$/.test(raw)) {
|
|
1789
|
+
throw new ParseError(input, loc.start, `Invalid float "${raw}": incomplete exponent`);
|
|
1790
|
+
}
|
|
1791
|
+
// Validate no decimal point in exponent
|
|
1792
|
+
if (/[eE][+\-]?.*\./.test(raw)) {
|
|
1793
|
+
throw new ParseError(input, loc.start, `Invalid float "${raw}": decimal point not allowed in exponent`);
|
|
1794
|
+
}
|
|
1795
|
+
// Validate underscore before exponent
|
|
1796
|
+
if (/_[eE]/.test(raw)) {
|
|
1797
|
+
throw new ParseError(input, loc.start, 'Underscore before exponent is not allowed');
|
|
1798
|
+
}
|
|
1799
|
+
// Validate underscore at start of exponent
|
|
1800
|
+
if (/[eE][+\-]?_/.test(raw)) {
|
|
1801
|
+
throw new ParseError(input, loc.start, 'Underscore at start of exponent is not allowed');
|
|
1802
|
+
}
|
|
1803
|
+
// Validate no dot after exponent (e.g., 1e2.3 is invalid)
|
|
1804
|
+
if (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
|
|
1805
|
+
throw new ParseError(input, cursor.peek().value.loc.start, `Invalid float "${raw}.": cannot have decimal point after exponent`);
|
|
1806
|
+
}
|
|
1807
|
+
// Validate no leading zeros in integer part (after optional sign)
|
|
1808
|
+
// e.g. 00e1, +00e1, 0_0e1 are invalid
|
|
1809
|
+
const withoutUnderscores = raw.replace(IS_DIVIDER, '');
|
|
1810
|
+
if (/^[+\-]?0\d/.test(withoutUnderscores) && !IS_HEX.test(raw) && !IS_OCTAL.test(raw) && !IS_BINARY.test(raw)) {
|
|
1811
|
+
throw new ParseError(input, loc.start, 'Leading zeros are not allowed in the integer part of a float');
|
|
1812
|
+
}
|
|
1358
1813
|
value = Number(raw.replace(IS_DIVIDER, ''));
|
|
1359
1814
|
}
|
|
1815
|
+
// Reject non-special floats that parse to NaN (e.g. "Inf", "NaN", "1ee2")
|
|
1816
|
+
if (Number.isNaN(value) && !IS_NAN.test(raw)) {
|
|
1817
|
+
throw new ParseError(input, loc.start, `Invalid float "${raw}"`);
|
|
1818
|
+
}
|
|
1360
1819
|
return { type: NodeType.Float, loc, raw, value };
|
|
1361
1820
|
}
|
|
1362
|
-
function integer(cursor) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
};
|
|
1821
|
+
function integer(cursor, input) {
|
|
1822
|
+
const raw = cursor.value.raw;
|
|
1823
|
+
const loc = cursor.value.loc;
|
|
1824
|
+
// Guard: values that look like dates/times must never be parsed as integers.
|
|
1825
|
+
// (Prevents parseInt() from accepting prefixes like "199-09-09" -> 199.)
|
|
1826
|
+
if (/^\d{1,}-\d{1,}/.test(raw) ||
|
|
1827
|
+
/^\d{1,}:\d{1,}/.test(raw) ||
|
|
1828
|
+
/^\d{6}-\d{2}$/.test(raw)) {
|
|
1829
|
+
throw new ParseError(input, loc.start, `Invalid integer "${raw}"`);
|
|
1830
|
+
}
|
|
1831
|
+
{
|
|
1832
|
+
// > Integer values -0 and +0 are valid and identical to an unprefixed zero
|
|
1833
|
+
if (raw === '-0' || raw === '+0') {
|
|
1834
|
+
return {
|
|
1835
|
+
type: NodeType.Integer,
|
|
1836
|
+
loc: loc,
|
|
1837
|
+
raw: raw,
|
|
1838
|
+
value: 0
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
// Validation: No double signs (++99, --99)
|
|
1842
|
+
if (/^[+\-]{2,}/.test(raw)) {
|
|
1843
|
+
throw new ParseError(input, loc.start, 'Double sign is not allowed in integers');
|
|
1844
|
+
}
|
|
1845
|
+
// Validation: No leading zeros (except for hex/octal/binary with prefixes)
|
|
1846
|
+
// Check after removing underscores to catch cases like 0_0 which is equivalent to 00
|
|
1847
|
+
const withoutUnderscores = raw.replace(/_/g, '');
|
|
1848
|
+
if (/^[+\-]?0\d/.test(withoutUnderscores) && !IS_HEX.test(raw) && !IS_OCTAL.test(raw) && !IS_BINARY.test(raw)) {
|
|
1849
|
+
throw new ParseError(input, loc.start, 'Leading zeros are not allowed in decimal integers');
|
|
1850
|
+
}
|
|
1851
|
+
// Validation: No trailing underscores
|
|
1852
|
+
if (/_$/.test(raw)) {
|
|
1853
|
+
throw new ParseError(input, loc.start, 'Underscores in numbers must be surrounded by digits');
|
|
1854
|
+
}
|
|
1855
|
+
// Validation: No leading underscores (after optional sign)
|
|
1856
|
+
if (/^[+\-]?_/.test(raw)) {
|
|
1857
|
+
throw new ParseError(input, loc.start, 'Underscores in numbers must be surrounded by digits');
|
|
1858
|
+
}
|
|
1859
|
+
// Validation: No consecutive underscores
|
|
1860
|
+
if (/__/.test(raw)) {
|
|
1861
|
+
throw new ParseError(input, loc.start, 'Consecutive underscores in numbers are not allowed');
|
|
1862
|
+
}
|
|
1371
1863
|
}
|
|
1372
1864
|
let radix = 10;
|
|
1373
|
-
|
|
1865
|
+
let numericPart;
|
|
1866
|
+
// Hexadecimal validation
|
|
1867
|
+
if (IS_HEX.test(raw)) {
|
|
1374
1868
|
radix = 16;
|
|
1869
|
+
// Validation: Capital prefix not allowed
|
|
1870
|
+
if (/^[+\-]?0X/.test(raw)) {
|
|
1871
|
+
throw new ParseError(input, loc.start, 'Hexadecimal prefix must be lowercase "0x"');
|
|
1872
|
+
}
|
|
1873
|
+
// Validation: Underscore after prefix
|
|
1874
|
+
if (/^[+\-]?0x_/.test(raw)) {
|
|
1875
|
+
throw new ParseError(input, loc.start, 'Underscores in numbers must be surrounded by digits');
|
|
1876
|
+
}
|
|
1877
|
+
numericPart = raw.replace(/^[+\-]?0x/i, '');
|
|
1878
|
+
// Validation: Incomplete hexadecimal
|
|
1879
|
+
if (!numericPart || numericPart === '_' || /^_/.test(numericPart)) {
|
|
1880
|
+
throw new ParseError(input, loc.start, 'Incomplete hexadecimal number');
|
|
1881
|
+
}
|
|
1882
|
+
// Validation: Invalid hexadecimal digits
|
|
1883
|
+
const hexDigits = numericPart.replace(/_/g, '');
|
|
1884
|
+
if (!/^[0-9a-fA-F]+$/.test(hexDigits)) {
|
|
1885
|
+
throw new ParseError(input, loc.start, 'Invalid hexadecimal digits');
|
|
1886
|
+
}
|
|
1887
|
+
// Validation: Signed non-decimal numbers not allowed
|
|
1888
|
+
if (/^[+\-]/.test(raw)) {
|
|
1889
|
+
throw new ParseError(input, loc.start, 'Hexadecimal numbers cannot have a sign prefix');
|
|
1890
|
+
}
|
|
1375
1891
|
}
|
|
1376
|
-
|
|
1892
|
+
// Octal validation
|
|
1893
|
+
else if (IS_OCTAL.test(raw)) {
|
|
1377
1894
|
radix = 8;
|
|
1895
|
+
// Validation: Capital prefix not allowed
|
|
1896
|
+
if (/^[+\-]?0O/.test(raw)) {
|
|
1897
|
+
throw new ParseError(input, loc.start, 'Octal prefix must be lowercase "0o"');
|
|
1898
|
+
}
|
|
1899
|
+
// Validation: Underscore after prefix
|
|
1900
|
+
if (/^[+\-]?0o_/.test(raw)) {
|
|
1901
|
+
throw new ParseError(input, loc.start, 'Underscores in numbers must be surrounded by digits');
|
|
1902
|
+
}
|
|
1903
|
+
numericPart = raw.replace(/^[+\-]?0o/i, '');
|
|
1904
|
+
// Validation: Incomplete octal
|
|
1905
|
+
if (!numericPart || numericPart === '_' || /^_/.test(numericPart)) {
|
|
1906
|
+
throw new ParseError(input, loc.start, 'Incomplete octal number');
|
|
1907
|
+
}
|
|
1908
|
+
// Validation: Invalid octal digits
|
|
1909
|
+
const octalDigits = numericPart.replace(/_/g, '');
|
|
1910
|
+
if (!/^[0-7]+$/.test(octalDigits)) {
|
|
1911
|
+
throw new ParseError(input, loc.start, 'Invalid octal digits (must be 0-7)');
|
|
1912
|
+
}
|
|
1913
|
+
// Validation: Signed non-decimal numbers not allowed
|
|
1914
|
+
if (/^[+\-]/.test(raw)) {
|
|
1915
|
+
throw new ParseError(input, loc.start, 'Octal numbers cannot have a sign prefix');
|
|
1916
|
+
}
|
|
1378
1917
|
}
|
|
1379
|
-
|
|
1918
|
+
// Binary validation
|
|
1919
|
+
else if (IS_BINARY.test(raw)) {
|
|
1380
1920
|
radix = 2;
|
|
1921
|
+
// Validation: Capital prefix not allowed
|
|
1922
|
+
if (/^[+\-]?0B/.test(raw)) {
|
|
1923
|
+
throw new ParseError(input, loc.start, 'Binary prefix must be lowercase "0b"');
|
|
1924
|
+
}
|
|
1925
|
+
// Validation: Underscore after prefix
|
|
1926
|
+
if (/^[+\-]?0b_/.test(raw)) {
|
|
1927
|
+
throw new ParseError(input, loc.start, 'Underscores in numbers must be surrounded by digits');
|
|
1928
|
+
}
|
|
1929
|
+
numericPart = raw.replace(/^[+\-]?0b/i, '');
|
|
1930
|
+
// Validation: Incomplete binary
|
|
1931
|
+
if (!numericPart || numericPart === '_' || /^_/.test(numericPart)) {
|
|
1932
|
+
throw new ParseError(input, loc.start, 'Incomplete binary number');
|
|
1933
|
+
}
|
|
1934
|
+
// Validation: Invalid binary digits
|
|
1935
|
+
const binaryDigits = numericPart.replace(/_/g, '');
|
|
1936
|
+
if (!/^[01]+$/.test(binaryDigits)) {
|
|
1937
|
+
throw new ParseError(input, loc.start, 'Invalid binary digits (must be 0 or 1)');
|
|
1938
|
+
}
|
|
1939
|
+
// Validation: Signed non-decimal numbers not allowed
|
|
1940
|
+
if (/^[+\-]/.test(raw)) {
|
|
1941
|
+
throw new ParseError(input, loc.start, 'Binary numbers cannot have a sign prefix');
|
|
1942
|
+
}
|
|
1381
1943
|
}
|
|
1382
|
-
const value = parseInt(
|
|
1383
|
-
.
|
|
1944
|
+
const value = parseInt(raw
|
|
1945
|
+
.replace(IS_DIVIDER, '')
|
|
1384
1946
|
.replace(IS_OCTAL, '')
|
|
1385
1947
|
.replace(IS_BINARY, ''), radix);
|
|
1948
|
+
if (Number.isNaN(value)) {
|
|
1949
|
+
throw new ParseError(input, loc.start, `Invalid integer "${raw}"`);
|
|
1950
|
+
}
|
|
1386
1951
|
return {
|
|
1387
1952
|
type: NodeType.Integer,
|
|
1388
|
-
loc:
|
|
1389
|
-
raw:
|
|
1953
|
+
loc: loc,
|
|
1954
|
+
raw: raw,
|
|
1390
1955
|
value
|
|
1391
1956
|
};
|
|
1392
1957
|
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Walk a Block (Comment, Table, or KeyValue)
|
|
1960
|
+
* This new version avoids recursion for key-value pairs to improve performance on large files.
|
|
1961
|
+
* @param cursor Cursor<Token>
|
|
1962
|
+
* @param input string
|
|
1963
|
+
* @returns Block[]
|
|
1964
|
+
*/
|
|
1965
|
+
function walkBlock(cursor, input) {
|
|
1966
|
+
if (cursor.value.type === TokenType.Comment) {
|
|
1967
|
+
return [comment(cursor)];
|
|
1968
|
+
}
|
|
1969
|
+
else if (cursor.value.type === TokenType.Bracket) {
|
|
1970
|
+
// For tables, we can't easily avoid recursion, so just use the existing function
|
|
1971
|
+
// In practice, top-level tables aren't deeply nested
|
|
1972
|
+
return [table(cursor, input)];
|
|
1973
|
+
}
|
|
1974
|
+
else if (cursor.value.type === TokenType.Literal) {
|
|
1975
|
+
return keyValue(cursor, input);
|
|
1976
|
+
}
|
|
1977
|
+
else if (cursor.value.type === TokenType.Equal) {
|
|
1978
|
+
throw new ParseError(input, cursor.value.loc.start, `Missing key before '='`);
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
throw new ParseError(input, cursor.value.loc.start, `Unexpected token "${cursor.value.type}". Expected Comment, Bracket, or String`);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* Walk a KeyValue pair or Comment
|
|
1986
|
+
* This new version avoids recursion for key-value pairs to improve performance on large files.
|
|
1987
|
+
* @param cursor Cursor<Token>
|
|
1988
|
+
* @param input string
|
|
1989
|
+
* @returns Array<KeyValue | Comment>
|
|
1990
|
+
*/
|
|
1991
|
+
function keyValue(cursor, input) {
|
|
1992
|
+
// 3. KeyValue
|
|
1993
|
+
//
|
|
1994
|
+
// key = value
|
|
1995
|
+
// ^-^ key
|
|
1996
|
+
// ^ equals
|
|
1997
|
+
// ^---^ value
|
|
1998
|
+
// Match the more helpful diagnostic when users write `key: value`.
|
|
1999
|
+
// Depending on tokenization, the ':' may be attached to the key token (e.g. 'name:').
|
|
2000
|
+
const rawKeyToken = cursor.value.raw;
|
|
2001
|
+
if (rawKeyToken.endsWith(':')) {
|
|
2002
|
+
throw new ParseError(input, { line: cursor.value.loc.start.line, column: cursor.value.loc.start.column + [...rawKeyToken].length - 1 }, `Use '=' to separate keys and values, not ':'`);
|
|
2003
|
+
}
|
|
2004
|
+
// Validate that multiline strings are not used as keys
|
|
2005
|
+
if (rawKeyToken.startsWith('"""') || rawKeyToken.startsWith("'''")) {
|
|
2006
|
+
throw new ParseError(input, cursor.value.loc.start, 'Multiline strings (""" or \'\'\') cannot be used as keys');
|
|
2007
|
+
}
|
|
2008
|
+
// Validate bare key characters (TOML 1.1.0: A-Za-z0-9_- only)
|
|
2009
|
+
const isQuotedKey = rawKeyToken.startsWith('"') || rawKeyToken.startsWith("'");
|
|
2010
|
+
if (!isQuotedKey) {
|
|
2011
|
+
for (let i = 0; i < rawKeyToken.length; i++) {
|
|
2012
|
+
const char = rawKeyToken[i];
|
|
2013
|
+
if (!/[A-Za-z0-9_-]/.test(char)) {
|
|
2014
|
+
throw new ParseError(input, { line: cursor.value.loc.start.line, column: cursor.value.loc.start.column + i }, `Invalid character '${char}' in bare key. Bare keys can only contain A-Z, a-z, 0-9, _, and -`);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
let keyValue2;
|
|
2019
|
+
try {
|
|
2020
|
+
keyValue2 = [parseString(cursor.value.raw)];
|
|
2021
|
+
}
|
|
2022
|
+
catch (err) {
|
|
2023
|
+
const e = err;
|
|
2024
|
+
throw new ParseError(input, cursor.value.loc.start, e.message);
|
|
2025
|
+
}
|
|
2026
|
+
const key = {
|
|
2027
|
+
type: NodeType.Key,
|
|
2028
|
+
loc: cloneLocation(cursor.value.loc),
|
|
2029
|
+
raw: cursor.value.raw,
|
|
2030
|
+
value: keyValue2
|
|
2031
|
+
};
|
|
2032
|
+
while (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
|
|
2033
|
+
cursor.next();
|
|
2034
|
+
cursor.next();
|
|
2035
|
+
// Validate each part of a dotted key
|
|
2036
|
+
const partRaw = cursor.value.raw;
|
|
2037
|
+
if (partRaw.startsWith('"""') || partRaw.startsWith("'''")) {
|
|
2038
|
+
throw new ParseError(input, cursor.value.loc.start, 'Multiline strings (""" or \'\'\') cannot be used as keys');
|
|
2039
|
+
}
|
|
2040
|
+
const partIsQuoted = partRaw.startsWith('"') || partRaw.startsWith("'");
|
|
2041
|
+
if (!partIsQuoted) {
|
|
2042
|
+
for (let i = 0; i < partRaw.length; i++) {
|
|
2043
|
+
const char = partRaw[i];
|
|
2044
|
+
if (!/[A-Za-z0-9_-]/.test(char)) {
|
|
2045
|
+
throw new ParseError(input, { line: cursor.value.loc.start.line, column: cursor.value.loc.start.column + i }, `Invalid character '${char}' in bare key. Bare keys can only contain A-Z, a-z, 0-9, _, and -`);
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
key.loc.end = cursor.value.loc.end;
|
|
2050
|
+
key.raw += `.${cursor.value.raw}`;
|
|
2051
|
+
try {
|
|
2052
|
+
key.value.push(parseString(cursor.value.raw));
|
|
2053
|
+
}
|
|
2054
|
+
catch (err) {
|
|
2055
|
+
const e = err;
|
|
2056
|
+
throw new ParseError(input, cursor.value.loc.start, e.message);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
cursor.next();
|
|
2060
|
+
// TOML key/value pairs must include '=' on the same line as the key.
|
|
2061
|
+
// Example invalid TOML (spec: bare-key-2):
|
|
2062
|
+
// barekey\n = 123
|
|
2063
|
+
if (!cursor.done && cursor.value.loc.start.line !== key.loc.end.line) {
|
|
2064
|
+
throw new ParseError(input, cursor.value.loc.start, `Expected "=" for key-value on the same line as the key`);
|
|
2065
|
+
}
|
|
2066
|
+
if (cursor.done || cursor.value.type !== TokenType.Equal) {
|
|
2067
|
+
if (!cursor.done && cursor.value.raw === ':') {
|
|
2068
|
+
throw new ParseError(input, cursor.value.loc.start, `Use '=' to separate keys and values, not ':'`);
|
|
2069
|
+
}
|
|
2070
|
+
throw new ParseError(input, cursor.done ? key.loc.end : cursor.value.loc.start, `Expected "=" for key-value`);
|
|
2071
|
+
}
|
|
2072
|
+
const equals = cursor.value.loc.start.column;
|
|
2073
|
+
const equalsLine = cursor.value.loc.start.line;
|
|
2074
|
+
cursor.next();
|
|
2075
|
+
if (cursor.done) {
|
|
2076
|
+
throw new ParseError(input, key.loc.start, `Expected value for key-value, reached end of file`);
|
|
2077
|
+
}
|
|
2078
|
+
// TOML values must be on the same line as the '=' sign.
|
|
2079
|
+
// Example invalid TOML (key/newline-06):
|
|
2080
|
+
// key =\n1
|
|
2081
|
+
if (cursor.value.loc.start.line !== equalsLine) {
|
|
2082
|
+
throw new ParseError(input, cursor.value.loc.start, `Expected value on the same line as the '=' sign`);
|
|
2083
|
+
}
|
|
2084
|
+
if (cursor.done) {
|
|
2085
|
+
throw new ParseError(input, key.loc.start, `Expected value for key-value`);
|
|
2086
|
+
}
|
|
2087
|
+
const results = walkValue$1(cursor, input);
|
|
2088
|
+
const value = results[0];
|
|
2089
|
+
const comments = results.slice(1);
|
|
2090
|
+
// Key/value pairs must be separated by a newline (or EOF). Whitespace alone isn't enough.
|
|
2091
|
+
// Example invalid TOML: first = "Tom" last = "Preston-Werner"
|
|
2092
|
+
//
|
|
2093
|
+
// Note: don't reject valid inline-tables like { a = 1, b = 2 } where tokens like ',' or '}'
|
|
2094
|
+
// legitimately follow a value on the same line.
|
|
2095
|
+
if (!cursor.peek().done) {
|
|
2096
|
+
const nextToken = cursor.peek().value;
|
|
2097
|
+
// Check for Dot token after a numeric value (likely multiple decimal points)
|
|
2098
|
+
if (nextToken.type === TokenType.Dot &&
|
|
2099
|
+
nextToken.loc.start.line === value.loc.end.line &&
|
|
2100
|
+
(value.type === NodeType.Float || value.type === NodeType.Integer)) {
|
|
2101
|
+
throw new ParseError(input, nextToken.loc.start, 'Invalid number: multiple decimal points not allowed');
|
|
2102
|
+
}
|
|
2103
|
+
const startsNewStatement = nextToken.type === TokenType.Literal ||
|
|
2104
|
+
nextToken.type === TokenType.Bracket;
|
|
2105
|
+
if (startsNewStatement && nextToken.loc.start.line === value.loc.end.line) {
|
|
2106
|
+
throw new ParseError(input, nextToken.loc.start, 'Key/value pairs must be separated by a newline');
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
return [
|
|
2110
|
+
{
|
|
2111
|
+
type: NodeType.KeyValue,
|
|
2112
|
+
key,
|
|
2113
|
+
value: value,
|
|
2114
|
+
loc: { start: clonePosition(key.loc.start), end: clonePosition(value.loc.end) },
|
|
2115
|
+
equals
|
|
2116
|
+
},
|
|
2117
|
+
...comments
|
|
2118
|
+
];
|
|
2119
|
+
}
|
|
2120
|
+
function walkValue$1(cursor, input) {
|
|
2121
|
+
if (cursor.value.type === TokenType.Literal) {
|
|
2122
|
+
const raw = cursor.value.raw;
|
|
2123
|
+
if (raw[0] === DOUBLE_QUOTE || raw[0] === SINGLE_QUOTE) {
|
|
2124
|
+
return [string(cursor, input)];
|
|
2125
|
+
}
|
|
2126
|
+
else if (raw === TRUE || raw === FALSE) {
|
|
2127
|
+
return [boolean(cursor)];
|
|
2128
|
+
// Route anything that looks like a date or time through datetime() so invalid formats throw,
|
|
2129
|
+
// instead of being mis-parsed as integers (e.g., "199-09-09" -> 199).
|
|
2130
|
+
}
|
|
2131
|
+
else if (/^\d/.test(raw) &&
|
|
2132
|
+
(/^\d{1,}-\d{1,}/.test(raw) || /^\d{1,}:\d{1,}/.test(raw))) {
|
|
2133
|
+
return [datetime(cursor, input)];
|
|
2134
|
+
}
|
|
2135
|
+
else if ((!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) ||
|
|
2136
|
+
IS_INF.test(raw) ||
|
|
2137
|
+
IS_NAN.test(raw) ||
|
|
2138
|
+
(HAS_E.test(raw) && !IS_HEX.test(raw))) {
|
|
2139
|
+
return [float(cursor, input)];
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
return [integer(cursor, input)];
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
else if (cursor.value.type === TokenType.Curly) {
|
|
2146
|
+
const [inline_table, comments] = inlineTable(cursor, input);
|
|
2147
|
+
return [inline_table, ...comments];
|
|
2148
|
+
}
|
|
2149
|
+
else if (cursor.value.type === TokenType.Bracket) {
|
|
2150
|
+
const [inline_array, comments] = inlineArray(cursor, input);
|
|
2151
|
+
return [inline_array, ...comments];
|
|
2152
|
+
}
|
|
2153
|
+
else if (cursor.value.type === TokenType.Dot) {
|
|
2154
|
+
throw new ParseError(input, cursor.value.loc.start, `Invalid number: cannot start with a dot. Numbers must start with a digit`);
|
|
2155
|
+
}
|
|
2156
|
+
else {
|
|
2157
|
+
throw new ParseError(input, cursor.value.loc.start, `Unrecognized token type`);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
1393
2160
|
function inlineTable(cursor, input) {
|
|
1394
2161
|
if (cursor.value.raw !== '{') {
|
|
1395
|
-
throw new ParseError(input, cursor.value.loc.start, `Expected "{" for inline table
|
|
2162
|
+
throw new ParseError(input, cursor.value.loc.start, `Expected "{" for inline table`);
|
|
1396
2163
|
}
|
|
1397
2164
|
// 6. InlineTable
|
|
1398
2165
|
const value = {
|
|
@@ -1404,7 +2171,6 @@ function inlineTable(cursor, input) {
|
|
|
1404
2171
|
cursor.next();
|
|
1405
2172
|
while (!cursor.done &&
|
|
1406
2173
|
!(cursor.value.type === TokenType.Curly && cursor.value.raw === '}')) {
|
|
1407
|
-
// TOML 1.1.0: Handle comments in inline tables
|
|
1408
2174
|
if (cursor.value.type === TokenType.Comment) {
|
|
1409
2175
|
comments.push(comment(cursor));
|
|
1410
2176
|
cursor.next();
|
|
@@ -1415,29 +2181,37 @@ function inlineTable(cursor, input) {
|
|
|
1415
2181
|
if (!previous) {
|
|
1416
2182
|
throw new ParseError(input, cursor.value.loc.start, 'Found "," without previous value in inline table');
|
|
1417
2183
|
}
|
|
2184
|
+
if (previous.comma) {
|
|
2185
|
+
throw new ParseError(input, cursor.value.loc.start, 'Found consecutive commas in inline table (double comma is not allowed)');
|
|
2186
|
+
}
|
|
1418
2187
|
previous.comma = true;
|
|
1419
2188
|
previous.loc.end = cursor.value.loc.start;
|
|
1420
2189
|
cursor.next();
|
|
1421
2190
|
continue;
|
|
1422
2191
|
}
|
|
1423
|
-
const
|
|
1424
|
-
if (
|
|
1425
|
-
throw new ParseError(input, cursor.value.loc.start,
|
|
2192
|
+
const previous = value.items[value.items.length - 1];
|
|
2193
|
+
if (previous && !previous.comma) {
|
|
2194
|
+
throw new ParseError(input, cursor.value.loc.start, 'Missing comma between inline table items');
|
|
2195
|
+
}
|
|
2196
|
+
// Recursively parse the key-value, but without generators
|
|
2197
|
+
const blocks = walkBlock(cursor, input);
|
|
2198
|
+
const item = blocks[0];
|
|
2199
|
+
const additional_comments = blocks.slice(1);
|
|
2200
|
+
if (item.type === NodeType.KeyValue) {
|
|
2201
|
+
value.items.push({
|
|
2202
|
+
type: NodeType.InlineItem,
|
|
2203
|
+
loc: cloneLocation(item.loc),
|
|
2204
|
+
item: item,
|
|
2205
|
+
comma: false
|
|
2206
|
+
});
|
|
2207
|
+
merge(comments, additional_comments);
|
|
1426
2208
|
}
|
|
1427
|
-
const inline_item = {
|
|
1428
|
-
type: NodeType.InlineItem,
|
|
1429
|
-
loc: cloneLocation(item.loc),
|
|
1430
|
-
item,
|
|
1431
|
-
comma: false
|
|
1432
|
-
};
|
|
1433
|
-
value.items.push(inline_item);
|
|
1434
|
-
merge(comments, additional_comments);
|
|
1435
2209
|
cursor.next();
|
|
1436
2210
|
}
|
|
1437
2211
|
if (cursor.done ||
|
|
1438
2212
|
cursor.value.type !== TokenType.Curly ||
|
|
1439
2213
|
cursor.value.raw !== '}') {
|
|
1440
|
-
throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "}"
|
|
2214
|
+
throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "}"`);
|
|
1441
2215
|
}
|
|
1442
2216
|
value.loc.end = cursor.value.loc.end;
|
|
1443
2217
|
return [value, comments];
|
|
@@ -1445,14 +2219,14 @@ function inlineTable(cursor, input) {
|
|
|
1445
2219
|
function inlineArray(cursor, input) {
|
|
1446
2220
|
// 7. InlineArray
|
|
1447
2221
|
if (cursor.value.raw !== '[') {
|
|
1448
|
-
throw new ParseError(input, cursor.value.loc.start, `Expected "[" for inline array
|
|
2222
|
+
throw new ParseError(input, cursor.value.loc.start, `Expected "[" for inline array`);
|
|
1449
2223
|
}
|
|
1450
2224
|
const value = {
|
|
1451
2225
|
type: NodeType.InlineArray,
|
|
1452
2226
|
loc: cloneLocation(cursor.value.loc),
|
|
1453
2227
|
items: []
|
|
1454
2228
|
};
|
|
1455
|
-
|
|
2229
|
+
const comments = [];
|
|
1456
2230
|
cursor.next();
|
|
1457
2231
|
while (!cursor.done &&
|
|
1458
2232
|
!(cursor.value.type === TokenType.Bracket && cursor.value.raw === ']')) {
|
|
@@ -1461,6 +2235,9 @@ function inlineArray(cursor, input) {
|
|
|
1461
2235
|
if (!previous) {
|
|
1462
2236
|
throw new ParseError(input, cursor.value.loc.start, 'Found "," without previous value for inline array');
|
|
1463
2237
|
}
|
|
2238
|
+
if (previous.comma) {
|
|
2239
|
+
throw new ParseError(input, cursor.value.loc.start, 'Found consecutive commas in array (double comma is not allowed)');
|
|
2240
|
+
}
|
|
1464
2241
|
previous.comma = true;
|
|
1465
2242
|
previous.loc.end = cursor.value.loc.start;
|
|
1466
2243
|
}
|
|
@@ -1468,14 +2245,19 @@ function inlineArray(cursor, input) {
|
|
|
1468
2245
|
comments.push(comment(cursor));
|
|
1469
2246
|
}
|
|
1470
2247
|
else {
|
|
1471
|
-
const [
|
|
1472
|
-
|
|
2248
|
+
const previous = value.items[value.items.length - 1];
|
|
2249
|
+
if (previous && !previous.comma) {
|
|
2250
|
+
throw new ParseError(input, cursor.value.loc.start, 'Missing comma between array elements');
|
|
2251
|
+
}
|
|
2252
|
+
const results = walkValue$1(cursor, input);
|
|
2253
|
+
const item = results[0];
|
|
2254
|
+
const additional_comments = results.slice(1);
|
|
2255
|
+
value.items.push({
|
|
1473
2256
|
type: NodeType.InlineItem,
|
|
1474
2257
|
loc: cloneLocation(item.loc),
|
|
1475
2258
|
item,
|
|
1476
2259
|
comma: false
|
|
1477
|
-
};
|
|
1478
|
-
value.items.push(inline_item);
|
|
2260
|
+
});
|
|
1479
2261
|
merge(comments, additional_comments);
|
|
1480
2262
|
}
|
|
1481
2263
|
cursor.next();
|
|
@@ -1483,7 +2265,7 @@ function inlineArray(cursor, input) {
|
|
|
1483
2265
|
if (cursor.done ||
|
|
1484
2266
|
cursor.value.type !== TokenType.Bracket ||
|
|
1485
2267
|
cursor.value.raw !== ']') {
|
|
1486
|
-
throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "]"
|
|
2268
|
+
throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "]"`);
|
|
1487
2269
|
}
|
|
1488
2270
|
value.loc.end = cursor.value.loc.end;
|
|
1489
2271
|
return [value, comments];
|
|
@@ -3218,6 +4000,21 @@ function getLine(lines, index) {
|
|
|
3218
4000
|
return lines[index - 1];
|
|
3219
4001
|
}
|
|
3220
4002
|
|
|
4003
|
+
/**
|
|
4004
|
+
* Recursively tracks all nested inline tables within an inline table.
|
|
4005
|
+
* This ensures that nested inline tables like { nest = {} } are also tracked as immutable.
|
|
4006
|
+
*/
|
|
4007
|
+
function trackNestedInlineTables(inlineTable, basePath, inlineTables) {
|
|
4008
|
+
for (const item of inlineTable.items) {
|
|
4009
|
+
const keyValue = item.item;
|
|
4010
|
+
const fullPath = basePath.concat(keyValue.key.value);
|
|
4011
|
+
if (keyValue.value.type === NodeType.InlineTable) {
|
|
4012
|
+
inlineTables.add(joinKey(fullPath));
|
|
4013
|
+
// Recursively track nested inline tables
|
|
4014
|
+
trackNestedInlineTables(keyValue.value, fullPath, inlineTables);
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
3221
4018
|
/**
|
|
3222
4019
|
* Converts the given AST to a JavaScript object.
|
|
3223
4020
|
*
|
|
@@ -3230,13 +4027,16 @@ function toJS(ast, input = '') {
|
|
|
3230
4027
|
const tables = new Set();
|
|
3231
4028
|
const table_arrays = new Set();
|
|
3232
4029
|
const defined = new Set();
|
|
4030
|
+
const implicit_tables = new Set();
|
|
4031
|
+
const inline_tables = new Set();
|
|
3233
4032
|
let active = result;
|
|
3234
4033
|
let skip_depth = 0;
|
|
4034
|
+
let active_path = [];
|
|
3235
4035
|
traverse(ast, {
|
|
3236
4036
|
[NodeType.Table](node) {
|
|
3237
4037
|
const key = node.key.item.value;
|
|
3238
4038
|
try {
|
|
3239
|
-
validateKey(result, key, node.type, { tables, table_arrays, defined });
|
|
4039
|
+
validateKey(result, [], key, node.type, { tables, table_arrays, defined, implicit_tables, inline_tables });
|
|
3240
4040
|
}
|
|
3241
4041
|
catch (err) {
|
|
3242
4042
|
const e = err;
|
|
@@ -3246,11 +4046,12 @@ function toJS(ast, input = '') {
|
|
|
3246
4046
|
tables.add(joined_key);
|
|
3247
4047
|
defined.add(joined_key);
|
|
3248
4048
|
active = ensureTable(result, key);
|
|
4049
|
+
active_path = key;
|
|
3249
4050
|
},
|
|
3250
4051
|
[NodeType.TableArray](node) {
|
|
3251
4052
|
const key = node.key.item.value;
|
|
3252
4053
|
try {
|
|
3253
|
-
validateKey(result, key, node.type, { tables, table_arrays, defined });
|
|
4054
|
+
validateKey(result, [], key, node.type, { tables, table_arrays, defined, implicit_tables, inline_tables });
|
|
3254
4055
|
}
|
|
3255
4056
|
catch (err) {
|
|
3256
4057
|
const e = err;
|
|
@@ -3260,6 +4061,7 @@ function toJS(ast, input = '') {
|
|
|
3260
4061
|
table_arrays.add(joined_key);
|
|
3261
4062
|
defined.add(joined_key);
|
|
3262
4063
|
active = ensureTableArray(result, key);
|
|
4064
|
+
active_path = key;
|
|
3263
4065
|
},
|
|
3264
4066
|
[NodeType.KeyValue]: {
|
|
3265
4067
|
enter(node) {
|
|
@@ -3267,16 +4069,48 @@ function toJS(ast, input = '') {
|
|
|
3267
4069
|
return;
|
|
3268
4070
|
const key = node.key.value;
|
|
3269
4071
|
try {
|
|
3270
|
-
validateKey(active, key, node.type, {
|
|
4072
|
+
validateKey(active, active_path, key, node.type, {
|
|
4073
|
+
tables,
|
|
4074
|
+
table_arrays,
|
|
4075
|
+
defined,
|
|
4076
|
+
implicit_tables,
|
|
4077
|
+
inline_tables
|
|
4078
|
+
});
|
|
3271
4079
|
}
|
|
3272
4080
|
catch (err) {
|
|
3273
4081
|
const e = err;
|
|
3274
4082
|
throw new ParseError(input, node.key.loc.start, e.message);
|
|
3275
4083
|
}
|
|
3276
|
-
|
|
4084
|
+
// Track implicit tables created by dotted keys.
|
|
4085
|
+
// Example: within [fruit], `apple.color = ...` implicitly defines tables `fruit.apple`.
|
|
4086
|
+
// Example: `type.name = ...` implicitly defines table `product.type`.
|
|
4087
|
+
if (key.length > 1) {
|
|
4088
|
+
for (let i = 1; i < key.length; i++) {
|
|
4089
|
+
const implicit = joinKey(active_path.concat(key.slice(0, i)));
|
|
4090
|
+
implicit_tables.add(implicit);
|
|
4091
|
+
defined.add(implicit);
|
|
4092
|
+
}
|
|
4093
|
+
}
|
|
4094
|
+
let value;
|
|
4095
|
+
try {
|
|
4096
|
+
value = toValue(node.value);
|
|
4097
|
+
}
|
|
4098
|
+
catch (err) {
|
|
4099
|
+
// Convert plain Errors from toValue() to ParseErrors with location info
|
|
4100
|
+
const e = err;
|
|
4101
|
+
throw new ParseError(input, node.value.loc.start, e.message);
|
|
4102
|
+
}
|
|
4103
|
+
// Inline tables are immutable in TOML: once defined, they cannot be extended.
|
|
4104
|
+
// Track their key path so later dotted keys can be rejected.
|
|
4105
|
+
if (node.value.type === NodeType.InlineTable) {
|
|
4106
|
+
const base_path = active_path.concat(key);
|
|
4107
|
+
inline_tables.add(joinKey(base_path));
|
|
4108
|
+
// Also track nested inline tables within this inline table
|
|
4109
|
+
trackNestedInlineTables(node.value, base_path, inline_tables);
|
|
4110
|
+
}
|
|
3277
4111
|
const target = key.length > 1 ? ensureTable(active, key.slice(0, -1)) : active;
|
|
3278
4112
|
target[last(key)] = value;
|
|
3279
|
-
defined.add(joinKey(key));
|
|
4113
|
+
defined.add(joinKey(active_path.concat(key)));
|
|
3280
4114
|
}
|
|
3281
4115
|
},
|
|
3282
4116
|
[NodeType.InlineTable]: {
|
|
@@ -3295,9 +4129,39 @@ function toValue(node) {
|
|
|
3295
4129
|
switch (node.type) {
|
|
3296
4130
|
case NodeType.InlineTable:
|
|
3297
4131
|
const result = blank();
|
|
4132
|
+
const defined_keys = new Set();
|
|
4133
|
+
const defined_prefixes = new Map(); // prefix -> one of the full keys that uses it
|
|
3298
4134
|
node.items.forEach(({ item }) => {
|
|
3299
4135
|
const key = item.key.value;
|
|
3300
4136
|
const value = toValue(item.value);
|
|
4137
|
+
// Check for duplicate keys and conflicting key paths
|
|
4138
|
+
const full_key = joinKey(key);
|
|
4139
|
+
// Check if this exact key was already defined
|
|
4140
|
+
if (defined_keys.has(full_key)) {
|
|
4141
|
+
throw new Error(`Duplicate key "${full_key}" in inline table`);
|
|
4142
|
+
}
|
|
4143
|
+
// Check if any prefix of this key conflicts with an existing key
|
|
4144
|
+
// e.g., if "a.b" is defined, we can't later define "a.b.c" (would overwrite the value)
|
|
4145
|
+
for (let i = 1; i < key.length; i++) {
|
|
4146
|
+
const prefix = joinKey(key.slice(0, i));
|
|
4147
|
+
if (defined_keys.has(prefix)) {
|
|
4148
|
+
throw new Error(`Key "${full_key}" conflicts with already defined key "${prefix}" in inline table`);
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
// Check if this key is a prefix of an already defined key
|
|
4152
|
+
// e.g., if "a.b.c" is defined, we can't later define "a.b" (would overwrite the table)
|
|
4153
|
+
if (defined_prefixes.has(full_key)) {
|
|
4154
|
+
const existing = defined_prefixes.get(full_key);
|
|
4155
|
+
throw new Error(`Key "${full_key}" conflicts with already defined key "${existing}" in inline table`);
|
|
4156
|
+
}
|
|
4157
|
+
defined_keys.add(full_key);
|
|
4158
|
+
// Track all prefixes of this key
|
|
4159
|
+
for (let i = 1; i < key.length; i++) {
|
|
4160
|
+
const prefix = joinKey(key.slice(0, i));
|
|
4161
|
+
if (!defined_prefixes.has(prefix)) {
|
|
4162
|
+
defined_prefixes.set(prefix, full_key);
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
3301
4165
|
const target = key.length > 1 ? ensureTable(result, key.slice(0, -1)) : result;
|
|
3302
4166
|
target[last(key)] = value;
|
|
3303
4167
|
});
|
|
@@ -3318,7 +4182,67 @@ function toValue(node) {
|
|
|
3318
4182
|
throw new Error(`Unrecognized value type "${node.type}"`);
|
|
3319
4183
|
}
|
|
3320
4184
|
}
|
|
3321
|
-
function validateKey(object, key, type, state) {
|
|
4185
|
+
function validateKey(object, prefix, key, type, state) {
|
|
4186
|
+
const full_key = prefix.concat(key);
|
|
4187
|
+
const joined_full_key = joinKey(full_key);
|
|
4188
|
+
// 0. Inline tables are immutable.
|
|
4189
|
+
// Once a key is assigned an inline table, it cannot be extended by dotted keys or table headers.
|
|
4190
|
+
// (toml-test invalid: spec-1.1.0/common-49-0, inline-table/overwrite-02, inline-table/overwrite-05)
|
|
4191
|
+
if (type === NodeType.KeyValue && key.length > 1) {
|
|
4192
|
+
for (let i = 1; i < key.length; i++) {
|
|
4193
|
+
const candidate = joinKey(prefix.concat(key.slice(0, i)));
|
|
4194
|
+
if (state.inline_tables.has(candidate)) {
|
|
4195
|
+
throw new Error(`Invalid key, cannot extend an inline table at ${candidate}`);
|
|
4196
|
+
}
|
|
4197
|
+
}
|
|
4198
|
+
}
|
|
4199
|
+
// Also check if a table header tries to extend an inline table
|
|
4200
|
+
if ((type === NodeType.Table || type === NodeType.TableArray) && state.inline_tables.has(joined_full_key)) {
|
|
4201
|
+
throw new Error(`Invalid key, cannot extend an inline table at ${joined_full_key}`);
|
|
4202
|
+
}
|
|
4203
|
+
// Check if table header path contains an inline table
|
|
4204
|
+
if (type === NodeType.Table || type === NodeType.TableArray) {
|
|
4205
|
+
for (let i = 1; i < key.length; i++) {
|
|
4206
|
+
const candidate = joinKey(prefix.concat(key.slice(0, i)));
|
|
4207
|
+
if (state.inline_tables.has(candidate)) {
|
|
4208
|
+
throw new Error(`Invalid key, cannot extend an inline table at ${candidate}`);
|
|
4209
|
+
}
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
// 0a. Dotted key-value assignments cannot traverse into an array-of-tables.
|
|
4213
|
+
// This would be ambiguous (which element?) and is rejected by toml-test's
|
|
4214
|
+
// append-with-dotted-keys fixtures.
|
|
4215
|
+
if (type === NodeType.KeyValue && key.length > 1) {
|
|
4216
|
+
for (let i = 1; i < key.length; i++) {
|
|
4217
|
+
const candidate = joinKey(prefix.concat(key.slice(0, i)));
|
|
4218
|
+
if (state.table_arrays.has(candidate)) {
|
|
4219
|
+
throw new Error(`Invalid key, cannot traverse into an array of tables at ${candidate}`);
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
// 0b. Tables created implicitly by dotted keys cannot be re-opened via table headers.
|
|
4224
|
+
// (toml-test invalid: spec-1.1.0/common-46-0 and common-46-1)
|
|
4225
|
+
if ((type === NodeType.Table || type === NodeType.TableArray) && state.implicit_tables.has(joined_full_key)) {
|
|
4226
|
+
throw new Error(`Invalid key, a table has already been defined implicitly named ${joined_full_key}`);
|
|
4227
|
+
}
|
|
4228
|
+
// 0c. A table path cannot later be re-assigned as a value.
|
|
4229
|
+
// Example: `type.name = "Nail"` then `type = { edible = false }` is invalid.
|
|
4230
|
+
// Example: `a.b.c = 1` then `a.b = 2` is invalid (a.b was implicitly created as a table).
|
|
4231
|
+
// (toml-test invalid: spec-1.1.0/common-50-0, table/append-with-dotted-keys-05)
|
|
4232
|
+
if (type === NodeType.KeyValue && state.implicit_tables.has(joined_full_key)) {
|
|
4233
|
+
throw new Error(`Invalid key, a table has already been defined named ${joined_full_key}`);
|
|
4234
|
+
}
|
|
4235
|
+
// 0d. Dotted keys cannot extend tables that were explicitly defined earlier.
|
|
4236
|
+
// Example: `[a.b.c]` followed by `[a]` then `b.c.t = "value"` is invalid.
|
|
4237
|
+
// (toml-test invalid: table/append-with-dotted-keys-01, table/append-with-dotted-keys-02)
|
|
4238
|
+
if (type === NodeType.KeyValue && key.length > 1) {
|
|
4239
|
+
for (let i = 1; i <= key.length; i++) {
|
|
4240
|
+
const candidate = joinKey(prefix.concat(key.slice(0, i)));
|
|
4241
|
+
if (state.tables.has(candidate)) {
|
|
4242
|
+
throw new Error(`Invalid key, cannot add to an explicitly defined table ${candidate} using dotted keys`);
|
|
4243
|
+
}
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
3322
4246
|
// 1. Cannot override primitive value
|
|
3323
4247
|
let parts = [];
|
|
3324
4248
|
let index = 0;
|
|
@@ -3327,20 +4251,28 @@ function validateKey(object, key, type, state) {
|
|
|
3327
4251
|
if (!has(object, part))
|
|
3328
4252
|
return;
|
|
3329
4253
|
if (isPrimitive(object[part])) {
|
|
3330
|
-
|
|
4254
|
+
const fullKey = joinKey(prefix.concat(parts));
|
|
4255
|
+
throw new Error(`Invalid key, a value has already been defined for ${fullKey}`);
|
|
3331
4256
|
}
|
|
3332
|
-
const joined_parts = joinKey(parts);
|
|
4257
|
+
const joined_parts = joinKey(prefix.concat(parts));
|
|
3333
4258
|
if (Array.isArray(object[part]) && !state.table_arrays.has(joined_parts)) {
|
|
3334
4259
|
throw new Error(`Invalid key, cannot add to a static array at ${joined_parts}`);
|
|
3335
4260
|
}
|
|
3336
4261
|
const next_is_last = index++ < key.length - 1;
|
|
3337
4262
|
object = Array.isArray(object[part]) && next_is_last ? last(object[part]) : object[part];
|
|
3338
4263
|
}
|
|
3339
|
-
const joined_key =
|
|
4264
|
+
const joined_key = joined_full_key;
|
|
3340
4265
|
// 2. Cannot override table
|
|
3341
4266
|
if (object && type === NodeType.Table && state.defined.has(joined_key)) {
|
|
3342
4267
|
throw new Error(`Invalid key, a table has already been defined named ${joined_key}`);
|
|
3343
4268
|
}
|
|
4269
|
+
// 2b. Cannot assign a value to a path that is already a table (explicit or implicit).
|
|
4270
|
+
if (object && type === NodeType.KeyValue && key.length === 1 && state.defined.has(joined_key)) {
|
|
4271
|
+
// If the path exists as a structured value, overriding it is invalid.
|
|
4272
|
+
if (!isPrimitive(object)) {
|
|
4273
|
+
throw new Error(`Invalid key, a table has already been defined named ${joined_key}`);
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
3344
4276
|
// 3. Cannot add table array to static array or table
|
|
3345
4277
|
if (object && type === NodeType.TableArray && !state.table_arrays.has(joined_key)) {
|
|
3346
4278
|
throw new Error(`Invalid key, cannot add an array of tables to a table at ${joined_key}`);
|