/** What a piece of JSON text is, for highlighting. */ export type JsonTokenKind = 'key' | 'string' | 'number' | 'literal' | 'plain'; /** A run of JSON text of one kind. */ export interface JsonToken { kind: JsonTokenKind; text: string; } // A string (a key when a colon follows), a number, a literal, or anything // else (punctuation and spaces), which is plain. const TOKEN = /("(?:\\.|[^"\\])*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|\b(true|false|null)\b/g; /** * The tokens of JSON text, in order, so it can be highlighted as text rather * than as HTML. Neighbouring tokens of the same kind are joined, so a * document gives as few as possible. Text that isn't valid JSON still comes * back whole, with what doesn't match as plain. */ export function tokeniseJson(text: string): JsonToken[] { const tokens: JsonToken[] = []; const push = (kind: JsonTokenKind, part: string): void => { if (part === '') return; const last = tokens.at(-1); if (last?.kind === kind) last.text += part; else tokens.push({ kind, text: part }); }; let end = 0; for (const match of text.matchAll(TOKEN)) { push('plain', text.slice(end, match.index)); const [whole, string, colon, number, literal] = match; if (string !== undefined) { push(colon === undefined ? 'string' : 'key', string); push('plain', colon ?? ''); } else if (number !== undefined) { push('number', number); } else if (literal !== undefined) { push('literal', literal); } end = match.index + whole.length; } push('plain', text.slice(end)); return tokens; }