// vibed import { deepStrictEqual, equal, ok, strictEqual, throws, } from "node:assert/strict"; import { FjsonError, parse, parseObject, parseTree } from "./mod.ts"; function parseError(source: string): FjsonError { try { parse(source); } catch (error) { ok(error instanceof FjsonError, `expected FjsonError for ${source}`); return error; } throw new Error(`expected parse error for ${JSON.stringify(source)}`); } function parseObjectError(source: string): FjsonError { try { parseObject(source); } catch (error) { ok(error instanceof FjsonError, `expected FjsonError for ${source}`); return error; } throw new Error(`expected parse error for ${JSON.stringify(source)}`); } Deno.test("parses a package config with or without root braces", () => { const source = ` // Dependencies are private to this package. imports: { assert: 'jsr:@std/assert@^1', "@my/library": "../library/mod.ts", }, options: { enabled: true, fallback: null, targets: ["linux", /* another platform */ "macos",], }, `; for (const text of [source, `{${source}}`]) { const value = parseObject(text) as Record>; strictEqual(value["imports"]["assert"], "jsr:@std/assert@^1"); strictEqual(value["imports"]["@my/library"], "../library/mod.ts"); strictEqual(value["options"]["enabled"], true); strictEqual(value["options"]["fallback"], null); deepStrictEqual(value["options"]["targets"], ["linux", "macos"]); } }); Deno.test("empty or comment-only documents yield an empty object", () => { for (const source of ["", " \t\r\n", "// only a comment", "/* comment */"]) { deepStrictEqual(parse(source), {}); } deepStrictEqual(parse("{}"), {}); deepStrictEqual(parseObject("{ /* comment */ }"), {}); const value = parseObject("a/*1*/:/*2*/[/*3*/]/*4*/,/*5*/b: {/*6*/},// eof"); deepStrictEqual(value, { a: [], b: {} }); deepStrictEqual(parseObject("a: false // eof"), { a: false }); }); Deno.test("implicit commas separate entries; block comments do not", () => { for (const newline of ["\n", "\r\n", "\r"]) { for ( const separator of [ newline, ` // comment${newline}`, ` /* comment */${newline}/* next */ `, ` /* multiline${newline}comment */${newline}`, `${newline}/* multiline${newline}comment */ `, `${newline}// blank${newline}${newline}`, `,${newline}`, `${newline},${newline}`, ] ) { const source = `a: 1${separator}b: [true${separator}false${separator}null${newline}]${newline}`; for (const text of [source, `{${newline}${source}}`]) { deepStrictEqual(parseObject(text), { a: 1, b: [true, false, null], }, JSON.stringify(text)); } } } // Newlines inside block comments are not separators. for ( const source of [ "a: 1 /* comment */ b: 2", "a: [1 /* comment */ 2]", "a: 1 /* multiline\ncomment */ b: 2", "a: [1 /* multiline\r\ncomment */ 2]", ] ) { ok(parseError(source).message.includes("Expected `,` or newline"), source); } }); Deno.test("requires explicit commas without a separator newline", () => { for ( const source of [ "a: 1 b: 2", "{a: true b: false}", "a: [1 2]", "a: ['one' 'two']", "a: [\n1\n] b: 2", "a: [{\nx: 1\n} {}]", "a: ['one\\ntwo' 'three']", ] ) { ok( parseError(source).message.includes("Expected `,` or newline"), source, ); } for (const source of ["a: 1,", "{a: 1,}", "a: [1,],", "a: 1,\nb: 2,"]) { parseObject(source); } }); Deno.test("strings decode escapes without interpreting comment markers", () => { const value = parseObject(` url: "https://example.com/*path*/", text: '"\\'\\\\\\/\\b\\f\\n\\r\\t', unicode: "α\\u0061\\uD83D\\uDE00", "quoted\\u0020key": 'value', `); strictEqual(value["url"], "https://example.com/*path*/"); strictEqual(value["text"], "\"'\\/\b\f\n\r\t"); strictEqual(value["unicode"], "αa😀"); strictEqual(value["quoted key"], "value"); }); Deno.test("unicode escapes reject unpaired surrogates", () => { for ( const [source, kind, message] of [ ["a: '\\uDC00'", "unicode", "Unexpected low Unicode surrogate"], ["a: '\\uD800\\u0041'", "unicode", "Expected a low Unicode surrogate"], ["a: '\\uD800'", "unicode", "Unpaired Unicode surrogate"], ] as const ) { const error = parseError(source); strictEqual(error.kind, kind); ok(error.message.includes(message), `${source}: ${error.message}`); } }); Deno.test("accepts identifier keys but not bare string values", () => { deepStrictEqual( parseObject("_key: 1, $key: 2, key123: 3, true: false, '日本語': 'ok'"), { _key: 1, $key: 2, key123: 3, true: false, "日本語": "ok" }, ); for (const source of ["a: bare", "a: undefined", "a: trueish", "a: Null"]) { ok(parseError(source).message.includes("strings must be quoted"), source); } for ( const source of ["123: true", "日本語: 1", "@scope/pkg: 1", "some-key: 1"] ) { throws(() => parseObject(source), FjsonError, source); } }); Deno.test("numbers keep JSON spellings and follow JSON.parse semantics", () => { for ( const [source, expected] of [ ["0", 0], ["42", 42], ["-12.34", -12.34], ["1e3", 1000], ["2.5E-10", 2.5e-10], ["6e+2", 600], ["-1.25e2", -125], ] as const ) { strictEqual(parse(source), expected, source); } ok(Object.is(parse("-0"), -0)); // Out of double range yields Infinity, like JSON.parse; precision loss matches too. strictEqual(parse("1e9999"), Infinity); strictEqual(parse("9007199254740993"), 9007199254740992); for ( const spelling of [ "-", "01", "-01", "1.", ".5", "+1", "1e", "1e+", "0x10", "1_000", "1n", "NaN", "Infinity", "-Infinity", "1.2.3", ] ) { throws(() => parse(`value: ${spelling}`), FjsonError, spelling); } }); Deno.test("rejects duplicate decoded keys and identifies both definitions", () => { const source = "imports: {\n assert: 'first'\n '\\u0061ssert': 'second'\n}"; const error = parseError(source); ok(error.message.includes('Duplicate key "assert"'), error.message); strictEqual(source.slice(error.span.start, error.span.end), "'\\u0061ssert'"); ok(error.previous !== undefined); strictEqual(source.slice(error.previous.start, error.previous.end), "assert"); strictEqual( error.render("avril.conf", source), 'avril.conf:3:3: Duplicate key "assert"\navril.conf:2:3: First defined here', ); parseObject("a: {key: 1}, b: {key: 2}"); }); Deno.test("diagnostics use original lines and unicode character columns", () => { const source = "label: '😀',\n imports: {\n x: 'α', y 'bad',\n },\n"; const error = parseError(source); strictEqual( error.render("avril.conf", source), "avril.conf:3:15: Expected `:`", ); strictEqual(source.slice(error.span.start, error.span.end), "'"); const truncated = "a: true,\n// heading\nb:"; strictEqual( parseError(truncated).render("avril.conf", truncated), "avril.conf:3:3: Expected a value", ); }); Deno.test("parses every JSON root and disambiguates implicit keys", () => { const cases: [string, unknown][] = [ ["null", null], ["false /* tail */", false], ["// head\n42", 42], ["'😀'", "😀"], ["[1\ntrue\nnull\n{label: 'ok'}]", [1, true, null, { label: "ok" }]], ["{}", {}], ["// empty", {}], ["true /* : */ : false", { true: false }], ["'quoted'\n/* keep */ : 1", { quoted: 1 }], ["'not a key' /* : */", "not a key"], ["name: 'fJSON'\nflags: [true, false,]", { name: "fJSON", flags: [true, false], }], ]; for (const [source, expected] of cases) { deepStrictEqual(parse(source), expected, source); } }); Deno.test("rejects trailing values, duplicates, and invalid roots", () => { for ( const source of [ "1 2", "'a' 'b'", "[1],", "true, false", "bare", "a: 1\na: 2", "{a: 1} extra: 2", ] ) { throws(() => parse(source), FjsonError, source); } const source = "a: '😀'\n'\\u0061': false"; strictEqual( parseError(source).render("test", source), 'test:2:1: Duplicate key "a"\ntest:1:1: First defined here', ); }); Deno.test("rejects malformed or executable input", () => { for (const source of ["[]", "42"]) { const error = parseObjectError(source); ok(error.message.includes("object key"), `${source}: ${error}`); } const cases: [string, string][] = [ ["{a: 1} b: 2", "after the root object"], ["{a: 1},", "after the root object"], ["a: {", "Expected `}`"], ["a: [", "Expected `]`"], ["a: [1", "Expected `]`"], ["a: {b: 1", "Expected `}`"], ["a: 1,,", "object key"], ["a: [,]", "Expected a value"], ["a: [1,,]", "Expected a value"], ["a: 1;", "Expected `,` or newline"], ["a = 1", "Expected `:`"], ["a: () => 1", "Expected a value"], ["a: `template`", "Expected a value"], ["a: 'unterminated", "Unterminated string"], ["a: 'new\nline'", "Control characters"], ["a: '\0'", "Control characters"], ["a: '\\q'", "Invalid string escape"], ["a: '\\u12x4'", "hexadecimal digits"], ["/* never closed", "Unterminated block comment"], ["a: 1 / nope", "Expected `,` or newline"], ]; for (const [source, message] of cases) { const error = parseObjectError(source); ok(error.message.includes(message), `${JSON.stringify(source)}: ${error}`); ok( source.slice(error.span.start, error.span.end) !== undefined && error.span.start <= source.length && error.span.end <= source.length, `${JSON.stringify(source)}: ${JSON.stringify(error.span)}`, ); error.render("avril.conf", source); } }); Deno.test("bounds nesting without rejecting large flat configs", () => { const nested = `value: ${"[".repeat(129)}0${"]".repeat(129)}`; ok(parseError(nested).message.includes("nesting"), "object nesting"); parseObject(`value: ${"[".repeat(128)}0${"]".repeat(128)}`); const objects = `value: ${"{a:".repeat(129)}0${"}".repeat(129)}`; ok(parseError(objects).message.includes("nesting"), "object nesting"); const flat = `value: [${"0,".repeat(10_000)}]`; const value = parseObject(flat) as { value: unknown[] }; equal(value["value"].length, 10_000); }); Deno.test("custom limits control parsing", () => { const source = `${"[".repeat(4)}0${"]".repeat(4)}`; parse(source, { maxDepth: 3 }); const tight = (() => { try { parse(source, { maxDepth: 2 }); } catch (caught) { ok(caught instanceof FjsonError); return caught; } throw new Error("expected nesting error"); })(); strictEqual(tight.kind, "nesting"); ok(tight.message.includes("2 levels"), tight.message); parseObject("value: 0", { maxDepth: 0 }); throws(() => parseObject("value: [0]", { maxDepth: 0 }), FjsonError); }); Deno.test("invalid limits throw TypeError", () => { for (const maxDepth of [-1, 1.5, NaN, Infinity]) { throws(() => parse("[]", { maxDepth }), TypeError); } }); Deno.test("syntax spans exclude surrounding trivia and retain number spellings", () => { const body = "'\\u0061' /* key */ : [ /* item */ -0.25E+02 /* end */ ]"; for (const braced of [false, true]) { const source = "// head\n" + (braced ? "{ " : "") + body + ", /* tail */" + (braced ? " }" : "") + "\n// footer"; const root = parseTree(source); ok(root.kind === "object"); strictEqual( source.slice(root.span.start, root.span.end), braced ? "{ " + body + ", /* tail */ }" : body, ); strictEqual(root.members.length, 1); const member = root.members[0]; strictEqual(member.key, "a"); strictEqual( source.slice(member.keySpan.start, member.keySpan.end), "'\\u0061'", ); const array = member.value; ok(array.kind === "array"); strictEqual( source.slice(array.span.start, array.span.end), "[ /* item */ -0.25E+02 /* end */ ]", ); const number = array.items[0]; ok(number.kind === "number"); strictEqual(number.value, -25); strictEqual(number.raw, "-0.25E+02"); strictEqual(source.slice(number.span.start, number.span.end), number.raw); } const source = "/* head */ '\\u0061' \n/* tail */"; const root = parseTree(source); ok(root.kind === "string"); strictEqual(root.value, "a"); strictEqual(source.slice(root.span.start, root.span.end), "'\\u0061'"); }); Deno.test("newlines before values do not become separators after them", () => { deepStrictEqual(parse("true\n: false\nnull: true"), { true: false, null: true, }); deepStrictEqual(parse("'a'\n: 1\nb: 2"), { a: 1, b: 2 }); for (const source of ["a:\n1 b: 2", "'a'\n: 1 b: 2", "[\ntrue false]"]) { ok(parseError(source).message.includes("or newline"), source); } }); Deno.test("lexical errors retain precise spans including truncated escapes", () => { for ( const [source, start, end, message] of [ ["a: '\\q'", 5, 6, "Invalid string escape"], ["a: 'x\\", 6, 6, "Invalid string escape"], ["a: '\\u12", 8, 8, "Expected four hexadecimal digits"], ["a: '\\uD800'", 6, 10, "Unpaired Unicode surrogate"], ["a: 1e+", 6, 6, "Expected a digit"], ["a: 01", 4, 5, "Leading zeros"], ] as const ) { const error = parseError(source); deepStrictEqual(error.span, { start, end }, source); ok(error.message.includes(message), source); } });