From a6f32bb927a10d6b9010dcb2585d55d97a34a02a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:36:07 +0000 Subject: [PATCH 01/10] Buffer incomplete trailing row in streaming Reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On input not ending in the row-terminating empty line, Reader._finalize used to emit the incomplete trailing row — the non-resumable behavior, diverging from the Java/Rust/Scala readers which buffer it. Align with them: _finalize no longer flushes the partial cell/row buffers, so readRow() returns null without emitting the tail. Non-resumable parse() keeps emitting the incomplete tail; existing Reader tests now use properly terminated input. Empty-row handling in _processChunk is a separate known issue and is left untouched here. --- README.md | 2 ++ index.d.ts | 6 +++++- index.js | 16 +++++----------- test.js | 24 ++++++++++++++++++++---- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ba9b304..569f4dd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ for await (const row of reader) { The `Reader` parses incrementally as data arrives—it handles infinite streams and maintains bounded memory usage. +The `Reader` is resumable: at end of input, an incomplete trailing row (one not terminated by an empty line) is buffered, not emitted. Use the non-resumable `parse()` for batch input where the end of the string is the definitive end of data—it does emit the incomplete tail. + **Stream API:** - `read(stream)` - read entire stream into memory as array - `write(data, stream)` - write entire array to stream diff --git a/index.d.ts b/index.d.ts index 53122eb..24d73d1 100644 --- a/index.d.ts +++ b/index.d.ts @@ -82,13 +82,17 @@ export class Writer { /** * Reader for incrementally reading NSV rows + * + * Resumable: an incomplete trailing row (input not terminated by an empty + * line) is buffered at end of input, not emitted. Use parse() for batch + * input where EOF is the definitive end of data. */ export class Reader { constructor(input: Readable | string); /** * Read next row - * @returns Next row or null if no more rows + * @returns Next row, or null once no more complete rows can arrive */ readRow(): Promise; diff --git a/index.js b/index.js index 8a932c2..9f7634c 100644 --- a/index.js +++ b/index.js @@ -307,25 +307,19 @@ class Reader { /** * Finalize parsing when stream ends + * + * An incomplete trailing row (input not terminated by an empty line) is + * buffered, not emitted — resumable readers treat EOF as "no more data yet". + * Use parse() for batch input where EOF is the definitive end of data. * @private */ _finalize() { - // Handle any remaining buffered content - if (this._buffer.length > 0) { - this._currentRow.push(unescape(this._buffer)); - } - - // Add final row if it has content - if (this._currentRow.length > 0) { - this._rowQueue.push(this._currentRow); - } - this._done = true; } /** * Read next row - * @returns {Promise} Next row or null if no more rows + * @returns {Promise} Next row, or null once no more complete rows can arrive */ async readRow() { this._start(); diff --git a/test.js b/test.js index 5119260..b918815 100644 --- a/test.js +++ b/test.js @@ -184,7 +184,7 @@ async function runTests() { // Test 20: Reader - readRow { - const reader = new nsv.Reader('a\nb\n\nc\nd\n'); + const reader = new nsv.Reader('a\nb\n\nc\nd\n\n'); const row1 = await reader.readRow(); const row2 = await reader.readRow(); const row3 = await reader.readRow(); @@ -196,7 +196,7 @@ async function runTests() { // Test 21: Reader - readRows { - const reader = new nsv.Reader('a\nb\n\nc\nd\n'); + const reader = new nsv.Reader('a\nb\n\nc\nd\n\n'); const rows = await reader.readRows(); assertEqual(rows, [['a', 'b'], ['c', 'd']], 'Reader - readRows'); console.log('✓ Reader - readRows'); @@ -204,7 +204,7 @@ async function runTests() { // Test 22: Reader - async iterator { - const reader = new nsv.Reader('a\nb\n\nc\nd\n'); + const reader = new nsv.Reader('a\nb\n\nc\nd\n\n'); const rows = []; for await (const row of reader) { rows.push(row); @@ -215,13 +215,29 @@ async function runTests() { // Test 23: Reader from stream { - const stream = createReadableStream('a\nb\n\nc\nd\n'); + const stream = createReadableStream('a\nb\n\nc\nd\n\n'); const reader = new nsv.Reader(stream); const rows = await reader.readRows(); assertEqual(rows, [['a', 'b'], ['c', 'd']], 'Reader from stream'); console.log('✓ Reader from stream'); } + // Test 23b: Reader buffers incomplete trailing row (resumable EOF semantics) + { + const reader1 = new nsv.Reader('a\nb\n\nc\nd'); + assertEqual(await reader1.readRow(), ['a', 'b'], 'Reader - complete row before incomplete tail'); + assertEqual(await reader1.readRow(), null, 'Reader - incomplete tail buffered, not emitted'); + + // Cell-terminated but row-unterminated tail is buffered too + const reader2 = new nsv.Reader('a\nb\n\nc\nd\n'); + assertEqual(await reader2.readRow(), ['a', 'b'], 'Reader - complete row before unterminated row'); + assertEqual(await reader2.readRow(), null, 'Reader - unterminated row buffered, not emitted'); + + // parse() is non-resumable and keeps emitting the tail + assertEqual(nsv.parse('a\nb\n\nc\nd'), [['a', 'b'], ['c', 'd']], 'parse - emits incomplete tail'); + console.log('✓ Reader buffers incomplete trailing row'); + } + // Test 24: Empty data array { const result = nsv.stringify([]); From 83c21ec1d34d4e7c56e4b0b0627c4317d284ba1f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 19:45:51 +0000 Subject: [PATCH 02/10] Linearize Reader hot paths Three scaling fixes in the streaming Reader, all the same shape: batch work instead of paying per smallest unit. - _processChunk: scan chunks with indexOf and slice whole lines instead of appending to a string char by char; a line split across chunks accumulates pieces in a list joined once on completion. 16MB cell arriving in 8KB chunks: 9.6s -> 0.014s. - unescape: return early when the cell has no backslash. - readRow: consume the row queue via head index instead of shift(), which is O(n) per call once the queue is large; queue and head reset when drained. 1M-row drain went from minutes to 0.37s. Differential-tested against parse() over the nsv-tests enum+valid corpora at every chunk-split pair, and the 92MB champernowne fixed point streamed row-identical. --- index.js | 51 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/index.js b/index.js index fc34cb8..58dba80 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,9 @@ function unescape(str) { if (str === '\\') { return ''; } + if (!str.includes('\\')) { + return str; + } let result = ''; let i = 0; @@ -233,9 +236,10 @@ class Reader { */ constructor(input) { this.input = input; - this._buffer = ''; + this._lineParts = []; this._currentRow = []; this._rowQueue = []; + this._rowHead = 0; this._done = false; this._started = false; this._error = null; @@ -280,23 +284,27 @@ class Reader { * @private */ _processChunk(text) { - for (let i = 0; i < text.length; i++) { - const char = text[i]; - - if (char === '\n') { - if (this._buffer.length === 0) { - // Empty line - row complete - this._rowQueue.push(this._currentRow); - this._currentRow = []; - } else { - // Content before this newline - it's a cell - this._currentRow.push(unescape(this._buffer)); - this._buffer = ''; - } + let start = 0; + let pos; + while ((pos = text.indexOf('\n', start)) !== -1) { + let line = text.slice(start, pos); + if (this._lineParts.length > 0) { + this._lineParts.push(line); + line = this._lineParts.join(''); + this._lineParts = []; + } + if (line.length === 0) { + // Empty line - row complete + this._rowQueue.push(this._currentRow); + this._currentRow = []; } else { - // Regular character - this._buffer += char; + // Content before this newline - it's a cell + this._currentRow.push(unescape(line)); } + start = pos + 1; + } + if (start < text.length) { + this._lineParts.push(text.slice(start)); } } @@ -316,7 +324,7 @@ class Reader { this._start(); // Wait for a row to be available or stream to finish - while (this._rowQueue.length === 0 && !this._done) { + while (this._rowHead === this._rowQueue.length && !this._done) { if (this._error) throw this._error; // Wait a tick for more data await new Promise(resolve => setImmediate(resolve)); @@ -324,8 +332,13 @@ class Reader { if (this._error) throw this._error; - if (this._rowQueue.length > 0) { - return this._rowQueue.shift(); + if (this._rowHead < this._rowQueue.length) { + const row = this._rowQueue[this._rowHead++]; + if (this._rowHead === this._rowQueue.length) { + this._rowQueue = []; + this._rowHead = 0; + } + return row; } return null; From 1e3dd28e1fbe05048596bc6b77ca9cd69920e249 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:07:21 +0000 Subject: [PATCH 03/10] Revert README addition --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index 569f4dd..ba9b304 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,6 @@ for await (const row of reader) { The `Reader` parses incrementally as data arrives—it handles infinite streams and maintains bounded memory usage. -The `Reader` is resumable: at end of input, an incomplete trailing row (one not terminated by an empty line) is buffered, not emitted. Use the non-resumable `parse()` for batch input where the end of the string is the definitive end of data—it does emit the incomplete tail. - **Stream API:** - `read(stream)` - read entire stream into memory as array - `write(data, stream)` - write entire array to stream From bdc6e594018ac491e28f071ce0c17488ceeaa6dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:22:03 +0000 Subject: [PATCH 04/10] Revert doc wording in index.d.ts and readRow jsdoc --- index.d.ts | 6 +----- index.js | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/index.d.ts b/index.d.ts index 24d73d1..53122eb 100644 --- a/index.d.ts +++ b/index.d.ts @@ -82,17 +82,13 @@ export class Writer { /** * Reader for incrementally reading NSV rows - * - * Resumable: an incomplete trailing row (input not terminated by an empty - * line) is buffered at end of input, not emitted. Use parse() for batch - * input where EOF is the definitive end of data. */ export class Reader { constructor(input: Readable | string); /** * Read next row - * @returns Next row, or null once no more complete rows can arrive + * @returns Next row or null if no more rows */ readRow(): Promise; diff --git a/index.js b/index.js index 58dba80..f1085b6 100644 --- a/index.js +++ b/index.js @@ -318,7 +318,7 @@ class Reader { /** * Read next row - * @returns {Promise} Next row, or null once no more complete rows can arrive + * @returns {Promise} Next row or null if no more rows */ async readRow() { this._start(); From f6dca7b3449aa1bd2bd883d685f2ee9b6b56a866 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:42:54 +0000 Subject: [PATCH 05/10] Add Reader.partialRow() for explicit access to the unterminated tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row stream emits only terminated rows, and end of stream is final in Node, so without an accessor the withheld tail would be destroyed. partialRow() returns a copy of the row in progress — completed cells plus the unfinished cell run through unescape — or null, queryable at any point. For any input, readRows() plus partialRow() equals parse(). --- index.d.ts | 7 +++++++ index.js | 18 ++++++++++++++++++ test.js | 31 +++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/index.d.ts b/index.d.ts index 53122eb..cc7976d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -92,6 +92,13 @@ export class Reader { */ readRow(): Promise; + /** + * Cells of the unterminated row in progress, including the unfinished + * trailing cell, as consumed so far + * @returns A copy of the partial row, or null if there is none + */ + partialRow(): string[] | null; + /** * Read all remaining rows * @returns All remaining rows diff --git a/index.js b/index.js index f1085b6..464a324 100644 --- a/index.js +++ b/index.js @@ -344,6 +344,24 @@ class Reader { return null; } + /** + * Cells of the unterminated row in progress, including the unfinished + * trailing cell, as consumed so far + * @returns {string[]|null} A copy of the partial row, or null if there is none + */ + partialRow() { + this._start(); + + if (this._currentRow.length === 0 && this._lineParts.length === 0) { + return null; + } + const row = this._currentRow.slice(); + if (this._lineParts.length > 0) { + row.push(unescape(this._lineParts.join(''))); + } + return row; + } + /** * Read all remaining rows * @returns {Promise} All remaining rows diff --git a/test.js b/test.js index 5299f60..141542a 100644 --- a/test.js +++ b/test.js @@ -251,6 +251,37 @@ async function runTests() { console.log('✓ Reader buffers incomplete trailing row'); } + // Test 23d: partialRow recovers the unterminated tail + { + const inputs = [ + 'a\nb\n\nc\nd', + 'a\nb\n\nc\nd\n', + 'a\nx\\', + '\\', + ]; + for (const input of inputs) { + const reader = new nsv.Reader(input); + const rows = await reader.readRows(); + const partial = reader.partialRow(); + assertEqual( + partial === null ? rows : rows.concat([partial]), + nsv.parse(input), + `partialRow - rows + partial == parse (${JSON.stringify(input)})` + ); + } + + const reader = new nsv.Reader('a\nb\n\n'); + await reader.readRows(); + assertEqual(reader.partialRow(), null, 'partialRow - null on terminated input'); + + const reader2 = new nsv.Reader('a\nb'); + await reader2.readRows(); + const first = reader2.partialRow(); + first.push('mutated'); + assertEqual(reader2.partialRow(), ['a', 'b'], 'partialRow - returns a copy'); + console.log('✓ partialRow recovers the unterminated tail'); + } + // Test 24: Empty data array { const result = nsv.stringify([]); From b26165e4acebaeba06b2824daef9a108c1270ba5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:23:39 +0000 Subject: [PATCH 06/10] Defer parsing to row boundaries; expose raw tail via partial() The streaming layer no longer parses incrementally: it scans each chunk for the last row boundary (a newline preceded by a newline, with one carried bit for chunk-spanning pairs and stream start), hands everything up to it to parse(), and keeps the rest as the raw pending tail. The Reader can no longer disagree with parse() on row content by construction. partialRow() becomes partial(): the raw encoded tail, empty when none. Consumers who want the truncated row as cells run it through parse() themselves. For any input and chunking, readRows() concatenated with parse(partial()) equals parse(input). --- index.d.ts | 7 +++--- index.js | 68 ++++++++++++++++++++++++++++-------------------------- test.js | 22 +++++++++--------- 3 files changed, 49 insertions(+), 48 deletions(-) diff --git a/index.d.ts b/index.d.ts index cc7976d..26252dc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -93,11 +93,10 @@ export class Reader { readRow(): Promise; /** - * Cells of the unterminated row in progress, including the unfinished - * trailing cell, as consumed so far - * @returns A copy of the partial row, or null if there is none + * Raw encoded text of the unterminated row in progress, as consumed so far + * @returns The unparsed tail; empty when there is none */ - partialRow(): string[] | null; + partial(): string; /** * Read all remaining rows diff --git a/index.js b/index.js index 464a324..3f37aaa 100644 --- a/index.js +++ b/index.js @@ -236,8 +236,8 @@ class Reader { */ constructor(input) { this.input = input; - this._lineParts = []; - this._currentRow = []; + this._pieces = []; + this._prevNewline = true; this._rowQueue = []; this._rowHead = 0; this._done = false; @@ -283,29 +283,36 @@ class Reader { * Process a chunk of text, extracting complete rows * @private */ + /** + * Accumulate raw text and emit rows at row boundaries + * + * A newline terminates a row exactly when the previous character was a + * newline (or there is none), so the last boundary in a chunk is the last + * "\n\n" — with one carried bit covering pairs split across chunks and a + * leading newline at stream start. Everything up to it is complete rows, + * handed to parse(); everything after is the pending tail. + * @private + */ _processChunk(text) { - let start = 0; - let pos; - while ((pos = text.indexOf('\n', start)) !== -1) { - let line = text.slice(start, pos); - if (this._lineParts.length > 0) { - this._lineParts.push(line); - line = this._lineParts.join(''); - this._lineParts = []; - } - if (line.length === 0) { - // Empty line - row complete - this._rowQueue.push(this._currentRow); - this._currentRow = []; - } else { - // Content before this newline - it's a cell - this._currentRow.push(unescape(line)); - } - start = pos + 1; + if (text.length === 0) { + return; + } + let end = text.lastIndexOf('\n\n'); + if (end !== -1) { + end += 2; + } else if (this._prevNewline && text[0] === '\n') { + end = 1; } - if (start < text.length) { - this._lineParts.push(text.slice(start)); + this._prevNewline = text[text.length - 1] === '\n'; + if (end === -1) { + this._pieces.push(text); + return; + } + this._pieces.push(text.slice(0, end)); + for (const row of parse(this._pieces.join(''))) { + this._rowQueue.push(row); } + this._pieces = end < text.length ? [text.slice(end)] : []; } /** @@ -345,21 +352,16 @@ class Reader { } /** - * Cells of the unterminated row in progress, including the unfinished - * trailing cell, as consumed so far - * @returns {string[]|null} A copy of the partial row, or null if there is none + * Raw encoded text of the unterminated row in progress, as consumed so far + * @returns {string} The unparsed tail; empty when there is none */ - partialRow() { + partial() { this._start(); - if (this._currentRow.length === 0 && this._lineParts.length === 0) { - return null; - } - const row = this._currentRow.slice(); - if (this._lineParts.length > 0) { - row.push(unescape(this._lineParts.join(''))); + if (this._pieces.length > 1) { + this._pieces = [this._pieces.join('')]; } - return row; + return this._pieces.length === 1 ? this._pieces[0] : ''; } /** diff --git a/test.js b/test.js index 141542a..e40ddb1 100644 --- a/test.js +++ b/test.js @@ -251,35 +251,35 @@ async function runTests() { console.log('✓ Reader buffers incomplete trailing row'); } - // Test 23d: partialRow recovers the unterminated tail + // Test 23d: partial() exposes the unterminated tail { const inputs = [ 'a\nb\n\nc\nd', 'a\nb\n\nc\nd\n', 'a\nx\\', '\\', + 'a\nb\n\n', + '', + '\n', ]; for (const input of inputs) { const reader = new nsv.Reader(input); const rows = await reader.readRows(); - const partial = reader.partialRow(); assertEqual( - partial === null ? rows : rows.concat([partial]), + rows.concat(nsv.parse(reader.partial())), nsv.parse(input), - `partialRow - rows + partial == parse (${JSON.stringify(input)})` + `partial - rows + parse(partial) == parse (${JSON.stringify(input)})` ); } - const reader = new nsv.Reader('a\nb\n\n'); + const reader = new nsv.Reader('a\nb\n\nc\\nd'); await reader.readRows(); - assertEqual(reader.partialRow(), null, 'partialRow - null on terminated input'); + assertEqual(reader.partial(), 'c\\nd', 'partial - raw escaped text'); - const reader2 = new nsv.Reader('a\nb'); + const reader2 = new nsv.Reader('a\nb\n\n'); await reader2.readRows(); - const first = reader2.partialRow(); - first.push('mutated'); - assertEqual(reader2.partialRow(), ['a', 'b'], 'partialRow - returns a copy'); - console.log('✓ partialRow recovers the unterminated tail'); + assertEqual(reader2.partial(), '', 'partial - empty on terminated input'); + console.log('✓ partial() exposes the unterminated tail'); } // Test 24: Empty data array From d7ea48e42af8ee57e69c837d86ba0efb9bcf2592 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:54:27 +0000 Subject: [PATCH 07/10] Name the Reader state for what it is; derive line-start from the tail _pieces -> _tailPieces, _rowHead -> _rowQueueHead, _done -> _ended. The carried previous-byte-was-newline bit was redundant with the tail itself (empty or ending in newline), so it's a derivation now, not a member. Drop the _processChunk comment essay. --- index.js | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/index.js b/index.js index 3f37aaa..2a24eb8 100644 --- a/index.js +++ b/index.js @@ -236,11 +236,10 @@ class Reader { */ constructor(input) { this.input = input; - this._pieces = []; - this._prevNewline = true; + this._tailPieces = []; this._rowQueue = []; - this._rowHead = 0; - this._done = false; + this._rowQueueHead = 0; + this._ended = false; this._started = false; this._error = null; } @@ -284,13 +283,7 @@ class Reader { * @private */ /** - * Accumulate raw text and emit rows at row boundaries - * - * A newline terminates a row exactly when the previous character was a - * newline (or there is none), so the last boundary in a chunk is the last - * "\n\n" — with one carried bit covering pairs split across chunks and a - * leading newline at stream start. Everything up to it is complete rows, - * handed to parse(); everything after is the pending tail. + * Process a chunk of text, extracting complete rows * @private */ _processChunk(text) { @@ -300,19 +293,26 @@ class Reader { let end = text.lastIndexOf('\n\n'); if (end !== -1) { end += 2; - } else if (this._prevNewline && text[0] === '\n') { + } else if (text[0] === '\n' && this._atLineStart()) { end = 1; } - this._prevNewline = text[text.length - 1] === '\n'; if (end === -1) { - this._pieces.push(text); + this._tailPieces.push(text); return; } - this._pieces.push(text.slice(0, end)); - for (const row of parse(this._pieces.join(''))) { + this._tailPieces.push(text.slice(0, end)); + for (const row of parse(this._tailPieces.join(''))) { this._rowQueue.push(row); } - this._pieces = end < text.length ? [text.slice(end)] : []; + this._tailPieces = end < text.length ? [text.slice(end)] : []; + } + + /** + * @private + */ + _atLineStart() { + const pieces = this._tailPieces; + return pieces.length === 0 || pieces[pieces.length - 1].endsWith('\n'); } /** @@ -320,7 +320,7 @@ class Reader { * @private */ _finalize() { - this._done = true; + this._ended = true; } /** @@ -331,7 +331,7 @@ class Reader { this._start(); // Wait for a row to be available or stream to finish - while (this._rowHead === this._rowQueue.length && !this._done) { + while (this._rowQueueHead === this._rowQueue.length && !this._ended) { if (this._error) throw this._error; // Wait a tick for more data await new Promise(resolve => setImmediate(resolve)); @@ -339,11 +339,11 @@ class Reader { if (this._error) throw this._error; - if (this._rowHead < this._rowQueue.length) { - const row = this._rowQueue[this._rowHead++]; - if (this._rowHead === this._rowQueue.length) { + if (this._rowQueueHead < this._rowQueue.length) { + const row = this._rowQueue[this._rowQueueHead++]; + if (this._rowQueueHead === this._rowQueue.length) { this._rowQueue = []; - this._rowHead = 0; + this._rowQueueHead = 0; } return row; } @@ -358,10 +358,10 @@ class Reader { partial() { this._start(); - if (this._pieces.length > 1) { - this._pieces = [this._pieces.join('')]; + if (this._tailPieces.length > 1) { + this._tailPieces = [this._tailPieces.join('')]; } - return this._pieces.length === 1 ? this._pieces[0] : ''; + return this._tailPieces.length === 1 ? this._tailPieces[0] : ''; } /** From 55aeb87bff14b140d3db02db79c06cbc8fc1069d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:16:22 +0000 Subject: [PATCH 08/10] Accumulate the partial row as a string; drop tail terminology Rope concatenation makes string append O(1), so the fragment list was only earning its keep by making the line-start check readable without flattening; carrying that one bit explicitly is cheaper than the list. _partial mirrors the partial() accessor it backs. Also dedupe the _finalize doc block left by the merge and rename tests to row terms. --- index.d.ts | 4 ++-- index.js | 36 +++++++++++------------------------- test.js | 18 +++++++++--------- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/index.d.ts b/index.d.ts index 26252dc..f53a677 100644 --- a/index.d.ts +++ b/index.d.ts @@ -93,8 +93,8 @@ export class Reader { readRow(): Promise; /** - * Raw encoded text of the unterminated row in progress, as consumed so far - * @returns The unparsed tail; empty when there is none + * Raw encoded text of the row in progress, as consumed so far + * @returns The unparsed text; empty when there is none */ partial(): string; diff --git a/index.js b/index.js index f0ae9da..869d0d2 100644 --- a/index.js +++ b/index.js @@ -236,7 +236,8 @@ class Reader { */ constructor(input) { this.input = input; - this._tailPieces = []; + this._partial = ''; + this._atLineStart = true; this._rowQueue = []; this._rowQueueHead = 0; this._ended = false; @@ -278,10 +279,6 @@ class Reader { }); } - /** - * Process a chunk of text, extracting complete rows - * @private - */ /** * Process a chunk of text, extracting complete rows * @private @@ -293,30 +290,22 @@ class Reader { let end = text.lastIndexOf('\n\n'); if (end !== -1) { end += 2; - } else if (text[0] === '\n' && this._atLineStart()) { + } else if (text[0] === '\n' && this._atLineStart) { end = 1; } + this._atLineStart = text[text.length - 1] === '\n'; if (end === -1) { - this._tailPieces.push(text); + this._partial += text; return; } - this._tailPieces.push(text.slice(0, end)); - for (const row of parse(this._tailPieces.join(''))) { + for (const row of parse(this._partial + text.slice(0, end))) { this._rowQueue.push(row); } - this._tailPieces = end < text.length ? [text.slice(end)] : []; - } - - /** - * @private - */ - _atLineStart() { - const pieces = this._tailPieces; - return pieces.length === 0 || pieces[pieces.length - 1].endsWith('\n'); + this._partial = end < text.length ? text.slice(end) : ''; } /** - * Finalize when input ends; an incomplete trailing row stays buffered, not emitted + * Finalize when input ends; an unterminated final row stays in partial() * @private */ _finalize() { @@ -352,16 +341,13 @@ class Reader { } /** - * Raw encoded text of the unterminated row in progress, as consumed so far - * @returns {string} The unparsed tail; empty when there is none + * Raw encoded text of the row in progress, as consumed so far + * @returns {string} The unparsed text; empty when there is none */ partial() { this._start(); - if (this._tailPieces.length > 1) { - this._tailPieces = [this._tailPieces.join('')]; - } - return this._tailPieces.length === 1 ? this._tailPieces[0] : ''; + return this._partial; } /** diff --git a/test.js b/test.js index e40ddb1..5da248b 100644 --- a/test.js +++ b/test.js @@ -237,21 +237,21 @@ async function runTests() { console.log('✓ Reader - empty rows match parse()'); } - // Test 23c: Reader buffers incomplete trailing row (resumable EOF semantics) + // Test 23c: Reader withholds an unterminated final row { const reader1 = new nsv.Reader('a\nb\n\nc\nd'); - assertEqual(await reader1.readRow(), ['a', 'b'], 'Reader - complete row before incomplete tail'); - assertEqual(await reader1.readRow(), null, 'Reader - incomplete tail buffered, not emitted'); + assertEqual(await reader1.readRow(), ['a', 'b'], 'Reader - row before unterminated row'); + assertEqual(await reader1.readRow(), null, 'Reader - unterminated row not emitted'); const reader2 = new nsv.Reader('a\nb\n\nc\nd\n'); - assertEqual(await reader2.readRow(), ['a', 'b'], 'Reader - complete row before unterminated row'); - assertEqual(await reader2.readRow(), null, 'Reader - unterminated row buffered, not emitted'); + assertEqual(await reader2.readRow(), ['a', 'b'], 'Reader - row before cell-terminated row'); + assertEqual(await reader2.readRow(), null, 'Reader - cell-terminated row not emitted'); - assertEqual(nsv.parse('a\nb\n\nc\nd'), [['a', 'b'], ['c', 'd']], 'parse - emits incomplete tail'); - console.log('✓ Reader buffers incomplete trailing row'); + assertEqual(nsv.parse('a\nb\n\nc\nd'), [['a', 'b'], ['c', 'd']], 'parse - emits unterminated row'); + console.log('✓ Reader withholds an unterminated final row'); } - // Test 23d: partial() exposes the unterminated tail + // Test 23d: partial() exposes the row in progress { const inputs = [ 'a\nb\n\nc\nd', @@ -279,7 +279,7 @@ async function runTests() { const reader2 = new nsv.Reader('a\nb\n\n'); await reader2.readRows(); assertEqual(reader2.partial(), '', 'partial - empty on terminated input'); - console.log('✓ partial() exposes the unterminated tail'); + console.log('✓ partial() exposes the row in progress'); } // Test 24: Empty data array From b587d8e72f786b741e83bce56e2bd5e4a06d38d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:27:46 +0000 Subject: [PATCH 09/10] Inline _finalize; order lifecycle flags chronologically Since the unterminated final row stopped being flushed at end of input, _finalize was a one-line flag set with a name promising more. --- index.js | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index 869d0d2..874065f 100644 --- a/index.js +++ b/index.js @@ -240,8 +240,8 @@ class Reader { this._atLineStart = true; this._rowQueue = []; this._rowQueueHead = 0; - this._ended = false; this._started = false; + this._ended = false; this._error = null; } @@ -256,7 +256,7 @@ class Reader { // If input is a string, process it directly if (typeof this.input === 'string') { this._processChunk(this.input); - this._finalize(); + this._ended = true; return; } @@ -271,7 +271,7 @@ class Reader { }); this.input.on('end', () => { - this._finalize(); + this._ended = true; }); this.input.on('error', (error) => { @@ -304,14 +304,6 @@ class Reader { this._partial = end < text.length ? text.slice(end) : ''; } - /** - * Finalize when input ends; an unterminated final row stays in partial() - * @private - */ - _finalize() { - this._ended = true; - } - /** * Read next row * @returns {Promise} Next row or null if no more rows From 79ef8a424e0ff650758e8e9a724c15e7e19b1780 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 18:29:46 +0000 Subject: [PATCH 10/10] Coherence pass over the whole file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header described newlines as separators — the spec's named pitfall; they terminate. Reader class doc claimed per-chunk parsing it no longer does. partial() moved out of the middle of the read-method family. readRows/iterator no longer re-start what readRow starts. --- index.d.ts | 12 ++++++------ index.js | 31 +++++++++++++------------------ 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/index.d.ts b/index.d.ts index f53a677..343ec9e 100644 --- a/index.d.ts +++ b/index.d.ts @@ -92,12 +92,6 @@ export class Reader { */ readRow(): Promise; - /** - * Raw encoded text of the row in progress, as consumed so far - * @returns The unparsed text; empty when there is none - */ - partial(): string; - /** * Read all remaining rows * @returns All remaining rows @@ -108,4 +102,10 @@ export class Reader { * Async iterator support */ [Symbol.asyncIterator](): AsyncIterableIterator; + + /** + * Raw encoded text of the row in progress, as consumed so far + * @returns The unparsed text; empty when there is none + */ + partial(): string; } diff --git a/index.js b/index.js index 874065f..2883f91 100644 --- a/index.js +++ b/index.js @@ -2,9 +2,9 @@ * NSV (Newline-Separated Values) format parser and serializer * * NSV is a plain text data format for sequences of sequences. - * - Single newlines separate cells within a row - * - Double newlines separate rows - * - Backslash escapes: \\ for \, \n for newline, \ for empty cell + * - Each cell is terminated by a newline + * - Each row is terminated by an additional newline (an empty line) + * - Backslash escapes: \\ for \, \n for newline, lone \ for empty cell */ /** @@ -228,7 +228,6 @@ class Writer { /** * Create a reader for incrementally reading NSV rows - * Truly streams data - parses rows as chunks arrive without buffering entire input */ class Reader { /** @@ -332,23 +331,11 @@ class Reader { return null; } - /** - * Raw encoded text of the row in progress, as consumed so far - * @returns {string} The unparsed text; empty when there is none - */ - partial() { - this._start(); - - return this._partial; - } - /** * Read all remaining rows * @returns {Promise} All remaining rows */ async readRows() { - this._start(); - const rows = []; let row; while ((row = await this.readRow()) !== null) { @@ -361,13 +348,21 @@ class Reader { * Async iterator support */ async *[Symbol.asyncIterator]() { - this._start(); - let row; while ((row = await this.readRow()) !== null) { yield row; } } + + /** + * Raw encoded text of the row in progress, as consumed so far + * @returns {string} The unparsed text; empty when there is none + */ + partial() { + this._start(); + + return this._partial; + } } /**