001package com.hfg.javascript;
002
003import java.io.*;
004import java.util.Collection;
005import java.util.HashSet;
006import java.util.Map;
007import java.util.Set;
008import java.util.regex.Matcher;
009import java.util.regex.Pattern;
010
011import com.hfg.util.collection.CollectionUtil;
012import com.hfg.util.collection.OrderedMap;
013import com.hfg.util.StringUtil;
014import com.hfg.xml.XMLException;
015
016//------------------------------------------------------------------------------
017/**
018 A Javascript object (Map) container useful for creating JSON.
019 <div>
020 <a href='http://www.json.org/'>JSON.org</a>
021 </div>
022 <div>
023 JSON RFC: <a href='http://www.ietf.org/rfc/rfc4627.txt'>http://www.ietf.org/rfc/rfc4627.txt</a>
024 </div>
025
026 @author J. Alex Taylor, hairyfatguy.com
027 */
028//------------------------------------------------------------------------------
029// com.hfg Library
030//
031// This library is free software; you can redistribute it and/or
032// modify it under the terms of the GNU Lesser General Public
033// License as published by the Free Software Foundation; either
034// version 2.1 of the License, or (at your option) any later version.
035//
036// This library is distributed in the hope that it will be useful,
037// but WITHOUT ANY WARRANTY; without even the implied warranty of
038// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
039// Lesser General Public License for more details.
040//
041// You should have received a copy of the GNU Lesser General Public
042// License along with this library; if not, write to the Free Software
043// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
044//
045// J. Alex Taylor, President, Founder, CEO, COO, CFO, OOPS hairyfatguy.com
046// jataylor@hairyfatguy.com
047//------------------------------------------------------------------------------
048
049public class JsObjMap extends OrderedMap<String, Object> implements JsCollection
050{
051   private Set<String> mUnquotedValueKeys;
052
053   private static final Pattern KEY_PATTERN = Pattern.compile("(.+?)\\s*:");
054   private static final Pattern UNQUOTED_VALUE_PATTERN = Pattern.compile("(.+?)\\s*[,}]");
055
056   //###########################################################################
057   // CONSTRUCTORS
058   //###########################################################################
059
060   //--------------------------------------------------------------------------
061   public JsObjMap()
062   {
063
064   }
065
066   //--------------------------------------------------------------------------
067   public JsObjMap(int inInitialCapacity)
068   {
069      super(inInitialCapacity);
070   }
071
072   //--------------------------------------------------------------------------
073   public JsObjMap(int inInitialCapacity, float inLoadFactor)
074   {
075      super(inInitialCapacity, inLoadFactor);
076   }
077
078   //--------------------------------------------------------------------------
079   public JsObjMap(CharSequence inJSONString)
080   {
081      if (StringUtil.isSet(inJSONString))
082      {
083         parse(inJSONString);
084      }
085   }
086
087   //--------------------------------------------------------------------------
088   public JsObjMap(Map<String, Object> inMap)
089   {
090      if (CollectionUtil.hasValues(inMap))
091      {
092         for (Entry<String, Object> entry : inMap.entrySet())
093         {
094            put(entry.getKey(), entry.getValue());
095         }
096      }
097   }
098
099   //###########################################################################
100   // PUBLIC METHODS
101   //###########################################################################
102
103   //--------------------------------------------------------------------------
104   @Override
105   public String toString()
106   {
107      return toJavascript();
108   }
109
110   //--------------------------------------------------------------------------
111   public String toJSON()
112   {
113      ByteArrayOutputStream outStream;
114      try
115      {
116         outStream = new ByteArrayOutputStream(2048);
117         toJSON(outStream);
118         outStream.close();
119      }
120      catch (Exception e)
121      {
122         throw new XMLException(e);
123      }
124
125      return outStream.toString();
126   }
127
128   //---------------------------------------------------------------------------
129   public void toJSON(OutputStream inStream)
130   {
131      PrintWriter writer = new PrintWriter(inStream);
132
133      toJSON(writer);
134
135      writer.flush();
136   }
137
138   //--------------------------------------------------------------------------
139   public void toJSON(Writer inWriter)
140   {
141      try
142      {
143         inWriter.write("{ ");
144
145         int i = 0;
146         for (String key : keySet())
147         {
148            if (i > 0)
149            {
150               inWriter.write(", ");
151            }
152
153            inWriter.write("\"");
154            inWriter.write(JSONUtil.escapeString(key));
155            inWriter.write("\": ");
156
157            if (mUnquotedValueKeys != null
158                && mUnquotedValueKeys.contains(key))
159            {
160               inWriter.write(get(key).toString());
161            }
162            else
163            {
164               inWriter.write(composeValueJavascript(get(key)));
165            }
166  
167            i++;
168         }
169
170         inWriter.write(" }");
171      }
172      catch (IOException e)
173      {
174         throw new RuntimeException(e);
175      }
176   }
177
178
179   //--------------------------------------------------------------------------
180   /**
181    Produces a javascript serialization that will generally be the same as produced
182    by toJSON() except when values have been added via the putUnquoted() method.
183    */
184   public String toJavascript()
185   {
186      ByteArrayOutputStream outStream;
187      try
188      {
189         outStream = new ByteArrayOutputStream(2048);
190         toJavascript(outStream);
191         outStream.close();
192      }
193      catch (Exception e)
194      {
195         throw new XMLException(e);
196      }
197
198      return outStream.toString();
199   }
200
201   //---------------------------------------------------------------------------
202   /**
203    Produces a javascript serialization that will generally be the same as produced
204    by toJSON() except when values have been added via the putUnquoted() method.
205    */
206   public void toJavascript(OutputStream inStream)
207   {
208      PrintWriter writer = new PrintWriter(inStream);
209
210      toJavascript(writer);
211
212      writer.flush();
213   }
214
215   //--------------------------------------------------------------------------
216   /**
217    Produces a javascript serialization that will generally be the same as produced
218    by toJSON() except when values have been added via the putUnquoted() method.
219    */
220   public void toJavascript(Writer inWriter)
221   {
222      try
223      {
224         inWriter.write("{ ");
225
226         int i = 0;
227         for (String key : keySet())
228         {
229            if (i > 0)
230            {
231               inWriter.write(", ");
232            }
233
234            inWriter.write("\"");
235            inWriter.write(JSONUtil.escapeString(key));
236            inWriter.write("\": ");
237
238
239            if (mUnquotedValueKeys != null
240                  && mUnquotedValueKeys.contains(key))
241            {
242               inWriter.write(get(key).toString());
243            }
244            else
245            {
246               inWriter.write(composeValueJavascript(get(key)));
247            }
248            i++;
249         }
250
251         inWriter.write(" }");
252      }
253      catch (IOException e)
254      {
255         throw new RuntimeException(e);
256      }
257   }
258
259   //--------------------------------------------------------------------------
260   public String getString(String inKey)
261   {
262      Object value = get(inKey);
263      return (value != null ? value.toString() : null);
264   }
265
266   //--------------------------------------------------------------------------
267   /**
268    Puts the specified key / value pair into the map but does not quote the
269    value when calling toJavascript() [Note that not quoting a string value means
270    that the resulting serialization will no longer be valid JSON, but sending
271    function definitions in a JSON-like format can be useful when working with
272    javascript frameworks]. Numeric and boolean values should not be passed to
273    this method as they will be handle correctly by the standard put() method.
274    */
275   public void putUnquoted(String inKey, Object inValue)
276   {
277      put(inKey, inValue);
278
279      if (null == mUnquotedValueKeys)
280      {
281         mUnquotedValueKeys = new HashSet<>(5);
282      }
283
284      mUnquotedValueKeys.add(inKey);
285   }
286
287   //--------------------------------------------------------------------------
288   // If the values comes in too generically, we may need to dispatch to one of our specific put() methods.
289   public Object put(String inKey, Object inValue)
290   {
291      if (null == inKey)
292      {
293         throw new RuntimeException("A non-null value must be specified as the key!");
294      }
295
296      Object result;
297      if (inValue != null)
298      {
299         if (inValue instanceof JsArray)
300         {
301            result = put(inKey, (JsArray) inValue);
302         }
303         else if (inValue instanceof Collection)
304         {
305            result = put(inKey, (Collection) inValue);
306         }
307         else if (inValue instanceof Object[])
308         {
309            result = put(inKey, (Object[]) inValue);
310         }
311         else
312         {
313            result = super.put(inKey, inValue);
314         }
315      }
316      else
317      {
318         result = super.put(inKey, inValue);
319      }
320
321      return result;
322   }
323
324   //--------------------------------------------------------------------------
325   public Object put(String inKey, JsArray inValue)
326   {
327      if (null == inKey)
328      {
329         throw new RuntimeException("A non-null value must be specified as the key!");
330      }
331
332      return super.put(inKey, inValue);
333   }
334
335   //--------------------------------------------------------------------------
336   public Object put(String inKey, Collection inValues)
337   {
338      if (null == inKey)
339      {
340         throw new RuntimeException("A non-null value must be specified as the key!");
341      }
342
343      return super.put(inKey, inValues != null ? new JsArray(inValues) : null);
344   }
345
346   //--------------------------------------------------------------------------
347   public Object put(String inKey, Object[] inValues)
348   {
349      if (null == inKey)
350      {
351         throw new RuntimeException("A non-null value must be specified as the key!");
352      }
353
354      return super.put(inKey, inValues != null ? new JsArray(inValues) : null);
355   }
356
357   //--------------------------------------------------------------------------
358   @Override
359   public Object remove(Object inKey)
360   {
361      if (mUnquotedValueKeys != null)
362      {
363         mUnquotedValueKeys.remove(inKey);
364      }
365
366      return super.remove(inKey);
367   }
368
369   //###########################################################################
370   // PRIVATE METHODS
371   //###########################################################################
372
373   //--------------------------------------------------------------------------
374   private String composeValueJSON(Object inValue)
375   {
376      String value = "null";
377
378      if (inValue != null)
379      {
380         if (inValue instanceof Number
381             || inValue instanceof Boolean)
382         {
383            // Numeric and boolean values are written without quotes
384            value = inValue.toString();
385         }
386         else if (inValue instanceof JsArray)
387         {
388            value = ((JsArray) inValue).toJSON();
389         }
390         else if (inValue instanceof JsObjMap)
391         {
392            value = ((JsObjMap) inValue).toJSON();
393         }
394         else
395         {
396            // String values should be enclosed in double quotes
397            value = "\"" + JSONUtil.escapeString(inValue.toString()) + "\"";
398         }
399      }
400
401      return value;
402   }
403
404   //--------------------------------------------------------------------------
405   private String composeValueJavascript(Object inValue)
406   {
407      String value = "null";
408
409      if (inValue != null)
410      {
411         if (inValue instanceof Number
412               || inValue instanceof Boolean)
413         {
414            value = inValue.toString();
415         }
416         else if (inValue instanceof JsArray)
417         {
418            value = ((JsArray) inValue).toJavascript();
419         }
420         else if (inValue instanceof JsObjMap)
421         {
422            value = ((JsObjMap) inValue).toJavascript();
423         }
424         else
425         {
426            value = "\"" + JSONUtil.escapeString(inValue.toString()) + "\"";
427         }
428      }
429
430      return value;
431   }
432
433   //--------------------------------------------------------------------------
434   private void parse(CharSequence inString)
435   {
436      String str = inString.toString().trim();
437      if (str.charAt(0) != '{'
438          || str.charAt(str.length() - 1) != '}')
439      {
440         throw new RuntimeException("JSON map must be enclosed with {}!");
441      }
442
443      int index = 1;
444
445      String key = null;
446      while (index < str.length() - 1)
447      {
448         char theChar = str.charAt(index);
449         if (Character.isWhitespace(theChar))
450         {
451            index++;
452         }
453         else if (theChar == ',')
454         {
455            index++;
456         }
457         else if (theChar == '[')
458         {
459            // Find the ending bracket
460            if (null == key)
461            {
462               throw new RuntimeException("Poorly structured JSON! Array at index " + index + " in map does not have a key!");
463            }
464
465            int depth = 1;
466            char quote = ' ';
467            boolean inEscape = false;
468            boolean inValue = false;
469            int end = index + 1;
470            while (end < str.length() -1
471                   && (depth != 1
472                       || inValue
473                       || str.charAt(end) != ']'))
474            {
475               char theEndChar = str.charAt(end);
476               if (theEndChar == '\\'
477                   && inValue)
478               {
479                  inEscape = ! inEscape;
480               }
481               else if (inEscape)
482               {
483                  inEscape = false;
484               }
485
486               if (inEscape)
487               {
488                  continue;
489               }
490
491               if (theEndChar == '\''
492                        && (str.charAt(end -1) != '\\'
493                            || ! inEscape))
494               {
495                  if (! inValue)
496                  {
497                     inValue = true;
498                     quote = '\'';
499                  }
500                  else if (quote == '\'')
501                  {
502                     inValue = false;
503                  }
504               }
505               else if (theEndChar == '"'
506                        && (str.charAt(end -1) != '\\'
507                            || ! inEscape))
508               {
509                  if (! inValue)
510                  {
511                     inValue = true;
512                     quote = '"';
513                  }
514                  else if (quote == '"')
515                  {
516                     inValue = false;
517                  }
518               }
519               else if (! inValue
520                        && theEndChar == '[')
521               {
522                  depth++;
523               }
524               else if (! inValue
525                        && theEndChar == ']')
526               {
527                  depth--;
528               }
529
530               end++;
531            }
532
533            JsArray value = new JsArray(str.substring(index, end + 1));
534            put(key, value);
535            key = null;
536            index = end + 1;
537         }
538         else if (theChar == '{')
539         {
540            if (null == key)
541            {
542               throw new RuntimeException("Poorly structured JSON! Submap at index " + index + " in map does not have a key!");
543            }
544
545            // Find the ending brace
546            int depth = 1;
547            char quote = ' ';
548            boolean inEscape = false;
549            boolean inValue = false;
550            int end = index + 1;
551            while (end < str.length() -1
552                   && (depth != 1
553                       || inValue
554                       || str.charAt(end) != '}'))
555            {
556               char theEndChar = str.charAt(end);
557               if (theEndChar == '\\'
558                   && inValue)
559               {
560                  inEscape = ! inEscape;
561               }
562               else if (inEscape)
563               {
564                  inEscape = false;
565               }
566
567               if (inEscape)
568               {
569                  continue;
570               }
571
572               if (theEndChar == '\''
573                        && (str.charAt(end -1) != '\\'
574                            || ! inEscape))
575               {
576                  if (! inValue)
577                  {
578                     inValue = true;
579                     quote = '\'';
580                  }
581                  else if (quote == '\'')
582                  {
583                     inValue = false;
584                  }
585               }
586               else if (theEndChar == '"'
587                        && (str.charAt(end -1) != '\\'
588                            || ! inEscape))
589               {
590                  if (! inValue)
591                  {
592                     inValue = true;
593                     quote = '"';
594                  }
595                  else if (quote == '"')
596                  {
597                     inValue = false;
598                  }
599               }
600               else if (! inValue
601                        && theEndChar == '{')
602               {
603                  depth++;
604               }
605               else if (! inValue
606                        && theEndChar == '}')
607               {
608                  depth--;
609               }
610
611               end++;
612            }
613
614            JsObjMap value = new JsObjMap(str.substring(index, end + 1));
615            put(key, value);
616            key = null;
617            index = end + 1;
618         }
619         else if (key != null)
620         {
621            if (theChar == '"')
622            {
623               boolean inEscape = false;
624               int valueStartIndex = index;
625               StringBuilder valueBuffer = new StringBuilder();
626               // Continue until we find the next unescaped quote.
627               while ((index++) < str.length() - 1
628                      && ((theChar = str.charAt(index)) != '"') || inEscape)
629               {
630                  if (theChar == '\\')
631                  {
632                     inEscape = !inEscape;
633                  }
634                  else if (inEscape)
635                  {
636                     inEscape = false;
637                  }
638
639//                  if (! inEscape)
640                  {
641                     valueBuffer.append(theChar);
642                  }
643               }
644
645               if (index == str.length() - 1)
646               {
647                  throw new RuntimeException("Problem parsing value @ position " + valueStartIndex + " in JSON string!");
648               }
649
650               index++; // consume the trailing quote
651               put(key, JSONUtil.unescapeString(valueBuffer.toString()));
652//               put(key, valueBuffer.toString());
653            }
654            else
655            {
656               Matcher m = UNQUOTED_VALUE_PATTERN.matcher(str);
657               if (m.find(index))
658               {
659                  String stringValue = JSONUtil.unescapeString(m.group(1));
660                  index = m.end() - 1;
661
662                  put(key, JSONUtil.convertStringValueToObject(stringValue));
663               }
664            }
665
666            key = null;
667         }
668         else
669         {
670            // Parse the key name
671
672            // Is it in quotes?
673            if (theChar == '"')
674            {
675               boolean inEscape = false;
676               int valueStartIndex = index;
677               StringBuilder valueBuffer = new StringBuilder();
678               // Continue until we find the next unescaped quote.
679               while ((index++) < str.length() - 1
680                     && ((theChar = str.charAt(index)) != '"') || inEscape)
681               {
682                  if (theChar == '\\')
683                  {
684                     inEscape = !inEscape;
685                  }
686                  else if (inEscape)
687                  {
688                     inEscape = false;
689                  }
690
691//                  if (! inEscape)
692                  {
693                     valueBuffer.append(theChar);
694                  }
695               }
696
697               if (index == str.length() - 1)
698               {
699                  throw new RuntimeException("Problem parsing value @ position " + valueStartIndex + " in JSON string!");
700               }
701
702               index++; // consume the trailing quote
703               key = JSONUtil.unescapeString(valueBuffer.toString());
704
705               while (index < str.length() - 1
706                     && str.charAt(index++) != ':')
707               {
708               }
709
710               if (str.charAt(index - 1) != ':')
711               {
712                  throw new RuntimeException("Problem with format of hash string: " + StringUtil.singleQuote(inString) + "!");
713               }
714            }
715            else
716            {
717               Matcher m = KEY_PATTERN.matcher(str);
718               if (m.find(index))
719               {
720                  key = JSONUtil.unescapeString(StringUtil.unquote(m.group(1)));
721//               key = StringUtil.unquote(m.group(1));
722                  index = m.end();
723               }
724               else
725               {
726                  throw new RuntimeException("Problem with format of hash string: " + StringUtil.singleQuote(inString) + "!");
727               }
728            }
729         }
730      }
731   }
732}
733