char/fjson
friendly JSON implementation in TypeScript
git clone https://git.t4t.associates/char/fjson
17b13b5
main
1import { FjsonError , type Span } from "./errors.ts" ; 2 3type TokenData = 4| { kind :"{" | "}" | "[" | "]" | ":" | "," | "eof" | "invalid" } 5| { kind :"string" | "identifier" ; value :string } 6| { kind :"number" ; value :number ; raw :string }; 7 8export type Token = TokenData & { 9readonly span :Span ; 10readonly newlineBefore :boolean ; 11}; 12 13function isDigitCode ( code :number ) :boolean { 14return code >= 48 && code <= 57 ; 15} 16 17function isIdentifierStart ( ch :string | undefined ) :boolean { 18if ( ch === undefined ) return false ; 19const code = ch . charCodeAt ( 0 ); 20return ch === "_" || ch === "$" || 21( code >= 65 && code <= 90 ) || 22( code >= 97 && code <= 122 ); 23} 24 25function isIdentifierChar ( ch :string | undefined ) :boolean { 26return ch !== undefined && 27( isIdentifierStart ( ch ) || isDigitCode ( ch . charCodeAt ( 0 ))); 28} 29 30const escapes = new Map ([ 31[ '"' , '"' ], 32[ "'" , "'" ], 33[ "\\" , "\\" ], 34[ "/" , "/" ], 35[ "b" , "\b" ], 36[ "f" , "\f" ], 37[ "n" , "\n" ], 38[ "r" , "\r" ], 39[ "t" , "\t" ], 40]); 41 42export class Scanner { 43 #pos= 0 ; 44readonly #source:string ; 45readonly #buffer:Token [] = []; 46 47constructor ( source :string ) { 48this . #source= source ; 49} 50 51peek ( offset :0 | 1 = 0 ) :Token { 52while ( this . #buffer. length <= offset ) this . #buffer. push ( this . #scan()); 53return this . #buffer[ offset ]; 54} 55 56take () :Token { 57const token = this . peek (); 58this . #buffer. shift (); 59return token ; 60} 61 62 #scan() :Token { 63const newlineBefore = this . #trivia(); 64const start = this . #pos; 65const data = this . #token(); 66return { ...data , span :{ start, end :this . #pos}, newlineBefore}; 67} 68 69 #token() :TokenData { 70const ch = this . #source[ this . #pos]; 71if ( ch === undefined ) return { kind :"eof" }; 72if ( ch === '"' || ch === "'" ) { 73return { kind :"string" , value :this . #string() }; 74} 75if ( isIdentifierStart ( ch )) { 76return { kind :"identifier" , value :this . #identifier() }; 77} 78if ( ch === "-" || isDigitCode ( ch . charCodeAt ( 0 ))) { 79return { kind :"number" , ...this . #number() }; 80} 81this . #pos+= 1 ; 82switch ( ch ) { 83case "{" : 84case "}" : 85case "[" : 86case "]" : 87case ":" : 88case "," : 89return { kind :ch }; 90default : 91return { kind :"invalid" }; 92} 93} 94 95 #error( message :string ) :FjsonError { 96return new FjsonError ( "syntax" , message , { 97start :this . #pos, 98end :Math . min ( this . #pos+ 1 , this . #source. length ), 99}); 100} 101 102 #trivia() :boolean { 103let newline = false ; 104for (;;) { 105const ch = this . #source[ this . #pos]; 106if ( ch === " " || ch === "\t" ) { 107this . #pos+= 1 ; 108} else if ( ch === "\r" || ch === "\n" ) { 109newline = true ; 110this . #pos+= 1 ; 111} else if ( ch === "/" && this . #source[ this . #pos+ 1 ] === "/" ) { 112while ( 113this . #pos< this . #source. length && 114this . #source[ this . #pos] !== "\r" && 115this . #source[ this . #pos] !== "\n" 116) { 117this . #pos+= 1 ; 118} 119} else if ( ch === "/" && this . #source[ this . #pos+ 1 ] === "*" ) { 120const start = this . #pos; 121const end = this . #source. indexOf ( "*/" , this . #pos+ 2 ); 122if ( end === - 1 ) { 123throw new FjsonError ( "syntax" , "Unterminated block comment" , { 124 start, 125end :start + 2 , 126}); 127} 128// newlines inside block comments aren't implicit commas 129this . #pos= end + 2 ; 130} else { 131return newline ; 132} 133} 134} 135 136 #identifier() :string { 137const start = this . #pos; 138while ( isIdentifierChar ( this . #source[ this . #pos])) this . #pos+= 1 ; 139return this . #source. slice ( start , this . #pos); 140} 141 142 #number() :{ value :number ; raw :string } { 143const start = this . #pos; 144if ( this . #source[ this . #pos] === "-" ) this . #pos+= 1 ; 145const first = this . #source. charCodeAt ( this . #pos); 146if ( first === 48 ) { 147this . #pos+= 1 ; 148if ( isDigitCode ( this . #source. charCodeAt ( this . #pos))) { 149throw this . #error( "Leading zeros are not allowed" ); 150} 151} else if ( first >= 49 && first <= 57 ) { 152this . #digits(); 153} else { 154throw this . #error( "Expected a digit" ); 155} 156if ( this . #source[ this . #pos] === "." ) { 157this . #pos+= 1 ; 158this . #digits(); 159} 160const exponent = this . #source[ this . #pos]; 161if ( exponent === "e" || exponent === "E" ) { 162this . #pos+= 1 ; 163const sign = this . #source[ this . #pos]; 164if ( sign === "+" || sign === "-" ) this . #pos+= 1 ; 165this . #digits(); 166} 167const next = this . #source[ this . #pos]; 168if ( isIdentifierChar ( next ) || next === "." ) { 169throw this . #error( "Invalid decimal number" ); 170} 171const raw = this . #source. slice ( start , this . #pos); 172return { value :Number ( raw ), raw}; 173} 174 175 #digits() :void { 176if ( ! isDigitCode ( this . #source. charCodeAt ( this . #pos))) { 177throw this . #error( "Expected a digit" ); 178} 179while ( isDigitCode ( this . #source. charCodeAt ( this . #pos))) this . #pos+= 1 ; 180} 181 182 #string() :string { 183const start = this . #pos; 184const quote = this . #source[ this . #pos]; 185this . #pos+= 1 ; 186let value = "" ; 187for (;;) { 188if ( this . #pos>= this . #source. length ) { 189throw new FjsonError ( "syntax" , "Unterminated string" , { 190 start, 191end :start + 1 , 192}); 193} 194const ch = this . #source[ this . #pos]; 195if ( ch === quote ) { 196this . #pos+= 1 ; 197return value ; 198} 199if ( ch === "\\" ) { 200this . #pos+= 1 ; 201const escaped = this . #source[ this . #pos]; 202if ( escaped === "u" ) { 203this . #pos+= 1 ; 204value += this . #unicodeEscape(); 205} else { 206const decoded = escapes . get ( escaped ); 207if ( decoded === undefined ) throw this . #error( "Invalid string escape" ); 208value += decoded ; 209this . #pos+= 1 ; 210} 211continue ; 212} 213if ( this . #source. charCodeAt ( this . #pos) <= 0x1f ) { 214throw this . #error( "Control characters must be escaped in strings" ); 215} 216value += ch ; 217this . #pos+= 1 ; 218} 219} 220 221 #hexQuad() :number { 222let value = 0 ; 223for ( let i = 0 ; i < 4 ; i += 1 ) { 224const digit = parseInt ( this . #source[ this . #pos], 16 ); 225if ( Number . isNaN ( digit ) || this . #source[ this . #pos] === undefined ) { 226throw this . #error( "Expected four hexadecimal digits in Unicode escape" ); 227} 228this . #pos+= 1 ; 229value = value * 16 + digit ; 230} 231return value ; 232} 233 234 #unicodeEscape() :string { 235const quadStart = this . #pos; 236const first = this . #hexQuad(); 237if ( first >= 0xd800 && first <= 0xdbff ) { 238if ( ! this . #source. startsWith ( "\\u" , this . #pos)) { 239throw new FjsonError ( "unicode" , "Unpaired Unicode surrogate" , { 240start :quadStart , 241end :this . #pos, 242}); 243} 244this . #pos+= 2 ; 245const secondStart = this . #pos; 246const second = this . #hexQuad(); 247if ( second < 0xdc00 || second > 0xdfff ) { 248throw new FjsonError ( "unicode" , "Expected a low Unicode surrogate" , { 249start :secondStart , 250end :this . #pos, 251}); 252} 253return String . fromCodePoint ( 2540x10000 + (( first - 0xd800 ) << 10 ) + ( second - 0xdc00 ), 255); 256} 257if ( first >= 0xdc00 && first <= 0xdfff ) { 258throw new FjsonError ( "unicode" , "Unexpected low Unicode surrogate" , { 259start :quadStart , 260end :this . #pos, 261}); 262} 263return String . fromCharCode ( first ); 264} 265}