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>/* */</c> block) are tolerated — 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}