001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.marshall.json5;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022
023import org.apache.juneau.commons.lang.*;
024import org.apache.juneau.commons.utils.*;
025import org.apache.juneau.marshall.collections.*;
026import org.apache.juneau.marshall.json.*;
027import org.apache.juneau.marshall.parser.*;
028import org.apache.juneau.marshall.stream.*;
029
030/**
031 * Session object that lives for the duration of a single use of {@link Json5Parser}.
032 *
033 * <p>
034 * Extends {@link JsonParserSession} to support JSON5 syntax including unquoted attribute names,
035 * single-quoted strings, string concatenation, JavaScript comments, and relaxed number formats.
036 *
037 * <h5 class='section'>Notes:</h5><ul>
038 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
039 * </ul>
040 *
041 * <h5 class='section'>See Also:</h5><ul>
042 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/JsonSupport">JSON</a>
043 * </ul>
044 */
045@SuppressWarnings({
046   "java:S110", // Inheritance depth is intentional to reuse parser-session behavior layers
047   "java:S125", // State-machine comments are documentation, not commented-out code
048   "java:S135", // Multiple break statements necessary for state machine error handling
049   "java:S2677", // r.read() calls that consume an already-peeked character ignore the result
050   "java:S3776", // Cognitive complexity acceptable for parser state machine
051   "resource" // ParserReader is managed by caller
052})
053public class Json5ParserSession extends JsonParserSession {
054
055   private static final AsciiSet VALID_BARE_CHARS = AsciiSet.create().range('A', 'Z').range('a', 'z').range('0', '9').chars("$_-.").build();
056
057   /**
058    * Builder class.
059    */
060   public static class Builder extends JsonParserSession.Builder<Builder> {
061
062      /**
063       * Constructor
064       *
065       * @param ctx The context creating this session.
066       */
067      protected Builder(Json5Parser ctx) {
068         super(ctx);
069      }
070
071      @Override
072      public Json5ParserSession build() {
073         return new Json5ParserSession(this);
074      }
075   }
076
077   /**
078    * Creates a new builder for this object.
079    *
080    * @param ctx The context creating this session.
081    * @return A new builder.
082    */
083   public static Builder create(Json5Parser ctx) {
084      return new Builder(ctx);
085   }
086
087   /**
088    * Constructor.
089    *
090    * @param builder The builder for this object.
091    */
092   protected Json5ParserSession(Builder builder) {
093      super(builder);
094   }
095
096   /**
097    * Opens a low-level pull-parser cursor over a JSON5 document, bound to this live session.
098    *
099    * <p>
100    * Same honored/ignored builder properties as {@link JsonParserSession#readTokens(Object)};
101    * the JSON5 dialect relaxations (single-quoted strings, bare identifiers, trailing commas,
102    * missing values) are applied unconditionally as part of the format.
103    *
104    * @param input The input.  Accepts {@link Reader}, {@link CharSequence}, {@link InputStream},
105    *    or <code><jk>byte</jk>[]</code>.
106    * @return A new {@link Json5TokenReader}.
107    * @throws IOException If a problem occurred opening the underlying input.
108    */
109   @SuppressWarnings({
110      "java:S2095" // ParserPipe lifecycle is transferred to the returned Json5TokenReader, which closes it via its own close(); the caller owns the cursor via try-with-resources.
111   })
112   @Override /* JsonParserSession */
113   public TokenReader readTokens(Object input) throws IOException {
114      var pipe = new ParserPipe(
115         input,
116         isDebug(),
117         false /* strict */,
118         isAutoCloseStreams(),
119         isUnbuffered(),
120         getStreamCharset());
121      return new Json5TokenReader(pipe, new JsonTokenReader.Settings(isTrimStrings()), this);
122   }
123
124   @Override
125   protected String readFieldName(ParserReader r) throws IOException, ParseException {
126      int c = r.peek();
127      if (c == '\'' || c == '"')
128         return readString(r);
129      if (! VALID_BARE_CHARS.contains(c))
130         throw new ParseException(this, "Could not find the start of the field name.");
131      r.mark();
132      while (c != -1) {
133         c = r.read();
134         if (! VALID_BARE_CHARS.contains(c)) {
135            r.unread();
136            var s = r.getMarked().intern();
137            return eq(s, "null") ? null : s;
138         }
139      }
140      throw new ParseException(this, "Could not find the end of the field name.");
141   }
142
143   @Override
144   protected String readString(ParserReader r) throws IOException, ParseException {
145      r.mark();
146      int qc = r.read();
147      final boolean isQuoted = (qc == '\'' || qc == '"');
148      String s = null;
149      boolean isInEscape = false;
150      int c = 0;
151      while (c != -1) {
152         c = r.read();
153         if (isInEscape) {
154            // @formatter:off
155            switch (c) {
156               case 'n': r.replace('\n'); break;
157               case 'r': r.replace('\r'); break;
158               case 't': r.replace('\t'); break;
159               case 'f': r.replace('\f'); break;
160               case 'b': r.replace('\b'); break;
161               case '\\': r.replace('\\'); break;
162               case '/': r.replace('/'); break;
163               case '\'': r.replace('\''); break;
164               case '"': r.replace('"'); break;
165               case 'u': {
166                  String n = r.read(4);
167                  try {
168                     r.replace(Integer.parseInt(n, 16), 6);
169                  } catch (@SuppressWarnings("unused") NumberFormatException e) {
170                     throw new ParseException(this, "Invalid Unicode escape sequence in string.");
171                  }
172                  break;
173               }
174               case '\n':
175                  r.delete();
176                  break;
177               case '\r':
178                  r.delete();
179                  if (r.peek() == '\n') {
180                     r.read();
181                     r.delete();
182                  }
183                  break;
184               default:
185                  throw new ParseException(this, "Invalid escape sequence in string.");
186            }
187            // @formatter:on
188            isInEscape = false;
189         } else {
190            if (c == '\\') {
191               isInEscape = true;
192               r.delete();
193            } else if (isQuoted) {
194               if (c == qc) {
195                  s = r.getMarked(1, -1);
196                  break;
197               }
198            } else {
199               if (c == ',' || c == '}' || c == ']' || isWhitespace(c) || c == -1) {
200                  s = r.getMarked(0, c == -1 ? 0 : -1);
201                  if (c != -1)
202                     r.unread();
203                  break;
204               }
205            }
206         }
207      }
208      if (s == null)
209         throw new ParseException(this, "Could not find expected end character '%s'.", (char)qc);
210
211      skipCommentsAndSpace(r);
212      if (r.peek() == '+') {
213         @SuppressWarnings({
214            "unused" // Intentionally unused; variable/parameter is required by the interface contract
215         })
216         int ignored = r.read();
217         skipCommentsAndSpace(r);
218         s += readString(r);
219      }
220      return trim(s);
221   }
222
223   @Override
224   protected boolean isNumberStartChar(int c) {
225      return super.isNumberStartChar(c) || c == '+' || c == 'I' || c == 'N';
226   }
227
228   @Override
229   protected Number readNumber(ParserReader r, Class<? extends Number> type) throws IOException, ParseException {
230      int c = r.peek();
231      if (c == '\'' || c == '"')
232         return parseJson5Number(readString(r), type);
233      return parseJson5Number(readJson5NumberLexeme(r), type);
234   }
235
236   @Override
237   protected Number readNumber(ParserReader r, String s, Class<? extends Number> type) throws ParseException {
238      return parseJson5Number(s, type);
239   }
240
241   /**
242    * Collects a JSON5 number lexeme: optional sign, then {@code Infinity}/{@code NaN}, {@code 0x}
243    * hex, or a decimal (including leading/trailing {@code .}).
244    *
245    * @param r The parser reader, positioned at the first number character.
246    * @return The number lexeme.
247    * @throws IOException If a problem occurred reading from the underlying stream.
248    */
249   static String readJson5NumberLexeme(ParserReader r) throws IOException {
250      r.mark();
251      var c = r.peek();
252      if (c == '+' || c == '-') {
253         r.read();
254         c = r.peek();
255      }
256      if (c == 'I' || c == 'N') {
257         while (true) {
258            c = r.read();
259            if (c == -1 || ! Character.isLetter(c)) {
260               if (c != -1)
261                  r.unread();
262               break;
263            }
264         }
265         return r.getMarked();
266      }
267      while (true) {
268         c = r.read();
269         if (c == -1 || ! StringUtils.isNumberChar((char)c)) {
270            if (c != -1)
271               r.unread();
272            break;
273         }
274      }
275      return r.getMarked();
276   }
277
278   /**
279    * Parses a JSON5 number lexeme (hex, signed {@code Infinity}/{@code NaN}, leading {@code +},
280    * leading/trailing decimal point).
281    *
282    * @param s The lexeme. Must not be <jk>null</jk>.
283    * @param type The target number type, or <jk>null</jk> / {@link Number} for auto-detect.
284    * @return The parsed number.
285    * @throws ParseException If the lexeme is not a JSON5 number.
286    */
287   static Number parseJson5Number(String s, Class<? extends Number> type) throws ParseException {
288      if (s == null)
289         throw new ParseException("Invalid JSON5 number: '%s'", s);
290      // Empty quoted strings coerce to 0 via StringUtils.parseNumber (existing Json5 bean databind).
291      if (! s.isEmpty()) {
292         var signed = s.charAt(0) == '+' ? s.substring(1) : s;
293         if (eq(signed, "Infinity"))
294            return Double.POSITIVE_INFINITY;
295         if (eq(signed, "-Infinity"))
296            return Double.NEGATIVE_INFINITY;
297         if (eqa(signed, "NaN", "-NaN"))
298            return Double.NaN;
299      }
300      try {
301         return StringUtils.parseNumber(s, type);
302      } catch (NumberFormatException e) {
303         throw new ParseException(e);
304      }
305   }
306
307   @Override
308   protected void skipCommentsAndSpace(ParserReader r) throws IOException, ParseException {
309      int c = 0;
310      while ((c = r.read()) != -1) {
311         if (! isWhitespace(c)) {
312            if (c == '/') {
313               skipComments(r);
314            } else {
315               r.unread();
316               return;
317            }
318         }
319      }
320   }
321
322   @Override
323   protected boolean isCommentOrWhitespace(int cp) {
324      if (cp == '/')
325         return true;
326      return Character.isWhitespace(cp);
327   }
328
329   @Override
330   protected boolean isWhitespace(int cp) {
331      return Character.isWhitespace(cp);
332   }
333
334   @Override
335   protected void onEmptyInput() {
336      // JSON5 allows empty input, returning null.
337   }
338
339   @Override
340   protected void onMissingValue() {
341      // JSON5 allows missing values, returning null.
342   }
343
344   @Override
345   protected boolean allowsTrailingComma() {
346      return true;
347   }
348
349   @Override
350   protected boolean canCoerceNonStringToString() {
351      return true;
352   }
353
354   @Override /* Overridden from JsonParserSession */
355   protected MarshalledMap newGenericMap() {
356      return new Json5Map(this);
357   }
358
359   @Override /* Overridden from JsonParserSession */
360   protected MarshalledList newGenericList() {
361      return new Json5List(this);
362   }
363}