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.marshall.json.*;
024import org.apache.juneau.marshall.parser.*;
025import org.apache.juneau.marshall.stream.*;
026
027/**
028 * Reference implementation of the public {@link TokenReader} surface for the JSON5 format.
029 *
030 * <p>
031 * Subclasses {@link JsonTokenReader} to add the JSON5 dialect's relaxations:
032 * <ul>
033 *    <li>Strings can be single-quoted, double-quoted, or unquoted bare identifiers.
034 *    <li>Field names can be single-quoted, double-quoted, or unquoted bare identifiers.
035 *    <li>Trailing commas before <c>]</c> / <c>}</c> are allowed.
036 *    <li>Missing values in arrays / objects emit {@link TokenType#VALUE_NULL}.
037 *          <li>JavaScript comments (<c>//</c> line, <c>/* &#42;/</c> block) are tolerated &mdash; already
038 *       accepted by the parent class.
039 *    <li>JSON5 numbers: leading <c>+</c>, leading/trailing decimal points, hexadecimal, {@code Infinity}/{@code NaN}.
040 *    <li>String line-continuation (backslash + line terminator is omitted from the value).
041 * </ul>
042 *
043 * <p>
044 * The cursor is a true O(1)-memory streaming cursor ({@link #isStreaming()} == <jk>true</jk>).
045 *
046 * <h5 class='section'>Notes:</h5><ul>
047 *    <li class='warn'>This class is not thread safe.
048 *    <li>The cursor is purely structural; object swaps and {@code @Schema} annotations have no
049 *       effect.  Use the POJO databind path on {@link Json5Parser} for those.
050 * </ul>
051 */
052@SuppressWarnings({
053   "resource" // The cursor's underlying ParserPipe is owned by the caller via try-with-resources on the cursor itself; Eclipse JDT flags the inner pipe as unclosed but that's by design.
054})
055public class Json5TokenReader extends JsonTokenReader {
056
057   /**
058    * Constructor with default settings.
059    *
060    * @param pipe The parser input pipe to read from.  Must not be <jk>null</jk>.
061    * @throws IOException If a problem occurred opening the underlying reader.
062    */
063   public Json5TokenReader(ParserPipe pipe) throws IOException {
064      super(pipe);
065   }
066
067   /**
068    * Constructor used by {@link Json5ParserSession#readTokens(Object)} to plumb the calling
069    * session through so that {@link #read(Class)} can delegate to the JSON5 databind path.
070    *
071    * @param pipe The parser input pipe to read from.  Must not be <jk>null</jk>.
072    * @param settings The cursor-level settings.  Must not be <jk>null</jk>.
073    * @param session The {@link Json5ParserSession} for {@link #read(Class)} delegation, or
074    *    <jk>null</jk> to disable {@code read}.
075    * @throws IOException If a problem occurred opening the underlying reader.
076    */
077   public Json5TokenReader(ParserPipe pipe, Settings settings, Json5ParserSession session) throws IOException {
078      super(pipe, settings, session);
079   }
080
081   // =================================================================================
082   // JSON5 dialect relaxations
083   // =================================================================================
084
085   @Override /* JsonTokenReader */
086   protected boolean allowsTrailingComma() {
087      return true;
088   }
089
090   @Override /* JsonTokenReader */
091   protected void readNumberValue() throws IOException, ParseException {
092      var s = Json5ParserSession.readJson5NumberLexeme(r);
093      if (s.isEmpty())
094         throw parseException("Invalid JSON5 number: '%s'", s);
095      currentNumberLexeme = s;
096      currentToken = TokenType.VALUE_NUMBER;
097   }
098
099   @Override /* TokenReader */
100   public Number getNumber() throws ParseException {
101      if (currentToken != TokenType.VALUE_NUMBER)
102         throw new IllegalStateException("Current token is not VALUE_NUMBER (was " + currentToken + ")");
103      return Json5ParserSession.parseJson5Number(currentNumberLexeme, null);
104   }
105
106   @Override /* JsonTokenReader */
107   protected void readValueStart(int c) throws IOException, ParseException {
108      // Missing-value: a leading comma or container-end at value position emits VALUE_NULL and
109      // re-injects the structural char so the next state transition picks it up.
110      if (c == ',' || c == ']' || c == '}') {
111         r.unread();
112         currentToken = TokenType.VALUE_NULL;
113         afterValue();
114         return;
115      }
116      // Single-quoted strings (JSON5).
117      if (c == '\'') {
118         r.unread();
119         currentString = maybeTrimString(readString());
120         currentToken = TokenType.VALUE_STRING;
121         afterValue();
122         return;
123      }
124      // JSON5 numbers that JSON itself would not start: leading '+', leading '.', Infinity, NaN.
125      if (c == '+' || c == '.') {
126         r.unread();
127         readNumberValue();
128         afterValue();
129         return;
130      }
131      // Bare identifier as a string value (e.g. `[foo, bar]` -> ["foo", "bar"]).
132      if (isBareStart(c)) {
133         r.unread();
134         // Bare identifiers might also be the keyword literals true/false/null — distinguish.
135         var word = readBareWord();
136         switch (word) {
137            case "true":  currentBoolean = true;  currentToken = TokenType.VALUE_BOOLEAN; afterValue(); return;
138            case "false": currentBoolean = false; currentToken = TokenType.VALUE_BOOLEAN; afterValue(); return;
139            case "null":  currentToken = TokenType.VALUE_NULL; afterValue(); return;
140            case "Infinity", "NaN":
141               currentNumberLexeme = word;
142               currentToken = TokenType.VALUE_NUMBER;
143               afterValue();
144               return;
145            default:
146               currentString = maybeTrimString(word);
147               currentToken = TokenType.VALUE_STRING;
148               afterValue();
149               return;
150         }
151      }
152      super.readValueStart(c);
153   }
154
155   @Override /* JsonTokenReader */
156   protected void readFieldNameOrEndObject(int c) throws IOException, ParseException {
157      if (c == '}') {
158         popContainer();
159         currentToken = TokenType.END_OBJECT;
160         afterValue();
161         return;
162      }
163      // Single-quoted field name.
164      if (c == '\'') {
165         r.unread();
166         currentFieldName = maybeTrimString(readString());
167         currentToken = TokenType.FIELD_NAME;
168         state = S02_expectColon;
169         return;
170      }
171      // Bare identifier field name.
172      if (isBareStart(c)) {
173         r.unread();
174         currentFieldName = maybeTrimString(readBareWord());
175         currentToken = TokenType.FIELD_NAME;
176         state = S02_expectColon;
177         return;
178      }
179      super.readFieldNameOrEndObject(c);
180   }
181
182   @Override /* JsonTokenReader */
183   protected void readCommaOrEndArray(int c) throws IOException, ParseException {
184      if (c == ']') {
185         popContainer();
186         currentToken = TokenType.END_ARRAY;
187         afterValue();
188         return;
189      }
190      if (c != ',')
191         throw parseException("Expected , or ] but got '%s'", (char) c);
192      var c2 = readSkipWsAndComments();
193      if (c2 == -1)
194         throw parseException("Unexpected end of input after ','");
195      // JSON5 trailing comma: `[1,]` is legal — `]` here closes the array.
196      if (c2 == ']') {
197         popContainer();
198         currentToken = TokenType.END_ARRAY;
199         afterValue();
200         return;
201      }
202      state = S00_expectValue;
203      readValueStart(c2);
204   }
205
206   @Override /* JsonTokenReader */
207   protected void readCommaOrEndObject(int c) throws IOException, ParseException {
208      if (c == '}') {
209         popContainer();
210         currentToken = TokenType.END_OBJECT;
211         afterValue();
212         return;
213      }
214      if (c != ',')
215         throw parseException("Expected , or } but got '%s'", (char) c);
216      var c2 = readSkipWsAndComments();
217      if (c2 == -1)
218         throw parseException("Unexpected end of input after ','");
219      // JSON5 trailing comma: `{a:1,}` is legal — `}` here closes the object.
220      if (c2 == '}') {
221         popContainer();
222         currentToken = TokenType.END_OBJECT;
223         afterValue();
224         return;
225      }
226      state = S01_expectFieldName;
227      readFieldNameOrEndObject(c2);
228   }
229
230   @Override /* JsonTokenReader */
231   @SuppressWarnings({
232      "java:S2677", // r.read() consumes the already-peeked '\n' of a CRLF line continuation; cannot hit EOF.
233      "java:S3776" // Cognitive complexity acceptable for JSON5 quoted-string escape-handling state machine.
234   })
235   protected String readString() throws IOException, ParseException {
236      // JSON5 strings may be single- or double-quoted, and a backslash before a line
237      // terminator is a line-continuation (the terminator is omitted from the value).
238      r.mark();
239      var qc = r.read();
240      if (qc != '"' && qc != '\'')
241         throw parseException("Did not find quote character marking beginning of string");
242      String s = null;
243      var inEscape = false;
244      var c = 0;
245      while (c != -1) {
246         c = r.read();
247         if (inEscape) {
248            switch (c) {
249               case 'n': r.replace('\n'); break;
250               case 'r': r.replace('\r'); break;
251               case 't': r.replace('\t'); break;
252               case 'f': r.replace('\f'); break;
253               case 'b': r.replace('\b'); break;
254               case '\\': r.replace('\\'); break;
255               case '/': r.replace('/'); break;
256               case '\'': r.replace('\''); break;
257               case '"': r.replace('"'); break;
258               case '\n':
259                  r.delete();
260                  break;
261               case '\r':
262                  r.delete();
263                  if (r.peek() == '\n') {
264                     r.read();
265                     r.delete();
266                  }
267                  break;
268               case 'u': {
269                  var hex = r.read(4);
270                  try {
271                     r.replace(Integer.parseInt(hex, 16), 6);
272                  } catch (@SuppressWarnings("unused") NumberFormatException e) {
273                     throw parseException("Invalid Unicode escape sequence in string");
274                  }
275                  break;
276               }
277               default:
278                  throw parseException("Invalid escape sequence in string");
279            }
280            inEscape = false;
281         } else {
282            if (c <= 0x1F && c != -1)
283               throw parseException("Unescaped control character encountered: '0x%s'", f("%04X", c));
284            if (c == '\\') {
285               inEscape = true;
286               r.delete();
287            } else if (c == qc) {
288               s = r.getMarked(1, -1);
289               break;
290            }
291         }
292      }
293      if (s == null)
294         throw parseException("Unterminated string");
295      return s;
296   }
297
298   private String readBareWord() throws IOException, ParseException {
299      // The leading char was already validated via isBareStart(); since every bare-start char is
300      // also a valid bare-continuation char, the loop consumes it on the first iteration.
301      r.mark();
302      while (true) {
303         var c = r.read();
304         if (c == -1 || !isBareCont(c)) {
305            if (c != -1) r.unread();
306            return r.getMarked();
307         }
308      }
309   }
310
311   private static boolean isBareStart(int c) {
312      return Character.isLetter(c) || c == '_' || c == '$';
313   }
314
315   private static boolean isBareCont(int c) {
316      return Character.isLetterOrDigit(c) || c == '_' || c == '$';
317   }
318
319   // Local trim helper — the parent's maybeTrim is private and not visible to this subclass.
320   private String maybeTrimString(String s) {
321      if (s == null || !settings.trimStrings())
322         return s;
323      return s.trim();
324   }
325}