import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js"; import { isSerializableJson } from "@inspector/core/json/jsonUtils.js"; /** * What a JSON-object editor's draft text currently means: the object to emit, * or why it cannot be emitted. */ export type JsonObjectDraft = | { ok: true; value: StrictJsonObject } | { ok: false; error: string }; /** * A plain JSON object, i.e. not an array and not a scalar. * * The `StrictJsonObject` narrowing is sound because the only caller parses with * `JSON.parse`, whose output cannot contain `undefined` — the value that * separates the strict type from `JsonValue`. */ export function isJsonObject(value: unknown): value is StrictJsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } /** * Whether the draft text writes a whole number out in full that `JSON.parse` * cannot represent exactly. * * `isSerializableJson` catches a literal too large to represent at all — * `1e400` parses to `Infinity` and writes back as `null`. This catches the * quieter one: `{"id":9007199254740993}` parses to `…992`, and * `{"id":1000000000000000000001}` parses to `1e21` and is written back as * `1e+21`. Either way the editor shows digits the wire will not carry. * Snowflake-style ids are the realistic case. * * It reads the **source text**, which is the only place the answer exists: the * parsed number has already lost whatever it lost, so nothing about its value * distinguishes `1000000000000000000001` from `1e21`. An earlier attempt * inferred it from `String(value)` and was wrong in both directions — it * refused `2^54` (written in full and exactly representable) and accepted every * full-form literal at or above 1e21, where JS switches to exponent form. * * Only integer literals written out in full are checked. An exponent or a * fraction is a double by nature and is sent as the double it parsed to, so * nothing is lost between typing and sending — and `{"n":1e308}` stays * acceptable, which `parseJsonObjectDraft`'s own tests pin. */ export function hasImpreciseIntegerLiteral(text: string): boolean { for (const literal of jsonNumberLiterals(text)) { if (!/^-?\d+$/.test(literal)) continue; // BigInt on both sides, so the comparison is exact: `Number(literal)` is an // integer-valued double, and converting it back shows what survived. if (BigInt(literal) !== BigInt(Number(literal))) return true; } return false; } /** Matches one JSON number token, anchored where the scan currently stands. */ const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y; /** * The numeric literals of a JSON document, in source order, skipping anything * inside a string. * * The string-skipping is the whole reason this is a scanner rather than a * regex over the text: `{"id":"9007199254740993"}` carries those digits as a * *string*, which is sent back exactly as written and must not be refused. * Object keys are strings too, so they are skipped by the same rule. */ function* jsonNumberLiterals(text: string): Generator { let index = 0; let inString = false; while (index < text.length) { const char = text[index]; if (inString) { // A backslash escapes the next character, including a closing quote. index += char === "\\" ? 2 : 1; if (char === '"') inString = false; continue; } if (char === '"') { inString = true; index += 1; continue; } if (char === "-" || (char >= "0" && char <= "9")) { JSON_NUMBER.lastIndex = index; const match = JSON_NUMBER.exec(text); /* v8 ignore next -- the sticky pattern always matches from a digit or a `-`, which is the only way this branch is entered. */ if (!match) break; yield match[0]; index = JSON_NUMBER.lastIndex; continue; } index += 1; } } /** The message every draft parser uses for {@link hasRoundedInteger}. */ export const ROUNDED_INTEGER_ERROR = "A whole number written out in full must be within ±(2^53 − 1) — a longer one loses digits when parsed, so a different value would be sent"; /** * Read a JSON-object editor's draft text. * * Empty text is the object `{}` rather than an error — clearing the box is how * "send nothing" is spelled, and an empty editor wearing a red error would read * as broken. * * The two failure modes are reported separately because they are different * mistakes: text that is not JSON at all is usually mid-edit, while text that * is valid JSON but not an object (`[1,2]`, `"a"`, `42`) is a shape the caller * cannot use no matter how finished it is. * * Lives in `utils` rather than beside its component because it is a pure * transform — and because exporting it from the component's module trips * `react-refresh/only-export-components`. */ export function parseJsonObjectDraft(text: string): JsonObjectDraft { if (text.trim() === "") return { ok: true, value: {} }; let parsed: unknown; try { parsed = JSON.parse(text); } catch { return { ok: false, error: "Not valid JSON — changes are not applied" }; } if (!isJsonObject(parsed)) { return { ok: false, error: "Must be a JSON object (`{ … }`) — changes are not applied", }; } // `JSON.parse` accepts numeric literals it cannot represent: `1e400` parses // to `Infinity`, which `JSON.stringify` then writes as `null`. Accepting it // here would show the user one value and send another. if (!isSerializableJson(parsed)) { return { ok: false, error: "Numbers must be finite — a value like `1e400` overflows and would be sent as null", }; } if (hasImpreciseIntegerLiteral(text)) { return { ok: false, error: ROUNDED_INTEGER_ERROR }; } return { ok: true, value: parsed }; }