links-notation 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,6 +147,45 @@ const group = new LinksGroup(parsed);
147
147
  console.log(group.format());
148
148
  ```
149
149
 
150
+ ### Streaming Parser (for Large Messages)
151
+
152
+ The `StreamParser` allows you to parse Links Notation incrementally, processing data as it arrives without loading the entire message into memory. This is ideal for:
153
+
154
+ - Large files that don't fit in memory
155
+ - Network streaming (TCP/HTTP)
156
+ - Real-time processing of incoming data
157
+
158
+ ```javascript
159
+ import { StreamParser } from 'links-notation';
160
+
161
+ const parser = new StreamParser({ collect: false, maxBufferSize: 1024 * 1024 });
162
+
163
+ // Listen for parsed links
164
+ parser.on('link', (link) => {
165
+ console.log('Parsed link:', link.toString());
166
+ });
167
+
168
+ // Listen for errors with location information
169
+ parser.on('error', (error) => {
170
+ console.error(
171
+ `Error at line ${error.line}, col ${error.column}: ${error.message}`
172
+ );
173
+ });
174
+
175
+ // Feed data incrementally (e.g., from network chunks)
176
+ parser.write('papa (lovesMama: loves mama)\n');
177
+ parser.write('son lovesMama\n');
178
+ parser.write('daughter lovesMama\n');
179
+
180
+ // Finish the last record. Links are not retained when collection is disabled.
181
+ parser.end();
182
+ ```
183
+
184
+ `StreamParser.parse(chunks)` and `StreamParser.parseAsync(chunks)` expose native
185
+ sync and async iterables. The parser also provides `position()`, `drain()`,
186
+ `reset()`, and a maximum unresolved-record size. See the
187
+ [streaming parser example](../examples/js_streaming_parser.js).
188
+
150
189
  ### TypeScript Usage Examples
151
190
 
152
191
  ```typescript
@@ -314,6 +353,19 @@ Container for grouping related links.
314
353
  - `constructor(links)` - Create a new group
315
354
  - `format()` - Format the group as a string
316
355
 
356
+ #### `StreamParser`
357
+
358
+ EventEmitter-based streaming parser for incremental parsing.
359
+
360
+ - `constructor(options)` - Create a new streaming parser
361
+ - `options.maxInputSize` - Maximum input size in bytes (default: 10MB)
362
+ - `options.maxDepth` - Maximum nesting depth (default: 1000)
363
+ - `write(chunk)` - Feed a chunk of data to the parser
364
+ - `end()` - Finish parsing and return all parsed links
365
+ - `position()` - Get current parsing position (line, column, offset)
366
+ - Event: `'link'` - Emitted when a link is parsed (callback receives Link object)
367
+ - Event: `'error'` - Emitted on parse error (callback receives Error with line/column info)
368
+
317
369
  #### `ParseError`
318
370
 
319
371
  Thrown by `parse(input)` when the document does not parse. The message says
package/README.ru.md CHANGED
@@ -105,6 +105,27 @@ const group = new LinksGroup(parsed);
105
105
  console.log(group.format());
106
106
  ```
107
107
 
108
+ ### Потоковый парсер
109
+
110
+ `StreamParser` принимает произвольные порции данных и отправляет события только
111
+ для завершённых связей верхнего уровня. Отключите накопление для обработки с
112
+ ограниченной памятью.
113
+
114
+ ```javascript
115
+ import { StreamParser } from 'links-notation';
116
+
117
+ const stream = new StreamParser({ collect: false, maxBufferSize: 1024 * 1024 });
118
+ stream.on('link', (link) => console.log(link.toString()));
119
+ stream.on('error', (error) => console.error(error));
120
+ stream.write('профиль:\n имя Ада\n');
121
+ stream.end('следующая связь');
122
+ ```
123
+
124
+ `StreamParser.parse(chunks)` и `StreamParser.parseAsync(chunks)` предоставляют
125
+ нативные синхронные и асинхронные итераторы. Также доступны `position()`,
126
+ `drain()`, `reset()` и ограничение размера незавершённой записи. См.
127
+ [исполняемый пример](../examples/js_streaming_parser.js).
128
+
108
129
  ## Примеры синтаксиса
109
130
 
110
131
  ### Дуплеты (2-кортежи)
package/dist/index.js CHANGED
@@ -275,7 +275,8 @@ var ELLIPSIS = "...";
275
275
 
276
276
  class ParseError extends Error {
277
277
  constructor(input, error) {
278
- const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
278
+ const start = positionAt(input, error?.location?.start?.offset ?? 0);
279
+ const end = positionAt(input, error?.location?.end?.offset ?? start.offset);
279
280
  const lineText = lineAt(input, start.offset);
280
281
  const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
281
282
  const snippet = quote(start.line, lineText, start.column);
@@ -283,7 +284,7 @@ class ParseError extends Error {
283
284
  ${snippet}`);
284
285
  this.name = "ParseError";
285
286
  this.cause = error;
286
- this.location = error.location;
287
+ this.location = { ...error.location, start, end };
287
288
  this.offset = start.offset;
288
289
  this.line = start.line;
289
290
  this.column = start.column;
@@ -294,12 +295,32 @@ ${snippet}`);
294
295
  }
295
296
  function lineAt(input, offset) {
296
297
  const at = Math.max(0, Math.min(offset, input.length));
297
- const start = input.lastIndexOf(`
298
- `, at - 1) + 1;
299
- const end = input.indexOf(`
300
- `, start);
301
- const line = input.slice(start, end === -1 ? input.length : end);
302
- return line.endsWith("\r") ? line.slice(0, -1) : line;
298
+ const before = input.slice(0, at);
299
+ const start = Math.max(before.lastIndexOf(`
300
+ `), before.lastIndexOf("\r")) + 1;
301
+ const end = input.slice(start).search(/[\r\n]/);
302
+ return input.slice(start, end === -1 ? input.length : start + end);
303
+ }
304
+ function positionAt(input, offset) {
305
+ const at = Math.max(0, Math.min(offset, input.length));
306
+ let line = 1;
307
+ let column = 1;
308
+ for (let index = 0;index < at; index++) {
309
+ if (input[index] === "\r") {
310
+ line++;
311
+ column = 1;
312
+ if (input[index + 1] === `
313
+ ` && index + 1 < at)
314
+ index++;
315
+ } else if (input[index] === `
316
+ `) {
317
+ line++;
318
+ column = 1;
319
+ } else {
320
+ column++;
321
+ }
322
+ }
323
+ return { offset: at, line, column };
303
324
  }
304
325
  function quote(number, lineText, column) {
305
326
  const [quoted, at] = windowAround(lineText, column);
@@ -400,7 +421,7 @@ function isSubstantiveBody(content) {
400
421
  return false;
401
422
  }
402
423
  }
403
- if (!/\s/.test(character)) {
424
+ if (!/[ \t\n\r]/.test(character)) {
404
425
  hasVisible = true;
405
426
  }
406
427
  }
@@ -2220,7 +2241,7 @@ function peg$parse(input, options) {
2220
2241
  return false;
2221
2242
  }
2222
2243
  }
2223
- if (!/\s/.test(c)) {
2244
+ if (!/[ \t\n\r]/.test(c)) {
2224
2245
  hasVisible = true;
2225
2246
  }
2226
2247
  }
@@ -2419,6 +2440,222 @@ class Parser {
2419
2440
  return new Link(item.id ?? null, []);
2420
2441
  }
2421
2442
  }
2443
+ // src/StreamParser.js
2444
+ import { EventEmitter } from "events";
2445
+ var DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
2446
+
2447
+ class StreamParseError extends Error {
2448
+ constructor(error, startOffset, startLine) {
2449
+ const localOffset = error?.offset ?? 0;
2450
+ const localLine = error?.line ?? 1;
2451
+ const column = error?.column ?? 1;
2452
+ const line = startLine + localLine - 1;
2453
+ super(`Stream parse error at line ${line}, column ${column}: ${error.message}`);
2454
+ this.name = "StreamParseError";
2455
+ this.cause = error;
2456
+ this.offset = startOffset + localOffset;
2457
+ this.line = line;
2458
+ this.column = column;
2459
+ this.found = error?.found ?? null;
2460
+ this.lineText = error?.lineText ?? "";
2461
+ this.snippet = error?.snippet ?? "";
2462
+ }
2463
+ }
2464
+
2465
+ class StreamParser extends EventEmitter {
2466
+ constructor(options = {}) {
2467
+ super();
2468
+ this.parser = options.parser ?? new Parser(options);
2469
+ this.comments = options.comments ?? this.parser.comments ?? true;
2470
+ this.maxBufferSize = options.maxBufferSize ?? options.maxInputSize ?? DEFAULT_MAX_BUFFER_SIZE;
2471
+ this.collect = options.collect ?? true;
2472
+ this.reset();
2473
+ }
2474
+ write(chunk) {
2475
+ if (typeof chunk !== "string") {
2476
+ return this._fail(new TypeError("Input must be a string"));
2477
+ }
2478
+ if (this.ended) {
2479
+ return this._fail(new Error("Cannot write after end()"));
2480
+ }
2481
+ const emitted = [];
2482
+ for (let index = 0;index < chunk.length; index++) {
2483
+ const character = chunk[index];
2484
+ this.currentLine += character;
2485
+ this.offset += 1;
2486
+ if (character === `
2487
+ `) {
2488
+ this.buffer += this.currentLine;
2489
+ this.currentLine = "";
2490
+ this.lineClassified = false;
2491
+ this.line += 1;
2492
+ this.column = 1;
2493
+ } else {
2494
+ if (!this.lineClassified && character !== " " && character !== "\t" && character !== "\r") {
2495
+ this.lineClassified = true;
2496
+ const isComment = this.comments && character === "#";
2497
+ if (!isComment) {
2498
+ const indentation = leadingSpaces(this.currentLine);
2499
+ this._startContentLine(indentation, emitted);
2500
+ }
2501
+ }
2502
+ this.column += 1;
2503
+ }
2504
+ if (this.buffer.length + this.currentLine.length > this.maxBufferSize) {
2505
+ return this._fail(new RangeError(`Buffered record exceeds maximum size of ${this.maxBufferSize} characters`));
2506
+ }
2507
+ }
2508
+ return emitted;
2509
+ }
2510
+ end(chunk = "") {
2511
+ const emitted = chunk === "" ? [] : this.write(chunk);
2512
+ if (this.ended) {
2513
+ return this.collect ? [...this.links] : emitted;
2514
+ }
2515
+ const document = this.buffer + this.currentLine;
2516
+ if (document.length > 0) {
2517
+ let links;
2518
+ try {
2519
+ links = this.parser.parse(document);
2520
+ } catch (error) {
2521
+ const streamError = new StreamParseError(error, this.segmentOffset, this.segmentLine);
2522
+ return this._fail(streamError);
2523
+ }
2524
+ this._publish(links, emitted);
2525
+ this._advanceSegment(document);
2526
+ }
2527
+ this.buffer = "";
2528
+ this.currentLine = "";
2529
+ this.baseIndentation = null;
2530
+ this.ended = true;
2531
+ const result = this.collect ? [...this.links] : emitted;
2532
+ this.emit("end", result);
2533
+ return result;
2534
+ }
2535
+ drain() {
2536
+ const links = this.links;
2537
+ this.links = [];
2538
+ return links;
2539
+ }
2540
+ reset() {
2541
+ this.buffer = "";
2542
+ this.currentLine = "";
2543
+ this.baseIndentation = null;
2544
+ this.lineClassified = false;
2545
+ this.links = [];
2546
+ this.offset = 0;
2547
+ this.line = 1;
2548
+ this.column = 1;
2549
+ this.segmentOffset = 0;
2550
+ this.segmentLine = 1;
2551
+ this.ended = false;
2552
+ return this;
2553
+ }
2554
+ position() {
2555
+ return {
2556
+ offset: this.offset,
2557
+ line: this.line,
2558
+ column: this.column,
2559
+ buffered: this.buffer.length + this.currentLine.length
2560
+ };
2561
+ }
2562
+ static *parse(chunks, options = {}) {
2563
+ const parser = new StreamParser({ ...options, collect: false });
2564
+ for (const chunk of chunks) {
2565
+ yield* parser.write(chunk);
2566
+ }
2567
+ yield* parser.end();
2568
+ }
2569
+ static async* parseAsync(chunks, options = {}) {
2570
+ const parser = new StreamParser({ ...options, collect: false });
2571
+ for await (const chunk of chunks) {
2572
+ yield* parser.write(chunk);
2573
+ }
2574
+ yield* parser.end();
2575
+ }
2576
+ _startContentLine(indentation, emitted) {
2577
+ if (this.buffer.length > 0 && this.baseIndentation !== null && indentation <= this.baseIndentation && structurallyComplete(this.buffer, this.comments)) {
2578
+ let links;
2579
+ try {
2580
+ links = this.parser.parse(this.buffer);
2581
+ } catch {
2582
+ links = null;
2583
+ }
2584
+ if (links !== null) {
2585
+ this._publish(links, emitted);
2586
+ this._advanceSegment(this.buffer);
2587
+ this.buffer = "";
2588
+ this.baseIndentation = null;
2589
+ }
2590
+ }
2591
+ if (this.baseIndentation === null) {
2592
+ this.baseIndentation = indentation;
2593
+ }
2594
+ }
2595
+ _publish(links, emitted) {
2596
+ for (const link of links) {
2597
+ emitted.push(link);
2598
+ if (this.collect)
2599
+ this.links.push(link);
2600
+ this.emit("link", link);
2601
+ }
2602
+ }
2603
+ _advanceSegment(document) {
2604
+ this.segmentOffset += document.length;
2605
+ this.segmentLine += countNewlines(document);
2606
+ }
2607
+ _fail(error) {
2608
+ this.emit("error", error);
2609
+ throw error;
2610
+ }
2611
+ }
2612
+ function leadingSpaces(line) {
2613
+ let indentation = 0;
2614
+ while (line[indentation] === " ")
2615
+ indentation += 1;
2616
+ return indentation;
2617
+ }
2618
+ function countNewlines(value) {
2619
+ let count = 0;
2620
+ for (const character of value) {
2621
+ if (character === `
2622
+ `)
2623
+ count += 1;
2624
+ }
2625
+ return count;
2626
+ }
2627
+ function structurallyComplete(document, comments) {
2628
+ const quotes = ['"', "'", "`"];
2629
+ const beforeReference = [" ", "\t", `
2630
+ `, "\r", "(", ":"];
2631
+ const beforeComment = [" ", "\t", `
2632
+ `, "\r"];
2633
+ let depth = 0;
2634
+ for (let position = 0;position < document.length; position++) {
2635
+ const character = document[position];
2636
+ const previous = position === 0 ? null : document[position - 1];
2637
+ if (quotes.includes(character) && (previous === null || beforeReference.includes(previous))) {
2638
+ const end = quotedReferenceEnd(document, position);
2639
+ if (end === null)
2640
+ return false;
2641
+ position = end - 1;
2642
+ continue;
2643
+ }
2644
+ if (comments && character === "#" && (previous === null || beforeComment.includes(previous))) {
2645
+ const newline = document.indexOf(`
2646
+ `, position);
2647
+ if (newline === -1)
2648
+ return true;
2649
+ position = newline;
2650
+ continue;
2651
+ }
2652
+ if (character === "(")
2653
+ depth += 1;
2654
+ if (character === ")")
2655
+ depth -= 1;
2656
+ }
2657
+ return depth === 0;
2658
+ }
2422
2659
  // src/FormatOptions.js
2423
2660
  class FormatOptions {
2424
2661
  constructor(options = {}) {
@@ -2454,6 +2691,8 @@ export {
2454
2691
  LinksGroup,
2455
2692
  ParseError,
2456
2693
  Parser,
2694
+ StreamParseError,
2695
+ StreamParser,
2457
2696
  formatLinks,
2458
2697
  stripComments
2459
2698
  };
package/index.d.ts CHANGED
@@ -160,6 +160,82 @@ export class Parser {
160
160
  parse(input: string): Link[];
161
161
  }
162
162
 
163
+ /** Options for incremental parsing. */
164
+ export interface StreamParserOptions extends ParserOptions {
165
+ /** Canonical parser instance to use for completed records. */
166
+ parser?: Parser;
167
+
168
+ /** Largest unresolved record retained in memory (default: 10MB). */
169
+ maxBufferSize?: number;
170
+
171
+ /** Retain emitted links for end() and drain() (default: true). */
172
+ collect?: boolean;
173
+ }
174
+
175
+ /** Position immediately after the last character written. */
176
+ export interface StreamPosition {
177
+ offset: number;
178
+ line: number;
179
+ column: number;
180
+ buffered: number;
181
+ }
182
+
183
+ /** Canonical parse error translated to coordinates in the complete stream. */
184
+ export class StreamParseError extends Error {
185
+ cause: Error;
186
+ offset: number;
187
+ line: number;
188
+ column: number;
189
+ found: string | null;
190
+ lineText: string;
191
+ snippet: string;
192
+ }
193
+
194
+ /** Incrementally emits complete top-level Links Notation records. */
195
+ export class StreamParser {
196
+ parser: Parser;
197
+ comments: boolean;
198
+ maxBufferSize: number;
199
+ collect: boolean;
200
+
201
+ constructor(options?: StreamParserOptions);
202
+
203
+ /** Feed a chunk and return links made complete by it. */
204
+ write(chunk: string): Link[];
205
+
206
+ /** Finish the stream, optionally after consuming one final chunk. */
207
+ end(chunk?: string): Link[];
208
+
209
+ /** Return and forget retained links. */
210
+ drain(): Link[];
211
+
212
+ /** Reuse this parser while preserving its options and listeners. */
213
+ reset(): this;
214
+
215
+ /** Return the absolute stream position and unresolved buffer size. */
216
+ position(): StreamPosition;
217
+
218
+ on(event: 'link', listener: (link: Link) => void): this;
219
+ on(event: 'end', listener: (links: Link[]) => void): this;
220
+ on(event: 'error', listener: (error: Error) => void): this;
221
+ once(event: 'link', listener: (link: Link) => void): this;
222
+ once(event: 'end', listener: (links: Link[]) => void): this;
223
+ once(event: 'error', listener: (error: Error) => void): this;
224
+ off(event: string, listener: (...arguments_: any[]) => void): this;
225
+
226
+ /** Lazily parse a synchronous iterable of chunks. */
227
+ static parse(
228
+ chunks: Iterable<string>,
229
+ options?: StreamParserOptions
230
+ ): IterableIterator<Link>;
231
+
232
+ /** Lazily parse an asynchronous iterable of chunks. */
233
+ static parseAsync(
234
+ chunks: AsyncIterable<string> | Iterable<string>,
235
+ options?: StreamParserOptions
236
+ ): AsyncIterableIterator<Link>;
237
+ }
238
+
163
239
  /**
164
240
  * Blank out every comment in a document, keeping its length so that every
165
241
  * character stays at the offset it was written at.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/ParseError.js CHANGED
@@ -19,7 +19,8 @@ export class ParseError extends Error {
19
19
  * @param {Error} error - The error the generated parser threw
20
20
  */
21
21
  constructor(input, error) {
22
- const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 };
22
+ const start = positionAt(input, error?.location?.start?.offset ?? 0);
23
+ const end = positionAt(input, error?.location?.end?.offset ?? start.offset);
23
24
  const lineText = lineAt(input, start.offset);
24
25
  const summary = `line ${start.line}, column ${start.column}: ${error.message}`;
25
26
  const snippet = quote(start.line, lineText, start.column);
@@ -29,7 +30,7 @@ export class ParseError extends Error {
29
30
  /** @type {Error} The error the generated parser threw */
30
31
  this.cause = error;
31
32
  /** @type {{start: {offset: number, line: number, column: number}}} */
32
- this.location = error.location;
33
+ this.location = { ...error.location, start, end };
33
34
  /** @type {number} Offset of the offending position from the start of the document */
34
35
  this.offset = start.offset;
35
36
  /** @type {number} Line the offending position is on, counted from 1 */
@@ -53,10 +54,37 @@ export class ParseError extends Error {
53
54
  */
54
55
  function lineAt(input, offset) {
55
56
  const at = Math.max(0, Math.min(offset, input.length));
56
- const start = input.lastIndexOf('\n', at - 1) + 1;
57
- const end = input.indexOf('\n', start);
58
- const line = input.slice(start, end === -1 ? input.length : end);
59
- return line.endsWith('\r') ? line.slice(0, -1) : line;
57
+ const before = input.slice(0, at);
58
+ const start =
59
+ Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
60
+ const end = input.slice(start).search(/[\r\n]/);
61
+ return input.slice(start, end === -1 ? input.length : start + end);
62
+ }
63
+
64
+ /**
65
+ * Count CR, LF and CRLF as line breaks, as the grammar does. Peggy's error
66
+ * location counts a lone CR as an ordinary character.
67
+ * @param {string} input - The document being parsed
68
+ * @param {number} offset - Offset of the position
69
+ * @returns {{offset: number, line: number, column: number}} The position
70
+ */
71
+ function positionAt(input, offset) {
72
+ const at = Math.max(0, Math.min(offset, input.length));
73
+ let line = 1;
74
+ let column = 1;
75
+ for (let index = 0; index < at; index++) {
76
+ if (input[index] === '\r') {
77
+ line++;
78
+ column = 1;
79
+ if (input[index + 1] === '\n' && index + 1 < at) index++;
80
+ } else if (input[index] === '\n') {
81
+ line++;
82
+ column = 1;
83
+ } else {
84
+ column++;
85
+ }
86
+ }
87
+ return { offset: at, line, column };
60
88
  }
61
89
 
62
90
  /**
@@ -0,0 +1,284 @@
1
+ import { EventEmitter } from 'events';
2
+ import { Parser } from './Parser.js';
3
+ import { quotedReferenceEnd } from './comments.js';
4
+
5
+ const DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
6
+
7
+ /**
8
+ * An error raised while finishing a streamed document.
9
+ *
10
+ * The canonical parser reports positions relative to the currently buffered
11
+ * record. This wrapper translates them back to the complete stream.
12
+ */
13
+ export class StreamParseError extends Error {
14
+ constructor(error, startOffset, startLine) {
15
+ const localOffset = error?.offset ?? 0;
16
+ const localLine = error?.line ?? 1;
17
+ const column = error?.column ?? 1;
18
+ const line = startLine + localLine - 1;
19
+ super(
20
+ `Stream parse error at line ${line}, column ${column}: ${error.message}`
21
+ );
22
+ this.name = 'StreamParseError';
23
+ this.cause = error;
24
+ this.offset = startOffset + localOffset;
25
+ this.line = line;
26
+ this.column = column;
27
+ this.found = error?.found ?? null;
28
+ this.lineText = error?.lineText ?? '';
29
+ this.snippet = error?.snippet ?? '';
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Incrementally parses complete top-level Links Notation records.
35
+ *
36
+ * A line can acquire indented children, so its terminating newline alone does
37
+ * not prove that the record is complete. The parser commits a buffered record
38
+ * as soon as the first non-indented character of the next record arrives. It
39
+ * uses the regular Parser as the source of truth, which keeps quoted strings,
40
+ * nested contexts, comments, and future grammar changes in one place.
41
+ */
42
+ export class StreamParser extends EventEmitter {
43
+ constructor(options = {}) {
44
+ super();
45
+ this.parser = options.parser ?? new Parser(options);
46
+ this.comments = options.comments ?? this.parser.comments ?? true;
47
+ this.maxBufferSize =
48
+ options.maxBufferSize ?? options.maxInputSize ?? DEFAULT_MAX_BUFFER_SIZE;
49
+ this.collect = options.collect ?? true;
50
+ this.reset();
51
+ }
52
+
53
+ /** Feed a string chunk and return the links made complete by that chunk. */
54
+ write(chunk) {
55
+ if (typeof chunk !== 'string') {
56
+ return this._fail(new TypeError('Input must be a string'));
57
+ }
58
+ if (this.ended) {
59
+ return this._fail(new Error('Cannot write after end()'));
60
+ }
61
+
62
+ const emitted = [];
63
+ for (let index = 0; index < chunk.length; index++) {
64
+ const character = chunk[index];
65
+ this.currentLine += character;
66
+ this.offset += 1;
67
+
68
+ if (character === '\n') {
69
+ this.buffer += this.currentLine;
70
+ this.currentLine = '';
71
+ this.lineClassified = false;
72
+ this.line += 1;
73
+ this.column = 1;
74
+ } else {
75
+ if (
76
+ !this.lineClassified &&
77
+ character !== ' ' &&
78
+ character !== '\t' &&
79
+ character !== '\r'
80
+ ) {
81
+ this.lineClassified = true;
82
+ const isComment = this.comments && character === '#';
83
+ if (!isComment) {
84
+ const indentation = leadingSpaces(this.currentLine);
85
+ this._startContentLine(indentation, emitted);
86
+ }
87
+ }
88
+ this.column += 1;
89
+ }
90
+
91
+ if (this.buffer.length + this.currentLine.length > this.maxBufferSize) {
92
+ return this._fail(
93
+ new RangeError(
94
+ `Buffered record exceeds maximum size of ${this.maxBufferSize} characters`
95
+ )
96
+ );
97
+ }
98
+ }
99
+ return emitted;
100
+ }
101
+
102
+ /**
103
+ * Finish the stream. With collection enabled (the default), returns every
104
+ * undrained link; otherwise returns only links completed by this call.
105
+ */
106
+ end(chunk = '') {
107
+ const emitted = chunk === '' ? [] : this.write(chunk);
108
+ if (this.ended) {
109
+ return this.collect ? [...this.links] : emitted;
110
+ }
111
+
112
+ const document = this.buffer + this.currentLine;
113
+ if (document.length > 0) {
114
+ let links;
115
+ try {
116
+ links = this.parser.parse(document);
117
+ } catch (error) {
118
+ const streamError = new StreamParseError(
119
+ error,
120
+ this.segmentOffset,
121
+ this.segmentLine
122
+ );
123
+ return this._fail(streamError);
124
+ }
125
+ this._publish(links, emitted);
126
+ this._advanceSegment(document);
127
+ }
128
+
129
+ this.buffer = '';
130
+ this.currentLine = '';
131
+ this.baseIndentation = null;
132
+ this.ended = true;
133
+ const result = this.collect ? [...this.links] : emitted;
134
+ this.emit('end', result);
135
+ return result;
136
+ }
137
+
138
+ /** Return and forget links retained since the previous drain. */
139
+ drain() {
140
+ const links = this.links;
141
+ this.links = [];
142
+ return links;
143
+ }
144
+
145
+ /** Reuse this parser for a new stream while keeping listeners and options. */
146
+ reset() {
147
+ this.buffer = '';
148
+ this.currentLine = '';
149
+ this.baseIndentation = null;
150
+ this.lineClassified = false;
151
+ this.links = [];
152
+ this.offset = 0;
153
+ this.line = 1;
154
+ this.column = 1;
155
+ this.segmentOffset = 0;
156
+ this.segmentLine = 1;
157
+ this.ended = false;
158
+ return this;
159
+ }
160
+
161
+ /** Position immediately after the last character written. */
162
+ position() {
163
+ return {
164
+ offset: this.offset,
165
+ line: this.line,
166
+ column: this.column,
167
+ buffered: this.buffer.length + this.currentLine.length,
168
+ };
169
+ }
170
+
171
+ /** Lazily parse a synchronous iterable of chunks. */
172
+ static *parse(chunks, options = {}) {
173
+ const parser = new StreamParser({ ...options, collect: false });
174
+ for (const chunk of chunks) {
175
+ yield* parser.write(chunk);
176
+ }
177
+ yield* parser.end();
178
+ }
179
+
180
+ /** Lazily parse an async iterable of chunks. */
181
+ static async *parseAsync(chunks, options = {}) {
182
+ const parser = new StreamParser({ ...options, collect: false });
183
+ for await (const chunk of chunks) {
184
+ yield* parser.write(chunk);
185
+ }
186
+ yield* parser.end();
187
+ }
188
+
189
+ _startContentLine(indentation, emitted) {
190
+ if (
191
+ this.buffer.length > 0 &&
192
+ this.baseIndentation !== null &&
193
+ indentation <= this.baseIndentation &&
194
+ structurallyComplete(this.buffer, this.comments)
195
+ ) {
196
+ let links;
197
+ try {
198
+ links = this.parser.parse(this.buffer);
199
+ } catch {
200
+ links = null;
201
+ }
202
+ if (links !== null) {
203
+ this._publish(links, emitted);
204
+ this._advanceSegment(this.buffer);
205
+ this.buffer = '';
206
+ this.baseIndentation = null;
207
+ }
208
+ }
209
+
210
+ if (this.baseIndentation === null) {
211
+ this.baseIndentation = indentation;
212
+ }
213
+ }
214
+
215
+ _publish(links, emitted) {
216
+ for (const link of links) {
217
+ emitted.push(link);
218
+ if (this.collect) this.links.push(link);
219
+ this.emit('link', link);
220
+ }
221
+ }
222
+
223
+ _advanceSegment(document) {
224
+ this.segmentOffset += document.length;
225
+ this.segmentLine += countNewlines(document);
226
+ }
227
+
228
+ _fail(error) {
229
+ this.emit('error', error);
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ function leadingSpaces(line) {
235
+ let indentation = 0;
236
+ while (line[indentation] === ' ') indentation += 1;
237
+ return indentation;
238
+ }
239
+
240
+ function countNewlines(value) {
241
+ let count = 0;
242
+ for (const character of value) {
243
+ if (character === '\n') count += 1;
244
+ }
245
+ return count;
246
+ }
247
+
248
+ function structurallyComplete(document, comments) {
249
+ const quotes = ['"', "'", '`'];
250
+ const beforeReference = [' ', '\t', '\n', '\r', '(', ':'];
251
+ const beforeComment = [' ', '\t', '\n', '\r'];
252
+ let depth = 0;
253
+
254
+ for (let position = 0; position < document.length; position++) {
255
+ const character = document[position];
256
+ const previous = position === 0 ? null : document[position - 1];
257
+
258
+ if (
259
+ quotes.includes(character) &&
260
+ (previous === null || beforeReference.includes(previous))
261
+ ) {
262
+ const end = quotedReferenceEnd(document, position);
263
+ if (end === null) return false;
264
+ position = end - 1;
265
+ continue;
266
+ }
267
+
268
+ if (
269
+ comments &&
270
+ character === '#' &&
271
+ (previous === null || beforeComment.includes(previous))
272
+ ) {
273
+ const newline = document.indexOf('\n', position);
274
+ if (newline === -1) return true;
275
+ position = newline;
276
+ continue;
277
+ }
278
+
279
+ if (character === '(') depth += 1;
280
+ if (character === ')') depth -= 1;
281
+ }
282
+
283
+ return depth === 0;
284
+ }
package/src/comments.js CHANGED
@@ -155,7 +155,7 @@ function isSubstantiveBody(content) {
155
155
  return false;
156
156
  }
157
157
  }
158
- if (!/\s/.test(character)) {
158
+ if (!/[ \t\n\r]/.test(character)) {
159
159
  hasVisible = true;
160
160
  }
161
161
  }
package/src/grammar.pegjs CHANGED
@@ -83,7 +83,7 @@
83
83
  return false;
84
84
  }
85
85
  }
86
- if (!/\s/.test(c)) {
86
+ if (!/[ \t\n\r]/.test(c)) {
87
87
  hasVisible = true;
88
88
  }
89
89
  }
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { Link, formatLinks } from './Link.js';
2
2
  export { LinksGroup } from './LinksGroup.js';
3
3
  export { Parser } from './Parser.js';
4
+ export { StreamParseError, StreamParser } from './StreamParser.js';
4
5
  export { ParseError } from './ParseError.js';
5
6
  export { FormatConfig } from './FormatConfig.js';
6
7
  export { FormatOptions } from './FormatOptions.js';
@@ -1881,7 +1881,7 @@ function peg$parse(input, options) {
1881
1881
  return false;
1882
1882
  }
1883
1883
  }
1884
- if (!/\s/.test(c)) {
1884
+ if (!/[ \t\n\r]/.test(c)) {
1885
1885
  hasVisible = true;
1886
1886
  }
1887
1887
  }
@@ -0,0 +1,46 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { Parser } from '../src/Parser.js';
3
+
4
+ const parser = new Parser();
5
+
6
+ function render(node) {
7
+ if (!node.values || node.values.length === 0) {
8
+ return `<${node.id ?? ''}>`;
9
+ }
10
+ const head =
11
+ node.id === null || node.id === undefined ? '' : `<${node.id}>: `;
12
+ return `(${head}${node.values.map(render).join(' ')})`;
13
+ }
14
+
15
+ function parsesAs(source) {
16
+ return parser.parse(source).map(render).join('\n');
17
+ }
18
+
19
+ test('CR is a line break', () => {
20
+ expect(parsesAs('a\rb')).toBe('(<a>)\n(<b>)');
21
+ expect(parsesAs('a\r\nb')).toBe('(<a>)\n(<b>)');
22
+ });
23
+
24
+ test('only grammar whitespace makes an empty document', () => {
25
+ expect(parsesAs(' \t\n\r')).toBe('');
26
+ for (const character of ['\u0085', '\u00a0', '\u2028', '\u3000', '\ufeff']) {
27
+ expect(parsesAs(character)).toBe(`(<${character}>)`);
28
+ }
29
+ });
30
+
31
+ test('Unicode spaces are substantive even quote bodies', () => {
32
+ for (const character of ['\u0085', '\u00a0', '\u2028', '\u3000', '\ufeff']) {
33
+ expect(parsesAs(`''${character}''`)).toBe(`(<${character}>)`);
34
+ }
35
+ });
36
+
37
+ test('trailing indentation is document whitespace', () => {
38
+ expect(parsesAs('a\n ')).toBe('(<a>)');
39
+ expect(parsesAs('a\n \t')).toBe('(<a>)');
40
+ });
41
+
42
+ test('a single reference is a link value at every depth', () => {
43
+ expect(parsesAs('1')).toBe('(<1>)');
44
+ expect(parsesAs('(1)')).toBe('(<1>)');
45
+ expect(parsesAs('((1))')).toBe('((<1>))');
46
+ });
@@ -50,6 +50,14 @@ test('reports the line a late defect is on', () => {
50
50
  expect(error.lineText).toBe('e: f: g');
51
51
  });
52
52
 
53
+ test('lone CR counts as one line break in diagnostics', () => {
54
+ const error = syntaxError('a\rb: c: d');
55
+
56
+ expect(error.line).toBe(2);
57
+ expect(error.column).toBe(5);
58
+ expect(error.lineText).toBe('b: c: d');
59
+ });
60
+
53
61
  test('reports the end of the document when a group is never closed', () => {
54
62
  const error = syntaxError('a (b\n');
55
63
 
@@ -0,0 +1,165 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ formatLinks,
4
+ Parser,
5
+ StreamParseError,
6
+ StreamParser,
7
+ } from '../src/index.js';
8
+
9
+ const document = `first loves data
10
+ # streamed comment
11
+ profile:
12
+ name Ada
13
+ note "line one
14
+ line two"
15
+ (nested:
16
+ child value)
17
+ last sees first`;
18
+
19
+ const formatted = (links) => formatLinks(links);
20
+
21
+ describe('StreamParser', () => {
22
+ test('matches canonical parser one symbol at a time', () => {
23
+ const stream = new StreamParser();
24
+ for (const character of document) stream.write(character);
25
+
26
+ expect(formatted(stream.end())).toBe(
27
+ formatted(new Parser().parse(document))
28
+ );
29
+ });
30
+
31
+ test('supports line chunks final record and position', () => {
32
+ const stream = new StreamParser();
33
+ for (const line of document.match(/.*(?:\n|$)/g).filter(Boolean)) {
34
+ stream.write(line);
35
+ }
36
+
37
+ expect(formatted(stream.end())).toBe(
38
+ formatted(new Parser().parse(document))
39
+ );
40
+ expect(stream.position().offset).toBe(document.length);
41
+ expect(stream.position().buffered).toBe(0);
42
+ });
43
+
44
+ test('emits a record as soon as the next top-level record starts', () => {
45
+ const stream = new StreamParser();
46
+ const seen = [];
47
+ stream.on('link', (link) => seen.push(link.toString()));
48
+
49
+ expect(stream.write('first loves data\n')).toEqual([]);
50
+ expect(stream.write('s').map(String)).toEqual(['(first loves data)']);
51
+ expect(seen).toEqual(['(first loves data)']);
52
+ });
53
+
54
+ test('emits only complete records', () => {
55
+ const stream = new StreamParser();
56
+ const seen = [];
57
+ stream.on('link', (link) => seen.push(link));
58
+
59
+ for (const character of 'profile:\n name Ada\n role engineer\n') {
60
+ stream.write(character);
61
+ }
62
+ expect(seen).toEqual([]);
63
+
64
+ stream.write('n');
65
+ expect(formatted(seen)).toBe('(profile: (name Ada) (role engineer))');
66
+ });
67
+
68
+ test('does not split multiline quotes or parenthesized contexts', () => {
69
+ const stream = new StreamParser();
70
+ const seen = [];
71
+ stream.on('link', (link) => seen.push(link));
72
+
73
+ stream.write('"first\nsecond"\n(nested:\n value one)\n');
74
+ expect(seen).toHaveLength(1);
75
+ stream.write('n');
76
+ expect(seen).toHaveLength(2);
77
+ });
78
+
79
+ test('accepts a final record without a newline', () => {
80
+ const stream = new StreamParser();
81
+ stream.write('only one record');
82
+ expect(formatted(stream.end())).toBe('(only one record)');
83
+ });
84
+
85
+ test('reports positions relative to the whole stream', () => {
86
+ const stream = new StreamParser();
87
+ stream.on('error', () => {});
88
+ stream.write('good link\nnext link\nb');
89
+
90
+ let caught;
91
+ try {
92
+ stream.end('ad: value: nope');
93
+ } catch (error) {
94
+ caught = error;
95
+ }
96
+ expect(caught).toBeInstanceOf(StreamParseError);
97
+ expect(caught.line).toBe(3);
98
+ expect(caught.offset).toBeGreaterThan('good link\nnext link\n'.length);
99
+ });
100
+
101
+ test('can avoid retaining output for bounded-memory event processing', () => {
102
+ const stream = new StreamParser({ collect: false });
103
+ const seen = [];
104
+ stream.on('link', (link) => seen.push(link.toString()));
105
+ stream.write('one link\nt');
106
+ expect(stream.drain()).toEqual([]);
107
+ expect(stream.end('wo link').map(String)).toEqual(['(two link)']);
108
+ expect(seen).toEqual(['(one link)', '(two link)']);
109
+ });
110
+
111
+ test('supports drain reset and bounded memory', () => {
112
+ const stream = new StreamParser();
113
+ stream.write('one link\nt');
114
+ expect(stream.drain().map(String)).toEqual(['(one link)']);
115
+ expect(stream.end('wo link').map(String)).toEqual(['(two link)']);
116
+
117
+ stream.reset().write('fresh record');
118
+ expect(stream.end().map(String)).toEqual(['(fresh record)']);
119
+
120
+ const bounded = new StreamParser({ maxBufferSize: 4 });
121
+ bounded.on('error', () => {});
122
+ expect(() => bounded.write('12345')).toThrow(RangeError);
123
+ });
124
+
125
+ test('provides lazy adapters', async () => {
126
+ expect(
127
+ [...StreamParser.parse(['one link\n', 'two link'])].map(String)
128
+ ).toEqual(['(one link)', '(two link)']);
129
+
130
+ async function* chunks() {
131
+ yield 'one link\n';
132
+ yield 'two link';
133
+ }
134
+ const links = [];
135
+ for await (const link of StreamParser.parseAsync(chunks())) {
136
+ links.push(link.toString());
137
+ }
138
+ expect(links).toEqual(['(one link)', '(two link)']);
139
+ });
140
+
141
+ test('limits only the unresolved record, not total stream size', () => {
142
+ const stream = new StreamParser({ maxBufferSize: 12, collect: false });
143
+ stream.on('error', () => {});
144
+ for (let index = 0; index < 100; index++) stream.write(`a${index}\n`);
145
+ expect(stream.end().map(String)).toEqual(['(a99)']);
146
+ });
147
+
148
+ test('rejects writes after finish', () => {
149
+ const stream = new StreamParser();
150
+ stream.end('one');
151
+ stream.on('error', () => {});
152
+ expect(() => stream.write('two')).toThrow('after end');
153
+ });
154
+
155
+ test('tracks the absolute stream position', () => {
156
+ const stream = new StreamParser();
157
+ stream.write('one\ntw');
158
+ expect(stream.position()).toEqual({
159
+ offset: 6,
160
+ line: 2,
161
+ column: 3,
162
+ buffered: 2,
163
+ });
164
+ });
165
+ });