From 2719ece19dee2796923737518bf635728e150f3d Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 2 Oct 2026 08:57:31 +0100 Subject: [PATCH 1/5] fix: highlight escaped JSON strings correctly --- .changeset/json-string-escapes.md | 8 +++ packages/cli/src/__tests__/lib/output.test.ts | 62 +++++++++++++++++++ packages/cli/src/lib/output.ts | 2 +- 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 .changeset/json-string-escapes.md diff --git a/.changeset/json-string-escapes.md b/.changeset/json-string-escapes.md new file mode 100644 index 000000000..c41788ac5 --- /dev/null +++ b/.changeset/json-string-escapes.md @@ -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. diff --git a/packages/cli/src/__tests__/lib/output.test.ts b/packages/cli/src/__tests__/lib/output.test.ts index f87d5de98..e61895b92 100644 --- a/packages/cli/src/__tests__/lib/output.test.ts +++ b/packages/cli/src/__tests__/lib/output.test.ts @@ -1,6 +1,8 @@ +import { stripVTControlCharacters } from "node:util"; import chalk from "chalk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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 })); @@ -156,6 +158,66 @@ 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", value: '"cat.com"' }, + { name: "embedded quotation marks", value: 'say "hello"' }, + { 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", ({ value }) => { + const data = { value }; + formatOutput(data); + + const output = String(logSpy.mock.calls[0]?.[0]); + expect(output).toBe( + `{\n ${theme.jsonKey('"value"')}: ${theme.jsonString(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" }], diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index c72600507..103882a05 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -80,7 +80,7 @@ function formatJson(data: unknown): string { return json .replace(/"([^"]+)":/g, (_match, key) => `${theme.jsonKey(`"${key}"`)}:`) .replace( - /: "([^"]*)"/g, + /: "((?:[^"\\]|\\.)*)"/g, (_match, value) => `: ${theme.jsonString(`"${value}"`)}` ) .replace(/: (-?\d+\.?\d*)/g, (_match, num) => `: ${theme.jsonNumber(num)}`) From 199c247759872edeea109e9e7d62764d0259454c Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 2 Oct 2026 09:51:47 +0100 Subject: [PATCH 2/5] fix: preserve escaped key text in JSON strings --- packages/cli/src/__tests__/lib/output.test.ts | 4 ++++ packages/cli/src/lib/output.ts | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/lib/output.test.ts b/packages/cli/src/__tests__/lib/output.test.ts index e61895b92..3effebec5 100644 --- a/packages/cli/src/__tests__/lib/output.test.ts +++ b/packages/cli/src/__tests__/lib/output.test.ts @@ -164,6 +164,10 @@ describe("formatOutput", () => { { name: "empty strings", value: "" }, { name: "escaped quotation marks", value: '"cat.com"' }, { name: "embedded quotation marks", value: 'say "hello"' }, + { + name: "escaped quotation marks followed by a colon", + value: '"key": text', + }, { name: "backslashes before quotation marks", value: '\\"cat.com\\"' }, { name: "Windows paths with trailing backslashes", diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index 103882a05..a2d1bf89a 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -78,7 +78,10 @@ function formatJson(data: unknown): string { } return json - .replace(/"([^"]+)":/g, (_match, key) => `${theme.jsonKey(`"${key}"`)}:`) + .replace( + /"((?:[^"\\]|\\.)*)":/g, + (_match, key) => `${theme.jsonKey(`"${key}"`)}:` + ) .replace( /: "((?:[^"\\]|\\.)*)"/g, (_match, value) => `: ${theme.jsonString(`"${value}"`)}` From 7f894ae8bf27be214f40103afd431fba80fe4731 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 2 Oct 2026 14:55:23 +0100 Subject: [PATCH 3/5] test: make quoted string serialization explicit --- packages/cli/src/__tests__/lib/output.test.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/lib/output.test.ts b/packages/cli/src/__tests__/lib/output.test.ts index 3effebec5..6177e034a 100644 --- a/packages/cli/src/__tests__/lib/output.test.ts +++ b/packages/cli/src/__tests__/lib/output.test.ts @@ -162,7 +162,11 @@ describe("formatOutput", () => { const cases = [ { name: "plain strings", value: "cat.com" }, { name: "empty strings", value: "" }, - { name: "escaped quotation marks", value: '"cat.com"' }, + { + 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", @@ -191,16 +195,19 @@ describe("formatOutput", () => { chalk.level = originalChalkLevel; }); - it.each(cases)("highlights complete $name", ({ value }) => { - const data = { value }; - formatOutput(data); + 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(JSON.stringify(value))}\n}` - ); - expect(JSON.parse(stripVTControlCharacters(output))).toEqual(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", From 9e5c9d2f5811a48cb7246170988832bb48eed500 Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 2 Oct 2026 14:55:50 +0100 Subject: [PATCH 4/5] test: make quote-and-colon serialization explicit --- packages/cli/src/__tests__/lib/output.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/__tests__/lib/output.test.ts b/packages/cli/src/__tests__/lib/output.test.ts index 6177e034a..cbb79cd01 100644 --- a/packages/cli/src/__tests__/lib/output.test.ts +++ b/packages/cli/src/__tests__/lib/output.test.ts @@ -170,6 +170,7 @@ describe("formatOutput", () => { { name: "embedded quotation marks", value: 'say "hello"' }, { name: "escaped quotation marks followed by a colon", + serializedValue: String.raw`"\"key\": text"`, value: '"key": text', }, { name: "backslashes before quotation marks", value: '\\"cat.com\\"' }, From 8dc77142629d25a4869a2e68d4c391ff120cc8be Mon Sep 17 00:00:00 2001 From: Ben Dixon Date: Fri, 2 Oct 2026 14:56:23 +0100 Subject: [PATCH 5/5] docs: explain JSON highlighting regexes --- packages/cli/src/lib/output.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index a2d1bf89a..00c3b2ae8 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -79,10 +79,14 @@ function formatJson(data: unknown): string { return json .replace( + // 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, (_match, value) => `: ${theme.jsonString(`"${value}"`)}` )