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}