char/fjson

friendly JSON implementation in TypeScript

git clone https://git.t4t.associates/char/fjson

Charlotte Somrestructurebb03820

main
12.6 KiB427 linesraw
1import { FjsonError, type Span } from "./errors.ts";
2import { type Options, resolveMaxDepth } from "./options.ts";
3import {
4  type ArrayNode,
5  type JsonObject,
6  type JsonValue,
7  nodeToValue,
8  type ObjectNode,
9  parseObjectTree,
10  parseTree,
11  type SyntaxNode,
12  toJsonValue,
13} from "./parse.ts";
14import { keyText, printJson } from "./stringify.ts";
15
16/** path segments are object keys or array indices */
17export type PathSegment = string | number;
18
19type Edit = [span: Span, text: string];
20
21function lineStart(source: string, at: number): number {
22  const slice = source.slice(0, at);
23  return Math.max(slice.lastIndexOf("\r"), slice.lastIndexOf("\n")) + 1;
24}
25
26function lineIndent(source: string, at: number): string {
27  const start = lineStart(source, at);
28  let end = start;
29  while (
30    end < at && (source[end] === " " || source[end] === "\t")
31  ) end += 1;
32  return source.slice(start, end);
33}
34
35function newlineOf(source: string): string {
36  if (source.includes("\r\n")) return "\r\n";
37  if (source.includes("\r")) return "\r";
38  return "\n";
39}
40
41function isBlank(source: string, from: number, to: number): boolean {
42  for (let i = from; i < to; i += 1) {
43    if (source[i] !== " " && source[i] !== "\t") return false;
44  }
45  return true;
46}
47
48function findPunctuation(
49  source: string,
50  from: number,
51  to: number,
52  target: string,
53): number | undefined {
54  let at = from;
55  while (at < to) {
56    if (source.startsWith("//", at)) {
57      const end = source.slice(at, to).search(/[\r\n]/);
58      at += end === -1 ? to - at : end;
59    } else if (source.startsWith("/*", at)) {
60      const close = source.indexOf("*/", at + 2);
61      if (close === -1 || close + 2 > to) return undefined;
62      at = close + 2;
63    } else if (source[at] === target) {
64      return at;
65    } else {
66      at += 1;
67    }
68  }
69  return undefined;
70}
71
72function jsonEqual(a: JsonValue, b: JsonValue): boolean {
73  if (a === b) return true;
74  if (Array.isArray(a) || Array.isArray(b)) {
75    return Array.isArray(a) && Array.isArray(b) &&
76      a.length === b.length &&
77      a.every((item, index) => jsonEqual(item, (b as JsonValue[])[index]));
78  }
79  if (
80    typeof a === "object" && a !== null && typeof b === "object" && b !== null
81  ) {
82    const left = a as JsonObject;
83    const right = b as JsonObject;
84    const keys = Object.keys(left);
85    return keys.length === Object.keys(right).length &&
86      keys.every((key) =>
87        Object.hasOwn(right, key) && jsonEqual(left[key], right[key])
88      );
89  }
90  return false;
91}
92
93function mismatch(span: Span): FjsonError {
94  return new FjsonError(
95    "syntax",
96    "Path segment does not match its container",
97    span,
98  );
99}
100
101function child(node: SyntaxNode, segment: PathSegment): SyntaxNode | undefined {
102  if (node.kind === "object" && typeof segment === "string") {
103    return node.members.find((member) => member.key === segment)?.value;
104  }
105  if (
106    node.kind === "array" && typeof segment === "number" &&
107    Number.isInteger(segment) && segment >= 0
108  ) {
109    return node.items[segment];
110  }
111  throw mismatch(node.span);
112}
113
114/** an fJSON document with source-preserving mutations */
115export class Document {
116  #text: string;
117  #tree: SyntaxNode;
118  readonly #resolvedOptions: Options;
119
120  private constructor(source: string, root: SyntaxNode, options: Options) {
121    this.#text = source;
122    this.#tree = root;
123    this.#resolvedOptions = options;
124  }
125
126  /** parse an fJSON document */
127  static parse(source: string, options?: Options): Document {
128    return Document.#fromTree(source, parseTree(source, options), options);
129  }
130
131  /** parse a document which **must** be an object */
132  static parseObject(source: string, options?: Options): Document {
133    return Document.#fromTree(
134      source,
135      parseObjectTree(source, options),
136      options,
137    );
138  }
139
140  static #fromTree(
141    source: string,
142    root: SyntaxNode,
143    options?: Options,
144  ): Document {
145    return new Document(source, root, {
146      maxDepth: resolveMaxDepth(options),
147    });
148  }
149
150  /** options this document was parsed with (for reuse by edits) */
151  get options(): Options {
152    return { ...this.#resolvedOptions };
153  }
154
155  /** source text. */
156  get source(): string {
157    return this.#text;
158  }
159
160  /** spanned AST syntax tree */
161  root(): SyntaxNode {
162    return this.#tree;
163  }
164
165  /** convert to simple data */
166  toValue(): JsonValue {
167    return nodeToValue(this.#tree);
168  }
169
170  /** replace a value (source-preserving) */
171  set(path: readonly PathSegment[], value: unknown): void {
172    const json = toJsonValue(value, this.#resolvedOptions);
173    let edits: Edit[] | undefined;
174    if (path.length === 0) {
175      edits = this.#replacement(this.#tree, json);
176    } else {
177      const parent = this.#locate(path.slice(0, -1));
178      const last = path[path.length - 1];
179      const existing = child(parent, last);
180      if (existing !== undefined) {
181        edits = this.#replacement(existing, json);
182      } else if (parent.kind === "object" && typeof last === "string") {
183        edits = [this.#insertion(parent, `${keyText(last)}: `, json)];
184      } else if (
185        parent.kind === "array" && typeof last === "number" &&
186        last === parent.items.length
187      ) {
188        edits = [this.#insertion(parent, "", json)];
189      } else {
190        throw new FjsonError(
191          "syntax",
192          "Array index out of bounds",
193          parent.span,
194        );
195      }
196    }
197    if (edits !== undefined) this.#applyEdits(edits);
198  }
199
200  /** remove a value (source-preserving) */
201  remove(path: readonly PathSegment[]): boolean {
202    if (path.length === 0) {
203      throw new FjsonError("syntax", "Cannot remove the root", this.#tree.span);
204    }
205    const parent = this.#locate(path.slice(0, -1));
206    const last = path[path.length - 1];
207    const target = child(parent, last);
208    if (target === undefined) return false;
209    if (parent.kind !== "object" && parent.kind !== "array") {
210      throw mismatch(parent.span); // Unreachable: child() rejects other shapes.
211    }
212    const edits: Edit[] = [[target.span, ""]];
213    let next: number | undefined;
214    if (parent.kind === "object" && typeof last === "string") {
215      const index = parent.members.findIndex((member) => member.key === last);
216      const member = parent.members[index];
217      edits.push([member.keySpan, ""]);
218      const colon = findPunctuation(
219        this.#text,
220        member.keySpan.end,
221        target.span.start,
222        ":",
223      );
224      if (colon === undefined) throw new Error("unreachable: missing colon");
225      edits.push([{ start: colon, end: colon + 1 }, ""]);
226      next = parent.members[index + 1]?.keySpan.start;
227    } else if (parent.kind === "array" && typeof last === "number") {
228      next = parent.items[last + 1]?.span.start;
229    }
230    const comma = findPunctuation(
231      this.#text,
232      target.span.end,
233      next ?? this.#containerEnd(parent),
234      ",",
235    );
236    if (comma !== undefined) edits.push([{ start: comma, end: comma + 1 }, ""]);
237    this.#applyEdits(edits);
238    return true;
239  }
240
241  /** merge overrides into the document. see {@linkcode apply} */
242  apply(...overrides: unknown[]): void {
243    for (const override of overrides) this.#merge(override, [], []);
244  }
245
246  #locate(path: readonly PathSegment[]): SyntaxNode {
247    let node = this.#tree;
248    for (const segment of path) {
249      const next = child(node, segment);
250      if (next === undefined) {
251        throw new FjsonError("syntax", "Path does not exist", node.span);
252      }
253      node = next;
254    }
255    return node;
256  }
257
258  #replacement(
259    existing: SyntaxNode,
260    value: JsonValue,
261  ): Edit[] | undefined {
262    if (jsonEqual(nodeToValue(existing), value)) return undefined;
263    const implicit = existing.kind === "object" &&
264      !this.#text.startsWith("{", existing.span.start);
265    const indent = lineIndent(this.#text, existing.span.start);
266    let text: string;
267    if (
268      typeof value === "string" &&
269      this.#text.startsWith("'", existing.span.start)
270    ) {
271      const json = JSON.stringify(value);
272      text = `'${json.slice(1, -1).replaceAll("'", "\\'")}'`;
273    } else {
274      text = reindent(
275        printJson(value, implicit),
276        indent,
277        newlineOf(this.#text),
278      );
279    }
280    if (
281      existing.span.start === existing.span.end && existing.span.start > 0 &&
282      !/[\r\n]$/.test(this.#text.slice(0, existing.span.start))
283    ) {
284      text = `${newlineOf(this.#text)}${indent}${text}`;
285    }
286    const edits: Edit[] = [[existing.span, text]];
287    if (implicit) {
288      const comma = findPunctuation(
289        this.#text,
290        existing.span.end,
291        this.#text.length,
292        ",",
293      );
294      if (comma !== undefined) {
295        edits.push([{ start: comma, end: comma + 1 }, ""]);
296      }
297    }
298    return edits;
299  }
300
301  #containerEnd(container: ArrayNode | ObjectNode): number {
302    if (
303      container.kind === "object" &&
304      !this.#text.startsWith("{", container.span.start)
305    ) {
306      return this.#text.length;
307    }
308    return container.span.end;
309  }
310
311  #insertion(
312    container: ArrayNode | ObjectNode,
313    prefix: string,
314    value: JsonValue,
315  ): Edit {
316    const braced = this.#text.startsWith("{", container.span.start) ||
317      this.#text.startsWith("[", container.span.start);
318    const end = this.#containerEnd(container) - (braced ? 1 : 0);
319    const base = lineIndent(this.#text, container.span.start);
320    const first = container.kind === "object"
321      ? container.members[0]?.keySpan.start
322      : container.items[0]?.span.start;
323    const indent = first !== undefined &&
324        isBlank(this.#text, lineStart(this.#text, first), first)
325      ? lineIndent(this.#text, first)
326      : braced
327      ? `${base}  `
328      : base;
329    const line = lineStart(this.#text, end);
330    const atLineStart = isBlank(this.#text, line, end);
331    const at = braced && atLineStart ? line : end;
332    const newline = newlineOf(this.#text);
333    let text = "";
334    if (at > 0 && !/[\r\n]$/.test(this.#text.slice(0, at))) text += newline;
335    text += indent + prefix +
336      reindent(printJson(value, false), indent, newline);
337    if (braced) {
338      text += newline;
339      if (!atLineStart) text += base;
340    } else if (/[\r\n]$/.test(this.#text)) {
341      text += newline;
342    }
343    return [{ start: at, end: at }, text];
344  }
345
346  #applyEdits(edits: Edit[]): void {
347    const ordered = [...edits].sort((a, b) => b[0].start - a[0].start);
348    let source = this.#text;
349    for (const [span, text] of ordered) {
350      source = source.slice(0, span.start) + text + source.slice(span.end);
351    }
352    try {
353      this.#tree = parseTree(source, this.#resolvedOptions);
354    } catch (error) {
355      if (error instanceof FjsonError) {
356        throw new FjsonError(
357          error.kind,
358          `Edit would produce invalid fJSON: ${error.message}`,
359          this.#tree.span,
360        );
361      }
362      throw error;
363    }
364    this.#text = source;
365  }
366
367  #merge(value: unknown, path: string[], ancestors: object[]): void {
368    if (value === null || typeof value !== "object" || Array.isArray(value)) {
369      throw new TypeError("fJSON overrides must be objects");
370    }
371    if (ancestors.includes(value)) {
372      throw new TypeError("Cannot apply cyclic overrides");
373    }
374    const maxDepth = resolveMaxDepth(this.#resolvedOptions);
375    if (path.length > maxDepth) {
376      throw new RangeError(
377        `fJSON nesting exceeds ${maxDepth} levels below the root`,
378      );
379    }
380    ancestors.push(value);
381    for (const key of Object.keys(value)) {
382      const prop = (value as Record<string, unknown>)[key];
383      path.push(key);
384      const segments: PathSegment[] = [...path];
385      const nested = prop !== null && typeof prop === "object" &&
386        !Array.isArray(prop) && typeof prop !== "function";
387      try {
388        if (prop === undefined) {
389          this.remove(segments);
390        } else if (nested) {
391          if (lookup(this.#tree, path)?.kind !== "object") {
392            this.set(segments, {});
393          }
394        } else {
395          // roundtrip to JSON for semantic filtering
396          const json = JSON.stringify(prop);
397          if (json !== undefined) this.set(segments, JSON.parse(json));
398        }
399      } catch (error) {
400        if (error instanceof FjsonError) {
401          if (error.kind === "nesting") throw new RangeError(error.message);
402          throw new TypeError(error.message);
403        }
404        throw error;
405      }
406      if (nested) this.#merge(prop, path, ancestors);
407      path.pop();
408    }
409    ancestors.pop();
410  }
411}
412
413function lookup(
414  root: SyntaxNode,
415  path: readonly string[],
416): SyntaxNode | undefined {
417  let node: SyntaxNode | undefined = root;
418  for (const key of path) {
419    if (node?.kind !== "object") return undefined;
420    node = node.members.find((member) => member.key === key)?.value;
421  }
422  return node;
423}
424
425function reindent(printed: string, indent: string, newline: string): string {
426  return printed.split("\n").join(newline + indent);
427}