From 3a8291e341043be8ddfa0a9fc557e66a68f8ab3b Mon Sep 17 00:00:00 2001 From: Abhishek Chauhan Date: Wed, 30 Sep 2026 09:13:33 -0400 Subject: [PATCH] fix(csv-stringify): quote fields prefixed by escape_formulas With `escape_formulas`, a leading `'` is prepended to the value after the checks deciding whether the field must be quoted were already computed. When `quote`, `escape`, `delimiter` or `record_delimiter` is set to `'`, the added character is neither escaped nor does it trigger quoting, so the output cannot be parsed back, e.g. `[["=1", "x"]]` with `quote: "'"` produced `'=1,x`. Run those checks on the prefixed value. `quoted_match` is still tested against the original value, since it expresses which user values should be quoted. --- packages/csv-stringify/lib/api/index.js | 18 +++-- .../test/option.escape_formulas.ts | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 1fb77be2..e4b04d43 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -212,14 +212,6 @@ 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; - // Testing `\n` and `\r` covers the three sequences `parse` discovers - 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 @@ -249,6 +241,16 @@ const stringifier = function (options, state, info) { break; } } + // Inspect the value once the formula prefix is added, it may match + // the `quote`, `escape` or `delimiter` options + 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 === false ? [] : ["\n", "\r"]), + ]); const shouldQuote = containsQuote === true || containsdelimiter || diff --git a/packages/csv-stringify/test/option.escape_formulas.ts b/packages/csv-stringify/test/option.escape_formulas.ts index a06d9064..462018b4 100644 --- a/packages/csv-stringify/test/option.escape_formulas.ts +++ b/packages/csv-stringify/test/option.escape_formulas.ts @@ -90,4 +90,77 @@ describe("Option `escape_formulas`", function () { }, ); }); + + it("with a `quote` option set to the escape prefix", function (next) { + stringify( + [ + ["=a", 1], + ["=b,c", 2], + ], + { + escape_formulas: true, + quote: "'", + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql(dedent` + '"'=a',1 + '"'=b,c',2 + `); + next(); + }, + ); + }); + + it("with an `escape` option set to the escape prefix", function (next) { + stringify( + [["=a,b", 1]], + { + escape_formulas: true, + escape: "'", + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql(`"''=a,b",1`); + next(); + }, + ); + }); + + it("with a `delimiter` option set to the escape prefix", function (next) { + stringify( + [["=a", 1]], + { + escape_formulas: true, + delimiter: "'", + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql(`"'=a"'1`); + next(); + }, + ); + }); + + it("with a `record_delimiter` option set to the escape prefix", function (next) { + stringify( + [ + ["=a", 1], + ["b", 2], + ], + { + escape_formulas: true, + record_delimiter: "'", + eof: false, + }, + (err, data) => { + if (err) return next(err); + data.should.eql(`"'=a",1'b,2`); + next(); + }, + ); + }); });