From a6af2c920faab1a2b3797caf45fc7e3294f5d346 Mon Sep 17 00:00:00 2001 From: Sarath Francis Date: Wed, 10 Jun 2026 03:56:33 -0400 Subject: [PATCH] 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 75295232..af972875 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -176,6 +176,7 @@ const stringifier = function (options, state, info) { quoted_string, quoted_match, record_delimiter, + quote_record_delimiter, escape_formulas, } = options; if ("" === value && "" === field) { @@ -208,10 +209,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"; 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..47b96b42 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 b1209700..f9949cec 100644 --- a/packages/csv-stringify/lib/index.d.ts +++ b/packages/csv-stringify/lib/index.d.ts @@ -118,6 +118,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 */ @@ -200,6 +207,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 d91c21de..f40b23c3 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 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_record_delimiter.ts b/packages/csv-stringify/test/option.quote_record_delimiter.ts new file mode 100644 index 00000000..80abff2d --- /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(); + }, + ); + }); +});