Skip to content
Open
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
6 changes: 4 additions & 2 deletions dotenv/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ type LineParseResult = {
};

const KEY_VALUE_REGEXP =
/^\s*(?:export\s+)?(?<key>[^\s=#]+?)\s*=[\ \t]*('\r?\n?(?<notInterpolated>(.|\r\n|\n)*?)\r?\n?'|"\r?\n?(?<interpolated>(.|\r\n|\n)*?)\r?\n?"|(?<unquoted>[^\r\n#]*)) *#*.*$/gm;
/^\s*(?:export\s+)?(?<key>[^\s=#]+?)\s*=[\ \t]*('\r?\n?(?<notInterpolated>(?:.|\r\n|\n)*?)\r?\n?'|"\r?\n?(?<interpolated>(?:[^"\\]|\\.)*)\r?\n?"|(?<unquoted>[^\r\n#]*)) *#*.*$/gm;

const VALID_KEY_REGEXP = /^[a-zA-Z_][a-zA-Z0-9_]*$/;

Expand All @@ -19,11 +19,13 @@ const CHARACTERS_MAP: { [key: string]: string } = {
"\\n": "\n",
"\\r": "\r",
"\\t": "\t",
'\\"': '"',
"\\\\": "\\",
};

function expandCharacters(str: string): string {
return str.replace(
/\\([nrt])/g,
/\\([nrt"\\])/g,
($1: keyof typeof CHARACTERS_MAP): string => CHARACTERS_MAP[$1] ?? "",
);
}
Expand Down
39 changes: 39 additions & 0 deletions dotenv/parse_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,42 @@ Deno.test("parse() result is not affected by extended Object.prototype", () => {
assertEquals(result.foo, undefined);
assertEquals(result.bar, "1");
});

Deno.test("parse() round-trips through stringify()", async (t) => {
const { stringify } = await import("./stringify.ts");

await t.step("basic value", () => {
const original = { HELLO: "world" };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with spaces", () => {
const original = { GREETING: "hello world" };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with single quote", () => {
const original = { PARSE: "par'se" };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with double quote (JSON-like)", () => {
const original = { JSON: '{"key":"value"}' };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with both quote types", () => {
const original = { MIXED: `a'b"c` };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with newline", () => {
const original = { MULTILINE: "hello\nworld" };
assertEquals(parse(stringify(original)), original);
});

await t.step("value with backslash and quotes", () => {
const original = { BS: String.raw`test\"value` };
assertEquals(parse(stringify(original)), original);
});
});
20 changes: 15 additions & 5 deletions dotenv/stringify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
export function stringify(object: Record<string, string>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(object)) {
let quote;
let quote: string | undefined;

let escapedValue = value ?? "";
if (key.startsWith("#")) {
Expand All @@ -28,11 +28,21 @@ export function stringify(object: Record<string, string>): string {
`key starts with a '#' indicates a comment and is ignored: '${key}'`,
);
continue;
} else if (escapedValue.includes("\n") || escapedValue.includes("'")) {
// escape inner new lines
escapedValue = escapedValue.replaceAll("\n", "\\n");
}

const hasNewline = escapedValue.includes("\n");
const hasSingleQuote = escapedValue.includes("'");
const hasDoubleQuote = escapedValue.includes('"');

// Use double quotes when the value contains newlines (so they can be
// expanded back) or single quotes (which are safe inside double quotes).
if (hasNewline || hasSingleQuote) {
quote = `"`;
} else if (escapedValue.match(/\W/)) {
// Escape backslashes first so that existing backslashes are not
// confused with escape sequences when parsed.
escapedValue = escapedValue.replaceAll("\\", "\\\\");
if (hasNewline) escapedValue = escapedValue.replaceAll("\n", "\\n");
} else if (hasDoubleQuote || escapedValue.match(/\W/)) {
quote = "'";
}

Expand Down
20 changes: 20 additions & 0 deletions dotenv/stringify_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,24 @@ Deno.test("stringify()", async (t) => {
stringify({ PARSE: "par'se" }),
`PARSE="par'se"`,
));
await t.step("handles double-quote characters", () =>
assertEquals(
stringify({ JSON: '{"key":"value"}' }),
`JSON='{"key":"value"}'`,
));
await t.step("handles both quote characters", () =>
assertEquals(
stringify({ MIXED: `a'b"c` }),
`MIXED="a'b\\"c"`,
));
await t.step("handles backslash with double quotes", () =>
assertEquals(
stringify({ BS: String.raw`test\"value` }),
`BS="test\\\\\\"value"`,
));
await t.step("handles newline with single quotes", () =>
assertEquals(
stringify({ NL: "hello\nit's me" }),
`NL="hello\\nit's me"`,
));
});
Loading