char/fjson

friendly JSON implementation in TypeScript

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

Charlotte Somsplit scanner from parser17b13b5

main
13.4 KiB419 linesraw
1// vibed
2
3import {
4  deepStrictEqual,
5  equal,
6  ok,
7  strictEqual,
8  throws,
9} from "node:assert/strict";
10import { FjsonError, parse, parseObject, parseTree } from "./mod.ts";
11
12function parseError(source: string): FjsonError {
13  try {
14    parse(source);
15  } catch (error) {
16    ok(error instanceof FjsonError, `expected FjsonError for ${source}`);
17    return error;
18  }
19  throw new Error(`expected parse error for ${JSON.stringify(source)}`);
20}
21
22function parseObjectError(source: string): FjsonError {
23  try {
24    parseObject(source);
25  } catch (error) {
26    ok(error instanceof FjsonError, `expected FjsonError for ${source}`);
27    return error;
28  }
29  throw new Error(`expected parse error for ${JSON.stringify(source)}`);
30}
31
32Deno.test("parses a package config with or without root braces", () => {
33  const source = `
34    // Dependencies are private to this package.
35    imports: {
36      assert: 'jsr:@std/assert@^1',
37      "@my/library": "../library/mod.ts",
38    },
39    options: {
40      enabled: true,
41      fallback: null,
42      targets: ["linux", /* another platform */ "macos",],
43    },
44  `;
45  for (const text of [source, `{${source}}`]) {
46    const value = parseObject(text) as Record<string, Record<string, unknown>>;
47    strictEqual(value["imports"]["assert"], "jsr:@std/assert@^1");
48    strictEqual(value["imports"]["@my/library"], "../library/mod.ts");
49    strictEqual(value["options"]["enabled"], true);
50    strictEqual(value["options"]["fallback"], null);
51    deepStrictEqual(value["options"]["targets"], ["linux", "macos"]);
52  }
53});
54
55Deno.test("empty or comment-only documents yield an empty object", () => {
56  for (const source of ["", " \t\r\n", "// only a comment", "/* comment */"]) {
57    deepStrictEqual(parse(source), {});
58  }
59  deepStrictEqual(parse("{}"), {});
60  deepStrictEqual(parseObject("{ /* comment */ }"), {});
61  const value = parseObject("a/*1*/:/*2*/[/*3*/]/*4*/,/*5*/b: {/*6*/},// eof");
62  deepStrictEqual(value, { a: [], b: {} });
63  deepStrictEqual(parseObject("a: false // eof"), { a: false });
64});
65
66Deno.test("implicit commas separate entries; block comments do not", () => {
67  for (const newline of ["\n", "\r\n", "\r"]) {
68    for (
69      const separator of [
70        newline,
71        ` // comment${newline}`,
72        ` /* comment */${newline}/* next */ `,
73        ` /* multiline${newline}comment */${newline}`,
74        `${newline}/* multiline${newline}comment */ `,
75        `${newline}// blank${newline}${newline}`,
76        `,${newline}`,
77        `${newline},${newline}`,
78      ]
79    ) {
80      const source =
81        `a: 1${separator}b: [true${separator}false${separator}null${newline}]${newline}`;
82      for (const text of [source, `{${newline}${source}}`]) {
83        deepStrictEqual(parseObject(text), {
84          a: 1,
85          b: [true, false, null],
86        }, JSON.stringify(text));
87      }
88    }
89  }
90  // Newlines inside block comments are not separators.
91  for (
92    const source of [
93      "a: 1 /* comment */ b: 2",
94      "a: [1 /* comment */ 2]",
95      "a: 1 /* multiline\ncomment */ b: 2",
96      "a: [1 /* multiline\r\ncomment */ 2]",
97    ]
98  ) {
99    ok(parseError(source).message.includes("Expected `,` or newline"), source);
100  }
101});
102
103Deno.test("requires explicit commas without a separator newline", () => {
104  for (
105    const source of [
106      "a: 1 b: 2",
107      "{a: true b: false}",
108      "a: [1 2]",
109      "a: ['one' 'two']",
110      "a: [\n1\n] b: 2",
111      "a: [{\nx: 1\n} {}]",
112      "a: ['one\\ntwo' 'three']",
113    ]
114  ) {
115    ok(
116      parseError(source).message.includes("Expected `,` or newline"),
117      source,
118    );
119  }
120  for (const source of ["a: 1,", "{a: 1,}", "a: [1,],", "a: 1,\nb: 2,"]) {
121    parseObject(source);
122  }
123});
124
125Deno.test("strings decode escapes without interpreting comment markers", () => {
126  const value = parseObject(`
127    url: "https://example.com/*path*/",
128    text: '"\\'\\\\\\/\\b\\f\\n\\r\\t',
129    unicode: "α\\u0061\\uD83D\\uDE00",
130    "quoted\\u0020key": 'value',
131  `);
132  strictEqual(value["url"], "https://example.com/*path*/");
133  strictEqual(value["text"], "\"'\\/\b\f\n\r\t");
134  strictEqual(value["unicode"], "αa😀");
135  strictEqual(value["quoted key"], "value");
136});
137
138Deno.test("unicode escapes reject unpaired surrogates", () => {
139  for (
140    const [source, kind, message] of [
141      ["a: '\\uDC00'", "unicode", "Unexpected low Unicode surrogate"],
142      ["a: '\\uD800\\u0041'", "unicode", "Expected a low Unicode surrogate"],
143      ["a: '\\uD800'", "unicode", "Unpaired Unicode surrogate"],
144    ] as const
145  ) {
146    const error = parseError(source);
147    strictEqual(error.kind, kind);
148    ok(error.message.includes(message), `${source}: ${error.message}`);
149  }
150});
151
152Deno.test("accepts identifier keys but not bare string values", () => {
153  deepStrictEqual(
154    parseObject("_key: 1, $key: 2, key123: 3, true: false, '日本語': 'ok'"),
155    { _key: 1, $key: 2, key123: 3, true: false, "日本語": "ok" },
156  );
157  for (const source of ["a: bare", "a: undefined", "a: trueish", "a: Null"]) {
158    ok(parseError(source).message.includes("strings must be quoted"), source);
159  }
160  for (
161    const source of ["123: true", "日本語: 1", "@scope/pkg: 1", "some-key: 1"]
162  ) {
163    throws(() => parseObject(source), FjsonError, source);
164  }
165});
166
167Deno.test("numbers keep JSON spellings and follow JSON.parse semantics", () => {
168  for (
169    const [source, expected] of [
170      ["0", 0],
171      ["42", 42],
172      ["-12.34", -12.34],
173      ["1e3", 1000],
174      ["2.5E-10", 2.5e-10],
175      ["6e+2", 600],
176      ["-1.25e2", -125],
177    ] as const
178  ) {
179    strictEqual(parse(source), expected, source);
180  }
181  ok(Object.is(parse("-0"), -0));
182  // Out of double range yields Infinity, like JSON.parse; precision loss matches too.
183  strictEqual(parse("1e9999"), Infinity);
184  strictEqual(parse("9007199254740993"), 9007199254740992);
185  for (
186    const spelling of [
187      "-",
188      "01",
189      "-01",
190      "1.",
191      ".5",
192      "+1",
193      "1e",
194      "1e+",
195      "0x10",
196      "1_000",
197      "1n",
198      "NaN",
199      "Infinity",
200      "-Infinity",
201      "1.2.3",
202    ]
203  ) {
204    throws(() => parse(`value: ${spelling}`), FjsonError, spelling);
205  }
206});
207
208Deno.test("rejects duplicate decoded keys and identifies both definitions", () => {
209  const source = "imports: {\n  assert: 'first'\n  '\\u0061ssert': 'second'\n}";
210  const error = parseError(source);
211  ok(error.message.includes('Duplicate key "assert"'), error.message);
212  strictEqual(source.slice(error.span.start, error.span.end), "'\\u0061ssert'");
213  ok(error.previous !== undefined);
214  strictEqual(source.slice(error.previous.start, error.previous.end), "assert");
215  strictEqual(
216    error.render("avril.conf", source),
217    'avril.conf:3:3: Duplicate key "assert"\navril.conf:2:3: First defined here',
218  );
219  parseObject("a: {key: 1}, b: {key: 2}");
220});
221
222Deno.test("diagnostics use original lines and unicode character columns", () => {
223  const source = "label: '😀',\n  imports: {\n    x: 'α', y 'bad',\n  },\n";
224  const error = parseError(source);
225  strictEqual(
226    error.render("avril.conf", source),
227    "avril.conf:3:15: Expected `:`",
228  );
229  strictEqual(source.slice(error.span.start, error.span.end), "'");
230  const truncated = "a: true,\n// heading\nb:";
231  strictEqual(
232    parseError(truncated).render("avril.conf", truncated),
233    "avril.conf:3:3: Expected a value",
234  );
235});
236
237Deno.test("parses every JSON root and disambiguates implicit keys", () => {
238  const cases: [string, unknown][] = [
239    ["null", null],
240    ["false /* tail */", false],
241    ["// head\n42", 42],
242    ["'😀'", "😀"],
243    ["[1\ntrue\nnull\n{label: 'ok'}]", [1, true, null, { label: "ok" }]],
244    ["{}", {}],
245    ["// empty", {}],
246    ["true /* : */ : false", { true: false }],
247    ["'quoted'\n/* keep */ : 1", { quoted: 1 }],
248    ["'not a key' /* : */", "not a key"],
249    ["name: 'fJSON'\nflags: [true, false,]", {
250      name: "fJSON",
251      flags: [true, false],
252    }],
253  ];
254  for (const [source, expected] of cases) {
255    deepStrictEqual(parse(source), expected, source);
256  }
257});
258
259Deno.test("rejects trailing values, duplicates, and invalid roots", () => {
260  for (
261    const source of [
262      "1 2",
263      "'a' 'b'",
264      "[1],",
265      "true, false",
266      "bare",
267      "a: 1\na: 2",
268      "{a: 1} extra: 2",
269    ]
270  ) {
271    throws(() => parse(source), FjsonError, source);
272  }
273  const source = "a: '😀'\n'\\u0061': false";
274  strictEqual(
275    parseError(source).render("test", source),
276    'test:2:1: Duplicate key "a"\ntest:1:1: First defined here',
277  );
278});
279
280Deno.test("rejects malformed or executable input", () => {
281  for (const source of ["[]", "42"]) {
282    const error = parseObjectError(source);
283    ok(error.message.includes("object key"), `${source}: ${error}`);
284  }
285  const cases: [string, string][] = [
286    ["{a: 1} b: 2", "after the root object"],
287    ["{a: 1},", "after the root object"],
288    ["a: {", "Expected `}`"],
289    ["a: [", "Expected `]`"],
290    ["a: [1", "Expected `]`"],
291    ["a: {b: 1", "Expected `}`"],
292    ["a: 1,,", "object key"],
293    ["a: [,]", "Expected a value"],
294    ["a: [1,,]", "Expected a value"],
295    ["a: 1;", "Expected `,` or newline"],
296    ["a = 1", "Expected `:`"],
297    ["a: () => 1", "Expected a value"],
298    ["a: `template`", "Expected a value"],
299    ["a: 'unterminated", "Unterminated string"],
300    ["a: 'new\nline'", "Control characters"],
301    ["a: '\0'", "Control characters"],
302    ["a: '\\q'", "Invalid string escape"],
303    ["a: '\\u12x4'", "hexadecimal digits"],
304    ["/* never closed", "Unterminated block comment"],
305    ["a: 1 / nope", "Expected `,` or newline"],
306  ];
307  for (const [source, message] of cases) {
308    const error = parseObjectError(source);
309    ok(error.message.includes(message), `${JSON.stringify(source)}: ${error}`);
310    ok(
311      source.slice(error.span.start, error.span.end) !== undefined &&
312        error.span.start <= source.length &&
313        error.span.end <= source.length,
314      `${JSON.stringify(source)}: ${JSON.stringify(error.span)}`,
315    );
316    error.render("avril.conf", source);
317  }
318});
319
320Deno.test("bounds nesting without rejecting large flat configs", () => {
321  const nested = `value: ${"[".repeat(129)}0${"]".repeat(129)}`;
322  ok(parseError(nested).message.includes("nesting"), "object nesting");
323  parseObject(`value: ${"[".repeat(128)}0${"]".repeat(128)}`);
324  const objects = `value: ${"{a:".repeat(129)}0${"}".repeat(129)}`;
325  ok(parseError(objects).message.includes("nesting"), "object nesting");
326  const flat = `value: [${"0,".repeat(10_000)}]`;
327  const value = parseObject(flat) as { value: unknown[] };
328  equal(value["value"].length, 10_000);
329});
330
331Deno.test("custom limits control parsing", () => {
332  const source = `${"[".repeat(4)}0${"]".repeat(4)}`;
333  parse(source, { maxDepth: 3 });
334  const tight = (() => {
335    try {
336      parse(source, { maxDepth: 2 });
337    } catch (caught) {
338      ok(caught instanceof FjsonError);
339      return caught;
340    }
341    throw new Error("expected nesting error");
342  })();
343  strictEqual(tight.kind, "nesting");
344  ok(tight.message.includes("2 levels"), tight.message);
345  parseObject("value: 0", { maxDepth: 0 });
346  throws(() => parseObject("value: [0]", { maxDepth: 0 }), FjsonError);
347});
348
349Deno.test("invalid limits throw TypeError", () => {
350  for (const maxDepth of [-1, 1.5, NaN, Infinity]) {
351    throws(() => parse("[]", { maxDepth }), TypeError);
352  }
353});
354
355Deno.test("syntax spans exclude surrounding trivia and retain number spellings", () => {
356  const body = "'\\u0061' /* key */ : [ /* item */ -0.25E+02 /* end */ ]";
357  for (const braced of [false, true]) {
358    const source = "// head\n" + (braced ? "{ " : "") + body +
359      ", /* tail */" + (braced ? " }" : "") + "\n// footer";
360    const root = parseTree(source);
361    ok(root.kind === "object");
362    strictEqual(
363      source.slice(root.span.start, root.span.end),
364      braced ? "{ " + body + ", /* tail */ }" : body,
365    );
366    strictEqual(root.members.length, 1);
367    const member = root.members[0];
368    strictEqual(member.key, "a");
369    strictEqual(
370      source.slice(member.keySpan.start, member.keySpan.end),
371      "'\\u0061'",
372    );
373    const array = member.value;
374    ok(array.kind === "array");
375    strictEqual(
376      source.slice(array.span.start, array.span.end),
377      "[ /* item */ -0.25E+02 /* end */ ]",
378    );
379    const number = array.items[0];
380    ok(number.kind === "number");
381    strictEqual(number.value, -25);
382    strictEqual(number.raw, "-0.25E+02");
383    strictEqual(source.slice(number.span.start, number.span.end), number.raw);
384  }
385
386  const source = "/* head */ '\\u0061' \n/* tail */";
387  const root = parseTree(source);
388  ok(root.kind === "string");
389  strictEqual(root.value, "a");
390  strictEqual(source.slice(root.span.start, root.span.end), "'\\u0061'");
391});
392
393Deno.test("newlines before values do not become separators after them", () => {
394  deepStrictEqual(parse("true\n: false\nnull: true"), {
395    true: false,
396    null: true,
397  });
398  deepStrictEqual(parse("'a'\n: 1\nb: 2"), { a: 1, b: 2 });
399  for (const source of ["a:\n1 b: 2", "'a'\n: 1 b: 2", "[\ntrue false]"]) {
400    ok(parseError(source).message.includes("or newline"), source);
401  }
402});
403
404Deno.test("lexical errors retain precise spans including truncated escapes", () => {
405  for (
406    const [source, start, end, message] of [
407      ["a: '\\q'", 5, 6, "Invalid string escape"],
408      ["a: 'x\\", 6, 6, "Invalid string escape"],
409      ["a: '\\u12", 8, 8, "Expected four hexadecimal digits"],
410      ["a: '\\uD800'", 6, 10, "Unpaired Unicode surrogate"],
411      ["a: 1e+", 6, 6, "Expected a digit"],
412      ["a: 01", 4, 5, "Leading zeros"],
413    ] as const
414  ) {
415    const error = parseError(source);
416    deepStrictEqual(error.span, { start, end }, source);
417    ok(error.message.includes(message), source);
418  }
419});