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 e551863fd..676e8799a 100644 --- a/packages/cli/src/__tests__/lib/output.test.ts +++ b/packages/cli/src/__tests__/lib/output.test.ts @@ -1,3 +1,4 @@ +import { stripVTControlCharacters } from "node:util"; import chalk from "chalk"; import { afterEach, @@ -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 })); @@ -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", + 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" }], diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts index c72600507..00c3b2ae8 100644 --- a/packages/cli/src/lib/output.ts +++ b/packages/cli/src/lib/output.ts @@ -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, (_match, value) => `: ${theme.jsonString(`"${value}"`)}` ) .replace(/: (-?\d+\.?\d*)/g, (_match, num) => `: ${theme.jsonNumber(num)}`)