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
8 changes: 8 additions & 0 deletions .changeset/json-string-escapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"cf": patch
---

Fix JSON string highlighting for escaped quotation marks

Keep the complete string value highlighted when JSON contains escaped quotes
or backslashes. Non-interactive output remains plain, parseable JSON.
74 changes: 74 additions & 0 deletions packages/cli/src/__tests__/lib/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripVTControlCharacters } from "node:util";
import chalk from "chalk";
import {
afterEach,
Expand All @@ -8,6 +9,7 @@ import {
vi,
} from "vite-plus/test";
import { formatOutput } from "../../lib/output.js";
import { theme } from "../../lib/ui/theme.js";
import { Page } from "../../sdk/sdk/core/pagination/Page.js";

const ciInfo = vi.hoisted(() => ({ isCI: false }));
Expand Down Expand Up @@ -163,6 +165,78 @@ describe("formatOutput", () => {
expect(loggedJson()).toEqual(["a", "b"]);
});

describe("JSON string highlighting", () => {
const cases = [
{ name: "plain strings", value: "cat.com" },
{ name: "empty strings", value: "" },
{
name: "escaped quotation marks",
serializedValue: String.raw`"\"cat.com\""`,
value: '"cat.com"',
},
{ name: "embedded quotation marks", value: 'say "hello"' },
{
name: "escaped quotation marks followed by a colon",
Comment thread
NuroDev marked this conversation as resolved.
serializedValue: String.raw`"\"key\": text"`,
value: '"key": text',
},
{ name: "backslashes before quotation marks", value: '\\"cat.com\\"' },
{
name: "Windows paths with trailing backslashes",
value: "C:\\Users\\cat\\",
},
{ name: "consecutive backslashes", value: "\\\\server\\share\\" },
{ name: "other JSON escapes", value: "line\n\t\r\b\f\u0000/end" },
];
let originalChalkLevel: typeof chalk.level;

beforeEach(() => {
originalChalkLevel = chalk.level;
chalk.level = 3;
vi.stubEnv("FORCE_COLOR", "1");
vi.stubEnv("NO_COLOR", undefined);
setStdinTTY(true);
setStdoutTTY(true);
});

afterEach(() => {
chalk.level = originalChalkLevel;
});

it.each(cases)(
"highlights complete $name",
({ serializedValue, value }) => {
const data = { value };
formatOutput(data);

const output = String(logSpy.mock.calls[0]?.[0]);
expect(output).toBe(
`{\n ${theme.jsonKey('"value"')}: ${theme.jsonString(serializedValue ?? JSON.stringify(value))}\n}`
);
expect(JSON.parse(stripVTControlCharacters(output))).toEqual(data);
}
);

it.each(["non-TTY stdout", "NO_COLOR"])(
"preserves plain, parseable JSON with %s",
(mode) => {
if (mode === "NO_COLOR") {
vi.stubEnv("NO_COLOR", "1");
} else {
setStdoutTTY(false);
}

const data = cases.map(({ value }) => ({ value }));
formatOutput(data);

expect(logSpy).toHaveBeenCalledExactlyOnceWith(
JSON.stringify(data, null, 2)
);
expect(loggedJson()).toEqual(data);
}
);
});

it("prints the payload from a Fern page response", () => {
formatOutput({
result: [{ id: "one" }, { id: "two" }],
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,16 @@ function formatJson(data: unknown): string {
}

return json
.replace(/"([^"]+)":/g, (_match, key) => `${theme.jsonKey(`"${key}"`)}:`)
.replace(
/: "([^"]*)"/g,
// Match a quoted key followed by a colon, consuming escapes as pairs
// so escaped quotes inside a value cannot be mistaken for key delimiters.
/"((?:[^"\\]|\\.)*)":/g,
(_match, key) => `${theme.jsonKey(`"${key}"`)}:`
)
.replace(
// Match a quoted value after a colon, consuming escapes as pairs
// so only an unescaped quote can end the string.
/: "((?:[^"\\]|\\.)*)"/g,
Comment thread
NuroDev marked this conversation as resolved.
Comment thread
NuroDev marked this conversation as resolved.
Comment thread
petebacondarwin marked this conversation as resolved.
(_match, value) => `: ${theme.jsonString(`"${value}"`)}`
)
.replace(/: (-?\d+\.?\d*)/g, (_match, num) => `: ${theme.jsonNumber(num)}`)
Expand Down
Loading