imapflow 2.2.5 → 2.2.7

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cjs/commands/append.js +10 -3
  3. package/dist/cjs/commands/authenticate.js +27 -18
  4. package/dist/cjs/commands/close.d.ts +12 -1
  5. package/dist/cjs/commands/close.js +4 -2
  6. package/dist/cjs/commands/copyuid-parser.js +16 -2
  7. package/dist/cjs/commands/delete.js +2 -1
  8. package/dist/cjs/commands/esearch-parser.js +8 -2
  9. package/dist/cjs/commands/fetch.js +35 -9
  10. package/dist/cjs/commands/id.js +8 -1
  11. package/dist/cjs/commands/idle.js +15 -5
  12. package/dist/cjs/commands/list.js +10 -1
  13. package/dist/cjs/commands/login.js +5 -1
  14. package/dist/cjs/commands/logout.js +7 -0
  15. package/dist/cjs/commands/namespace.js +7 -3
  16. package/dist/cjs/commands/quota.js +3 -1
  17. package/dist/cjs/commands/rename.js +2 -1
  18. package/dist/cjs/commands/select.js +5 -0
  19. package/dist/cjs/commands/status.js +6 -1
  20. package/dist/cjs/commands/store.d.ts +1 -1
  21. package/dist/cjs/commands/store.js +1 -2
  22. package/dist/cjs/download.js +82 -91
  23. package/dist/cjs/handler/imap-compiler.js +45 -29
  24. package/dist/cjs/handler/limits.d.ts +11 -0
  25. package/dist/cjs/handler/limits.js +16 -1
  26. package/dist/cjs/handler/parser-instance.d.ts +10 -0
  27. package/dist/cjs/handler/parser-instance.js +25 -10
  28. package/dist/cjs/handler/token-parser.js +162 -78
  29. package/dist/cjs/imap-flow.js +235 -89
  30. package/dist/cjs/package-info.d.ts +1 -1
  31. package/dist/cjs/package-info.js +1 -1
  32. package/dist/cjs/proxy-connection.js +7 -7
  33. package/dist/cjs/search-compiler.js +37 -16
  34. package/dist/cjs/special-use.js +10 -5
  35. package/dist/cjs/tools.d.ts +10 -1
  36. package/dist/cjs/tools.js +30 -3
  37. package/dist/cjs/types.d.ts +19 -4
  38. package/dist/esm/commands/append.js +11 -4
  39. package/dist/esm/commands/authenticate.js +28 -19
  40. package/dist/esm/commands/close.d.ts +12 -1
  41. package/dist/esm/commands/close.js +5 -3
  42. package/dist/esm/commands/copyuid-parser.js +16 -2
  43. package/dist/esm/commands/delete.js +2 -1
  44. package/dist/esm/commands/esearch-parser.js +8 -2
  45. package/dist/esm/commands/fetch.js +35 -9
  46. package/dist/esm/commands/id.js +9 -2
  47. package/dist/esm/commands/idle.js +16 -6
  48. package/dist/esm/commands/list.js +10 -1
  49. package/dist/esm/commands/login.js +6 -2
  50. package/dist/esm/commands/logout.js +7 -0
  51. package/dist/esm/commands/namespace.js +7 -3
  52. package/dist/esm/commands/quota.js +3 -1
  53. package/dist/esm/commands/rename.js +2 -1
  54. package/dist/esm/commands/select.js +5 -0
  55. package/dist/esm/commands/status.js +7 -2
  56. package/dist/esm/commands/store.d.ts +1 -1
  57. package/dist/esm/commands/store.js +1 -2
  58. package/dist/esm/download.js +82 -91
  59. package/dist/esm/handler/imap-compiler.js +45 -29
  60. package/dist/esm/handler/limits.d.ts +11 -0
  61. package/dist/esm/handler/limits.js +14 -0
  62. package/dist/esm/handler/parser-instance.d.ts +10 -0
  63. package/dist/esm/handler/parser-instance.js +25 -10
  64. package/dist/esm/handler/token-parser.js +163 -79
  65. package/dist/esm/imap-flow.js +235 -89
  66. package/dist/esm/package-info.d.ts +1 -1
  67. package/dist/esm/package-info.js +1 -1
  68. package/dist/esm/proxy-connection.js +7 -7
  69. package/dist/esm/search-compiler.js +37 -16
  70. package/dist/esm/special-use.js +10 -5
  71. package/dist/esm/tools.d.ts +10 -1
  72. package/dist/esm/tools.js +29 -3
  73. package/dist/esm/types.d.ts +19 -4
  74. package/package.json +3 -2
@@ -1,13 +1,12 @@
1
1
  /* eslint new-cap: 0 */
2
2
  import imapFormalSyntax from './imap-formal-syntax.js';
3
- import { MAX_LITERAL_SIZE, normalizeLimit, createLiteralTooLargeError } from './limits.js';
3
+ import { MAX_LITERAL_SIZE, ERROR_CONTEXT_LENGTH, boundedInput, normalizeLimit, createLiteralTooLargeError } from './limits.js';
4
4
  const STATE_ATOM = 0x001;
5
5
  const STATE_LITERAL = 0x002;
6
6
  const STATE_NORMAL = 0x003;
7
7
  const STATE_PARTIAL = 0x004;
8
8
  const STATE_SEQUENCE = 0x005;
9
9
  const STATE_STRING = 0x006;
10
- const RE_DIGITS = /^\d+$/;
11
10
  const RE_SINGLE_DIGIT = /^\d$/;
12
11
  // Prevents stack overflow from maliciously crafted deeply-nested IMAP input (e.g., (((((...))))))
13
12
  const MAX_NODE_DEPTH = 25;
@@ -68,7 +67,7 @@ export class TokenParser {
68
67
  if (!node.isClosed) {
69
68
  let error = new Error(`Unexpected end of input at position ${this.pos + this.str.length - 1} [E9]`);
70
69
  error.code = 'ParserError9';
71
- error.parserContext = { input: this.str, pos: this.pos + this.str.length - 1 };
70
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + this.str.length - 1 };
72
71
  throw error;
73
72
  }
74
73
  let type = (node.type || '').toString().toUpperCase();
@@ -142,7 +141,8 @@ export class TokenParser {
142
141
  if (node.depth > MAX_NODE_DEPTH) {
143
142
  let error = new Error('Too much nesting in IMAP string');
144
143
  error.code = 'MAX_IMAP_NESTING_REACHED';
145
- error._imapStr = this.str;
144
+ // A bounded prefix only, the error travels whole into logs and rejections
145
+ error._imapStr = this.str.slice(0, ERROR_CONTEXT_LENGTH);
146
146
  throw error;
147
147
  }
148
148
  if (typeof startPos === 'number') {
@@ -169,6 +169,63 @@ export class TokenParser {
169
169
  i++;
170
170
  }
171
171
  };
172
+ // Whether chr is the closing delimiter of node: ")" ends a LIST, "]" ends a SECTION
173
+ const closesNode = (c, node) => !!node && ((c === ')' && node.type === 'LIST') || (c === ']' && node.type === 'SECTION'));
174
+ // Facts about the ATOM, SEQUENCE or PARTIAL token being read, kept up to date as its
175
+ // characters are appended. Reading them back from the value instead (value.at(-1),
176
+ // value.includes('*'), a regex test) makes V8 flatten the string built with += on every
177
+ // character, which is quadratic in the token length, and the server decides that length:
178
+ // a 600 KB ESEARCH result took seconds of event loop time
179
+ let tokenLength = 0;
180
+ let tokenLast = '';
181
+ let tokenPrev = '';
182
+ let tokenDigitsOnly = true;
183
+ let tokenHasStar = false;
184
+ let tokenHasDot = false;
185
+ const appendToToken = (c) => {
186
+ this.currentNode.value += c;
187
+ tokenLength++;
188
+ tokenPrev = tokenLast;
189
+ tokenLast = c;
190
+ tokenDigitsOnly = tokenDigitsOnly && RE_SINGLE_DIGIT.test(c);
191
+ tokenHasStar = tokenHasStar || c === '*';
192
+ tokenHasDot = tokenHasDot || c === '.';
193
+ };
194
+ // A digit-led token is only a guess at a sequence set. ":" and "," are ATOM-CHARs, so a
195
+ // server sends a mailbox name, keyword or label such as "2024:Q1" or "1,a" unquoted, and
196
+ // rejecting it would drop the whole response line: the mailbox missing from LIST, the
197
+ // message missing from FETCH. When the sequence grammar breaks, such a token is read on as
198
+ // an atom, and the atom rules decide whether the current character is acceptable. A token
199
+ // holding "*" can not be an atom ("*" is not an ATOM-CHAR), so it keeps the sequence errors
200
+ const continueAsAtom = () => {
201
+ this.currentNode.type = 'ATOM';
202
+ this.state = STATE_ATOM;
203
+ // read the current character again, in STATE_ATOM
204
+ i--;
205
+ };
206
+ // Starts the value of the current node as a new token, with its first character if any
207
+ const startToken = (first) => {
208
+ this.currentNode.value = '';
209
+ tokenLength = 0;
210
+ tokenLast = tokenPrev = '';
211
+ tokenDigitsOnly = true;
212
+ tokenHasStar = tokenHasDot = false;
213
+ if (first) {
214
+ appendToToken(first);
215
+ }
216
+ };
217
+ // ImapStream supplies one buffer per literal marker it framed, {0} included. A marker
218
+ // without a buffer means the line and its literals do not match up, so fail with a parser
219
+ // error instead of reading a missing buffer or shifting every later literal by one
220
+ const takeLiteral = (literals) => {
221
+ if (!literals.length) {
222
+ let error = new Error(`Literal without data at position ${this.pos + i} [E35]`);
223
+ error.code = 'ParserError35';
224
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
225
+ throw error;
226
+ }
227
+ return literals.shift();
228
+ };
172
229
  // Any ATOM supported char starts a new Atom sequence, otherwise throw an error
173
230
  // Allow \ as the first char for atom to support system flags
174
231
  // Allow % to support LIST '' %
@@ -179,12 +236,12 @@ export class TokenParser {
179
236
  if (!imapFormalSyntax['ATOM-CHAR']().includes(chr) && chr !== '\\' && chr !== '%' && chr.charCodeAt(0) < 0x80) {
180
237
  let error = new Error(`Unexpected char at position ${this.pos + i} [E13: ${JSON.stringify(chr)}]`);
181
238
  error.code = 'ParserError13';
182
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
239
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
183
240
  throw error;
184
241
  }
185
242
  this.currentNode = this.createNode(this.currentNode, this.pos + i);
186
243
  this.currentNode.type = 'ATOM';
187
- this.currentNode.value = chr;
244
+ startToken(chr);
188
245
  this.state = STATE_ATOM;
189
246
  };
190
247
  for (i = 0, len = this.str.length; i < len; i++) {
@@ -210,7 +267,7 @@ export class TokenParser {
210
267
  if (this.currentNode.type !== 'LIST') {
211
268
  let error = new Error(`Unexpected list terminator ) at position ${this.pos + i} [E10]`);
212
269
  error.code = 'ParserError10';
213
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
270
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
214
271
  throw error;
215
272
  }
216
273
  this.currentNode.isClosed = true;
@@ -223,7 +280,7 @@ export class TokenParser {
223
280
  if (this.currentNode.type !== 'SECTION') {
224
281
  let error = new Error(`Unexpected section terminator ] at position ${this.pos + i} [E11]`);
225
282
  error.code = 'ParserError11';
226
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
283
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
227
284
  throw error;
228
285
  }
229
286
  this.currentNode.isClosed = true;
@@ -239,12 +296,13 @@ export class TokenParser {
239
296
  if (this.str.charAt(i - 1) !== ']') {
240
297
  this.currentNode = this.createNode(this.currentNode, this.pos + i);
241
298
  this.currentNode.type = 'ATOM';
242
- this.currentNode.value = chr;
299
+ startToken(chr);
243
300
  this.state = STATE_ATOM;
244
301
  }
245
302
  else {
246
303
  this.currentNode = this.createNode(this.currentNode, this.pos + i);
247
304
  this.currentNode.type = 'PARTIAL';
305
+ startToken();
248
306
  this.state = STATE_PARTIAL;
249
307
  this.currentNode.isClosed = false;
250
308
  }
@@ -254,17 +312,19 @@ export class TokenParser {
254
312
  case '~': {
255
313
  let nextChr = this.str.charAt(i + 1);
256
314
  if (nextChr !== '{') {
257
- if (imapFormalSyntax['ATOM-CHAR']().includes(nextChr)) {
258
- // '~' not followed by '{' but followed by an ATOM char: treat as ATOM
259
- this.currentNode = this.createNode(this.currentNode, this.pos + i);
260
- this.currentNode.type = 'ATOM';
261
- this.currentNode.value = chr;
262
- this.state = STATE_ATOM;
315
+ // '~' is an ATOM-CHAR itself, so when it does not start a literal8
316
+ // it starts an atom, including the one-character atom "~" that a
317
+ // space, the end of the line or a closing delimiter ends
318
+ if (imapFormalSyntax['ATOM-CHAR']().includes(nextChr) ||
319
+ nextChr === ' ' ||
320
+ nextChr === '' ||
321
+ closesNode(nextChr, this.currentNode)) {
322
+ startAtom();
263
323
  break;
264
324
  }
265
325
  let error = new Error(`Unexpected literal8 marker at position ${this.pos + i} [E12]`);
266
326
  error.code = 'ParserError12';
267
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
327
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
268
328
  throw error;
269
329
  }
270
330
  // Mark the next literal as literal8 type; consumed when '{' is encountered
@@ -285,7 +345,7 @@ export class TokenParser {
285
345
  case '*':
286
346
  this.currentNode = this.createNode(this.currentNode, this.pos + i);
287
347
  this.currentNode.type = 'SEQUENCE';
288
- this.currentNode.value = chr;
348
+ startToken(chr);
289
349
  this.currentNode.isClosed = false;
290
350
  this.state = STATE_SEQUENCE;
291
351
  break;
@@ -366,8 +426,7 @@ export class TokenParser {
366
426
  break;
367
427
  }
368
428
  // ')' or ']' terminates the atom AND closes the enclosing LIST or SECTION
369
- if (this.currentNode.parentNode &&
370
- ((chr === ')' && this.currentNode.parentNode.type === 'LIST') || (chr === ']' && this.currentNode.parentNode.type === 'SECTION'))) {
429
+ if (closesNode(chr, this.currentNode.parentNode)) {
371
430
  this.currentNode.endPos = this.pos + i - 1;
372
431
  this.currentNode = this.currentNode.parentNode;
373
432
  this.currentNode.isClosed = true;
@@ -403,14 +462,16 @@ export class TokenParser {
403
462
  }
404
463
  // If the atom so far is all digits and we see ',' or ':', it is actually
405
464
  // a sequence set (e.g., "1:5" or "1,3,5"), so reclassify and switch state
406
- if ((chr === ',' || chr === ':') && RE_DIGITS.test(this.currentNode.value)) {
465
+ if ((chr === ',' || chr === ':') && tokenLength && tokenDigitsOnly) {
407
466
  this.currentNode.type = 'SEQUENCE';
408
467
  this.currentNode.isClosed = true;
409
468
  this.state = STATE_SEQUENCE;
410
469
  }
411
470
  // [ starts a section group for this element
412
471
  // Allowed only for selected elements, otherwise falls through to regular ATOM processing
413
- if (chr === '[' && ['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK'].includes(this.currentNode.value.toUpperCase())) {
472
+ if (chr === '[' &&
473
+ tokenLength <= 11 &&
474
+ ['BODY', 'BODY.PEEK', 'BINARY', 'BINARY.PEEK'].includes(this.currentNode.value.toUpperCase())) {
414
475
  this.currentNode.endPos = this.pos + i;
415
476
  this.currentNode = this.createNode(this.currentNode.parentNode, this.pos + i);
416
477
  this.currentNode.type = 'SECTION';
@@ -423,19 +484,19 @@ export class TokenParser {
423
484
  chr.charCodeAt(0) < 0x80 && // allow 8bit (presumably unicode) bytes
424
485
  chr !== ']' &&
425
486
  !(chr === '*' && this.currentNode.value === '\\') &&
426
- (!this.parent || !this.parent.command || !['NO', 'BAD', 'OK'].includes(this.parent.command))) {
487
+ (!this.parent || !this.parent.command || !['NO', 'BAD', 'OK'].includes(this.parent.command.toUpperCase()))) {
427
488
  let error = new Error(`Unexpected char at position ${this.pos + i} [E16: ${JSON.stringify(chr)}]`);
428
489
  error.code = 'ParserError16';
429
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
490
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
430
491
  throw error;
431
492
  }
432
493
  else if (this.currentNode.value === '\\*') {
433
494
  let error = new Error(`Unexpected char at position ${this.pos + i} [E17: ${JSON.stringify(chr)}]`);
434
495
  error.code = 'ParserError17';
435
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
496
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
436
497
  throw error;
437
498
  }
438
- this.currentNode.value += chr;
499
+ appendToToken(chr);
439
500
  break;
440
501
  case STATE_STRING:
441
502
  // DQUOTE ends the string sequence
@@ -453,7 +514,7 @@ export class TokenParser {
453
514
  if (i >= len) {
454
515
  let error = new Error(`Unexpected end of input at position ${this.pos + i} [E18]`);
455
516
  error.code = 'ParserError18';
456
- error.parserContext = { input: this.str, pos: this.pos + i };
517
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i };
457
518
  throw error;
458
519
  }
459
520
  chr = this.str.charAt(i);
@@ -462,10 +523,10 @@ export class TokenParser {
462
523
  break;
463
524
  case STATE_PARTIAL:
464
525
  if (chr === '>') {
465
- if (this.currentNode.value.at(-1) === '.') {
526
+ if (tokenLast === '.') {
466
527
  let error = new Error(`Unexpected end of partial at position ${this.pos + i} [E19]`);
467
528
  error.code = 'ParserError19';
468
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
529
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
469
530
  throw error;
470
531
  }
471
532
  this.currentNode.endPos = this.pos + i;
@@ -475,25 +536,25 @@ export class TokenParser {
475
536
  checkSP();
476
537
  break;
477
538
  }
478
- if (chr === '.' && (this.currentNode.value === '' || this.currentNode.value.includes('.'))) {
539
+ if (chr === '.' && (!tokenLength || tokenHasDot)) {
479
540
  let error = new Error(`Unexpected partial separator . at position ${this.pos + i} [E20]`);
480
541
  error.code = 'ParserError20';
481
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
542
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
482
543
  throw error;
483
544
  }
484
545
  if (!imapFormalSyntax.DIGIT().includes(chr) && chr !== '.') {
485
546
  let error = new Error(`Unexpected char at position ${this.pos + i} [E21: ${JSON.stringify(chr)}]`);
486
547
  error.code = 'ParserError21';
487
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
548
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
488
549
  throw error;
489
550
  }
490
- if ((this.currentNode.value === '0' || this.currentNode.value.endsWith('.0')) && chr !== '.') {
551
+ if (tokenLast === '0' && (tokenLength === 1 || tokenPrev === '.') && chr !== '.') {
491
552
  let error = new Error(`Invalid partial at position ${this.pos + i} [E22: ${JSON.stringify(chr)}]`);
492
553
  error.code = 'ParserError22';
493
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
554
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
494
555
  throw error;
495
556
  }
496
- this.currentNode.value += chr;
557
+ appendToToken(chr);
497
558
  break;
498
559
  case STATE_LITERAL:
499
560
  if (this.currentNode.started) {
@@ -525,7 +586,7 @@ export class TokenParser {
525
586
  if (!('literalLength' in this.currentNode)) {
526
587
  let error = new Error(`Unexpected literal prefix end char } at position ${this.pos + i} [E23]`);
527
588
  error.code = 'ParserError23';
528
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
589
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
529
590
  throw error;
530
591
  }
531
592
  if (this.str.charAt(i + 1) === '\n') {
@@ -537,19 +598,17 @@ export class TokenParser {
537
598
  else {
538
599
  let error = new Error(`Unexpected char at position ${this.pos + i} [E24: ${JSON.stringify(chr)}]`);
539
600
  error.code = 'ParserError24';
540
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
601
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
541
602
  throw error;
542
603
  }
543
604
  this.currentNode.literalLength = Number(this.currentNode.literalLength);
544
605
  if (!this.currentNode.literalLength) {
545
606
  // special case where literal content length is 0
546
607
  // close the node right away, do not wait for additional input
547
- if (this.options.literals && this.options.literals.length) {
548
- // ImapStream queues a Buffer for every literal marker it
549
- // extracts, including {0} - consume the queue entry so
550
- // subsequent literals in the same response stay aligned
551
- // with their markers instead of shifting by one
552
- this.currentNode.value = this.options.literals.shift();
608
+ if (this.options.literals) {
609
+ // consume the queue entry of the {0} marker too, so later
610
+ // literals in the same response stay aligned with their markers
611
+ this.currentNode.value = takeLiteral(this.options.literals);
553
612
  }
554
613
  this.currentNode.endPos = this.pos + i;
555
614
  this.currentNode.isClosed = true;
@@ -559,7 +618,7 @@ export class TokenParser {
559
618
  }
560
619
  else if (this.options.literals) {
561
620
  // use the next precached literal values
562
- this.currentNode.value = this.options.literals.shift();
621
+ this.currentNode.value = takeLiteral(this.options.literals);
563
622
  // only APPEND arguments are kept as Buffers
564
623
  /*
565
624
  if ((this.parent.command || '').toString().toUpperCase() !== 'APPEND') {
@@ -589,7 +648,7 @@ export class TokenParser {
589
648
  if (literalLength > this.maxLiteralSize || literalLength > available) {
590
649
  let overMax = literalLength > this.maxLiteralSize;
591
650
  let error = createLiteralTooLargeError(literalLength, overMax ? this.maxLiteralSize : available, overMax ? null : `the ${available} bytes available in the input`);
592
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
651
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
593
652
  throw error;
594
653
  }
595
654
  this.currentNode.started = true;
@@ -603,95 +662,120 @@ export class TokenParser {
603
662
  if (!imapFormalSyntax.DIGIT().includes(chr)) {
604
663
  let error = new Error(`Unexpected char at position ${this.pos + i} [E25: ${JSON.stringify(chr)}]`);
605
664
  error.code = 'ParserError25';
606
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
665
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
607
666
  throw error;
608
667
  }
609
668
  if (this.currentNode.literalLength === '0') {
610
669
  let error = new Error(`Invalid literal at position ${this.pos + i} [E26]`);
611
670
  error.code = 'ParserError26';
612
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
671
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
613
672
  throw error;
614
673
  }
615
674
  this.currentNode.literalLength = (this.currentNode.literalLength || '') + chr;
616
675
  break;
617
- case STATE_SEQUENCE:
618
- // space finishes the sequence set
619
- if (chr === ' ') {
620
- if (!RE_SINGLE_DIGIT.test(this.currentNode.value.at(-1)) && this.currentNode.value.at(-1) !== '*') {
621
- let error = new Error(`Unexpected whitespace at position ${this.pos + i} [E27]`);
676
+ case STATE_SEQUENCE: {
677
+ // A space ends the sequence set, and so does the closing delimiter of the
678
+ // enclosing list or section ("PARTIAL (1:100 5,7,9)", "[COPYUID 1 1:3 4,5]")
679
+ let closesParent = closesNode(chr, this.currentNode.parentNode);
680
+ if (chr === ' ' || closesParent) {
681
+ if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
682
+ if (!tokenHasStar) {
683
+ // a dangling separator, "10:" or "1,"
684
+ continueAsAtom();
685
+ break;
686
+ }
687
+ let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E27: ${JSON.stringify(chr)}]`);
622
688
  error.code = 'ParserError27';
623
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
689
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
624
690
  throw error;
625
691
  }
626
- if (this.currentNode.value !== '*' &&
627
- this.currentNode.value.at(-1) === '*' &&
628
- this.currentNode.value.at(-2) !== ':') {
629
- let error = new Error(`Unexpected whitespace at position ${this.pos + i} [E28]`);
692
+ if (this.currentNode.value !== '*' && tokenLast === '*' && tokenPrev !== ':') {
693
+ let error = new Error(`Unexpected end of sequence at position ${this.pos + i} [E28: ${JSON.stringify(chr)}]`);
630
694
  error.code = 'ParserError28';
631
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
695
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
632
696
  throw error;
633
697
  }
634
698
  this.currentNode.isClosed = true;
635
699
  this.currentNode.endPos = this.pos + i - 1;
636
700
  this.currentNode = this.currentNode.parentNode;
637
701
  this.state = STATE_NORMAL;
638
- break;
639
- }
640
- else if (this.currentNode.parentNode && chr === ']' && this.currentNode.parentNode.type === 'SECTION') {
641
- this.currentNode.endPos = this.pos + i - 1;
642
- this.currentNode = this.currentNode.parentNode;
643
- this.currentNode.isClosed = true;
644
- this.currentNode.endPos = this.pos + i;
645
- this.currentNode = this.currentNode.parentNode;
646
- this.state = STATE_NORMAL;
647
- checkSP();
702
+ if (closesParent) {
703
+ this.currentNode.isClosed = true;
704
+ this.currentNode.endPos = this.pos + i;
705
+ this.currentNode = this.currentNode.parentNode;
706
+ checkSP();
707
+ }
648
708
  break;
649
709
  }
650
710
  if (chr === ':') {
651
- if (!RE_SINGLE_DIGIT.test(this.currentNode.value.at(-1)) && this.currentNode.value.at(-1) !== '*') {
711
+ if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
712
+ if (!tokenHasStar) {
713
+ continueAsAtom();
714
+ break;
715
+ }
652
716
  let error = new Error(`Unexpected range separator : at position ${this.pos + i} [E29]`);
653
717
  error.code = 'ParserError29';
654
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
718
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
655
719
  throw error;
656
720
  }
657
721
  }
658
722
  else if (chr === '*') {
659
- if (![',', ':'].includes(this.currentNode.value.at(-1))) {
723
+ if (![',', ':'].includes(tokenLast)) {
660
724
  let error = new Error(`Unexpected range wildcard at position ${this.pos + i} [E30]`);
661
725
  error.code = 'ParserError30';
662
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
726
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
663
727
  throw error;
664
728
  }
665
729
  }
666
730
  else if (chr === ',') {
667
- if (!RE_SINGLE_DIGIT.test(this.currentNode.value.at(-1)) && this.currentNode.value.at(-1) !== '*') {
731
+ if (!RE_SINGLE_DIGIT.test(tokenLast) && tokenLast !== '*') {
732
+ if (!tokenHasStar) {
733
+ continueAsAtom();
734
+ break;
735
+ }
668
736
  let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E31]`);
669
737
  error.code = 'ParserError31';
670
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
738
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
671
739
  throw error;
672
740
  }
673
- if (this.currentNode.value.at(-1) === '*' && this.currentNode.value.at(-2) !== ':') {
741
+ if (tokenLast === '*' && tokenPrev !== ':') {
674
742
  let error = new Error(`Unexpected sequence separator , at position ${this.pos + i} [E32]`);
675
743
  error.code = 'ParserError32';
676
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
744
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
677
745
  throw error;
678
746
  }
679
747
  }
680
748
  else if (!RE_SINGLE_DIGIT.test(chr)) {
749
+ if (!tokenHasStar) {
750
+ continueAsAtom();
751
+ break;
752
+ }
681
753
  let error = new Error(`Unexpected char at position ${this.pos + i} [E33: ${JSON.stringify(chr)}]`);
682
754
  error.code = 'ParserError33';
683
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
755
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
684
756
  throw error;
685
757
  }
686
- if (RE_SINGLE_DIGIT.test(chr) && this.currentNode.value.at(-1) === '*') {
758
+ if (RE_SINGLE_DIGIT.test(chr) && tokenLast === '*') {
687
759
  let error = new Error(`Unexpected number at position ${this.pos + i} [E34: ${JSON.stringify(chr)}]`);
688
760
  error.code = 'ParserError34';
689
- error.parserContext = { input: this.str, pos: this.pos + i, chr };
761
+ error.parserContext = { ...boundedInput(this.str), pos: this.pos + i, chr };
690
762
  throw error;
691
763
  }
692
- this.currentNode.value += chr;
764
+ appendToToken(chr);
693
765
  break;
766
+ }
694
767
  }
695
768
  }
769
+ // the same applies to a digit-led token that ends the input on a dangling separator ("10:")
770
+ if (this.state === STATE_SEQUENCE && (tokenLast === ':' || tokenLast === ',') && !tokenHasStar) {
771
+ this.currentNode.type = 'ATOM';
772
+ }
773
+ // A star-led sequence set ("*:4") is only closed by a space or a closing delimiter, so one
774
+ // that ends the input is closed here, under the same rules a space applies to it. A bare "*"
775
+ // is left to getAttributes(), which reads it as an atom
776
+ if (this.state === STATE_SEQUENCE && !this.currentNode.isClosed && (RE_SINGLE_DIGIT.test(tokenLast) || (tokenLast === '*' && tokenPrev === ':'))) {
777
+ this.currentNode.isClosed = true;
778
+ this.currentNode.endPos = this.pos + this.str.length - 1;
779
+ }
696
780
  }
697
781
  }