@@ -81,6 +87,10 @@ function isPlainObject(value: unknown): value is Record {
return prototype === null || prototype === Object.prototype;
}
+function isPrimitiveValue(value: unknown): boolean {
+ return isJSONTextNumber(value) || typeof value !== "object" || value === null;
+}
+
function JSONNodeRenderer({
data,
defaultExpandDepth,
@@ -171,7 +181,7 @@ function JSONNodeRenderer({
const color = styleConfig.json.key;
// For primitive values, render key and value inline
- if (propKey && (typeof data !== "object" || data === null)) {
+ if (propKey && isPrimitiveValue(data)) {
return (
+ {data.raw}
+ {maybeComma(isLastItemInParent)}
+
+ );
+ }
if (data === null) {
return (
@@ -592,6 +610,10 @@ function sortObjectKeysInternal(
value: unknown,
sortedValues: WeakMap
diff --git a/src/components/JobList.test.tsx b/src/components/JobList.test.tsx
index d8e202ce..56679cbe 100644
--- a/src/components/JobList.test.tsx
+++ b/src/components/JobList.test.tsx
@@ -19,7 +19,8 @@ import JobList from "./JobList";
type UseSettings = typeof import("@hooks/use-settings").useSettings;
type UseSettingsReturn = ReturnType
;
-const { mockUseSettings } = vi.hoisted(() => ({
+const { mockCompactJSONText, mockUseSettings } = vi.hoisted(() => ({
+ mockCompactJSONText: vi.fn(),
mockUseSettings: vi.fn() as MockedFunction,
}));
@@ -55,13 +56,28 @@ vi.mock("@hooks/use-settings", () => ({
useSettings: mockUseSettings,
}));
+vi.mock("@utils/jsonText", async (importOriginal) => {
+ const actual = await importOriginal();
+
+ return {
+ ...actual,
+ compactJSONText: (text: string) => {
+ mockCompactJSONText(text);
+ return actual.compactJSONText(text);
+ },
+ };
+});
+
describe("JobList", () => {
beforeEach(() => {
+ mockCompactJSONText.mockReset();
mockUseSettings.mockReset();
});
it("shows job args by default", () => {
- const job = jobMinimalFactory.build();
+ const job = jobMinimalFactory.build({
+ argsRaw: '{"z":2,"id":1970670598291982290,"a":1}',
+ });
const features = createFeatures({
jobListHideArgsByDefault: false,
});
@@ -87,7 +103,9 @@ describe("JobList", () => {
,
);
- expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
+ expect(
+ screen.getByText('{"a":1,"id":1970670598291982290,"z":2}'),
+ ).toBeInTheDocument();
});
it("hides job args when jobListHideArgsByDefault is true", () => {
@@ -117,9 +135,8 @@ describe("JobList", () => {
,
);
- expect(
- screen.queryByText(JSON.stringify(job.args)),
- ).not.toBeInTheDocument();
+ expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
+ expect(mockCompactJSONText).not.toHaveBeenCalled();
});
it("shows job args when user overrides default hide setting", () => {
@@ -150,7 +167,7 @@ describe("JobList", () => {
);
// Even though server default is to hide, user setting should make them visible
- expect(screen.getByText(JSON.stringify(job.args))).toBeInTheDocument();
+ expect(screen.getByText(job.argsRaw)).toBeInTheDocument();
});
it("hides job args when user overrides default show setting", () => {
@@ -181,9 +198,7 @@ describe("JobList", () => {
);
// Even though server default is to show, user setting should hide them
- expect(
- screen.queryByText(JSON.stringify(job.args)),
- ).not.toBeInTheDocument();
+ expect(screen.queryByText(job.argsRaw)).not.toBeInTheDocument();
});
it("requires confirmation before deleting selected jobs", async () => {
diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx
index 820004fd..8a7ab4f9 100644
--- a/src/components/JobList.tsx
+++ b/src/components/JobList.tsx
@@ -29,6 +29,7 @@ import {
JobStateFilterItem,
jobStateFilterItems,
} from "@utils/jobStateFilterItems";
+import { compactJSONText } from "@utils/jsonText";
import { classNames } from "@utils/style";
import React, {
FormEvent,
@@ -82,6 +83,14 @@ type JobListItemProps = {
) => void;
};
+const JobArgsPreview = ({ argsRaw }: { argsRaw: string }) => {
+ const argsPreview = useMemo(() => compactJSONText(argsRaw), [argsRaw]);
+
+ return (
+ {argsPreview}
+ );
+};
+
const JobListItem = ({
checked,
hideArgs,
@@ -132,11 +141,7 @@ const JobListItem = ({
- {showArgs && (
-
- {JSON.stringify(job.args)}
-
- )}
+ {showArgs && }
{job.queue}
diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx
index 3b59f709..7d6d4d32 100644
--- a/src/components/WorkflowDetail.test.tsx
+++ b/src/components/WorkflowDetail.test.tsx
@@ -218,6 +218,24 @@ describe("WorkflowDetail wait inspector", () => {
expect(screen.getByText("Not waiting")).toBeInTheDocument();
});
+ it("renders selected task args without rounding large numbers", async () => {
+ const argsRaw = '{"id":1970670598291982290}';
+ const task = workflowJobFactory.build({
+ argsRaw,
+ id: 1,
+ state: JobState.Completed,
+ task: "send_response",
+ waitReason: "none",
+ });
+
+ await renderWorkflowDetail(
+ { id: "wf-test-args", name: "Workflow Test", tasks: [task] },
+ task.id,
+ );
+
+ expect(screen.getByText(/1970670598291982290/)).toBeInTheDocument();
+ });
+
it("updates the lower inspector when the selected task changes", async () => {
const firstTask = workflowJobFactory.build({
id: 1,
diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx
index 210b185e..3dfeaf72 100644
--- a/src/components/WorkflowDetail.tsx
+++ b/src/components/WorkflowDetail.tsx
@@ -2,6 +2,7 @@ import ButtonForGroup from "@components/ButtonForGroup";
import { DurationCompact } from "@components/DurationCompact";
import { Subheading } from "@components/Heading";
import { RunningSpinnerIcon } from "@components/icons/jobStateIcons";
+import JSONTextView from "@components/JSONTextView";
import JSONView from "@components/JSONView";
import RelativeTimeFormatter from "@components/RelativeTimeFormatter";
import RetryWorkflowDialog from "@components/RetryWorkflowDialog";
@@ -335,7 +336,7 @@ const SelectedJobDetails = ({
Args
-
+
diff --git a/src/services/jobs.test.ts b/src/services/jobs.test.ts
new file mode 100644
index 00000000..e57f0887
--- /dev/null
+++ b/src/services/jobs.test.ts
@@ -0,0 +1,96 @@
+import { getJob, getJobKey, listJobs, listJobsKey } from "@services/jobs";
+import { JobState } from "@services/types";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+type APIAttemptErrorForTest = {
+ at: string;
+ attempt: number;
+ error: string;
+ trace: string;
+};
+
+type APIJobForTest = {
+ args: string;
+ attempt: number;
+ attempted_by: string[];
+ created_at: string;
+ errors: APIAttemptErrorForTest[];
+ finalized_at: undefined;
+ id: number;
+ kind: string;
+ max_attempts: number;
+ metadata: object;
+ priority: number;
+ queue: string;
+ scheduled_at: string;
+ state: JobState;
+ tags: string[];
+};
+
+const apiJob = (overrides: Partial
= {}): APIJobForTest => ({
+ args: '{"id":1970670598291982290}',
+ attempt: 0,
+ attempted_by: [],
+ created_at: "2026-04-21T17:57:00Z",
+ errors: [],
+ finalized_at: undefined,
+ id: 123,
+ kind: "RowOperation",
+ max_attempts: 25,
+ metadata: {},
+ priority: 1,
+ queue: "default",
+ scheduled_at: "2026-04-21T17:57:00Z",
+ state: JobState.Available,
+ tags: [],
+ ...overrides,
+});
+
+describe("jobs service", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ document.body.innerHTML = "";
+ });
+
+ it("preserves list job args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ data: [apiJob()] }), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ }),
+ );
+
+ const jobs = await listJobs({
+ client: undefined as never,
+ meta: undefined,
+ queryKey: listJobsKey({ limit: 10 }),
+ signal: new AbortController().signal,
+ });
+
+ expect(jobs[0]?.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+
+ it("preserves job detail args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify(apiJob()), {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ }),
+ );
+
+ const job = await getJob({
+ client: undefined as never,
+ meta: undefined,
+ queryKey: getJobKey(123n),
+ signal: new AbortController().signal,
+ });
+
+ expect(job.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+});
diff --git a/src/services/jobs.ts b/src/services/jobs.ts
index caddc780..4f4ff9aa 100644
--- a/src/services/jobs.ts
+++ b/src/services/jobs.ts
@@ -17,13 +17,15 @@ export type AttemptError = {
};
export type Job = {
- [Key in keyof JobFromAPI as SnakeToCamelCase]: Key extends
+ [Key in keyof Omit as SnakeToCamelCase]: Key extends
| StringEndingWithUnderscoreAt
| undefined
? Date
: JobFromAPI[Key] extends AttemptErrorFromAPI[]
? AttemptError[]
: JobFromAPI[Key];
+} & {
+ argsRaw: string;
};
export type JobFromAPI = {
@@ -43,18 +45,25 @@ export type JobLogs = {
};
export type JobMinimal = {
- [Key in keyof JobMinimalFromAPI as SnakeToCamelCase]: Key extends
+ [Key in keyof Omit<
+ JobMinimalFromAPI,
+ "args"
+ > as SnakeToCamelCase]: Key extends
| StringEndingWithUnderscoreAt
| undefined
? Date
: JobMinimalFromAPI[Key];
+} & {
+ argsRaw: string;
};
// Represents a Job as received from the API. This just like Job, except with
// string dates instead of Date objects and keys as snake_case instead of
// camelCase.
export type JobMinimalFromAPI = {
- args: object;
+ // JSON text as returned by River. Keep this unparsed to preserve large
+ // integer values exactly for display and copy.
+ args: string;
attempt: number;
attempted_at?: string;
attempted_by: string[];
@@ -100,7 +109,7 @@ type RiverJobLogEntry = {
export const apiJobMinimalToJobMinimal = (
job: JobMinimalFromAPI,
): JobMinimal => ({
- args: job.args,
+ argsRaw: job.args,
attempt: job.attempt,
attemptedAt: job.attempted_at ? new Date(job.attempted_at) : undefined,
attemptedBy: job.attempted_by,
diff --git a/src/services/workflows.test.ts b/src/services/workflows.test.ts
index 9844c582..4dd1d3bf 100644
--- a/src/services/workflows.test.ts
+++ b/src/services/workflows.test.ts
@@ -1,4 +1,7 @@
+import { JobState } from "@services/types";
import {
+ getWorkflow,
+ getWorkflowKey,
getWorkflowTaskSignals,
getWorkflowTaskWaitDiagnostics,
} from "@services/workflows";
@@ -10,6 +13,65 @@ describe("workflows service", () => {
document.body.innerHTML = "";
});
+ it("preserves workflow task args as raw JSON text", async () => {
+ document.body.innerHTML =
+ '';
+
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: "wf-args",
+ name: "Workflow Args",
+ tasks: [
+ {
+ args: '{"id":1970670598291982290}',
+ attempt: 0,
+ attempted_by: [],
+ created_at: "2026-04-21T17:57:00Z",
+ deps: [],
+ errors: [],
+ id: 123,
+ ignore_cancelled_deps: false,
+ ignore_deleted_deps: false,
+ ignore_discarded_deps: false,
+ kind: "RowOperation",
+ max_attempts: 25,
+ metadata: {
+ deps: [],
+ task: "row_operation",
+ workflow_id: "wf-args",
+ workflow_staged_at: "2026-04-21T17:57:00Z",
+ },
+ name: "row_operation",
+ priority: 1,
+ queue: "default",
+ scheduled_at: "2026-04-21T17:57:00Z",
+ state: JobState.Available,
+ tags: [],
+ wait_reason: "none",
+ workflow_id: "wf-args",
+ },
+ ],
+ }),
+ {
+ headers: { "Content-Type": "application/json" },
+ status: 200,
+ },
+ ),
+ );
+
+ const workflow = await getWorkflow({
+ client: undefined as never,
+ direction: "forward",
+ meta: undefined,
+ pageParam: undefined,
+ queryKey: getWorkflowKey("wf-args"),
+ signal: new AbortController().signal,
+ });
+
+ expect(workflow.tasks[0]?.argsRaw).toBe('{"id":1970670598291982290}');
+ });
+
it("parses task signal dates, ids, cursor ids, evidence, and scope", async () => {
document.body.innerHTML =
'';
diff --git a/src/test/factories/job.ts b/src/test/factories/job.ts
index efd797a6..c40e51b5 100644
--- a/src/test/factories/job.ts
+++ b/src/test/factories/job.ts
@@ -266,7 +266,7 @@ export const jobFactory = JobFactory.define(({ sequence }) => {
const createdAt = faker.date.recent({ days: 0.001 });
return {
- args: { baz: 1, foo: "bar" },
+ argsRaw: '{"baz":1,"foo":"bar"}',
attempt: 0,
attemptedAt: undefined,
attemptedBy: [],
diff --git a/src/test/factories/workflowJob.ts b/src/test/factories/workflowJob.ts
index 0f8ce16e..d5b59bb1 100644
--- a/src/test/factories/workflowJob.ts
+++ b/src/test/factories/workflowJob.ts
@@ -13,6 +13,7 @@ const defaultWorkflowStagedAt = new Date("2025-01-01T00:00:00.000Z");
const defaultWorkflowID = "wf-1";
type WorkflowJobFactoryParams = {
+ argsRaw?: string;
attemptedAt?: Date;
createdAt?: Date;
deps?: string[];
@@ -81,6 +82,7 @@ export const workflowJobFactory = Factory.define<
const baseJob = jobFactory.build({
...(attemptedAt ? { attemptedAt } : {}),
+ ...(params.argsRaw ? { argsRaw: params.argsRaw } : {}),
createdAt,
...(finalizedAt ? { finalizedAt } : {}),
id,
diff --git a/src/utils/jsonText.test.ts b/src/utils/jsonText.test.ts
new file mode 100644
index 00000000..069d4563
--- /dev/null
+++ b/src/utils/jsonText.test.ts
@@ -0,0 +1,73 @@
+import {
+ compactJSONText,
+ formatJSONText,
+ prepareJSONText,
+} from "@utils/jsonText";
+import { describe, expect, it } from "vitest";
+
+describe("jsonText", () => {
+ it("sorts object keys without parsing large number tokens", () => {
+ const rawJSON =
+ '{"z":2,"id":1970670598291982290,"nested":{"b":9223372036854775807,"a":1}}';
+
+ expect(compactJSONText(rawJSON)).toBe(
+ '{"id":1970670598291982290,"nested":{"a":1,"b":9223372036854775807},"z":2}',
+ );
+ });
+
+ it("pretty-prints sorted JSON without rounding numbers", () => {
+ const rawJSON = '{"z":2,"id":1970670598291982290,"a":1}';
+
+ expect(formatJSONText(rawJSON)).toBe(`{
+ "a": 1,
+ "id": 1970670598291982290,
+ "z": 2
+}`);
+ });
+
+ it("falls back to original text when args are not valid JSON", () => {
+ expect(formatJSONText("{not valid")).toBe("{not valid");
+ });
+
+ it("handles arrays, literals, strings, and every JSON number form", () => {
+ const rawJSON =
+ '[true,false,null,-0,-12,0.25,1.25e+30,"line\\n\\u263a",{},[]]';
+
+ expect(compactJSONText(rawJSON)).toBe(
+ '[true,false,null,-0,-12,0.25,1.25e+30,"line\\n☺",{},[]]',
+ );
+ expect(JSON.parse(formatJSONText(rawJSON))).toEqual(JSON.parse(rawJSON));
+ });
+
+ it("sorts nested objects and preserves escaped keys", () => {
+ const rawJSON = String.raw`{"z":0,"a\"key":"value","array":[{"b":2,"a":1}]}`;
+
+ expect(compactJSONText(rawJSON)).toBe(
+ String.raw`{"a\"key":"value","array":[{"a":1,"b":2}],"z":0}`,
+ );
+ });
+
+ it.each(["", "[1,]", '{"a":}', "01", '"unterminated', "true false"])(
+ "returns malformed input unchanged: %j",
+ (rawJSON) => {
+ expect(compactJSONText(rawJSON)).toBe(rawJSON);
+ expect(formatJSONText(rawJSON)).toBe(rawJSON);
+ expect(prepareJSONText(rawJSON)).toBeUndefined();
+ },
+ );
+
+ it("falls back safely when valid JSON exceeds the nesting limit", () => {
+ const rawJSON = `${"[".repeat(5_000)}0${"]".repeat(5_000)}`;
+
+ expect(compactJSONText(rawJSON)).toBe(rawJSON);
+ expect(formatJSONText(rawJSON)).toBe(rawJSON);
+ expect(prepareJSONText(rawJSON)).toBeUndefined();
+ });
+
+ it("uses compact sorted output when indentation expands excessively", () => {
+ const rawJSON = `${"[".repeat(99)}{"z":2,"a":1}${"]".repeat(99)}`;
+ const expected = `${"[".repeat(99)}{"a":1,"z":2}${"]".repeat(99)}`;
+
+ expect(formatJSONText(rawJSON)).toBe(expected);
+ });
+});
diff --git a/src/utils/jsonText.ts b/src/utils/jsonText.ts
new file mode 100644
index 00000000..bdfeb51b
--- /dev/null
+++ b/src/utils/jsonText.ts
@@ -0,0 +1,427 @@
+const JSON_TEXT_NUMBER = Symbol("JSONTextNumber");
+const MAX_JSON_NESTING_DEPTH = 100;
+const MAX_PRETTY_PRINT_LENGTH = 1_048_576;
+const MAX_PRETTY_PRINT_EXTRA_LENGTH = 16_384;
+const MAX_PRETTY_PRINT_EXPANSION = 4;
+
+export type JSONTextNumber = {
+ readonly [JSON_TEXT_NUMBER]: true;
+ readonly raw: string;
+};
+
+export type JSONTextValue =
+ | { [key: string]: JSONTextValue }
+ | boolean
+ | JSONTextNumber
+ | JSONTextValue[]
+ | null
+ | string;
+
+export type PreparedJSONText = {
+ copyText: string;
+ value: JSONTextValue;
+};
+
+type JSONTextNode =
+ | { entries: JSONTextObjectEntry[]; kind: "object" }
+ | { kind: "array"; values: JSONTextNode[] }
+ | { kind: "literal"; value: "false" | "null" | "true" }
+ | { kind: "number"; raw: string }
+ | { kind: "string"; value: string };
+
+type JSONTextObjectEntry = {
+ key: string;
+ value: JSONTextNode;
+};
+
+class BoundedStringBuilder {
+ private length = 0;
+ private readonly parts: string[] = [];
+
+ constructor(private readonly maxLength: number) {}
+
+ append(value: string): boolean {
+ if (this.length + value.length > this.maxLength) return false;
+
+ this.length += value.length;
+ this.parts.push(value);
+ return true;
+ }
+
+ toString(): string {
+ return this.parts.join("");
+ }
+}
+
+class JSONTextParser {
+ private position = 0;
+
+ constructor(private readonly text: string) {}
+
+ parse(): JSONTextNode | undefined {
+ try {
+ const value = this.parseValue();
+ this.skipWhitespace();
+ return this.position === this.text.length ? value : undefined;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private expect(char: string) {
+ if (this.text[this.position] !== char) {
+ throw new Error(`expected ${char}`);
+ }
+ this.position += 1;
+ }
+
+ private parseArray(depth: number): JSONTextNode {
+ this.expect("[");
+ this.skipWhitespace();
+
+ const values: JSONTextNode[] = [];
+ if (this.text[this.position] === "]") {
+ this.position += 1;
+ return { kind: "array", values };
+ }
+
+ while (true) {
+ values.push(this.parseValue(depth + 1));
+ this.skipWhitespace();
+
+ if (this.text[this.position] === "]") {
+ this.position += 1;
+ return { kind: "array", values };
+ }
+
+ this.expect(",");
+ this.skipWhitespace();
+ }
+ }
+
+ private parseLiteral(literal: "false" | "null" | "true"): JSONTextNode {
+ if (!this.text.startsWith(literal, this.position)) {
+ throw new Error(`expected ${literal}`);
+ }
+
+ this.position += literal.length;
+ return { kind: "literal", value: literal };
+ }
+
+ private parseNumber(): JSONTextNode {
+ const start = this.position;
+
+ if (this.text[this.position] === "-") {
+ this.position += 1;
+ }
+
+ if (this.text[this.position] === "0") {
+ this.position += 1;
+ } else if (isDigitOneToNine(this.text[this.position])) {
+ this.position += 1;
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ } else {
+ throw new Error("expected number");
+ }
+
+ if (this.text[this.position] === ".") {
+ this.position += 1;
+ if (!isDigit(this.text[this.position])) {
+ throw new Error("expected fractional digit");
+ }
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ }
+
+ if (this.text[this.position] === "e" || this.text[this.position] === "E") {
+ this.position += 1;
+ if (
+ this.text[this.position] === "+" ||
+ this.text[this.position] === "-"
+ ) {
+ this.position += 1;
+ }
+ if (!isDigit(this.text[this.position])) {
+ throw new Error("expected exponent digit");
+ }
+ while (isDigit(this.text[this.position])) {
+ this.position += 1;
+ }
+ }
+
+ return { kind: "number", raw: this.text.slice(start, this.position) };
+ }
+
+ private parseObject(depth: number): JSONTextNode {
+ this.expect("{");
+ this.skipWhitespace();
+
+ const entries: JSONTextObjectEntry[] = [];
+ if (this.text[this.position] === "}") {
+ this.position += 1;
+ return { entries, kind: "object" };
+ }
+
+ while (true) {
+ const key = this.parseStringValue();
+ this.skipWhitespace();
+ this.expect(":");
+ const value = this.parseValue(depth + 1);
+ entries.push({ key, value });
+ this.skipWhitespace();
+
+ if (this.text[this.position] === "}") {
+ this.position += 1;
+ return { entries, kind: "object" };
+ }
+
+ this.expect(",");
+ this.skipWhitespace();
+ }
+ }
+
+ private parseString(): JSONTextNode {
+ return { kind: "string", value: this.parseStringValue() };
+ }
+
+ private parseStringValue(): string {
+ const start = this.position;
+ this.expect('"');
+
+ while (this.position < this.text.length) {
+ const char = this.text[this.position];
+ if (char === '"') {
+ this.position += 1;
+ const parsed = JSON.parse(this.text.slice(start, this.position));
+ if (typeof parsed !== "string") {
+ throw new Error("expected string");
+ }
+ return parsed;
+ }
+
+ if (char === "\\") {
+ this.position += 2;
+ } else {
+ this.position += 1;
+ }
+ }
+
+ throw new Error("unterminated string");
+ }
+
+ private parseValue(depth = 0): JSONTextNode {
+ if (depth > MAX_JSON_NESTING_DEPTH) {
+ throw new Error("maximum JSON nesting depth exceeded");
+ }
+
+ this.skipWhitespace();
+
+ const char = this.text[this.position];
+ if (char === "{") return this.parseObject(depth);
+ if (char === "[") return this.parseArray(depth);
+ if (char === '"') return this.parseString();
+ if (char === "t") return this.parseLiteral("true");
+ if (char === "f") return this.parseLiteral("false");
+ if (char === "n") return this.parseLiteral("null");
+ return this.parseNumber();
+ }
+
+ private skipWhitespace() {
+ while (/[\t\n\r ]/.test(this.text[this.position] ?? "")) {
+ this.position += 1;
+ }
+ }
+}
+
+export function compactJSONText(text: string): string {
+ const parsed = parseJSONText(text);
+ if (!parsed) return text;
+
+ try {
+ return stringifyCompact(parsed);
+ } catch {
+ return text;
+ }
+}
+
+export function formatJSONText(text: string): string {
+ const parsed = parseJSONText(text);
+ if (!parsed) return text;
+
+ try {
+ return stringifyForDisplay(parsed, text.length);
+ } catch {
+ return text;
+ }
+}
+
+export function isJSONTextNumber(value: unknown): value is JSONTextNumber {
+ return (
+ typeof value === "object" && value !== null && JSON_TEXT_NUMBER in value
+ );
+}
+
+export function prepareJSONText(text: string): PreparedJSONText | undefined {
+ const parsed = parseJSONText(text);
+ if (!parsed) return undefined;
+
+ try {
+ return {
+ copyText: stringifyForDisplay(parsed, text.length),
+ value: nodeToValue(parsed),
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+function isDigit(char: string | undefined): boolean {
+ return char !== undefined && char >= "0" && char <= "9";
+}
+
+function isDigitOneToNine(char: string | undefined): boolean {
+ return char !== undefined && char >= "1" && char <= "9";
+}
+
+function nodeToValue(node: JSONTextNode): JSONTextValue {
+ switch (node.kind) {
+ case "array":
+ return node.values.map(nodeToValue);
+ case "literal":
+ if (node.value === "null") return null;
+ return node.value === "true";
+ case "number":
+ return { [JSON_TEXT_NUMBER]: true, raw: node.raw };
+ case "object": {
+ const value = Object.create(null) as { [key: string]: JSONTextValue };
+ for (const entry of node.entries) {
+ Object.defineProperty(value, entry.key, {
+ configurable: true,
+ enumerable: true,
+ value: nodeToValue(entry.value),
+ writable: true,
+ });
+ }
+ return value;
+ }
+ case "string":
+ return node.value;
+ }
+}
+
+function parseJSONText(text: string): JSONTextNode | undefined {
+ const parser = new JSONTextParser(text);
+ return parser.parse();
+}
+
+function sortableArrayIndex(key: string): number | undefined {
+ const index = Number(key);
+ if (
+ !Number.isInteger(index) ||
+ index < 0 ||
+ index >= 2 ** 32 - 1 ||
+ String(index) !== key
+ ) {
+ return undefined;
+ }
+
+ return index;
+}
+
+function sortedEntries(entries: JSONTextObjectEntry[]): JSONTextObjectEntry[] {
+ return [...entries].sort((left, right) => {
+ const leftIndex = sortableArrayIndex(left.key);
+ const rightIndex = sortableArrayIndex(right.key);
+
+ if (leftIndex !== undefined && rightIndex !== undefined) {
+ return leftIndex - rightIndex;
+ }
+ if (leftIndex !== undefined) return -1;
+ if (rightIndex !== undefined) return 1;
+ return left.key.localeCompare(right.key);
+ });
+}
+
+function stringifyCompact(node: JSONTextNode): string {
+ switch (node.kind) {
+ case "array":
+ return `[${node.values.map(stringifyCompact).join(",")}]`;
+ case "literal":
+ return node.value;
+ case "number":
+ return node.raw;
+ case "object":
+ return `{${sortedEntries(node.entries)
+ .map(
+ (entry) =>
+ `${JSON.stringify(entry.key)}:${stringifyCompact(entry.value)}`,
+ )
+ .join(",")}}`;
+ case "string":
+ return JSON.stringify(node.value);
+ }
+}
+
+function stringifyForDisplay(node: JSONTextNode, sourceLength: number): string {
+ const maxLength = Math.min(
+ MAX_PRETTY_PRINT_LENGTH,
+ Math.max(
+ sourceLength * MAX_PRETTY_PRINT_EXPANSION,
+ sourceLength + MAX_PRETTY_PRINT_EXTRA_LENGTH,
+ ),
+ );
+ const pretty = stringifyPrettyWithinLimit(node, maxLength);
+
+ return pretty ?? stringifyCompact(node);
+}
+
+function stringifyPrettyWithinLimit(
+ node: JSONTextNode,
+ maxLength: number,
+): string | undefined {
+ const builder = new BoundedStringBuilder(maxLength);
+ return writePretty(node, 0, builder) ? builder.toString() : undefined;
+}
+
+function writePretty(
+ node: JSONTextNode,
+ depth: number,
+ builder: BoundedStringBuilder,
+): boolean {
+ const indent = " ";
+ const currentIndent = indent.repeat(depth);
+ const childIndent = indent.repeat(depth + 1);
+
+ switch (node.kind) {
+ case "array": {
+ if (node.values.length === 0) return builder.append("[]");
+ if (!builder.append("[\n")) return false;
+ for (const [index, value] of node.values.entries()) {
+ if (index > 0 && !builder.append(",\n")) return false;
+ if (!builder.append(childIndent)) return false;
+ if (!writePretty(value, depth + 1, builder)) return false;
+ }
+ return builder.append(`\n${currentIndent}]`);
+ }
+ case "literal":
+ return builder.append(node.value);
+ case "number":
+ return builder.append(node.raw);
+ case "object": {
+ if (node.entries.length === 0) return builder.append("{}");
+ if (!builder.append("{\n")) return false;
+ for (const [index, entry] of sortedEntries(node.entries).entries()) {
+ if (index > 0 && !builder.append(",\n")) return false;
+ if (!builder.append(childIndent)) return false;
+ if (!builder.append(`${JSON.stringify(entry.key)}: `)) return false;
+ if (!writePretty(entry.value, depth + 1, builder)) return false;
+ }
+ return builder.append(`\n${currentIndent}}`);
+ }
+ case "string":
+ return builder.append(JSON.stringify(node.value));
+ }
+}