1 /** 2 This module implements the JSON namespace. 3 4 ──────────────────────────────────────────────────────────────────────────────── 5 6 Copyright (C) 2021 pillager86.rf.gd 7 8 This program is free software: you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation, either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT ANY 14 WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A 15 PARTICULAR PURPOSE. See the GNU General Public License for more details. 16 17 You should have received a copy of the GNU General Public License along with 18 this program. If not, see <https://www.gnu.org/licenses/>. 19 */ 20 module mildew.stdlib.json; 21 22 import std.conv: to, ConvException; 23 debug import std.stdio; 24 import std.uni: isNumber; 25 import std.utf: encode; 26 27 import mildew.environment; 28 import mildew.exceptions; 29 import mildew.interpreter; 30 import mildew.types; 31 32 /** 33 * Initializes the JSON namespace 34 * Params: 35 * interpreter = The Interpreter instance to load the namespace into 36 */ 37 void initializeJSONLibrary(Interpreter interpreter) 38 { 39 auto JSONnamespace = new ScriptObject("namespace", null); 40 JSONnamespace["parse"] = new ScriptFunction("JSON.parse", &native_JSON_parse); 41 JSONnamespace["stringify"] = new ScriptFunction("JSON.stringify", &native_JSON_stringify); 42 interpreter.forceSetGlobal("JSON", JSONnamespace, false); 43 } 44 45 /** 46 * Parses text and returns a ScriptObject. Throws ScriptRuntimeException if invalid. 47 */ 48 ScriptAny native_JSON_parse(Environment env, ScriptAny* thisObj, ScriptAny[] args, ref NativeFunctionError nfe) 49 { 50 if(args.length < 1) 51 { 52 nfe = NativeFunctionError.WRONG_NUMBER_OF_ARGS; 53 return ScriptAny.UNDEFINED; 54 } 55 auto str = args[0].toString(); 56 try 57 { 58 JSONReader.ignoreWhitespace(str); 59 if(str.length == 0) 60 return ScriptAny.UNDEFINED; 61 else if(str[0] == '{') 62 return ScriptAny(JSONReader.consumeObject(str)); 63 else if(str[0] == '[') 64 return ScriptAny(JSONReader.consumeArray(str)); 65 else 66 throw new ScriptRuntimeException("Unknown JSON value at top level"); 67 } 68 catch(Exception ex) 69 { 70 throw new ScriptRuntimeException("JSON.parse: " ~ ex.msg); 71 } 72 } 73 74 /** 75 * Reads JSON strings as Mildew objects. 76 */ 77 class JSONReader 78 { 79 80 private static void consume(ref string str, char c) 81 { 82 if(str.length < 1) 83 throw new ScriptRuntimeException("Expected " ~ c ~ " in non-empty string"); 84 if(str[0] != c) 85 throw new ScriptRuntimeException("Expected '" ~ c ~ "' not '" ~ str[0] ~ "'"); 86 str = str[1..$]; 87 } 88 89 /** 90 * Reads an array from a string 91 */ 92 static ScriptAny[] consumeArray(ref string str) 93 { 94 ScriptAny[] result; 95 consume(str, '['); 96 while(peek(str) != ']') 97 { 98 ignoreWhitespace(str); 99 if(isStringDelimiter(peek(str))) 100 result ~= ScriptAny(consumeString(str)); 101 else if(isNumber(peek(str)) || peek(str) == '-') 102 result ~= consumeNumber(str); 103 else if(peek(str) == '{') 104 result ~= ScriptAny(consumeObject(str)); 105 else if(peek(str) == '[') 106 result ~= ScriptAny(consumeArray(str)); 107 else if(peek(str) == 't' || peek(str) == 'f') 108 result ~= ScriptAny(consumeBoolean(str)); 109 else if(peek(str) == 'n') 110 result ~= consumeNull(str); 111 else 112 throw new ScriptRuntimeException("Not a valid JSON value in array"); 113 ignoreWhitespace(str); 114 if(peek(str) == ',') 115 next(str); 116 else if(peek(str) != ']') 117 throw new ScriptRuntimeException("Arrays must end with ']'"); 118 } 119 consume(str, ']'); 120 return result; 121 } 122 123 private static bool consumeBoolean(ref string str) 124 { 125 if(str[0] == 't' && str.length >= 4 && str[0..4] == "true") 126 { 127 str = str[4..$]; 128 return true; 129 } 130 else if(str[0] == 'f' && str.length >= 5 && str[0..5] == "false") 131 { 132 str = str[5..$]; 133 return false; 134 } 135 else 136 { 137 throw new ScriptRuntimeException("Expected boolean"); 138 } 139 } 140 141 private static ScriptAny consumeNull(ref string str) 142 { 143 if(str[0] == 'n' && str.length >= 4 && str[0..4] == "null") 144 { 145 str = str[4..$]; 146 return ScriptAny(null); 147 } 148 throw new ScriptRuntimeException("Expected null"); 149 } 150 151 private static ScriptAny consumeNumber(ref string str) 152 { 153 auto numberString = ""; 154 auto eCounter = 0; 155 auto dotCounter = 0; 156 auto dashCounter = 0; 157 while(isNumber(peek(str)) || peek(str) == '.' || peek(str) == 'e' || peek(str) == '-') 158 { 159 immutable ch = next(str); 160 if(ch == 'e') 161 ++eCounter; 162 else if(ch == '.') 163 ++dotCounter; 164 else if(ch == '-') 165 ++dashCounter; 166 if(eCounter > 1 || dotCounter > 1 || dashCounter > 2) 167 throw new ScriptRuntimeException("Too many 'e' or '.' or '-' in number literal"); 168 numberString ~= ch; 169 } 170 if(dotCounter == 0 && eCounter == 0) 171 return ScriptAny(to!long(numberString)); 172 173 return ScriptAny(to!double(numberString)); 174 } 175 176 /** 177 * Reads a Mildew object from a JSON string 178 */ 179 static ScriptObject consumeObject(ref string str) 180 { 181 auto object = new ScriptObject("Object", null); 182 183 consume(str, '{'); 184 ignoreWhitespace(str); 185 while(peek(str) != '}' && str.length > 0) 186 { 187 ignoreWhitespace(str); 188 // then key value pairs 189 auto key = JSONReader.consumeString(str); 190 ignoreWhitespace(str); 191 consume(str, ':'); 192 ignoreWhitespace(str); 193 ScriptAny value; 194 if(isStringDelimiter(peek(str))) 195 value = consumeString(str); 196 else if(isNumber(peek(str)) || peek(str) == '-') 197 value = consumeNumber(str); 198 else if(peek(str) == '{') 199 value = ScriptAny(consumeObject(str)); 200 else if(peek(str) == '[') 201 value = ScriptAny(consumeArray(str)); 202 else if(peek(str) == 't' || peek(str) == 'f') 203 value = ScriptAny(consumeBoolean(str)); 204 else if(peek(str) == 'n') 205 value = consumeNull(str); 206 else 207 throw new ScriptRuntimeException("Not a valid JSON value in object"); 208 ignoreWhitespace(str); 209 if(peek(str) == ',') 210 next(str); 211 else if(peek(str) != '}') 212 throw new ScriptRuntimeException("Expected comma between key-value pairs"); 213 object[key] = value; 214 } 215 consume(str, '}'); 216 217 return object; 218 } 219 220 private static string consumeString(ref string str) 221 { 222 if(str.length == 0) 223 throw new ScriptRuntimeException("Expected string value in non-empty string"); 224 if(!isStringDelimiter(str[0])) 225 throw new ScriptRuntimeException("Expected string delimiter not " ~ str[0]); 226 immutable delim = next(str); 227 auto value = ""; 228 auto ch = next(str); 229 while(ch != delim) 230 { 231 if(ch == '\\') 232 { 233 ch = next(str); 234 switch(ch) 235 { 236 case 'b': 237 value ~= '\b'; 238 break; 239 case 'f': 240 value ~= '\f'; 241 break; 242 case 'n': 243 value ~= '\n'; 244 break; 245 case 'r': 246 value ~= '\r'; 247 break; 248 case 't': 249 value ~= '\t'; 250 break; 251 case 'u': { 252 ch = next(str); 253 auto hexNumber = ""; 254 for(auto counter = 0; counter < 4; ++counter) 255 { 256 if(!isHexDigit(peek(str))) 257 break; 258 hexNumber ~= ch; 259 ch = next(str); 260 } 261 immutable hexValue = to!ushort(hexNumber); 262 char[] buf; 263 encode(buf, cast(dchar)hexValue); 264 value ~= buf; 265 266 break; 267 } 268 default: 269 value ~= ch; 270 } 271 } 272 else 273 { 274 value ~= ch; 275 } 276 ch = next(str); 277 } 278 279 return value; 280 } 281 282 /** 283 * This function accepts JSON arrays or objects only 284 */ 285 static ScriptAny consumeValue(string str) 286 { 287 if(str[0] == '{') 288 return ScriptAny(consumeObject(str)); 289 else if(str[0] == '[') 290 return ScriptAny(consumeArray(str)); 291 return ScriptAny.UNDEFINED; 292 } 293 294 private static void ignoreWhitespace(ref string str) 295 { 296 while ( 297 str.length > 0 298 && ( str[0] == ' ' 299 || str[0] == '\t' 300 || str[0] == '\n' 301 || str[0] == '\r' 302 ) 303 ) 304 { 305 str = str[1..$]; 306 } 307 } 308 309 private static bool isHexDigit(in char c) 310 { 311 import std.ascii: toLower; 312 return (c >= '0' && c <= '9') || (c.toLower >= 'a' || c.toLower <= 'f'); 313 } 314 315 private static bool isStringDelimiter(in char c) 316 { 317 return (c == '"' || c == '\'' || c == '`'); 318 } 319 320 private static char next(ref string str) 321 { 322 if(str.length == 0) 323 return '\0'; 324 immutable c = str[0]; 325 str = str[1..$]; 326 return c; 327 } 328 329 private static char peek(in string str) 330 { 331 if(str.length == 0) 332 return '\0'; 333 return str[0]; 334 } 335 } 336 337 /** 338 * Converts a Mildew Object into a JSON string. 339 */ 340 ScriptAny native_JSON_stringify(Environment env, ScriptAny* thisObj, 341 ScriptAny[] args, ref NativeFunctionError nfe) 342 { 343 if(args.length < 1) 344 { 345 nfe = NativeFunctionError.WRONG_NUMBER_OF_ARGS; 346 return ScriptAny.UNDEFINED; 347 } 348 ScriptAny replacer = args.length > 1 ? args[1] : ScriptAny.UNDEFINED; 349 auto writer = new JSONWriter(env, ScriptAny.UNDEFINED, replacer); 350 return ScriptAny(writer.produceValue(args[0])); 351 } 352 353 private class JSONWriter 354 { 355 import mildew.types.bindings: native_Function_call; 356 357 this(Environment e, ScriptAny t, ScriptAny r) 358 { 359 environment = e; 360 thisToUse = t; 361 replacer = r; 362 } 363 364 string produceArray(ScriptAny[] array) 365 { 366 string result = "["; 367 if(array in arrayLimiter) 368 ++arrayLimiter[cast(immutable)array]; 369 else 370 arrayLimiter[cast(immutable)array] = 1; 371 372 if(arrayLimiter[cast(immutable)array] > 256) 373 throw new Exception("Array recursion error"); 374 375 for(size_t i = 0; i < array.length; ++i) 376 { 377 auto strValue = ""; 378 if(replacer.type == ScriptAny.Type.FUNCTION) 379 { 380 NativeFunctionError nfe; 381 strValue = native_Function_call(environment, &replacer, 382 [thisToUse, ScriptAny(i), array[i]], nfe).toString(); 383 } 384 else 385 { 386 strValue = produceValue(array[i]); 387 } 388 if(strValue != "") 389 result ~= strValue; 390 else 391 result ~= produceNull(); 392 if(i < array.length - 1) 393 result ~= ","; 394 } 395 result ~= "]"; 396 397 --arrayLimiter[cast(immutable)array]; 398 399 return result; 400 } 401 402 string produceBoolean(bool b) 403 { 404 return b ? "true" : "false"; 405 } 406 407 string produceNull() 408 { 409 return "null"; 410 } 411 412 string produceNumber(ScriptAny number) 413 { 414 return number.toString(); 415 } 416 417 string produceObject(ScriptObject object) 418 { 419 if(object in recursion) 420 ++recursion[object]; 421 else 422 recursion[object] = 1; 423 424 if(recursion[object] > 256) 425 throw new Exception("Object recursion error"); 426 427 string result = "{"; 428 size_t counter = 0; 429 foreach(key, value ; object.dictionary) 430 { 431 auto strKey = produceString(key); 432 auto strValue = ""; 433 if(replacer.type == ScriptAny.Type.FUNCTION) 434 { 435 NativeFunctionError nfe; 436 strValue = native_Function_call(environment, &replacer, 437 [thisToUse, ScriptAny(key), value], nfe).toString(); 438 } 439 else 440 { 441 strValue = produceValue(value); 442 } 443 if(strValue != "") 444 { 445 result ~= strKey ~ ":" ~ strValue; 446 } 447 if(counter < object.dictionary.keys.length - 1) 448 result ~= ","; 449 ++counter; 450 } 451 result ~= "}"; 452 --recursion[object]; 453 return result; 454 } 455 456 string produceString(in string key) 457 { 458 string result = "\""; 459 foreach(ch ; key) 460 { 461 switch(ch) 462 { 463 case '"': 464 result ~= "\\\""; 465 break; 466 case '\\': 467 result ~= "\\\\"; 468 break; 469 case '\b': 470 result ~= "\\b"; 471 break; 472 case '\f': 473 result ~= "\\f"; 474 break; 475 case '\n': 476 result ~= "\\n"; 477 break; 478 case '\r': 479 result ~= "\\r"; 480 break; 481 case '\t': 482 result ~= "\\t"; 483 break; 484 default: 485 result ~= ch; 486 } 487 } 488 result ~= "\""; 489 return result; 490 } 491 492 493 string produceValue(ScriptAny value) 494 { 495 switch(value.type) 496 { 497 case ScriptAny.Type.NULL: 498 return produceNull(); 499 case ScriptAny.Type.BOOLEAN: 500 return produceBoolean(cast(bool)value); 501 case ScriptAny.Type.INTEGER: 502 case ScriptAny.Type.DOUBLE: 503 return produceNumber(value); 504 case ScriptAny.Type.ARRAY: 505 return produceArray(value.toValue!(ScriptAny[])); 506 case ScriptAny.Type.STRING: 507 return produceString(value.toString()); 508 case ScriptAny.Type.OBJECT: 509 return produceObject(value.toValue!ScriptObject); 510 default: 511 return ""; 512 } 513 } 514 515 Environment environment; 516 ScriptAny thisToUse; 517 ScriptAny replacer; 518 519 int[ScriptAny[]] arrayLimiter; 520 int[ScriptObject] recursion; 521 } 522 523 unittest 524 { 525 string str1 = ""; // @suppress(dscanner.suspicious.unmodified) 526 string str2 = null; // @suppress(dscanner.suspicious.unmodified) 527 assert(str1 == str2); // weird that this passes when produceObject didn't work the same 528 }