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