From d1f3692cc374f2f7b222419b2205cdc128f4f972 Mon Sep 17 00:00:00 2001 From: Sarath Francis Date: Wed, 10 Jun 2026 03:56:33 -0400 Subject: [PATCH 1/4] feat(csv-stringify): new quote_record_delimiter option Left to its default, `parse` discovers `\r\n`, `\n` and `\r` as record delimiters while `stringify` only writes `\n`, so a field holding a lone `\r` was written unquoted and read back as several records. The option quotes a field against the three sequences. It defaults to true unless `record_delimiter` is provided, in which case that delimiter stays the only one quoted against. --- packages/csv-stringify/lib/api/index.js | 10 +- .../lib/api/normalize_options.js | 16 +++ packages/csv-stringify/lib/index.d.ts | 14 ++ packages/csv-stringify/test/api.types.ts | 7 + .../test/option.escape_formulas.ts | 3 +- .../test/option.quote_record_delimiter.ts | 122 ++++++++++++++++++ 6 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 packages/csv-stringify/test/option.quote_record_delimiter.ts diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index b39268ae5..f1cdd6d9b 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -191,6 +191,7 @@ const stringifier = function (options, state, info) { quoted_string, quoted_match, record_delimiter, + quote_record_delimiter, escape_formulas, } = options; if ("" === value && "" === field) { @@ -214,10 +215,11 @@ const stringifier = function (options, state, info) { const containsdelimiter = emits_separator(value, delimiter); const containsQuote = quote !== "" && value.indexOf(quote) >= 0; const containsEscape = value.indexOf(escape) >= 0 && escape !== quote; - const containsRecordDelimiter = emits_separator( - value, - record_delimiter, - ); + // Testing `\n` and `\r` covers the three sequences `parse` discovers + const containsRecordDelimiter = + emits_separator(value, record_delimiter) || + (quote_record_delimiter === true && + (emits_separator(value, "\n") || emits_separator(value, "\r"))); const quotedString = quoted_string && typeof field === "string"; const quotedMatch = matches_quoted_match(value, quoted_match); // See https://github.com/adaltas/node-csv/pull/387 diff --git a/packages/csv-stringify/lib/api/normalize_options.js b/packages/csv-stringify/lib/api/normalize_options.js index d007f199b..47b96b42c 100644 --- a/packages/csv-stringify/lib/api/normalize_options.js +++ b/packages/csv-stringify/lib/api/normalize_options.js @@ -241,6 +241,22 @@ const normalize_options = function (opts) { ) { return [Error(`Invalid Option: "on_record" must be a function.`)]; } + // Normalize option `quote_record_delimiter` + if ( + options.quote_record_delimiter === undefined || + options.quote_record_delimiter === null + ) { + options.quote_record_delimiter = + options.record_delimiter === undefined || + options.record_delimiter === null; + } else if (typeof options.quote_record_delimiter !== "boolean") { + return [ + new CsvError("CSV_OPTION_QUOTE_RECORD_DELIMITER_INVALID_TYPE", [ + "option `quote_record_delimiter` must be a boolean,", + `got ${JSON.stringify(options.quote_record_delimiter)}`, + ]), + ]; + } // Normalize option `record_delimiter` if ( options.record_delimiter === undefined || diff --git a/packages/csv-stringify/lib/index.d.ts b/packages/csv-stringify/lib/index.d.ts index 704ac9559..c91b53697 100644 --- a/packages/csv-stringify/lib/index.d.ts +++ b/packages/csv-stringify/lib/index.d.ts @@ -112,6 +112,13 @@ export interface OptionsNormalized extends stream.TransformOptions { * defaults to '\n'. */ record_delimiter: RecordDelimiter; + /** + * Boolean, quote the fields containing one of the record delimiters discovered by `parse`, `\r\n`, `\n` and `\r`. + * Defaults to true unless `record_delimiter` is provided. + * It preserves round trip calls between `stringify` and `parse` with default options: `stringify` only writes `\n` while `parse` treats the three sequences as record delimiters, + * so an unquoted field holding a `\r` is otherwise read back as multiple records. + */ + quote_record_delimiter: boolean; /** * Boolean, default to false, if true, fields that begin with `=`, `+`, `-`, `@`, `\t`, or `\r` will be prepended with a `'` to protect against csv injection attacks */ @@ -194,6 +201,13 @@ export interface Options extends stream.TransformOptions { * defaults to '\n'. */ record_delimiter?: RecordDelimiter; + /** + * Boolean, quote the fields containing one of the record delimiters discovered by `parse`, `\r\n`, `\n` and `\r`. + * Defaults to true unless `record_delimiter` is provided. + * It preserves round trip calls between `stringify` and `parse` with default options: `stringify` only writes `\n` while `parse` treats the three sequences as record delimiters, + * so an unquoted field holding a `\r` is otherwise read back as multiple records. + */ + quote_record_delimiter?: boolean; /** * Boolean, default to false, if true, fields that begin with `=`, `+`, `-`, `@`, `\t`, or `\r` will be prepended with a `'` to protect against csv injection attacks */ diff --git a/packages/csv-stringify/test/api.types.ts b/packages/csv-stringify/test/api.types.ts index d91c21de6..f40b23c3c 100644 --- a/packages/csv-stringify/test/api.types.ts +++ b/packages/csv-stringify/test/api.types.ts @@ -26,6 +26,7 @@ describe("API Types", function () { "header_as_comment", "on_record", "quote", + "quote_record_delimiter", "quoted", "quoted_empty", "quoted_match", @@ -159,6 +160,12 @@ describe("API Types", function () { options.record_delimiter = "|"; options.record_delimiter = Buffer.from("|"); }); + + it("quote_record_delimiter", function () { + const options: Options = {}; + options.quote_record_delimiter = true; + options.quote_record_delimiter = false; + }); }); describe("CastingContext", function () { diff --git a/packages/csv-stringify/test/option.escape_formulas.ts b/packages/csv-stringify/test/option.escape_formulas.ts index eb462adc0..a06d90644 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_record_delimiter.ts b/packages/csv-stringify/test/option.quote_record_delimiter.ts new file mode 100644 index 000000000..80abff2d9 --- /dev/null +++ b/packages/csv-stringify/test/option.quote_record_delimiter.ts @@ -0,0 +1,122 @@ +import { stringify } from "../lib/index.js"; + +describe("Option `quote_record_delimiter`", function () { + it("quotes a field containing a carriage return", function (next) { + stringify([["a\rb"]], { eof: false }, (err, data) => { + if (err) return next(err); + data.should.eql('"a\rb"'); + next(); + }); + }); + + it("quotes a field containing a line feed", function (next) { + stringify([["a\nb"]], { eof: false }, (err, data) => { + if (err) return next(err); + data.should.eql('"a\nb"'); + next(); + }); + }); + + it("quotes a field containing a carriage return and line feed", function (next) { + stringify([["a\r\nb"]], { eof: false }, (err, data) => { + if (err) return next(err); + data.should.eql('"a\r\nb"'); + next(); + }); + }); + + it("defaults to false when `record_delimiter` is provided", function (next) { + stringify( + [["a\rb"]], + { record_delimiter: "::", eof: false }, + (err, data) => { + if (err) return next(err); + data.should.eql("a\rb"); + next(); + }, + ); + }); + + it("disabled with a default `record_delimiter`", function (next) { + stringify( + [["a\rb"]], + { quote_record_delimiter: false, eof: false }, + (err, data) => { + if (err) return next(err); + data.should.eql("a\rb"); + next(); + }, + ); + }); + + it("enabled with a custom `record_delimiter`", function (next) { + stringify( + [["a\rb"]], + { record_delimiter: "::", quote_record_delimiter: true, eof: false }, + (err, data) => { + if (err) return next(err); + data.should.eql('"a\rb"'); + next(); + }, + ); + }); + + it("quotes the configured `record_delimiter` when disabled", function (next) { + stringify( + [["a::b"]], + { record_delimiter: "::", quote_record_delimiter: false, eof: false }, + (err, data) => { + if (err) return next(err); + data.should.eql('"a::b"'); + next(); + }, + ); + }); + + it("applies to a `record_delimiter` returned by cast", function (next) { + // The emitted delimiter stays the global one, so `\r` is still quoted + stringify( + [["a\rb"], ["c"]], + { + cast: { string: (value) => ({ value, record_delimiter: "::" }) }, + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql('"a\rb"\nc'); + next(); + }, + ); + }); + + it("quotes a `record_delimiter` returned by cast", function (next) { + stringify( + [["a::b"]], + { + cast: { string: (value) => ({ value, record_delimiter: "::" }) }, + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql('"a::b"'); + next(); + }, + ); + }); + + it("defaults to false in cast when `record_delimiter` is provided", function (next) { + stringify( + [["a\rb"]], + { + record_delimiter: ";;", + cast: { string: (value) => ({ value, record_delimiter: "::" }) }, + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql("a\rb"); + next(); + }, + ); + }); +}); From 56db16de1d07c4cd414944c3f4021522f46afecc Mon Sep 17 00:00:00 2001 From: David Worms Date: Wed, 23 Sep 2026 13:12:29 +0200 Subject: [PATCH 2/4] refactor(csv-stringify): emits_separator array argument --- packages/csv-stringify/lib/api/index.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index f1cdd6d9b..4f932fb35 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -10,12 +10,13 @@ const bom_utf8 = Buffer.from([239, 187, 191]); // separator (eg value "a:" + delimiter "::" => "a:::", matched at offset 1). // Such fields must be quoted to round-trip, like RFC 4180 fields containing the // delimiter, generalized to multi-character delimiters and record delimiters. -const emits_separator = function (value, separator) { - return ( - separator.length !== 0 && - (value.indexOf(separator) !== -1 || - (separator.length > 1 && - (value + separator).indexOf(separator) < value.length)) +const emits_separator = function (value, separators) { + return separators.some( + (separator) => + separator.length !== 0 && + (value.indexOf(separator) !== -1 || + (separator.length > 1 && + (value + separator).indexOf(separator) < value.length)), ); }; // True when `value` matches one of the `quoted_match` patterns. The regexps @@ -212,14 +213,14 @@ const stringifier = function (options, state, info) { ), ]; } - const containsdelimiter = emits_separator(value, delimiter); + const containsdelimiter = emits_separator(value, [delimiter]); const containsQuote = quote !== "" && value.indexOf(quote) >= 0; const containsEscape = value.indexOf(escape) >= 0 && escape !== quote; // Testing `\n` and `\r` covers the three sequences `parse` discovers - const containsRecordDelimiter = - emits_separator(value, record_delimiter) || - (quote_record_delimiter === true && - (emits_separator(value, "\n") || emits_separator(value, "\r"))); + const containsRecordDelimiter = emits_separator(value, [ + record_delimiter, + ...(quote_record_delimiter === false ? [] : ["\n", "\r"]), + ]); const quotedString = quoted_string && typeof field === "string"; const quotedMatch = matches_quoted_match(value, quoted_match); // See https://github.com/adaltas/node-csv/pull/387 From bd88f123fb5f0f90fadc12737061b1eff2193a23 Mon Sep 17 00:00:00 2001 From: David Worms Date: Wed, 23 Sep 2026 13:14:09 +0200 Subject: [PATCH 3/4] refactor(csv-stringify): quote_record_delimiter default --- .../lib/api/normalize_options.js | 4 +- packages/csv-stringify/test/option.quote.ts | 1 + .../test/option.quote_record_delimiter.ts | 183 ++++++++---------- 3 files changed, 85 insertions(+), 103 deletions(-) diff --git a/packages/csv-stringify/lib/api/normalize_options.js b/packages/csv-stringify/lib/api/normalize_options.js index 47b96b42c..32e63033c 100644 --- a/packages/csv-stringify/lib/api/normalize_options.js +++ b/packages/csv-stringify/lib/api/normalize_options.js @@ -246,9 +246,7 @@ const normalize_options = function (opts) { options.quote_record_delimiter === undefined || options.quote_record_delimiter === null ) { - options.quote_record_delimiter = - options.record_delimiter === undefined || - options.record_delimiter === null; + options.quote_record_delimiter = !options.record_delimiter; } else if (typeof options.quote_record_delimiter !== "boolean") { return [ new CsvError("CSV_OPTION_QUOTE_RECORD_DELIMITER_INVALID_TYPE", [ diff --git a/packages/csv-stringify/test/option.quote.ts b/packages/csv-stringify/test/option.quote.ts index ca5d52b4f..66d1902fa 100644 --- a/packages/csv-stringify/test/option.quote.ts +++ b/packages/csv-stringify/test/option.quote.ts @@ -218,6 +218,7 @@ describe("Option `quote`", function () { record_delimiter: "__", }, (err, data) => { + if (err) return next(err); data.should.eql(dedent` 123 456,789__,1974 diff --git a/packages/csv-stringify/test/option.quote_record_delimiter.ts b/packages/csv-stringify/test/option.quote_record_delimiter.ts index 80abff2d9..243ca280f 100644 --- a/packages/csv-stringify/test/option.quote_record_delimiter.ts +++ b/packages/csv-stringify/test/option.quote_record_delimiter.ts @@ -1,122 +1,105 @@ -import { stringify } from "../lib/index.js"; +import "should"; +import { stringify, normalize_options } from "../lib/sync.js"; describe("Option `quote_record_delimiter`", function () { - it("quotes a field containing a carriage return", function (next) { - stringify([["a\rb"]], { eof: false }, (err, data) => { - if (err) return next(err); - data.should.eql('"a\rb"'); - next(); - }); - }); - - it("quotes a field containing a line feed", function (next) { - stringify([["a\nb"]], { eof: false }, (err, data) => { - if (err) return next(err); - data.should.eql('"a\nb"'); - next(); - }); + it("default to `true`", function () { + let options; + [, options] = normalize_options({}); + options.quote_record_delimiter.should.eql(true); + [, options] = normalize_options({ quote_record_delimiter: false }); + options.quote_record_delimiter.should.eql(false); }); - it("quotes a field containing a carriage return and line feed", function (next) { - stringify([["a\r\nb"]], { eof: false }, (err, data) => { - if (err) return next(err); - data.should.eql('"a\r\nb"'); - next(); - }); - }); - - it("defaults to false when `record_delimiter` is provided", function (next) { - stringify( - [["a\rb"]], - { record_delimiter: "::", eof: false }, - (err, data) => { - if (err) return next(err); - data.should.eql("a\rb"); - next(); - }, - ); + it("quotes a field containing a carriage return", function () { + stringify([["a\rb"]], { eof: false }).should.eql('"a\rb"'); + stringify([["a\rb"]], { + eof: false, + quote_record_delimiter: true, + }).should.eql('"a\rb"'); + stringify([["a\rb"]], { + eof: false, + quote_record_delimiter: false, + }).should.eql("a\rb"); }); - it("disabled with a default `record_delimiter`", function (next) { - stringify( - [["a\rb"]], - { quote_record_delimiter: false, eof: false }, - (err, data) => { - if (err) return next(err); - data.should.eql("a\rb"); - next(); - }, - ); + it("quotes a field containing a line feed", function () { + // quote_record_delimiter is `false` + // but the input contains the default record_delimiter `\n` + // which cause the `emits_separator` function to return true + stringify([["a\nb"]], { eof: false }).should.eql('"a\nb"'); + stringify([["a\nb"]], { + eof: false, + quote_record_delimiter: false, + }).should.eql('"a\nb"'); + stringify([["a\nb"]], { + eof: false, + quote_record_delimiter: true, + }).should.eql('"a\nb"'); }); - it("enabled with a custom `record_delimiter`", function (next) { - stringify( - [["a\rb"]], - { record_delimiter: "::", quote_record_delimiter: true, eof: false }, - (err, data) => { - if (err) return next(err); - data.should.eql('"a\rb"'); - next(); - }, - ); + it("quotes a field containing a carriage return and line feed", function () { + // quote_record_delimiter is `false` + // but the input **partially** contains the default record_delimiter `\n` + // which cause the `emits_separator` function to return true + stringify([["a\r\nb"]], { eof: false }).should.eql('"a\r\nb"'); + stringify([["a\r\nb"]], { + eof: false, + quote_record_delimiter: false, + }).should.eql('"a\r\nb"'); + stringify([["a\r\nb"]], { + eof: false, + quote_record_delimiter: true, + }).should.eql('"a\r\nb"'); }); - it("quotes the configured `record_delimiter` when disabled", function (next) { - stringify( - [["a::b"]], - { record_delimiter: "::", quote_record_delimiter: false, eof: false }, - (err, data) => { - if (err) return next(err); - data.should.eql('"a::b"'); - next(); - }, - ); - }); + describe("with `record_delimiter`", function () { + it("defaults to false when `record_delimiter` is provided", function () { + let options; + [, options] = normalize_options({ record_delimiter: "::" }); + options.quote_record_delimiter.should.eql(false); + [, options] = normalize_options({ + quote_record_delimiter: true, + record_delimiter: "::", + }); + options.quote_record_delimiter.should.eql(true); + }); - it("applies to a `record_delimiter` returned by cast", function (next) { - // The emitted delimiter stays the global one, so `\r` is still quoted - stringify( - [["a\rb"], ["c"]], - { - cast: { string: (value) => ({ value, record_delimiter: "::" }) }, + it("quotes the configured `record_delimiter` when disabled", function () { + stringify([["a::b"]], { + record_delimiter: "::", + quote_record_delimiter: false, eof: false, - }, - (err, data) => { - if (err) return next(err); - data.should.eql('"a\rb"\nc'); - next(); - }, - ); + }).should.eql('"a::b"'); + }); }); + describe("with `cast`", function () { + it("applies to a `record_delimiter` returned by cast", function () { + // The emitted delimiter stays the global one, so `\r` is still quoted + stringify([["a\rb"], ["c"]], { + cast: { + string: (value) => ({ + value, + quote_record_delimiter: true, + record_delimiter: "::", + }), + }, + eof: false, + }).should.eql('"a\rb"\nc'); + }); - it("quotes a `record_delimiter` returned by cast", function (next) { - stringify( - [["a::b"]], - { + it("quotes a `record_delimiter` returned by cast", function () { + stringify([["a::b"]], { cast: { string: (value) => ({ value, record_delimiter: "::" }) }, eof: false, - }, - (err, data) => { - if (err) return next(err); - data.should.eql('"a::b"'); - next(); - }, - ); - }); + }).should.eql('"a::b"'); + }); - it("defaults to false in cast when `record_delimiter` is provided", function (next) { - stringify( - [["a\rb"]], - { + it("defaults to false in cast when `record_delimiter` is provided", function () { + stringify([["a\rb"]], { record_delimiter: ";;", cast: { string: (value) => ({ value, record_delimiter: "::" }) }, eof: false, - }, - (err, data) => { - if (err) return next(err); - data.should.eql("a\rb"); - next(); - }, - ); + }).should.eql("a\rb"); + }); }); }); From 6418292ba05cf8fa2c5979ecb8bf5a78702b4185 Mon Sep 17 00:00:00 2001 From: David Worms Date: Wed, 23 Sep 2026 13:35:55 +0200 Subject: [PATCH 4/4] feat(csv-stringify): quote_record_delimiter samples --- .../option.quote_record_delimiter_default.js | 7 +++++++ .../option.quote_record_delimiter_disabled.js | 16 ++++++++++++++++ ...n.quote_record_delimiter_record_delimiter.js | 17 +++++++++++++++++ 3 files changed, 40 insertions(+) create mode 100644 packages/csv-stringify/samples/option.quote_record_delimiter_default.js create mode 100644 packages/csv-stringify/samples/option.quote_record_delimiter_disabled.js create mode 100644 packages/csv-stringify/samples/option.quote_record_delimiter_record_delimiter.js diff --git a/packages/csv-stringify/samples/option.quote_record_delimiter_default.js b/packages/csv-stringify/samples/option.quote_record_delimiter_default.js new file mode 100644 index 000000000..5bb3b5e00 --- /dev/null +++ b/packages/csv-stringify/samples/option.quote_record_delimiter_default.js @@ -0,0 +1,7 @@ +import { stringify } from "csv-stringify/sync"; +import assert from "node:assert"; + +// A carriage return inside a field is quoted by default +const records = stringify([["a\rb"], ["c\nd"], ["e::f"]], { eof: false }); + +assert.equal(records, '"a\rb"\n"c\nd"\ne::f'); diff --git a/packages/csv-stringify/samples/option.quote_record_delimiter_disabled.js b/packages/csv-stringify/samples/option.quote_record_delimiter_disabled.js new file mode 100644 index 000000000..860a021b4 --- /dev/null +++ b/packages/csv-stringify/samples/option.quote_record_delimiter_disabled.js @@ -0,0 +1,16 @@ +import { stringify } from "csv-stringify/sync"; +import assert from "node:assert"; + +// With quote_record_delimiter disabled, a carriage return is not quoted +const cr = stringify([["a\rb"]], { + quote_record_delimiter: false, + eof: false, +}); +assert.equal(cr, "a\rb"); + +// A line feed is still quoted because it matches the default record_delimiter +const lf = stringify([["a\nb"]], { + quote_record_delimiter: false, + eof: false, +}); +assert.equal(lf, '"a\nb"'); diff --git a/packages/csv-stringify/samples/option.quote_record_delimiter_record_delimiter.js b/packages/csv-stringify/samples/option.quote_record_delimiter_record_delimiter.js new file mode 100644 index 000000000..06a62fe9a --- /dev/null +++ b/packages/csv-stringify/samples/option.quote_record_delimiter_record_delimiter.js @@ -0,0 +1,17 @@ +import { stringify } from "csv-stringify/sync"; +import assert from "node:assert"; + +// When record_delimiter is set, quote_record_delimiter defaults to false. +// A carriage return is not quoted because it does not match the custom delimiter. +const cr = stringify([["a\rb"]], { + record_delimiter: "::", + eof: false, +}); +assert.equal(cr, "a\rb"); + +// The custom delimiter itself is always quoted. +const delim = stringify([["a::b"]], { + record_delimiter: "::", + eof: false, +}); +assert.equal(delim, '"a::b"');