@decimalturn/toml-patch 0.5.2 → 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.
@@ -1,4 +1,4 @@
1
- //! @decimalturn/toml-patch v0.5.2 - https://github.com/DecimalTurn/toml-patch - @license: MIT
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] || 0;
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
- return input.substr(start, end - start);
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 match;
186
- while ((match = BY_NEW_LINE.exec(input)) != null) {
187
- indexes.push(match.index + match[0].length - 1);
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 + 1);
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
- while (!cursor.peek().done && !IS_NEW_LINE.test(cursor.peek().value)) {
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
- let quotes = multiline_char + multiline_char + multiline_char;
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
- // The reason why we need to check if there is more than three is because we have to match the last 3 quotes, not the first 3 that appears consecutively
310
- // See spec-string-basic-multiline-9.toml
311
- while (!cursor.done && (!checkThree(input, cursor.index, multiline_char) || CheckMoreThanThree(input, cursor.index, multiline_char))) {
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)
@@ -416,6 +547,11 @@ function checkThree(input, current, check) {
416
547
  if (!has3) {
417
548
  return false;
418
549
  }
550
+ // Only check for escaping in basic strings (double quotes)
551
+ // Literal strings (single quotes) don't support escape sequences
552
+ if (check === SINGLE_QUOTE) {
553
+ return check; // No escaping in literal strings
554
+ }
419
555
  // Check if the sequence is escaped
420
556
  const precedingText = input.slice(0, current); // Get the text before the current position
421
557
  const backslashes = precedingText.match(/\\+$/); // Match trailing backslashes
@@ -425,15 +561,6 @@ function checkThree(input, current, check) {
425
561
  const isEscaped = backslashes[0].length % 2 !== 0; // Odd number of backslashes means escaped
426
562
  return isEscaped ? false : check; // Return `check` if not escaped, otherwise `false`
427
563
  }
428
- function CheckMoreThanThree(input, current, check) {
429
- if (!check) {
430
- return false;
431
- }
432
- return (input[current] === check &&
433
- input[current + 1] === check &&
434
- input[current + 2] === check &&
435
- input[current + 3] === check);
436
- }
437
564
 
438
565
  function last(values) {
439
566
  return values[values.length - 1];
@@ -564,11 +691,75 @@ function escapeDoubleQuotes(value) {
564
691
  }
565
692
  return result;
566
693
  }
694
+ function isBackslashEscaped(source, backslashOffset) {
695
+ let precedingBackslashes = 0;
696
+ for (let i = backslashOffset - 1; i >= 0 && source[i] === '\\'; i--) {
697
+ precedingBackslashes++;
698
+ }
699
+ return precedingBackslashes % 2 !== 0;
700
+ }
567
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
+ }
736
+ // TOML 1.1.0: Handle \xHH hex escapes (for codepoints < 255)
737
+ const HEX_ESCAPE = /\\x([a-fA-F0-9]{2})/g;
738
+ const hexEscapeSource = escaped;
739
+ let withHexEscapes = hexEscapeSource.replace(HEX_ESCAPE, (match, hex, offset) => {
740
+ if (isBackslashEscaped(hexEscapeSource, offset)) {
741
+ return match;
742
+ }
743
+ const codePoint = parseInt(hex, 16);
744
+ const asString = String.fromCharCode(codePoint);
745
+ // Escape for JSON if needed
746
+ if (codePoint < 0x20 || codePoint === 0x22 || codePoint === 0x5C) {
747
+ return trim(JSON.stringify(asString), 1);
748
+ }
749
+ return asString;
750
+ });
751
+ // TOML 1.1.0: Handle \e escape character (ESC = 0x1B)
752
+ const eEscapeSource = withHexEscapes;
753
+ withHexEscapes = eEscapeSource.replace(/\\e/g, (match, offset) => {
754
+ if (isBackslashEscaped(eEscapeSource, offset)) {
755
+ return match;
756
+ }
757
+ return '\\u001b';
758
+ });
568
759
  // JSON.parse handles everything except \UXXXXXXXX
569
760
  // replace those instances with code point, escape that, and then parse
570
761
  const LARGE_UNICODE = /\\U[a-fA-F0-9]{8}/g;
571
- const json_escaped = escaped.replace(LARGE_UNICODE, value => {
762
+ const json_escaped = withHexEscapes.replace(LARGE_UNICODE, value => {
572
763
  const code_point = parseInt(value.replace('\\U', ''), 16);
573
764
  const as_string = String.fromCodePoint(code_point);
574
765
  return trim(JSON.stringify(as_string), 1);
@@ -789,14 +980,15 @@ class DateFormatHelper {
789
980
  }
790
981
  // Patterns for different date/time formats
791
982
  DateFormatHelper.IS_DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
792
- DateFormatHelper.IS_TIME_ONLY = /^\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
793
- DateFormatHelper.IS_LOCAL_DATETIME_T = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
794
- DateFormatHelper.IS_LOCAL_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/;
795
- DateFormatHelper.IS_OFFSET_DATETIME_T = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
796
- DateFormatHelper.IS_OFFSET_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
983
+ DateFormatHelper.IS_TIME_ONLY = /^\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
984
+ DateFormatHelper.IS_LOCAL_DATETIME_T = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
985
+ DateFormatHelper.IS_LOCAL_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?$/;
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})$/;
987
+ DateFormatHelper.IS_OFFSET_DATETIME_SPACE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/;
797
988
  // Legacy patterns from parse-toml.ts (for compatibility)
798
- DateFormatHelper.IS_FULL_DATE = /(\d{4})-(\d{2})-(\d{2})/;
799
- DateFormatHelper.IS_FULL_TIME = /(\d{2}):(\d{2}):(\d{2})/;
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+))?/;
800
992
  /**
801
993
  * Custom Date class for local dates (date-only).
802
994
  * Format: 1979-05-27
@@ -818,9 +1010,15 @@ class LocalDate extends Date {
818
1010
  */
819
1011
  class LocalTime extends Date {
820
1012
  constructor(value, originalFormat) {
821
- // For local time, use a fixed date (1970-01-01) and the provided time
1013
+ // Normalize time to include seconds if missing (TOML 1.1.0 allows optional seconds)
1014
+ let normalizedValue = value;
1015
+ if (!/:\d{2}:\d{2}/.test(value)) {
1016
+ // No seconds present, add :00
1017
+ normalizedValue = value + ':00';
1018
+ }
1019
+ // For local time, use year 0000 as the base (TOML spec compliance)
822
1020
  // Add 'Z' to ensure it's parsed as UTC regardless of system timezone
823
- super(`1970-01-01T${value}Z`);
1021
+ super(`0000-01-01T${normalizedValue}Z`);
824
1022
  this.originalFormat = originalFormat;
825
1023
  }
826
1024
  toISOString() {
@@ -852,8 +1050,13 @@ class LocalTime extends Date {
852
1050
  */
853
1051
  class LocalDateTime extends Date {
854
1052
  constructor(value, useSpaceSeparator = false, originalFormat) {
1053
+ // Normalize time part to include seconds if missing (TOML 1.1.0 allows optional seconds)
1054
+ let normalizedValue = value;
1055
+ if (!/\d{2}:\d{2}:\d{2}/.test(value)) {
1056
+ normalizedValue = value.replace(/(\d{2}:\d{2})([\s\-+TZ]|$)/, '$1:00$2');
1057
+ }
855
1058
  // Convert space to T for Date parsing, but remember the original format
856
- super(value.replace(' ', 'T') + 'Z');
1059
+ super(normalizedValue.replace(' ', 'T') + 'Z');
857
1060
  this.useSpaceSeparator = false;
858
1061
  this.useSpaceSeparator = useSpaceSeparator;
859
1062
  this.originalFormat = originalFormat || value;
@@ -892,7 +1095,12 @@ class LocalDateTime extends Date {
892
1095
  */
893
1096
  class OffsetDateTime extends Date {
894
1097
  constructor(value, useSpaceSeparator = false) {
895
- super(value.replace(' ', 'T'));
1098
+ // Normalize time part to include seconds if missing (TOML 1.1.0 allows optional seconds)
1099
+ let normalizedValue = value;
1100
+ if (!/\d{2}:\d{2}:\d{2}/.test(value)) {
1101
+ normalizedValue = value.replace(/(\d{2}:\d{2})([\s\-+TZ]|$)/, '$1:00$2');
1102
+ }
1103
+ super(normalizedValue.replace(' ', 'T'));
896
1104
  this.useSpaceSeparator = false;
897
1105
  this.useSpaceSeparator = useSpaceSeparator;
898
1106
  this.originalFormat = value;
@@ -953,16 +1161,19 @@ const TRUE = 'true';
953
1161
  const FALSE = 'false';
954
1162
  const HAS_E = /e/i;
955
1163
  const IS_DIVIDER = /\_/g;
956
- const IS_INF = /inf/;
957
- const IS_NAN = /nan/;
958
- const IS_HEX = /^0x/;
959
- const IS_OCTAL = /^0o/;
960
- 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;
961
1169
  function* parseTOML(input) {
962
- const tokens = tokenize(input);
963
- const cursor = new Cursor(tokens);
1170
+ // Use non-generator parsing to avoid stack overflow on deeply nested structures
1171
+ const cursor = new Cursor(tokenize(input));
964
1172
  while (!cursor.next().done) {
965
- yield* walkBlock(cursor, input);
1173
+ const blocks = walkBlock(cursor, input);
1174
+ for (const block of blocks) {
1175
+ yield block;
1176
+ }
966
1177
  }
967
1178
  }
968
1179
  /**
@@ -977,57 +1188,14 @@ function* continueParsingTOML(existingAst, remainingString) {
977
1188
  for (const item of existingAst) {
978
1189
  yield item;
979
1190
  }
980
- // Parse and yield all items from the remaining string
981
- for (const item of parseTOML(remainingString)) {
982
- yield item;
983
- }
984
- }
985
- function* walkBlock(cursor, input) {
986
- if (cursor.value.type === TokenType.Comment) {
987
- yield comment(cursor);
988
- }
989
- else if (cursor.value.type === TokenType.Bracket) {
990
- yield table(cursor, input);
991
- }
992
- else if (cursor.value.type === TokenType.Literal) {
993
- yield* keyValue(cursor, input);
994
- }
995
- else {
996
- throw new ParseError(input, cursor.value.loc.start, `Unexpected token "${cursor.value.type}". Expected Comment, Bracket, or String`);
997
- }
998
- }
999
- function* walkValue$1(cursor, input) {
1000
- if (cursor.value.type === TokenType.Literal) {
1001
- if (cursor.value.raw[0] === DOUBLE_QUOTE || cursor.value.raw[0] === SINGLE_QUOTE) {
1002
- yield string(cursor);
1003
- }
1004
- else if (cursor.value.raw === TRUE || cursor.value.raw === FALSE) {
1005
- yield boolean(cursor);
1006
- }
1007
- else if (dateFormatHelper.IS_FULL_DATE.test(cursor.value.raw) || dateFormatHelper.IS_FULL_TIME.test(cursor.value.raw)) {
1008
- yield datetime(cursor, input);
1009
- }
1010
- else if ((!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) ||
1011
- IS_INF.test(cursor.value.raw) ||
1012
- IS_NAN.test(cursor.value.raw) ||
1013
- (HAS_E.test(cursor.value.raw) && !IS_HEX.test(cursor.value.raw))) {
1014
- yield float(cursor, input);
1015
- }
1016
- else {
1017
- 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;
1018
1197
  }
1019
1198
  }
1020
- else if (cursor.value.type === TokenType.Curly) {
1021
- yield inlineTable(cursor, input);
1022
- }
1023
- else if (cursor.value.type === TokenType.Bracket) {
1024
- const [inline_array, comments] = inlineArray(cursor, input);
1025
- yield inline_array;
1026
- yield* comments;
1027
- }
1028
- else {
1029
- throw new ParseError(input, cursor.value.loc.start, `Unrecognized token type "${cursor.value.type}". Expected String, Curly, or Bracket`);
1030
- }
1031
1199
  }
1032
1200
  function comment(cursor) {
1033
1201
  // # line comment
@@ -1061,8 +1229,22 @@ function table(cursor, input) {
1061
1229
  if (is_table && cursor.value.raw !== '[') {
1062
1230
  throw new ParseError(input, cursor.value.loc.start, `Expected table opening "[", found ${cursor.value.raw}`);
1063
1231
  }
1064
- if (!is_table && (cursor.value.raw !== '[' || cursor.peek().value.raw !== '[')) {
1065
- throw new ParseError(input, cursor.value.loc.start, `Expected array of tables opening "[[", found ${cursor.value.raw + cursor.peek().value.raw}`);
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
+ }
1066
1248
  }
1067
1249
  // Set start location from opening tag
1068
1250
  const key = is_table
@@ -1081,23 +1263,84 @@ function table(cursor, input) {
1081
1263
  if (cursor.done) {
1082
1264
  throw new ParseError(input, key.loc.start, `Expected table key, reached end of file`);
1083
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
+ }
1084
1297
  key.item = {
1085
1298
  type: NodeType.Key,
1086
1299
  loc: cloneLocation(cursor.value.loc),
1087
1300
  raw: cursor.value.raw,
1088
- value: [parseString(cursor.value.raw)]
1301
+ value: keyValue
1089
1302
  };
1090
1303
  while (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
1091
1304
  cursor.next();
1092
1305
  const dot = cursor.value;
1093
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
+ }
1094
1318
  const before = ' '.repeat(dot.loc.start.column - key.item.loc.end.column);
1095
1319
  const after = ' '.repeat(cursor.value.loc.start.column - dot.loc.end.column);
1096
1320
  key.item.loc.end = cursor.value.loc.end;
1097
1321
  key.item.raw += `${before}.${after}${cursor.value.raw}`;
1098
- key.item.value.push(parseString(cursor.value.raw));
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
+ }
1099
1329
  }
1100
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
+ }
1101
1344
  if (is_table && (cursor.done || cursor.value.raw !== ']')) {
1102
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}`);
1103
1346
  }
@@ -1110,15 +1353,37 @@ function table(cursor, input) {
1110
1353
  ? 'end of file'
1111
1354
  : cursor.value.raw + cursor.peek().value.raw}`);
1112
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
+ }
1113
1366
  // Set end location from closing tag
1114
1367
  if (!is_table)
1115
1368
  cursor.next();
1116
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
+ }
1117
1382
  // Add child items
1118
1383
  let items = [];
1119
1384
  while (!cursor.peek().done && cursor.peek().value.type !== TokenType.Bracket) {
1120
1385
  cursor.next();
1121
- merge(items, [...walkBlock(cursor, input)]);
1386
+ merge(items, walkBlock(cursor, input));
1122
1387
  }
1123
1388
  return {
1124
1389
  type: is_table ? NodeType.Table : NodeType.TableArray,
@@ -1132,56 +1397,20 @@ function table(cursor, input) {
1132
1397
  items
1133
1398
  };
1134
1399
  }
1135
- function keyValue(cursor, input) {
1136
- // 3. KeyValue
1137
- //
1138
- // key = value
1139
- // ^-^ key
1140
- // ^ equals
1141
- // ^---^ value
1142
- const key = {
1143
- type: NodeType.Key,
1144
- loc: cloneLocation(cursor.value.loc),
1145
- raw: cursor.value.raw,
1146
- value: [parseString(cursor.value.raw)]
1147
- };
1148
- while (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
1149
- cursor.next();
1150
- cursor.next();
1151
- key.loc.end = cursor.value.loc.end;
1152
- key.raw += `.${cursor.value.raw}`;
1153
- key.value.push(parseString(cursor.value.raw));
1154
- }
1155
- cursor.next();
1156
- if (cursor.done || cursor.value.type !== TokenType.Equal) {
1157
- 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);
1158
1404
  }
1159
- const equals = cursor.value.loc.start.column;
1160
- cursor.next();
1161
- if (cursor.done) {
1162
- 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);
1163
1408
  }
1164
- const [value, ...comments] = walkValue$1(cursor, input);
1165
- return [
1166
- {
1167
- type: NodeType.KeyValue,
1168
- key,
1169
- value: value,
1170
- loc: {
1171
- start: clonePosition(key.loc.start),
1172
- end: clonePosition(value.loc.end)
1173
- },
1174
- equals
1175
- },
1176
- ...comments
1177
- ];
1178
- }
1179
- function string(cursor) {
1180
1409
  return {
1181
1410
  type: NodeType.String,
1182
1411
  loc: cursor.value.loc,
1183
1412
  raw: cursor.value.raw,
1184
- value: parseString(cursor.value.raw)
1413
+ value
1185
1414
  };
1186
1415
  }
1187
1416
  function boolean(cursor) {
@@ -1234,6 +1463,10 @@ function datetime(cursor, input) {
1234
1463
  loc = { start, end: cursor.value.loc.end };
1235
1464
  raw += `.${cursor.value.raw}`;
1236
1465
  }
1466
+ // Validate datetime format
1467
+ {
1468
+ validateDateTimeFormat(raw, input, loc.start);
1469
+ }
1237
1470
  if (!dateFormatHelper.IS_FULL_DATE.test(raw)) {
1238
1471
  // Local time only (e.g., "07:32:00" or "07:32:00.999")
1239
1472
  if (dateFormatHelper.IS_TIME_ONLY.test(raw)) {
@@ -1276,18 +1509,227 @@ function datetime(cursor, input) {
1276
1509
  value
1277
1510
  };
1278
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
+ }
1279
1693
  function float(cursor, input) {
1280
1694
  let loc = cursor.value.loc;
1281
1695
  let raw = cursor.value.raw;
1282
1696
  let value;
1283
1697
  if (IS_INF.test(raw)) {
1284
- value = raw === '-inf' ? -Infinity : Infinity;
1698
+ value = raw.startsWith('-') ? -Infinity : Infinity;
1285
1699
  }
1286
1700
  else if (IS_NAN.test(raw)) {
1287
- value = raw === '-nan' ? -NaN : NaN;
1701
+ value = NaN;
1288
1702
  }
1289
1703
  else if (!cursor.peek().done && cursor.peek().value.type === TokenType.Dot) {
1290
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
+ }
1291
1733
  // From spec:
1292
1734
  // | A fractional part is a decimal point followed by one or more digits.
1293
1735
  //
@@ -1299,47 +1741,425 @@ function float(cursor, input) {
1299
1741
  cursor.next();
1300
1742
  raw += `.${cursor.value.raw}`;
1301
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
+ }
1302
1774
  value = Number(raw.replace(IS_DIVIDER, ''));
1303
1775
  }
1304
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
+ }
1305
1813
  value = Number(raw.replace(IS_DIVIDER, ''));
1306
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
+ }
1307
1819
  return { type: NodeType.Float, loc, raw, value };
1308
1820
  }
1309
- function integer(cursor) {
1310
- // > Integer values -0 and +0 are valid and identical to an unprefixed zero
1311
- if (cursor.value.raw === '-0' || cursor.value.raw === '+0') {
1312
- return {
1313
- type: NodeType.Integer,
1314
- loc: cursor.value.loc,
1315
- raw: cursor.value.raw,
1316
- value: 0
1317
- };
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
+ }
1318
1863
  }
1319
1864
  let radix = 10;
1320
- if (IS_HEX.test(cursor.value.raw)) {
1865
+ let numericPart;
1866
+ // Hexadecimal validation
1867
+ if (IS_HEX.test(raw)) {
1321
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
+ }
1322
1891
  }
1323
- else if (IS_OCTAL.test(cursor.value.raw)) {
1892
+ // Octal validation
1893
+ else if (IS_OCTAL.test(raw)) {
1324
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
+ }
1325
1917
  }
1326
- else if (IS_BINARY.test(cursor.value.raw)) {
1918
+ // Binary validation
1919
+ else if (IS_BINARY.test(raw)) {
1327
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
+ }
1328
1943
  }
1329
- const value = parseInt(cursor
1330
- .value.raw.replace(IS_DIVIDER, '')
1944
+ const value = parseInt(raw
1945
+ .replace(IS_DIVIDER, '')
1331
1946
  .replace(IS_OCTAL, '')
1332
1947
  .replace(IS_BINARY, ''), radix);
1948
+ if (Number.isNaN(value)) {
1949
+ throw new ParseError(input, loc.start, `Invalid integer "${raw}"`);
1950
+ }
1333
1951
  return {
1334
1952
  type: NodeType.Integer,
1335
- loc: cursor.value.loc,
1336
- raw: cursor.value.raw,
1953
+ loc: loc,
1954
+ raw: raw,
1337
1955
  value
1338
1956
  };
1339
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
+ }
1340
2160
  function inlineTable(cursor, input) {
1341
2161
  if (cursor.value.raw !== '{') {
1342
- throw new ParseError(input, cursor.value.loc.start, `Expected "{" for inline table, found ${cursor.value.raw}`);
2162
+ throw new ParseError(input, cursor.value.loc.start, `Expected "{" for inline table`);
1343
2163
  }
1344
2164
  // 6. InlineTable
1345
2165
  const value = {
@@ -1347,51 +2167,66 @@ function inlineTable(cursor, input) {
1347
2167
  loc: cloneLocation(cursor.value.loc),
1348
2168
  items: []
1349
2169
  };
2170
+ const comments = [];
1350
2171
  cursor.next();
1351
2172
  while (!cursor.done &&
1352
2173
  !(cursor.value.type === TokenType.Curly && cursor.value.raw === '}')) {
2174
+ if (cursor.value.type === TokenType.Comment) {
2175
+ comments.push(comment(cursor));
2176
+ cursor.next();
2177
+ continue;
2178
+ }
1353
2179
  if (cursor.value.type === TokenType.Comma) {
1354
2180
  const previous = value.items[value.items.length - 1];
1355
2181
  if (!previous) {
1356
2182
  throw new ParseError(input, cursor.value.loc.start, 'Found "," without previous value in inline table');
1357
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
+ }
1358
2187
  previous.comma = true;
1359
2188
  previous.loc.end = cursor.value.loc.start;
1360
2189
  cursor.next();
1361
2190
  continue;
1362
2191
  }
1363
- const [item] = walkBlock(cursor, input);
1364
- if (item.type !== NodeType.KeyValue) {
1365
- throw new ParseError(input, cursor.value.loc.start, `Only key-values are supported in inline tables, found ${item.type}`);
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);
1366
2208
  }
1367
- const inline_item = {
1368
- type: NodeType.InlineItem,
1369
- loc: cloneLocation(item.loc),
1370
- item,
1371
- comma: false
1372
- };
1373
- value.items.push(inline_item);
1374
2209
  cursor.next();
1375
2210
  }
1376
2211
  if (cursor.done ||
1377
2212
  cursor.value.type !== TokenType.Curly ||
1378
2213
  cursor.value.raw !== '}') {
1379
- throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "}", found ${cursor.done ? 'end of file' : cursor.value.raw}`);
2214
+ throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "}"`);
1380
2215
  }
1381
2216
  value.loc.end = cursor.value.loc.end;
1382
- return value;
2217
+ return [value, comments];
1383
2218
  }
1384
2219
  function inlineArray(cursor, input) {
1385
2220
  // 7. InlineArray
1386
2221
  if (cursor.value.raw !== '[') {
1387
- throw new ParseError(input, cursor.value.loc.start, `Expected "[" for inline array, found ${cursor.value.raw}`);
2222
+ throw new ParseError(input, cursor.value.loc.start, `Expected "[" for inline array`);
1388
2223
  }
1389
2224
  const value = {
1390
2225
  type: NodeType.InlineArray,
1391
2226
  loc: cloneLocation(cursor.value.loc),
1392
2227
  items: []
1393
2228
  };
1394
- let comments = [];
2229
+ const comments = [];
1395
2230
  cursor.next();
1396
2231
  while (!cursor.done &&
1397
2232
  !(cursor.value.type === TokenType.Bracket && cursor.value.raw === ']')) {
@@ -1400,6 +2235,9 @@ function inlineArray(cursor, input) {
1400
2235
  if (!previous) {
1401
2236
  throw new ParseError(input, cursor.value.loc.start, 'Found "," without previous value for inline array');
1402
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
+ }
1403
2241
  previous.comma = true;
1404
2242
  previous.loc.end = cursor.value.loc.start;
1405
2243
  }
@@ -1407,14 +2245,19 @@ function inlineArray(cursor, input) {
1407
2245
  comments.push(comment(cursor));
1408
2246
  }
1409
2247
  else {
1410
- const [item, ...additional_comments] = walkValue$1(cursor, input);
1411
- const inline_item = {
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({
1412
2256
  type: NodeType.InlineItem,
1413
2257
  loc: cloneLocation(item.loc),
1414
2258
  item,
1415
2259
  comma: false
1416
- };
1417
- value.items.push(inline_item);
2260
+ });
1418
2261
  merge(comments, additional_comments);
1419
2262
  }
1420
2263
  cursor.next();
@@ -1422,7 +2265,7 @@ function inlineArray(cursor, input) {
1422
2265
  if (cursor.done ||
1423
2266
  cursor.value.type !== TokenType.Bracket ||
1424
2267
  cursor.value.raw !== ']') {
1425
- throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "]", found ${cursor.done ? 'end of file' : cursor.value.raw}`);
2268
+ throw new ParseError(input, cursor.done ? value.loc.start : cursor.value.loc.start, `Expected "]"`);
1426
2269
  }
1427
2270
  value.loc.end = cursor.value.loc.end;
1428
2271
  return [value, comments];
@@ -2272,6 +3115,7 @@ const DEFAULT_TRAILING_COMMA = false;
2272
3115
  const DEFAULT_BRACKET_SPACING = true;
2273
3116
  const DEFAULT_INLINE_TABLE_START = 1;
2274
3117
  const DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES = false;
3118
+ const DEFAULT_USE_TABS_FOR_INDENTATION = false;
2275
3119
  // Detects if trailing commas are used in the existing TOML by examining the AST
2276
3120
  // Returns true if trailing commas are used, false if not or comma-separated structures found (ie. default to false)
2277
3121
  function detectTrailingComma(ast) {
@@ -2465,6 +3309,30 @@ function countTrailingNewlines(str, newlineChar) {
2465
3309
  }
2466
3310
  return count;
2467
3311
  }
3312
+ // Detects if tabs are used for indentation by checking the first few indented lines
3313
+ function detectTabsForIndentation(str) {
3314
+ const lines = str.split(/\r?\n/);
3315
+ let tabCount = 0;
3316
+ let spaceCount = 0;
3317
+ for (const line of lines) {
3318
+ // Skip empty lines
3319
+ if (line.length === 0)
3320
+ continue;
3321
+ // Check the first character of non-empty lines
3322
+ if (line[0] === '\t') {
3323
+ tabCount++;
3324
+ }
3325
+ else if (line[0] === ' ') {
3326
+ spaceCount++;
3327
+ }
3328
+ // If we've seen enough evidence, make a decision
3329
+ if (tabCount + spaceCount >= 5) {
3330
+ break;
3331
+ }
3332
+ }
3333
+ // Prefer tabs if we see more tabs than spaces
3334
+ return tabCount > spaceCount;
3335
+ }
2468
3336
  /**
2469
3337
  * Validates a format object and warns about unsupported properties.
2470
3338
  * Throws errors for supported properties with invalid types.
@@ -2475,61 +3343,62 @@ function validateFormatObject(format) {
2475
3343
  if (!format || typeof format !== 'object') {
2476
3344
  return {};
2477
3345
  }
2478
- const supportedProperties = new Set(['newLine', 'trailingNewline', 'trailingComma', 'bracketSpacing', 'inlineTableStart', 'truncateZeroTimeInDates']);
3346
+ const supportedProperties = new Set(['newLine', 'trailingNewline', 'trailingComma', 'bracketSpacing', 'inlineTableStart', 'truncateZeroTimeInDates', 'useTabsForIndentation']);
2479
3347
  const validatedFormat = {};
2480
3348
  const unsupportedProperties = [];
2481
3349
  const invalidTypeProperties = [];
2482
- // Check all enumerable properties of the format object
3350
+ // Check all enumerable properties of the format object, including properties
3351
+ // provided via the prototype chain (common in JS Object.create(...) patterns).
2483
3352
  for (const key in format) {
2484
- if (Object.prototype.hasOwnProperty.call(format, key)) {
2485
- if (supportedProperties.has(key)) {
2486
- const value = format[key];
2487
- // Type validation for each property
2488
- switch (key) {
2489
- case 'newLine':
2490
- if (typeof value === 'string') {
2491
- validatedFormat.newLine = value;
2492
- }
2493
- else {
2494
- invalidTypeProperties.push(`${key} (expected string, got ${typeof value})`);
2495
- }
2496
- break;
2497
- case 'trailingNewline':
2498
- if (typeof value === 'boolean' || typeof value === 'number') {
2499
- validatedFormat.trailingNewline = value;
2500
- }
2501
- else {
2502
- invalidTypeProperties.push(`${key} (expected boolean or number, got ${typeof value})`);
2503
- }
2504
- break;
2505
- case 'trailingComma':
2506
- case 'bracketSpacing':
2507
- case 'truncateZeroTimeInDates':
2508
- if (typeof value === 'boolean') {
2509
- validatedFormat[key] = value;
2510
- }
2511
- else {
2512
- invalidTypeProperties.push(`${key} (expected boolean, got ${typeof value})`);
2513
- }
2514
- break;
2515
- case 'inlineTableStart':
2516
- if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
2517
- validatedFormat.inlineTableStart = value;
2518
- }
2519
- else if (value === undefined || value === null) {
2520
- // Allow undefined/null to use default
2521
- validatedFormat.inlineTableStart = value;
2522
- }
2523
- else {
2524
- invalidTypeProperties.push(`${key} (expected non-negative integer or undefined, got ${typeof value})`);
2525
- }
2526
- break;
2527
- }
2528
- }
2529
- else {
2530
- unsupportedProperties.push(key);
3353
+ const isOwnEnumerable = Object.prototype.hasOwnProperty.call(format, key);
3354
+ if (supportedProperties.has(key)) {
3355
+ const value = format[key];
3356
+ // Type validation for each property
3357
+ switch (key) {
3358
+ case 'newLine':
3359
+ if (typeof value === 'string') {
3360
+ validatedFormat.newLine = value;
3361
+ }
3362
+ else {
3363
+ invalidTypeProperties.push(`${key} (expected string, got ${typeof value})`);
3364
+ }
3365
+ break;
3366
+ case 'trailingNewline':
3367
+ if (typeof value === 'boolean' || typeof value === 'number') {
3368
+ validatedFormat.trailingNewline = value;
3369
+ }
3370
+ else {
3371
+ invalidTypeProperties.push(`${key} (expected boolean or number, got ${typeof value})`);
3372
+ }
3373
+ break;
3374
+ case 'trailingComma':
3375
+ case 'bracketSpacing':
3376
+ case 'truncateZeroTimeInDates':
3377
+ case 'useTabsForIndentation':
3378
+ if (typeof value === 'boolean') {
3379
+ validatedFormat[key] = value;
3380
+ }
3381
+ else {
3382
+ invalidTypeProperties.push(`${key} (expected boolean, got ${typeof value})`);
3383
+ }
3384
+ break;
3385
+ case 'inlineTableStart':
3386
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 0) {
3387
+ validatedFormat.inlineTableStart = value;
3388
+ }
3389
+ else if (value === undefined || value === null) {
3390
+ // Allow undefined/null to use default
3391
+ validatedFormat.inlineTableStart = value;
3392
+ }
3393
+ else {
3394
+ invalidTypeProperties.push(`${key} (expected non-negative integer or undefined, got ${typeof value})`);
3395
+ }
3396
+ break;
2531
3397
  }
2532
3398
  }
3399
+ else if (isOwnEnumerable) {
3400
+ unsupportedProperties.push(key);
3401
+ }
2533
3402
  }
2534
3403
  // Warn about unsupported properties
2535
3404
  if (unsupportedProperties.length > 0) {
@@ -2550,7 +3419,7 @@ function validateFormatObject(format) {
2550
3419
  * @returns A resolved TomlFormat instance
2551
3420
  */
2552
3421
  function resolveTomlFormat(format, fallbackFormat) {
2553
- var _a, _b, _c, _d, _e;
3422
+ var _a, _b, _c, _d, _e, _f;
2554
3423
  if (format) {
2555
3424
  // If format is provided, validate and merge it with fallback
2556
3425
  if (format instanceof TomlFormat) {
@@ -2560,7 +3429,7 @@ function resolveTomlFormat(format, fallbackFormat) {
2560
3429
  // Validate the format object and warn about unsupported properties
2561
3430
  const validatedFormat = validateFormatObject(format);
2562
3431
  // Create a new TomlFormat instance with validated properties
2563
- return new TomlFormat((_a = validatedFormat.newLine) !== null && _a !== void 0 ? _a : fallbackFormat.newLine, (_b = validatedFormat.trailingNewline) !== null && _b !== void 0 ? _b : fallbackFormat.trailingNewline, (_c = validatedFormat.trailingComma) !== null && _c !== void 0 ? _c : fallbackFormat.trailingComma, (_d = validatedFormat.bracketSpacing) !== null && _d !== void 0 ? _d : fallbackFormat.bracketSpacing, validatedFormat.inlineTableStart !== undefined ? validatedFormat.inlineTableStart : fallbackFormat.inlineTableStart, (_e = validatedFormat.truncateZeroTimeInDates) !== null && _e !== void 0 ? _e : fallbackFormat.truncateZeroTimeInDates);
3432
+ return new TomlFormat((_a = validatedFormat.newLine) !== null && _a !== void 0 ? _a : fallbackFormat.newLine, (_b = validatedFormat.trailingNewline) !== null && _b !== void 0 ? _b : fallbackFormat.trailingNewline, (_c = validatedFormat.trailingComma) !== null && _c !== void 0 ? _c : fallbackFormat.trailingComma, (_d = validatedFormat.bracketSpacing) !== null && _d !== void 0 ? _d : fallbackFormat.bracketSpacing, validatedFormat.inlineTableStart !== undefined ? validatedFormat.inlineTableStart : fallbackFormat.inlineTableStart, (_e = validatedFormat.truncateZeroTimeInDates) !== null && _e !== void 0 ? _e : fallbackFormat.truncateZeroTimeInDates, (_f = validatedFormat.useTabsForIndentation) !== null && _f !== void 0 ? _f : fallbackFormat.useTabsForIndentation);
2564
3433
  }
2565
3434
  }
2566
3435
  else {
@@ -2572,8 +3441,7 @@ class TomlFormat {
2572
3441
  // These options were part of the original TimHall's version and are not yet implemented
2573
3442
  //printWidth?: number;
2574
3443
  //tabWidth?: number;
2575
- //useTabs?: boolean;
2576
- constructor(newLine, trailingNewline, trailingComma, bracketSpacing, inlineTableStart, truncateZeroTimeInDates) {
3444
+ constructor(newLine, trailingNewline, trailingComma, bracketSpacing, inlineTableStart, truncateZeroTimeInDates, useTabsForIndentation) {
2577
3445
  // Use provided values or fall back to defaults
2578
3446
  this.newLine = newLine !== null && newLine !== void 0 ? newLine : DEFAULT_NEWLINE;
2579
3447
  this.trailingNewline = trailingNewline !== null && trailingNewline !== void 0 ? trailingNewline : DEFAULT_TRAILING_NEWLINE;
@@ -2581,6 +3449,7 @@ class TomlFormat {
2581
3449
  this.bracketSpacing = bracketSpacing !== null && bracketSpacing !== void 0 ? bracketSpacing : DEFAULT_BRACKET_SPACING;
2582
3450
  this.inlineTableStart = inlineTableStart !== null && inlineTableStart !== void 0 ? inlineTableStart : DEFAULT_INLINE_TABLE_START;
2583
3451
  this.truncateZeroTimeInDates = truncateZeroTimeInDates !== null && truncateZeroTimeInDates !== void 0 ? truncateZeroTimeInDates : DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES;
3452
+ this.useTabsForIndentation = useTabsForIndentation !== null && useTabsForIndentation !== void 0 ? useTabsForIndentation : DEFAULT_USE_TABS_FOR_INDENTATION;
2584
3453
  }
2585
3454
  /**
2586
3455
  * Creates a new TomlFormat instance with default formatting preferences.
@@ -2594,7 +3463,7 @@ class TomlFormat {
2594
3463
  * - truncateZeroTimeInDates: false
2595
3464
  */
2596
3465
  static default() {
2597
- return new TomlFormat(DEFAULT_NEWLINE, DEFAULT_TRAILING_NEWLINE, DEFAULT_TRAILING_COMMA, DEFAULT_BRACKET_SPACING, DEFAULT_INLINE_TABLE_START, DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES);
3466
+ return new TomlFormat(DEFAULT_NEWLINE, DEFAULT_TRAILING_NEWLINE, DEFAULT_TRAILING_COMMA, DEFAULT_BRACKET_SPACING, DEFAULT_INLINE_TABLE_START, DEFAULT_TRUNCATE_ZERO_TIME_IN_DATES, DEFAULT_USE_TABS_FOR_INDENTATION);
2598
3467
  }
2599
3468
  /**
2600
3469
  * Auto-detects formatting preferences from an existing TOML string.
@@ -2634,6 +3503,8 @@ class TomlFormat {
2634
3503
  format.trailingComma = DEFAULT_TRAILING_COMMA;
2635
3504
  format.bracketSpacing = DEFAULT_BRACKET_SPACING;
2636
3505
  }
3506
+ // Detect if tabs are used for indentation
3507
+ format.useTabsForIndentation = detectTabsForIndentation(tomlString);
2637
3508
  // inlineTableStart uses default value since auto-detection would require
2638
3509
  // complex analysis of nested table formatting preferences
2639
3510
  format.inlineTableStart = DEFAULT_INLINE_TABLE_START;
@@ -2963,71 +3834,85 @@ const BY_NEW_LINE = /(\r\n|\n)/g;
2963
3834
  * It preserves the original formatting, spacing, and structure of the TOML file.
2964
3835
  *
2965
3836
  * @param ast - The Abstract Syntax Tree representing the parsed TOML document
2966
- * @param newline - The newline character(s) to use (\n by default)
2967
- * @param options - Optional configuration object
2968
- * @param options.trailingNewline - Number of trailing newlines to add (1 by default)
3837
+ * @param format - The formatting options to use for the output
2969
3838
  * @returns The reconstructed TOML document as a string
2970
3839
  *
2971
3840
  * @example
2972
3841
  * ```typescript
2973
- * const tomlString = toTOML(ast, '\n', { trailingNewline: 1 });
3842
+ * const tomlString = toTOML(ast, TomlFormat.default());
2974
3843
  * ```
2975
3844
  */
2976
3845
  function toTOML(ast, format) {
2977
3846
  const lines = [];
3847
+ const paddingChar = format.useTabsForIndentation ? '\t' : SPACE;
2978
3848
  traverse(ast, {
2979
3849
  [NodeType.TableKey](node) {
2980
3850
  const { start, end } = node.loc;
2981
- write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '[');
2982
- write(lines, { start: { line: end.line, column: end.column - 1 }, end }, ']');
3851
+ write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '[', paddingChar);
3852
+ write(lines, { start: { line: end.line, column: end.column - 1 }, end }, ']', paddingChar);
2983
3853
  },
2984
3854
  [NodeType.TableArrayKey](node) {
2985
3855
  const { start, end } = node.loc;
2986
- write(lines, { start, end: { line: start.line, column: start.column + 2 } }, '[[');
2987
- write(lines, { start: { line: end.line, column: end.column - 2 }, end }, ']]');
3856
+ write(lines, { start, end: { line: start.line, column: start.column + 2 } }, '[[', paddingChar);
3857
+ write(lines, { start: { line: end.line, column: end.column - 2 }, end }, ']]', paddingChar);
2988
3858
  },
2989
3859
  [NodeType.KeyValue](node) {
2990
3860
  const { start: { line } } = node.loc;
2991
- write(lines, { start: { line, column: node.equals }, end: { line, column: node.equals + 1 } }, '=');
3861
+ write(lines, { start: { line, column: node.equals }, end: { line, column: node.equals + 1 } }, '=', paddingChar);
2992
3862
  },
2993
3863
  [NodeType.Key](node) {
2994
- write(lines, node.loc, node.raw);
3864
+ write(lines, node.loc, node.raw, paddingChar);
2995
3865
  },
2996
3866
  [NodeType.String](node) {
2997
- write(lines, node.loc, node.raw);
3867
+ write(lines, node.loc, node.raw, paddingChar);
2998
3868
  },
2999
3869
  [NodeType.Integer](node) {
3000
- write(lines, node.loc, node.raw);
3870
+ write(lines, node.loc, node.raw, paddingChar);
3001
3871
  },
3002
3872
  [NodeType.Float](node) {
3003
- write(lines, node.loc, node.raw);
3873
+ write(lines, node.loc, node.raw, paddingChar);
3004
3874
  },
3005
3875
  [NodeType.Boolean](node) {
3006
- write(lines, node.loc, node.value.toString());
3876
+ write(lines, node.loc, node.value.toString(), paddingChar);
3007
3877
  },
3008
3878
  [NodeType.DateTime](node) {
3009
- write(lines, node.loc, node.raw);
3879
+ write(lines, node.loc, node.raw, paddingChar);
3010
3880
  },
3011
3881
  [NodeType.InlineArray](node) {
3012
3882
  const { start, end } = node.loc;
3013
- write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '[');
3014
- write(lines, { start: { line: end.line, column: end.column - 1 }, end }, ']');
3883
+ write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '[', paddingChar);
3884
+ write(lines, { start: { line: end.line, column: end.column - 1 }, end }, ']', paddingChar);
3015
3885
  },
3016
3886
  [NodeType.InlineTable](node) {
3017
3887
  const { start, end } = node.loc;
3018
- write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '{');
3019
- write(lines, { start: { line: end.line, column: end.column - 1 }, end }, '}');
3888
+ write(lines, { start, end: { line: start.line, column: start.column + 1 } }, '{', paddingChar);
3889
+ write(lines, { start: { line: end.line, column: end.column - 1 }, end }, '}', paddingChar);
3020
3890
  },
3021
3891
  [NodeType.InlineItem](node) {
3022
3892
  if (!node.comma)
3023
3893
  return;
3024
3894
  const start = node.loc.end;
3025
- write(lines, { start, end: { line: start.line, column: start.column + 1 } }, ',');
3895
+ write(lines, { start, end: { line: start.line, column: start.column + 1 } }, ',', paddingChar);
3026
3896
  },
3027
3897
  [NodeType.Comment](node) {
3028
- write(lines, node.loc, node.raw);
3898
+ write(lines, node.loc, node.raw, paddingChar);
3029
3899
  }
3030
3900
  });
3901
+ // Post-process: convert leading spaces to tabs if useTabsForIndentation is enabled
3902
+ if (format.useTabsForIndentation) {
3903
+ for (let i = 0; i < lines.length; i++) {
3904
+ const line = lines[i];
3905
+ // Find the leading whitespace
3906
+ const match = line.match(/^( +)/);
3907
+ if (match) {
3908
+ const leadingSpaces = match[1];
3909
+ // Replace entire leading space sequence with equivalent tabs
3910
+ // Each space becomes a tab (preserving the visual width)
3911
+ const leadingTabs = '\t'.repeat(leadingSpaces.length);
3912
+ lines[i] = leadingTabs + line.substring(leadingSpaces.length);
3913
+ }
3914
+ }
3915
+ }
3031
3916
  return lines.join(format.newLine) + format.newLine.repeat(format.trailingNewline);
3032
3917
  }
3033
3918
  /**
@@ -3044,6 +3929,7 @@ function toTOML(ast, format) {
3044
3929
  * - end: { line: number, column: number } - Ending position (1-indexed line, 0-indexed column)
3045
3930
  * @param raw - The raw string content to write at the specified location.
3046
3931
  * Can contain multiple lines separated by \n or \r\n.
3932
+ * @param paddingChar - The character to use for padding (space or tab)
3047
3933
  *
3048
3934
  * @throws {Error} When there's a mismatch between location span and raw string line count
3049
3935
  * @throws {Error} When attempting to write to an uninitialized line
@@ -3052,11 +3938,11 @@ function toTOML(ast, format) {
3052
3938
  * ```typescript
3053
3939
  * const lines = ['', ''];
3054
3940
  * const location = { start: { line: 1, column: 0 }, end: { line: 1, column: 3 } };
3055
- * write(lines, location, 'key');
3941
+ * write(lines, location, 'key', ' ');
3056
3942
  * // Result: lines[0] becomes 'key'
3057
3943
  * ```
3058
3944
  */
3059
- function write(lines, loc, raw) {
3945
+ function write(lines, loc, raw, paddingChar = SPACE) {
3060
3946
  const raw_lines = raw.split(BY_NEW_LINE).filter(line => line !== '\n' && line !== '\r\n');
3061
3947
  const expected_lines = loc.end.line - loc.start.line + 1;
3062
3948
  if (raw_lines.length !== expected_lines) {
@@ -3070,10 +3956,19 @@ function write(lines, loc, raw) {
3070
3956
  }
3071
3957
  const is_start_line = i === loc.start.line;
3072
3958
  const is_end_line = i === loc.end.line;
3073
- const before = is_start_line
3074
- ? line.substr(0, loc.start.column).padEnd(loc.start.column, SPACE)
3075
- : '';
3076
- const after = is_end_line ? line.substr(loc.end.column) : '';
3959
+ let before = '';
3960
+ if (is_start_line) {
3961
+ const existingBefore = line.substring(0, loc.start.column);
3962
+ if (existingBefore.length < loc.start.column) {
3963
+ // Need to pad - always use spaces during write phase
3964
+ // Tab conversion happens in post-processing for leading indentation only
3965
+ before = existingBefore.padEnd(loc.start.column, SPACE);
3966
+ }
3967
+ else {
3968
+ before = existingBefore;
3969
+ }
3970
+ }
3971
+ const after = is_end_line ? line.substring(loc.end.column) : '';
3077
3972
  lines[i - 1] = before + raw_lines[i - loc.start.line] + after;
3078
3973
  }
3079
3974
  }
@@ -3105,6 +4000,21 @@ function getLine(lines, index) {
3105
4000
  return lines[index - 1];
3106
4001
  }
3107
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
+ }
3108
4018
  /**
3109
4019
  * Converts the given AST to a JavaScript object.
3110
4020
  *
@@ -3117,13 +4027,16 @@ function toJS(ast, input = '') {
3117
4027
  const tables = new Set();
3118
4028
  const table_arrays = new Set();
3119
4029
  const defined = new Set();
4030
+ const implicit_tables = new Set();
4031
+ const inline_tables = new Set();
3120
4032
  let active = result;
3121
4033
  let skip_depth = 0;
4034
+ let active_path = [];
3122
4035
  traverse(ast, {
3123
4036
  [NodeType.Table](node) {
3124
4037
  const key = node.key.item.value;
3125
4038
  try {
3126
- validateKey(result, key, node.type, { tables, table_arrays, defined });
4039
+ validateKey(result, [], key, node.type, { tables, table_arrays, defined, implicit_tables, inline_tables });
3127
4040
  }
3128
4041
  catch (err) {
3129
4042
  const e = err;
@@ -3133,11 +4046,12 @@ function toJS(ast, input = '') {
3133
4046
  tables.add(joined_key);
3134
4047
  defined.add(joined_key);
3135
4048
  active = ensureTable(result, key);
4049
+ active_path = key;
3136
4050
  },
3137
4051
  [NodeType.TableArray](node) {
3138
4052
  const key = node.key.item.value;
3139
4053
  try {
3140
- validateKey(result, key, node.type, { tables, table_arrays, defined });
4054
+ validateKey(result, [], key, node.type, { tables, table_arrays, defined, implicit_tables, inline_tables });
3141
4055
  }
3142
4056
  catch (err) {
3143
4057
  const e = err;
@@ -3147,6 +4061,7 @@ function toJS(ast, input = '') {
3147
4061
  table_arrays.add(joined_key);
3148
4062
  defined.add(joined_key);
3149
4063
  active = ensureTableArray(result, key);
4064
+ active_path = key;
3150
4065
  },
3151
4066
  [NodeType.KeyValue]: {
3152
4067
  enter(node) {
@@ -3154,16 +4069,48 @@ function toJS(ast, input = '') {
3154
4069
  return;
3155
4070
  const key = node.key.value;
3156
4071
  try {
3157
- validateKey(active, key, node.type, { tables, table_arrays, defined });
4072
+ validateKey(active, active_path, key, node.type, {
4073
+ tables,
4074
+ table_arrays,
4075
+ defined,
4076
+ implicit_tables,
4077
+ inline_tables
4078
+ });
3158
4079
  }
3159
4080
  catch (err) {
3160
4081
  const e = err;
3161
4082
  throw new ParseError(input, node.key.loc.start, e.message);
3162
4083
  }
3163
- const value = toValue(node.value);
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
+ }
3164
4111
  const target = key.length > 1 ? ensureTable(active, key.slice(0, -1)) : active;
3165
4112
  target[last(key)] = value;
3166
- defined.add(joinKey(key));
4113
+ defined.add(joinKey(active_path.concat(key)));
3167
4114
  }
3168
4115
  },
3169
4116
  [NodeType.InlineTable]: {
@@ -3182,26 +4129,120 @@ function toValue(node) {
3182
4129
  switch (node.type) {
3183
4130
  case NodeType.InlineTable:
3184
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
3185
4134
  node.items.forEach(({ item }) => {
3186
4135
  const key = item.key.value;
3187
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
+ }
3188
4165
  const target = key.length > 1 ? ensureTable(result, key.slice(0, -1)) : result;
3189
4166
  target[last(key)] = value;
3190
4167
  });
3191
4168
  return result;
3192
4169
  case NodeType.InlineArray:
3193
4170
  return node.items.map(item => toValue(item.item));
4171
+ case NodeType.DateTime:
4172
+ // Preserve TOML date/time custom classes so format is retained when
4173
+ // round-tripping through stringify() (e.g. date-only, time-only, local vs offset).
4174
+ // These classes extend Date, so JS users can still treat them as Dates.
4175
+ return node.value;
3194
4176
  case NodeType.String:
3195
4177
  case NodeType.Integer:
3196
4178
  case NodeType.Float:
3197
4179
  case NodeType.Boolean:
3198
- case NodeType.DateTime:
3199
4180
  return node.value;
3200
4181
  default:
3201
4182
  throw new Error(`Unrecognized value type "${node.type}"`);
3202
4183
  }
3203
4184
  }
3204
- 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
+ }
3205
4246
  // 1. Cannot override primitive value
3206
4247
  let parts = [];
3207
4248
  let index = 0;
@@ -3210,20 +4251,28 @@ function validateKey(object, key, type, state) {
3210
4251
  if (!has(object, part))
3211
4252
  return;
3212
4253
  if (isPrimitive(object[part])) {
3213
- throw new Error(`Invalid key, a value has already been defined for ${parts.join('.')}`);
4254
+ const fullKey = joinKey(prefix.concat(parts));
4255
+ throw new Error(`Invalid key, a value has already been defined for ${fullKey}`);
3214
4256
  }
3215
- const joined_parts = joinKey(parts);
4257
+ const joined_parts = joinKey(prefix.concat(parts));
3216
4258
  if (Array.isArray(object[part]) && !state.table_arrays.has(joined_parts)) {
3217
4259
  throw new Error(`Invalid key, cannot add to a static array at ${joined_parts}`);
3218
4260
  }
3219
4261
  const next_is_last = index++ < key.length - 1;
3220
4262
  object = Array.isArray(object[part]) && next_is_last ? last(object[part]) : object[part];
3221
4263
  }
3222
- const joined_key = joinKey(key);
4264
+ const joined_key = joined_full_key;
3223
4265
  // 2. Cannot override table
3224
4266
  if (object && type === NodeType.Table && state.defined.has(joined_key)) {
3225
4267
  throw new Error(`Invalid key, a table has already been defined named ${joined_key}`);
3226
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
+ }
3227
4276
  // 3. Cannot add table array to static array or table
3228
4277
  if (object && type === NodeType.TableArray && !state.table_arrays.has(joined_key)) {
3229
4278
  throw new Error(`Invalid key, cannot add an array of tables to a table at ${joined_key}`);
@@ -3435,13 +4484,29 @@ function findByPath(node, path) {
3435
4484
  key = key.concat(array_index);
3436
4485
  }
3437
4486
  else if (isInlineItem(item) && isKeyValue(item.item)) {
4487
+ // For InlineItems wrapping KeyValues, extract the key
3438
4488
  key = item.item.key.value;
3439
4489
  }
3440
4490
  else if (isInlineItem(item)) {
3441
4491
  key = [index];
3442
4492
  }
3443
4493
  if (key.length && arraysEqual(key, path.slice(0, key.length))) {
3444
- found = findByPath(item, path.slice(key.length));
4494
+ // For InlineItems containing KeyValues, we need to search within the value
4495
+ // but still return the InlineItem or its contents appropriately
4496
+ if (isInlineItem(item) && isKeyValue(item.item)) {
4497
+ if (path.length === key.length) {
4498
+ // If we've matched the full path, return the InlineItem itself
4499
+ // so it can be found and replaced in the parent's items array
4500
+ found = item;
4501
+ }
4502
+ else {
4503
+ // Continue searching within the KeyValue's value
4504
+ found = findByPath(item.item.value, path.slice(key.length));
4505
+ }
4506
+ }
4507
+ else {
4508
+ found = findByPath(item, path.slice(key.length));
4509
+ }
3445
4510
  return true;
3446
4511
  }
3447
4512
  else {
@@ -3728,7 +4793,13 @@ function applyChanges(original, updated, changes, format) {
3728
4793
  insert(original, parent, child, undefined, true);
3729
4794
  }
3730
4795
  else {
3731
- insert(original, parent, child);
4796
+ // Unwrap InlineItem if we're adding to a Table (not InlineTable)
4797
+ // InlineItems should only exist within InlineTables or InlineArrays
4798
+ let childToInsert = child;
4799
+ if (isInlineItem(child) && (isTable(parent) || isDocument(parent))) {
4800
+ childToInsert = child.item;
4801
+ }
4802
+ insert(original, parent, childToInsert);
3732
4803
  }
3733
4804
  }
3734
4805
  }
@@ -3757,6 +4828,14 @@ function applyChanges(original, updated, changes, format) {
3757
4828
  parent = existing;
3758
4829
  existing = existing.item;
3759
4830
  }
4831
+ else if (isInlineItem(existing) && isInlineItem(replacement) && isKeyValue(existing.item) && isKeyValue(replacement.item)) {
4832
+ // Both are InlineItems wrapping KeyValues (nested inline table edits)
4833
+ // Preserve formatting and edit the value within
4834
+ preserveFormatting(existing.item.value, replacement.item.value);
4835
+ parent = existing.item;
4836
+ existing = existing.item.value;
4837
+ replacement = replacement.item.value;
4838
+ }
3760
4839
  else {
3761
4840
  parent = findParent(original, change.path);
3762
4841
  // Special handling for array element edits