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 }