From aa8f100ab49b4dd9c2d621ad010b3a12c9be834b Mon Sep 17 00:00:00 2001 From: Sarath Francis Date: Wed, 10 Jun 2026 03:56:33 -0400 Subject: [PATCH] fix(csv-stringify): quote fields against the delimiters parse discovers A field holding a lone `\r` was written unquoted, so parsing the output back split it into two records. The quoting test itself is fine: it checks the field against the configured record delimiter, whatever its characters. The mismatch is in the defaults. `stringify` defaults `record_delimiter` to `\n`, while `parse` defaults to discovering `\r\n`, `\n` and `\r`, so the delimiters the reader may find are not the one we write. Normalization now derives the set of delimiters to quote against: the three sequences `parse` discovers when the option is left out, or the configured delimiter alone once it is set explicitly. Line breaks stay plain data under a custom delimiter, as before. --- packages/csv-stringify/lib/api/index.js | 16 ++++++++++++---- .../csv-stringify/lib/api/normalize_options.js | 13 ++++++++++++- packages/csv-stringify/lib/index.js | 3 ++- packages/csv-stringify/lib/sync.js | 3 ++- .../csv-stringify/test/option.escape_formulas.ts | 3 ++- packages/csv-stringify/test/option.quote.ts | 11 +++++++++++ 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 0f214501..7ecde904 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -116,6 +116,8 @@ const stringifier = function (options, state, info) { let csvrecord = ""; for (let i = 0; i < record.length; i++) { let options, err; + // The delimiters to quote against, unless the cast provides its own + let record_delimiters = this.state.record_delimiters; let [value, field] = record[i]; if (typeof value === "string") { @@ -138,11 +140,16 @@ const stringifier = function (options, state, info) { ]; } // Merge global options with the ones returned by cast - options = { ...this.options, ...options }; - [err, options] = normalize_options(options); + const cast_options = options; + options = { ...this.options, ...cast_options }; + let cast_record_delimiters; + [err, options, cast_record_delimiters] = normalize_options(options); if (err !== undefined) { return [err]; } + if (cast_options.record_delimiter !== undefined) { + record_delimiters = cast_record_delimiters; + } } else if (value === undefined || value === null) { options = this.options; } else { @@ -160,7 +167,6 @@ const stringifier = function (options, state, info) { quoted_empty, quoted_string, quoted_match, - record_delimiter, escape_formulas, } = options; if ("" === value && "" === field) { @@ -194,7 +200,9 @@ const stringifier = function (options, state, info) { delimiter.length && value.indexOf(delimiter) >= 0; const containsQuote = quote !== "" && value.indexOf(quote) >= 0; const containsEscape = value.indexOf(escape) >= 0 && escape !== quote; - const containsRecordDelimiter = value.indexOf(record_delimiter) >= 0; + const containsRecordDelimiter = record_delimiters.some( + (record_delimiter) => value.indexOf(record_delimiter) >= 0, + ); const quotedString = quoted_string && typeof field === "string"; let quotedMatch = quoted_match && diff --git a/packages/csv-stringify/lib/api/normalize_options.js b/packages/csv-stringify/lib/api/normalize_options.js index d007f199..062c8b6b 100644 --- a/packages/csv-stringify/lib/api/normalize_options.js +++ b/packages/csv-stringify/lib/api/normalize_options.js @@ -242,11 +242,13 @@ const normalize_options = function (opts) { return [Error(`Invalid Option: "on_record" must be a function.`)]; } // Normalize option `record_delimiter` + let record_delimiter_defaulted = false; if ( options.record_delimiter === undefined || options.record_delimiter === null ) { options.record_delimiter = "\n"; + record_delimiter_defaulted = true; } else if (Buffer.isBuffer(options.record_delimiter)) { options.record_delimiter = options.record_delimiter.toString(); } else if (typeof options.record_delimiter !== "string") { @@ -273,7 +275,16 @@ const normalize_options = function (opts) { options.record_delimiter = "\u2028"; break; } - return [undefined, options]; + // The record delimiters a field must be quoted against. Left to its default, + // `parse` discovers `\r\n`, `\n` and `\r` while we only write `\n`, so a + // field holding one of the two others is split back into several records. + // Testing both characters covers the three sequences. An explicit delimiter + // is the only one a matching parser is configured with, thus the only one to + // quote against. + const record_delimiters = record_delimiter_defaulted + ? ["\n", "\r"] + : [options.record_delimiter]; + return [undefined, options, record_delimiters]; }; export { normalize_options }; diff --git a/packages/csv-stringify/lib/index.js b/packages/csv-stringify/lib/index.js index 54c2c1ae..198598e9 100644 --- a/packages/csv-stringify/lib/index.js +++ b/packages/csv-stringify/lib/index.js @@ -14,13 +14,14 @@ import { normalize_options } from "./api/normalize_options.js"; class Stringifier extends Transform { constructor(opts = {}) { super({ ...{ writableObjectMode: true }, ...opts }); - const [err, options] = normalize_options(opts); + const [err, options, record_delimiters] = normalize_options(opts); if (err !== undefined) throw err; // Expose options this.options = options; // Internal state this.state = { stop: false, + record_delimiters: record_delimiters, }; // Information this.info = { diff --git a/packages/csv-stringify/lib/sync.js b/packages/csv-stringify/lib/sync.js index 6ae6d0b2..3f921681 100644 --- a/packages/csv-stringify/lib/sync.js +++ b/packages/csv-stringify/lib/sync.js @@ -3,10 +3,11 @@ import { normalize_options } from "./api/normalize_options.js"; const stringify = function (records, opts = {}) { const data = []; - const [err, options] = normalize_options(opts); + const [err, options, record_delimiters] = normalize_options(opts); if (err !== undefined) throw err; const state = { stop: false, + record_delimiters: record_delimiters, }; // Information const info = { diff --git a/packages/csv-stringify/test/option.escape_formulas.ts b/packages/csv-stringify/test/option.escape_formulas.ts index 9963c653..1b2f99c3 100644 --- a/packages/csv-stringify/test/option.escape_formulas.ts +++ b/packages/csv-stringify/test/option.escape_formulas.ts @@ -39,7 +39,8 @@ describe("Option `escape_formulas`", function () { "'-c,3", "'@d,4", "'\te,5", - "'\rf,6", + // The carriage return forces quoting so the field round-trips. + '"\'\rf",6', "g,7", "'\uFF1Dh,8", "'\uFF0Bi,9", diff --git a/packages/csv-stringify/test/option.quote.ts b/packages/csv-stringify/test/option.quote.ts index 851b3150..75a199bb 100644 --- a/packages/csv-stringify/test/option.quote.ts +++ b/packages/csv-stringify/test/option.quote.ts @@ -184,6 +184,17 @@ describe("Option `quote`", function () { ); }); + it("quotes a field containing a carriage return", function (next) { + // The default record delimiter is "\n" but `parse` defaults to discovering + // "\r\n", "\n" and "\r", so a lone "\r" must be quoted to survive a round + // trip (it previously leaked through unquoted and split the record). + stringify([["a\rb"]], { eof: false }, (err, data) => { + if (err) return next(err); + data.should.eql('"a\rb"'); + next(); + }); + }); + it("field where quote string is empty", function (next) { stringify( [