import { FjsonError, type Span } from "./errors.ts"; import { type Options, resolveMaxDepth } from "./options.ts"; import { type ArrayNode, type JsonObject, type JsonValue, nodeToValue, type ObjectNode, parseObjectTree, parseTree, type SyntaxNode, toJsonValue, } from "./parse.ts"; import { keyText, printJson } from "./stringify.ts"; /** path segments are object keys or array indices */ export type PathSegment = string | number; type Edit = [span: Span, text: string]; function lineStart(source: string, at: number): number { const slice = source.slice(0, at); return Math.max(slice.lastIndexOf("\r"), slice.lastIndexOf("\n")) + 1; } function lineIndent(source: string, at: number): string { const start = lineStart(source, at); let end = start; while ( end < at && (source[end] === " " || source[end] === "\t") ) end += 1; return source.slice(start, end); } function newlineOf(source: string): string { if (source.includes("\r\n")) return "\r\n"; if (source.includes("\r")) return "\r"; return "\n"; } function isBlank(source: string, from: number, to: number): boolean { for (let i = from; i < to; i += 1) { if (source[i] !== " " && source[i] !== "\t") return false; } return true; } function findPunctuation( source: string, from: number, to: number, target: string, ): number | undefined { let at = from; while (at < to) { if (source.startsWith("//", at)) { const end = source.slice(at, to).search(/[\r\n]/); at += end === -1 ? to - at : end; } else if (source.startsWith("/*", at)) { const close = source.indexOf("*/", at + 2); if (close === -1 || close + 2 > to) return undefined; at = close + 2; } else if (source[at] === target) { return at; } else { at += 1; } } return undefined; } function jsonEqual(a: JsonValue, b: JsonValue): boolean { if (a === b) return true; if (Array.isArray(a) || Array.isArray(b)) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((item, index) => jsonEqual(item, (b as JsonValue[])[index])); } if ( typeof a === "object" && a !== null && typeof b === "object" && b !== null ) { const left = a as JsonObject; const right = b as JsonObject; const keys = Object.keys(left); return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key]) ); } return false; } function mismatch(span: Span): FjsonError { return new FjsonError( "syntax", "Path segment does not match its container", span, ); } function child(node: SyntaxNode, segment: PathSegment): SyntaxNode | undefined { if (node.kind === "object" && typeof segment === "string") { return node.members.find((member) => member.key === segment)?.value; } if ( node.kind === "array" && typeof segment === "number" && Number.isInteger(segment) && segment >= 0 ) { return node.items[segment]; } throw mismatch(node.span); } /** an fJSON document with source-preserving mutations */ export class Document { #text: string; #tree: SyntaxNode; readonly #resolvedOptions: Options; private constructor(source: string, root: SyntaxNode, options: Options) { this.#text = source; this.#tree = root; this.#resolvedOptions = options; } /** parse an fJSON document */ static parse(source: string, options?: Options): Document { return Document.#fromTree(source, parseTree(source, options), options); } /** parse a document which **must** be an object */ static parseObject(source: string, options?: Options): Document { return Document.#fromTree( source, parseObjectTree(source, options), options, ); } static #fromTree( source: string, root: SyntaxNode, options?: Options, ): Document { return new Document(source, root, { maxDepth: resolveMaxDepth(options), }); } /** options this document was parsed with (for reuse by edits) */ get options(): Options { return { ...this.#resolvedOptions }; } /** source text. */ get source(): string { return this.#text; } /** spanned AST syntax tree */ root(): SyntaxNode { return this.#tree; } /** convert to simple data */ toValue(): JsonValue { return nodeToValue(this.#tree); } /** replace a value (source-preserving) */ set(path: readonly PathSegment[], value: unknown): void { const json = toJsonValue(value, this.#resolvedOptions); let edits: Edit[] | undefined; if (path.length === 0) { edits = this.#replacement(this.#tree, json); } else { const parent = this.#locate(path.slice(0, -1)); const last = path[path.length - 1]; const existing = child(parent, last); if (existing !== undefined) { edits = this.#replacement(existing, json); } else if (parent.kind === "object" && typeof last === "string") { edits = [this.#insertion(parent, `${keyText(last)}: `, json)]; } else if ( parent.kind === "array" && typeof last === "number" && last === parent.items.length ) { edits = [this.#insertion(parent, "", json)]; } else { throw new FjsonError( "syntax", "Array index out of bounds", parent.span, ); } } if (edits !== undefined) this.#applyEdits(edits); } /** remove a value (source-preserving) */ remove(path: readonly PathSegment[]): boolean { if (path.length === 0) { throw new FjsonError("syntax", "Cannot remove the root", this.#tree.span); } const parent = this.#locate(path.slice(0, -1)); const last = path[path.length - 1]; const target = child(parent, last); if (target === undefined) return false; if (parent.kind !== "object" && parent.kind !== "array") { throw mismatch(parent.span); // Unreachable: child() rejects other shapes. } const edits: Edit[] = [[target.span, ""]]; let next: number | undefined; if (parent.kind === "object" && typeof last === "string") { const index = parent.members.findIndex((member) => member.key === last); const member = parent.members[index]; edits.push([member.keySpan, ""]); const colon = findPunctuation( this.#text, member.keySpan.end, target.span.start, ":", ); if (colon === undefined) throw new Error("unreachable: missing colon"); edits.push([{ start: colon, end: colon + 1 }, ""]); next = parent.members[index + 1]?.keySpan.start; } else if (parent.kind === "array" && typeof last === "number") { next = parent.items[last + 1]?.span.start; } const comma = findPunctuation( this.#text, target.span.end, next ?? this.#containerEnd(parent), ",", ); if (comma !== undefined) edits.push([{ start: comma, end: comma + 1 }, ""]); this.#applyEdits(edits); return true; } /** merge overrides into the document. see {@linkcode apply} */ apply(...overrides: unknown[]): void { for (const override of overrides) this.#merge(override, [], []); } #locate(path: readonly PathSegment[]): SyntaxNode { let node = this.#tree; for (const segment of path) { const next = child(node, segment); if (next === undefined) { throw new FjsonError("syntax", "Path does not exist", node.span); } node = next; } return node; } #replacement( existing: SyntaxNode, value: JsonValue, ): Edit[] | undefined { if (jsonEqual(nodeToValue(existing), value)) return undefined; const implicit = existing.kind === "object" && !this.#text.startsWith("{", existing.span.start); const indent = lineIndent(this.#text, existing.span.start); let text: string; if ( typeof value === "string" && this.#text.startsWith("'", existing.span.start) ) { const json = JSON.stringify(value); text = `'${json.slice(1, -1).replaceAll("'", "\\'")}'`; } else { text = reindent( printJson(value, implicit), indent, newlineOf(this.#text), ); } if ( existing.span.start === existing.span.end && existing.span.start > 0 && !/[\r\n]$/.test(this.#text.slice(0, existing.span.start)) ) { text = `${newlineOf(this.#text)}${indent}${text}`; } const edits: Edit[] = [[existing.span, text]]; if (implicit) { const comma = findPunctuation( this.#text, existing.span.end, this.#text.length, ",", ); if (comma !== undefined) { edits.push([{ start: comma, end: comma + 1 }, ""]); } } return edits; } #containerEnd(container: ArrayNode | ObjectNode): number { if ( container.kind === "object" && !this.#text.startsWith("{", container.span.start) ) { return this.#text.length; } return container.span.end; } #insertion( container: ArrayNode | ObjectNode, prefix: string, value: JsonValue, ): Edit { const braced = this.#text.startsWith("{", container.span.start) || this.#text.startsWith("[", container.span.start); const end = this.#containerEnd(container) - (braced ? 1 : 0); const base = lineIndent(this.#text, container.span.start); const first = container.kind === "object" ? container.members[0]?.keySpan.start : container.items[0]?.span.start; const indent = first !== undefined && isBlank(this.#text, lineStart(this.#text, first), first) ? lineIndent(this.#text, first) : braced ? `${base} ` : base; const line = lineStart(this.#text, end); const atLineStart = isBlank(this.#text, line, end); const at = braced && atLineStart ? line : end; const newline = newlineOf(this.#text); let text = ""; if (at > 0 && !/[\r\n]$/.test(this.#text.slice(0, at))) text += newline; text += indent + prefix + reindent(printJson(value, false), indent, newline); if (braced) { text += newline; if (!atLineStart) text += base; } else if (/[\r\n]$/.test(this.#text)) { text += newline; } return [{ start: at, end: at }, text]; } #applyEdits(edits: Edit[]): void { const ordered = [...edits].sort((a, b) => b[0].start - a[0].start); let source = this.#text; for (const [span, text] of ordered) { source = source.slice(0, span.start) + text + source.slice(span.end); } try { this.#tree = parseTree(source, this.#resolvedOptions); } catch (error) { if (error instanceof FjsonError) { throw new FjsonError( error.kind, `Edit would produce invalid fJSON: ${error.message}`, this.#tree.span, ); } throw error; } this.#text = source; } #merge(value: unknown, path: string[], ancestors: object[]): void { if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new TypeError("fJSON overrides must be objects"); } if (ancestors.includes(value)) { throw new TypeError("Cannot apply cyclic overrides"); } const maxDepth = resolveMaxDepth(this.#resolvedOptions); if (path.length > maxDepth) { throw new RangeError( `fJSON nesting exceeds ${maxDepth} levels below the root`, ); } ancestors.push(value); for (const key of Object.keys(value)) { const prop = (value as Record)[key]; path.push(key); const segments: PathSegment[] = [...path]; const nested = prop !== null && typeof prop === "object" && !Array.isArray(prop) && typeof prop !== "function"; try { if (prop === undefined) { this.remove(segments); } else if (nested) { if (lookup(this.#tree, path)?.kind !== "object") { this.set(segments, {}); } } else { // roundtrip to JSON for semantic filtering const json = JSON.stringify(prop); if (json !== undefined) this.set(segments, JSON.parse(json)); } } catch (error) { if (error instanceof FjsonError) { if (error.kind === "nesting") throw new RangeError(error.message); throw new TypeError(error.message); } throw error; } if (nested) this.#merge(prop, path, ancestors); path.pop(); } ancestors.pop(); } } function lookup( root: SyntaxNode, path: readonly string[], ): SyntaxNode | undefined { let node: SyntaxNode | undefined = root; for (const key of path) { if (node?.kind !== "object") return undefined; node = node.members.find((member) => member.key === key)?.value; } return node; } function reindent(printed: string, indent: string, newline: string): string { return printed.split("\n").join(newline + indent); }