Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions packages/csv-stringify/lib/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ const stringifier = function (options, state, info) {
delimiter,
escape,
quote,
relax_quotes,
quoted,
quoted_empty,
quoted_string,
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 11 additions & 0 deletions packages/csv-stringify/lib/api/normalize_options.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 13 additions & 0 deletions packages/csv-stringify/lib/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type CastReturnObject = { value: string | null | undefined } & Pick<
| "delimiter"
| "escape"
| "quote"
| "relax_quotes"
| "quoted"
| "quoted_empty"
| "quoted_string"
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
14 changes: 14 additions & 0 deletions packages/csv-stringify/samples/option.relax_quotes.js
Original file line number Diff line number Diff line change
@@ -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',
);
1 change: 1 addition & 0 deletions packages/csv-stringify/test/api.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe("API Types", function () {
"quoted_match",
"quoted_string",
"record_delimiter",
"relax_quotes",
]);
});

Expand Down
13 changes: 13 additions & 0 deletions packages/csv-stringify/test/option.relax_quotes.js
Original file line number Diff line number Diff line change
@@ -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"',
);
});
});
67 changes: 67 additions & 0 deletions packages/csv-stringify/test/option.relax_quotes.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
6 changes: 2 additions & 4 deletions packages/csv/test/option.quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
33 changes: 33 additions & 0 deletions packages/csv/test/option.relax_quotes.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading