diff --git a/packages/csv-stringify/lib/api/index.js b/packages/csv-stringify/lib/api/index.js index 1fb77be2b..690be4de0 100644 --- a/packages/csv-stringify/lib/api/index.js +++ b/packages/csv-stringify/lib/api/index.js @@ -186,6 +186,7 @@ const stringifier = function (options, state, info) { delimiter, escape, quote, + relax_quotes, quoted, quoted_empty, quoted_string, @@ -250,16 +251,20 @@ const stringifier = function (options, state, info) { } } const shouldQuote = - containsQuote === true || + (containsQuote === true && + (relax_quotes === false || + value.trimStart().startsWith(quote))) || containsdelimiter || containsRecordDelimiter || quoted || quotedString || quotedMatch; + // Escape all escape characters if quoted if (shouldQuote === true && containsEscape === true) { value = value.replaceAll(escape, () => escape + escape); } - if (containsQuote === true) { + // Escape all quote characters if quoted + if (shouldQuote === true) { value = value.replaceAll(quote, () => escape + quote); } if (shouldQuote === true) { diff --git a/packages/csv-stringify/lib/api/normalize_options.js b/packages/csv-stringify/lib/api/normalize_options.js index a1b58d70c..b354ed812 100644 --- a/packages/csv-stringify/lib/api/normalize_options.js +++ b/packages/csv-stringify/lib/api/normalize_options.js @@ -53,6 +53,17 @@ const normalize_options = function (opts) { ]), ]; } + // Normalize option `relax_quotes` + if (options.relax_quotes === undefined || options.relax_quotes === null) { + options.relax_quotes = false; + } else if (typeof options.relax_quotes !== "boolean") { + return [ + new CsvError("CSV_OPTION_RELAX_QUOTES_INVALID_TYPE", [ + "option `relax_quotes` must be a boolean,", + `got ${JSON.stringify(options.relax_quotes)}`, + ]), + ]; + } // Normalize option `quoted` if (options.quoted === undefined || options.quoted === null) { options.quoted = false; diff --git a/packages/csv-stringify/lib/index.d.ts b/packages/csv-stringify/lib/index.d.ts index 93c823210..ef2678a1e 100644 --- a/packages/csv-stringify/lib/index.d.ts +++ b/packages/csv-stringify/lib/index.d.ts @@ -11,6 +11,7 @@ export type CastReturnObject = { value: string | null | undefined } & Pick< | "delimiter" | "escape" | "quote" + | "relax_quotes" | "quoted" | "quoted_empty" | "quoted_string" @@ -89,6 +90,12 @@ export interface OptionsNormalized extends stream.TransformOptions { * The quote characters, defaults to the ", an empty quote value will preserve the original field. */ quote: string; + /** + * Preserve interior quotes in unquoted fields when quoting is enabled, defaults to false. + * Fields containing delimiters, line breaks, or leading quotes are still quoted. + * Parse this non-RFC 4180 output with `relax_quotes: true`. + */ + relax_quotes: boolean; /** * Boolean, default to false, quote all the non-empty fields even if not required. */ @@ -178,6 +185,12 @@ export interface Options extends stream.TransformOptions { * The quote characters, defaults to the ", an empty quote value will preserve the original field. */ quote?: string | Buffer | boolean; + /** + * Preserve interior quotes in unquoted fields when quoting is enabled, defaults to false. + * Fields containing delimiters, line breaks, or leading quotes are still quoted. + * Parse this non-RFC 4180 output with `relax_quotes: true`. + */ + relax_quotes?: boolean; /** * Boolean, default to false, quote all the non-empty fields even if not required. */ diff --git a/packages/csv-stringify/samples/option.relax_quotes.js b/packages/csv-stringify/samples/option.relax_quotes.js new file mode 100644 index 000000000..ce2bafa2b --- /dev/null +++ b/packages/csv-stringify/samples/option.relax_quotes.js @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import { stringify } from "csv-stringify/sync"; + +const output = stringify( + [['relax "quotes"', "I,contain,delimiters", '"I"contain"quotes"']], + { + relax_quotes: true, + }, +); + +assert.equal( + output, + 'relax "quotes","I,contain,delimiters","""I""contain""quotes"""\n', +); diff --git a/packages/csv-stringify/test/api.types.ts b/packages/csv-stringify/test/api.types.ts index f40b23c3c..ddc6a3804 100644 --- a/packages/csv-stringify/test/api.types.ts +++ b/packages/csv-stringify/test/api.types.ts @@ -32,6 +32,7 @@ describe("API Types", function () { "quoted_match", "quoted_string", "record_delimiter", + "relax_quotes", ]); }); diff --git a/packages/csv-stringify/test/option.relax_quotes.js b/packages/csv-stringify/test/option.relax_quotes.js new file mode 100644 index 000000000..4fe498607 --- /dev/null +++ b/packages/csv-stringify/test/option.relax_quotes.js @@ -0,0 +1,13 @@ +import "should"; +import { stringify } from "../lib/index.js"; + +describe("Option `relax_quotes`", function () { + it("validates boolean values", function () { + (() => stringify([], { relax_quotes: 1 })).should.throw( + "option `relax_quotes` must be a boolean, got 1", + ); + (() => stringify([], { relax_quotes: "true" })).should.throw( + 'option `relax_quotes` must be a boolean, got "true"', + ); + }); +}); diff --git a/packages/csv-stringify/test/option.relax_quotes.ts b/packages/csv-stringify/test/option.relax_quotes.ts new file mode 100644 index 000000000..ab551e332 --- /dev/null +++ b/packages/csv-stringify/test/option.relax_quotes.ts @@ -0,0 +1,67 @@ +import "should"; +import { stringify, normalize_options } from "../lib/sync.js"; + +describe("Option `relax_quotes`", function () { + it("normalisation defaults to false", function () { + const [, options] = normalize_options({}); + options.relax_quotes.should.eql(false); + }); + + it("preserves interior quotes in unquoted fields", function () { + stringify([['ab"c"', 'd"ef', 'hi""j']], { + relax_quotes: true, + }).should.eql('ab"c",d"ef,hi""j\n'); + }); + + it("still quotes delimiters and record delimiters", function () { + stringify([['a"b",c'], ['d"e"\nf'], ['h"i"\rj']], { + relax_quotes: true, + }).should.eql(['"a""b"",c"', '"d""e""\nf"', '"h""i""\rj"', ""].join("\n")); + }); + + it("still quotes leading quotes", function () { + stringify([['"abc"', '"def', ' "hij"']], { + relax_quotes: true, + }).should.eql('"""abc""","""def"," ""hij"""\n'); + }); + + it("respects explicit quoting selectors", function () { + for (const options of [ + { quoted: true }, + { quoted_string: true }, + { quoted_match: /def/ }, + ]) { + stringify([['abc "def"']], { + relax_quotes: true, + ...options, + }).should.eql('"abc ""def"""\n'); + } + }); + + it("escapes custom quotes only when enclosing the field", function () { + stringify([["a|b\\c", "a|b\\c,d", "|a\\b"]], { + relax_quotes: true, + quote: "|", + escape: "\\", + }).should.eql("a|b\\c,|a\\|b\\\\c,d|,|\\|a\\\\b|\n"); + }); + + it("supports multi-character quotes and delimiters", function () { + stringify([["a||b", "||a", "a||b::c"]], { + relax_quotes: true, + quote: "||", + delimiter: "::", + }).should.eql('a||b::||"||a||::||a"||b::c||\n'); + }); + + it("supports field-level cast options", function () { + stringify([[1, 2]], { + cast: { + number: (value) => ({ + value: `say "${value}"`, + relax_quotes: value === 1, + }), + }, + }).should.eql('say "1","say ""2"""\n'); + }); +}); diff --git a/packages/csv/test/option.quote.ts b/packages/csv/test/option.quote.ts index d1cd66c80..7b97c6221 100644 --- a/packages/csv/test/option.quote.ts +++ b/packages/csv/test/option.quote.ts @@ -11,10 +11,8 @@ describe("Option `quote`", function () { }); it("delimiter", function () { - const text = '"ab,c",",def"'; - const normalized = `${text}\n`; - stringify(parse(text)).should.eql(normalized); - parse(stringify(parse(text))).should.eql(parse(text)); + const text = '"ab,c",",def"\n'; + stringify(parse(text)).should.eql(text); }); it("record delimiter", function () { diff --git a/packages/csv/test/option.relax_quotes.js b/packages/csv/test/option.relax_quotes.js new file mode 100644 index 000000000..289c84cb6 --- /dev/null +++ b/packages/csv/test/option.relax_quotes.js @@ -0,0 +1,33 @@ +import "should"; + +const { parse, stringify } = await import("../lib/sync.js"); + +describe("Option `relax_quotes`", function () { + it("round trips without changing records or fields", function () { + const options = { relax_quotes: true }; + const text = 'ab"c",def\n"hi,j","k\nlm"\n"""nop""",q"xy\n'; + stringify(parse(text, options), options).should.eql(text); + }); + + it("round trips with custom separators and escaping", function () { + const options = { + relax_quotes: true, + delimiter: "::", + record_delimiter: "__", + quote: "|", + escape: "\\", + }; + const records = [ + ["a|b\\c", "a::b"], + ["a__b", "|leading"], + ]; + parse(stringify(records, options), options).should.eql(records); + }); + + it("preserves leading quotes when parsing with trim enabled", function () { + const options = { relax_quotes: true, trim: true }; + const text = '" ""hi""",plain\n'; + const records = parse(text, options); + parse(stringify(records, options), options).should.eql(records); + }); +});