char/fjson
friendly JSON implementation in TypeScript
git clone https://git.t4t.associates/char/fjson
17b13b5
main
1// vibed 2 3import { 4deepStrictEqual , 5equal , 6ok , 7strictEqual , 8throws , 9} from "node:assert/strict" ; 10import { FjsonError , parse , parseObject , parseTree } from "./mod.ts" ; 11 12function parseError ( source :string ) :FjsonError { 13try { 14parse ( source ); 15} catch ( error ) { 16ok ( error instanceof FjsonError , `expected FjsonError for ${ source } ` ); 17return error ; 18} 19throw new Error ( `expected parse error for ${ JSON . stringify ( source )} ` ); 20} 21 22function parseObjectError ( source :string ) :FjsonError { 23try { 24parseObject ( source ); 25} catch ( error ) { 26ok ( error instanceof FjsonError , `expected FjsonError for ${ source } ` ); 27return error ; 28} 29throw new Error ( `expected parse error for ${ JSON . stringify ( source )} ` ); 30} 31 32Deno . test ( "parses a package config with or without root braces" , () => { 33const source = ` 34// Dependencies are private to this package. 35imports: { 36assert: 'jsr:@std/assert@^1', 37"@my/library": "../library/mod.ts", 38}, 39options: { 40enabled: true, 41fallback: null, 42targets: ["linux", /* another platform */ "macos",], 43}, 44` ; 45for ( const text of [ source , `{ ${ source } }` ]) { 46const value = parseObject ( text ) as Record < string , Record < string , unknown >>; 47strictEqual ( value [ "imports" ][ "assert" ], "jsr:@std/assert@^1" ); 48strictEqual ( value [ "imports" ][ "@my/library" ], "../library/mod.ts" ); 49strictEqual ( value [ "options" ][ "enabled" ], true ); 50strictEqual ( value [ "options" ][ "fallback" ], null ); 51deepStrictEqual ( value [ "options" ][ "targets" ], [ "linux" , "macos" ]); 52} 53}); 54 55Deno . test ( "empty or comment-only documents yield an empty object" , () => { 56for ( const source of [ "" , " \t\r\n" , "// only a comment" , "/* comment */" ]) { 57deepStrictEqual ( parse ( source ), {}); 58} 59deepStrictEqual ( parse ( "{}" ), {}); 60deepStrictEqual ( parseObject ( "{ /* comment */ }" ), {}); 61const value = parseObject ( "a/*1*/:/*2*/[/*3*/]/*4*/,/*5*/b: {/*6*/},// eof" ); 62deepStrictEqual ( value , { a :[], b :{} }); 63deepStrictEqual ( parseObject ( "a: false // eof" ), { a :false }); 64}); 65 66Deno . test ( "implicit commas separate entries; block comments do not" , () => { 67for ( const newline of [ "\n" , "\r\n" , "\r" ]) { 68for ( 69const separator of [ 70newline , 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) { 80const source = 81`a: 1 ${ separator } b: [true ${ separator } false ${ separator } null ${ newline } ] ${ newline } ` ; 82for ( const text of [ source , `{ ${ newline } ${ source } }` ]) { 83deepStrictEqual ( parseObject ( text ), { 84a :1 , 85b :[ true , false , null ], 86}, JSON . stringify ( text )); 87} 88} 89} 90// Newlines inside block comments are not separators. 91for ( 92const 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) { 99ok ( parseError ( source ). message . includes ( "Expected `,` or newline" ), source ); 100} 101}); 102 103Deno . test ( "requires explicit commas without a separator newline" , () => { 104for ( 105const 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) { 115ok ( 116parseError ( source ). message . includes ( "Expected `,` or newline" ), 117source , 118); 119} 120for ( const source of [ "a: 1," , "{a: 1,}" , "a: [1,]," , "a: 1,\nb: 2," ]) { 121parseObject ( source ); 122} 123}); 124 125Deno . test ( "strings decode escapes without interpreting comment markers" , () => { 126const value = parseObject ( ` 127url: "https://example.com/*path*/", 128text: '"\\'\\\\\\/\\b\\f\\n\\r\\t', 129unicode: "α\\u0061\\uD83D\\uDE00", 130"quoted\\u0020key": 'value', 131` ); 132strictEqual ( value [ "url" ], "https://example.com/*path*/" ); 133strictEqual ( value [ "text" ], "\"'\\/\b\f\n\r\t" ); 134strictEqual ( value [ "unicode" ], "αa😀" ); 135strictEqual ( value [ "quoted key" ], "value" ); 136}); 137 138Deno . test ( "unicode escapes reject unpaired surrogates" , () => { 139for ( 140const [ 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) { 146const error = parseError ( source ); 147strictEqual ( error . kind , kind ); 148ok ( error . message . includes ( message ), ` ${ source } : ${ error . message } ` ); 149} 150}); 151 152Deno . test ( "accepts identifier keys but not bare string values" , () => { 153deepStrictEqual ( 154parseObject ( "_key: 1, $key: 2, key123: 3, true: false, '日本語': 'ok'" ), 155{ _key :1 , $key :2 , key123 :3 , true :false , "日本語" :"ok" }, 156); 157for ( const source of [ "a: bare" , "a: undefined" , "a: trueish" , "a: Null" ]) { 158ok ( parseError ( source ). message . includes ( "strings must be quoted" ), source ); 159} 160for ( 161const source of [ "123: true" , "日本語: 1" , "@scope/pkg: 1" , "some-key: 1" ] 162) { 163throws (() => parseObject ( source ), FjsonError , source ); 164} 165}); 166 167Deno . test ( "numbers keep JSON spellings and follow JSON.parse semantics" , () => { 168for ( 169const [ 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) { 179strictEqual ( parse ( source ), expected , source ); 180} 181ok ( Object . is ( parse ( "-0" ), - 0 )); 182// Out of double range yields Infinity, like JSON.parse; precision loss matches too. 183strictEqual ( parse ( "1e9999" ), Infinity ); 184strictEqual ( parse ( "9007199254740993" ), 9007199254740992 ); 185for ( 186const 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) { 204throws (() => parse ( `value: ${ spelling } ` ), FjsonError , spelling ); 205} 206}); 207 208Deno . test ( "rejects duplicate decoded keys and identifies both definitions" , () => { 209const source = "imports: {\n assert: 'first'\n '\\u0061ssert': 'second'\n}" ; 210const error = parseError ( source ); 211ok ( error . message . includes ( 'Duplicate key "assert"' ), error . message ); 212strictEqual ( source . slice ( error . span . start , error . span . end ), "'\\u0061ssert'" ); 213ok ( error . previous !== undefined ); 214strictEqual ( source . slice ( error . previous . start , error . previous . end ), "assert" ); 215strictEqual ( 216error . render ( "avril.conf" , source ), 217'avril.conf:3:3: Duplicate key "assert"\navril.conf:2:3: First defined here' , 218); 219parseObject ( "a: {key: 1}, b: {key: 2}" ); 220}); 221 222Deno . test ( "diagnostics use original lines and unicode character columns" , () => { 223const source = "label: '😀',\n imports: {\n x: 'α', y 'bad',\n },\n" ; 224const error = parseError ( source ); 225strictEqual ( 226error . render ( "avril.conf" , source ), 227"avril.conf:3:15: Expected `:`" , 228); 229strictEqual ( source . slice ( error . span . start , error . span . end ), "'" ); 230const truncated = "a: true,\n// heading\nb:" ; 231strictEqual ( 232parseError ( 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" , () => { 238const 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,]" , { 250name :"fJSON" , 251flags :[ true , false ], 252}], 253]; 254for ( const [ source , expected ] of cases ) { 255deepStrictEqual ( parse ( source ), expected , source ); 256} 257}); 258 259Deno . test ( "rejects trailing values, duplicates, and invalid roots" , () => { 260for ( 261const 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) { 271throws (() => parse ( source ), FjsonError , source ); 272} 273const source = "a: '😀'\n'\\u0061': false" ; 274strictEqual ( 275parseError ( 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" , () => { 281for ( const source of [ "[]" , "42" ]) { 282const error = parseObjectError ( source ); 283ok ( error . message . includes ( "object key" ), ` ${ source } : ${ error } ` ); 284} 285const 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]; 307for ( const [ source , message ] of cases ) { 308const error = parseObjectError ( source ); 309ok ( error . message . includes ( message ), ` ${ JSON . stringify ( source )} : ${ error } ` ); 310ok ( 311source . slice ( error . span . start , error . span . end ) !== undefined && 312error . span . start <= source . length && 313error . span . end <= source . length , 314` ${ JSON . stringify ( source )} : ${ JSON . stringify ( error . span )} ` , 315); 316error . render ( "avril.conf" , source ); 317} 318}); 319 320Deno . test ( "bounds nesting without rejecting large flat configs" , () => { 321const nested = `value: ${ "[" . repeat ( 129 )} 0 ${ "]" . repeat ( 129 )} ` ; 322ok ( parseError ( nested ). message . includes ( "nesting" ), "object nesting" ); 323parseObject ( `value: ${ "[" . repeat ( 128 )} 0 ${ "]" . repeat ( 128 )} ` ); 324const objects = `value: ${ "{a:" . repeat ( 129 )} 0 ${ "}" . repeat ( 129 )} ` ; 325ok ( parseError ( objects ). message . includes ( "nesting" ), "object nesting" ); 326const flat = `value: [ ${ "0," . repeat ( 10_000 )} ]` ; 327const value = parseObject ( flat ) as { value :unknown [] }; 328equal ( value [ "value" ]. length , 10_000 ); 329}); 330 331Deno . test ( "custom limits control parsing" , () => { 332const source = ` ${ "[" . repeat ( 4 )} 0 ${ "]" . repeat ( 4 )} ` ; 333parse ( source , { maxDepth :3 }); 334const tight = (() => { 335try { 336parse ( source , { maxDepth :2 }); 337} catch ( caught ) { 338ok ( caught instanceof FjsonError ); 339return caught ; 340} 341throw new Error ( "expected nesting error" ); 342})(); 343strictEqual ( tight . kind , "nesting" ); 344ok ( tight . message . includes ( "2 levels" ), tight . message ); 345parseObject ( "value: 0" , { maxDepth :0 }); 346throws (() => parseObject ( "value: [0]" , { maxDepth :0 }), FjsonError ); 347}); 348 349Deno . test ( "invalid limits throw TypeError" , () => { 350for ( const maxDepth of [ - 1 , 1.5 , NaN , Infinity ]) { 351throws (() => parse ( "[]" , { maxDepth}), TypeError ); 352} 353}); 354 355Deno . test ( "syntax spans exclude surrounding trivia and retain number spellings" , () => { 356const body = "'\\u0061' /* key */ : [ /* item */ -0.25E+02 /* end */ ]" ; 357for ( const braced of [ false , true ]) { 358const source = "// head\n" + ( braced ?"{ " :"" ) + body + 359", /* tail */" + ( braced ?" }" :"" ) + "\n// footer" ; 360const root = parseTree ( source ); 361ok ( root . kind === "object" ); 362strictEqual ( 363source . slice ( root . span . start , root . span . end ), 364braced ?"{ " + body + ", /* tail */ }" :body , 365); 366strictEqual ( root . members . length , 1 ); 367const member = root . members [ 0 ]; 368strictEqual ( member . key , "a" ); 369strictEqual ( 370source . slice ( member . keySpan . start , member . keySpan . end ), 371"'\\u0061'" , 372); 373const array = member . value ; 374ok ( array . kind === "array" ); 375strictEqual ( 376source . slice ( array . span . start , array . span . end ), 377"[ /* item */ -0.25E+02 /* end */ ]" , 378); 379const number = array . items [ 0 ]; 380ok ( number . kind === "number" ); 381strictEqual ( number . value , - 25 ); 382strictEqual ( number . raw , "-0.25E+02" ); 383strictEqual ( source . slice ( number . span . start , number . span . end ), number . raw ); 384} 385 386const source = "/* head */ '\\u0061' \n/* tail */" ; 387const root = parseTree ( source ); 388ok ( root . kind === "string" ); 389strictEqual ( root . value , "a" ); 390strictEqual ( source . slice ( root . span . start , root . span . end ), "'\\u0061'" ); 391}); 392 393Deno . test ( "newlines before values do not become separators after them" , () => { 394deepStrictEqual ( parse ( "true\n: false\nnull: true" ), { 395true :false , 396null :true , 397}); 398deepStrictEqual ( parse ( "'a'\n: 1\nb: 2" ), { a :1 , b :2 }); 399for ( const source of [ "a:\n1 b: 2" , "'a'\n: 1 b: 2" , "[\ntrue false]" ]) { 400ok ( parseError ( source ). message . includes ( "or newline" ), source ); 401} 402}); 403 404Deno . test ( "lexical errors retain precise spans including truncated escapes" , () => { 405for ( 406const [ 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) { 415const error = parseError ( source ); 416deepStrictEqual ( error . span , { start, end}, source ); 417ok ( error . message . includes ( message ), source ); 418} 419});