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.hjson; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.io.*; 023import java.util.Locale; 024 025/** 026 * Tokenizer for Hjson format. 027 * 028 * <p> 029 * Handles quoted strings, quoteless strings, multiline strings, comments, numbers, booleans, null, 030 * and structural tokens. 031 * 032 * <h5 class='section'>See Also:</h5><ul> 033 * <li class='link'><a class="doclink" href="https://hjson.github.io/syntax.html">Hjson Specification</a> 034 * </ul> 035 */ 036@SuppressWarnings({ 037 "java:S135", // Multiple break/continue necessary for tokenizer state machine loops 038 "java:S3776", // Cognitive complexity acceptable for Hjson grammar 039 "resource" // Reader field wraps the caller's reader (BufferedReader/PushbackReader hold only in-memory buffers); the underlying reader is caller-owned. 040}) 041public class HjsonTokenizer { 042 043 /** Token types produced by the tokenizer. */ 044 public enum TokenType { 045 /** Double or single-quoted string. */ 046 STRING, 047 /** Quoteless string (to end of line). */ 048 QUOTELESS, 049 /** Multiline string ('''...'''). */ 050 MULTILINE, 051 /** Number. */ 052 NUMBER, 053 /** Boolean true. */ 054 TRUE, 055 /** Boolean false. */ 056 FALSE, 057 /** Null value. */ 058 NULL, 059 /** Left brace {. */ 060 LBRACE, 061 /** Right brace }. */ 062 RBRACE, 063 /** Left bracket [. */ 064 LBRACKET, 065 /** Right bracket ]. */ 066 RBRACKET, 067 /** Colon :. */ 068 COLON, 069 /** Comma ,. */ 070 COMMA, 071 /** Significant newline (member separator). */ 072 NEWLINE, 073 /** End of input. */ 074 EOF 075 } 076 077 /** 078 * A token with optional string or number value. 079 * 080 * @param type Token type. 081 * @param stringValue String value for STRING, QUOTELESS, MULTILINE tokens; <jk>null</jk> otherwise. 082 * @param numberValue Number value for NUMBER tokens; <jk>null</jk> otherwise. 083 */ 084 public record Token(TokenType type, String stringValue, Number numberValue) { 085 /** 086 * Creates a structural or literal token with no value. 087 * 088 * @param type Token type. 089 * @return A new token. 090 */ 091 public static Token of(TokenType type) { 092 return new Token(type, null, null); 093 } 094 /** 095 * Creates a quoted-string token. 096 * 097 * @param value The string value. 098 * @return A new token. 099 */ 100 public static Token string(String value) { 101 return new Token(TokenType.STRING, value, null); 102 } 103 /** 104 * Creates a quoteless-string token. 105 * 106 * @param value The string value. 107 * @return A new token. 108 */ 109 public static Token quoteless(String value) { 110 return new Token(TokenType.QUOTELESS, value, null); 111 } 112 /** 113 * Creates a multiline-string token. 114 * 115 * @param value The string value. 116 * @return A new token. 117 */ 118 public static Token multiline(String value) { 119 return new Token(TokenType.MULTILINE, value, null); 120 } 121 /** 122 * Creates a number token. 123 * 124 * @param value The number value. 125 * @return A new token. 126 */ 127 public static Token number(Number value) { 128 return new Token(TokenType.NUMBER, null, value); 129 } 130 } 131 132 private final PushbackReader reader; 133 private Token peeked; 134 private int line = 1; 135 private int column = 1; 136 137 /** 138 * Constructor. 139 * 140 * @param reader The reader to tokenize. 141 * <br>Must not be <jk>null</jk>. 142 */ 143 public HjsonTokenizer(Reader reader) { 144 var in = reader instanceof BufferedReader reader2 ? reader2 : new BufferedReader(reader); 145 this.reader = new PushbackReader(in, 4); 146 } 147 148 /** 149 * Returns the current line number (1-based). 150 * 151 * @return The line number. 152 */ 153 public int getLine() { 154 return line; 155 } 156 157 /** 158 * Returns the current column number (1-based). 159 * 160 * @return The column number. 161 */ 162 public int getColumn() { 163 return column; 164 } 165 166 /** 167 * Peeks at the next token without consuming it. 168 * 169 * @return The next token. 170 * @throws IOException If a read error occurs. 171 */ 172 public Token peek() throws IOException { 173 if (peeked == null) 174 peeked = readToken(); 175 return peeked; 176 } 177 178 /** 179 * Reads and consumes the next token. 180 * 181 * @return The next token. 182 * @throws IOException If a read error occurs. 183 */ 184 public Token read() throws IOException { 185 if (peeked != null) { 186 var t = peeked; 187 peeked = null; 188 return t; 189 } 190 return readToken(); 191 } 192 193 private int readChar() throws IOException { 194 var c = reader.read(); 195 if (c == '\n' || (c == '\r' && peekChar() != '\n')) { 196 line++; 197 column = 1; 198 } else if (c >= 0) { 199 column++; 200 } 201 return c; 202 } 203 204 private int peekChar() throws IOException { 205 var c = reader.read(); 206 if (c >= 0) 207 reader.unread(c); 208 return c; 209 } 210 211 private void unread(int c) throws IOException { 212 if (c >= 0) { 213 reader.unread(c); 214 column--; 215 if (c == '\n') 216 line--; 217 } 218 } 219 220 /** 221 * Skips whitespace and comments. 222 * 223 * @throws IOException If a read error occurs. 224 */ 225 public void skipWhitespaceAndComments() throws IOException { 226 while (true) { 227 var c = readChar(); 228 if (c < 0) 229 return; 230 if (c == ' ' || c == '\t' || c == '\r' || c == '\n') 231 continue; 232 if (c == '#') { 233 skipLineComment(); 234 continue; 235 } 236 if (c == '/' && peekChar() == '/') { 237 readChar(); 238 skipLineComment(); 239 continue; 240 } 241 if (c == '/' && peekChar() == '*') { 242 readChar(); 243 skipBlockComment(); 244 continue; 245 } 246 unread(c); 247 return; 248 } 249 } 250 251 private void skipLineComment() throws IOException { 252 var c = readChar(); 253 while (c >= 0 && c != '\n' && c != '\r') 254 c = readChar(); 255 if (c == '\r' && peekChar() == '\n') 256 readChar(); 257 } 258 259 private void skipBlockComment() throws IOException { 260 var c = readChar(); 261 while (c >= 0) { 262 if (c == '*' && peekChar() == '/') { 263 readChar(); 264 return; 265 } 266 c = readChar(); 267 } 268 } 269 270 private Token readToken() throws IOException { 271 skipWhitespaceAndComments(); 272 var c = readChar(); 273 if (c < 0) 274 return Token.of(TokenType.EOF); 275 276 if (c == '{') return Token.of(TokenType.LBRACE); 277 if (c == '}') return Token.of(TokenType.RBRACE); 278 if (c == '[') return Token.of(TokenType.LBRACKET); 279 if (c == ']') return Token.of(TokenType.RBRACKET); 280 if (c == ':') return Token.of(TokenType.COLON); 281 if (c == ',') return Token.of(TokenType.COMMA); 282 if (c == '\n' || c == '\r') { 283 if (c == '\r' && peekChar() == '\n') 284 readChar(); 285 return Token.of(TokenType.NEWLINE); 286 } 287 288 if (c == '\'' && peekChar() == '\'' && peekChar(2) == '\'') { 289 readChar(); 290 readChar(); 291 var indentBaseline = getColumn() - 3; 292 return Token.multiline(readMultilineString(indentBaseline)); 293 } 294 if (c == '"' || c == '\'') { 295 unread(c); 296 return Token.string(readQuotedString()); 297 } 298 299 unread(c); 300 301 var sb = new StringBuilder(); 302 var next = readChar(); 303 while (next >= 0 && next != '\n' && next != '\r' && next != ',' && next != ':' && next != '{' && next != '}' && next != '[' && next != ']') { 304 sb.append((char) next); 305 next = readChar(); 306 } 307 if (next >= 0) 308 unread(next); 309 310 var raw = sb.toString().trim(); 311 312 if (raw.isEmpty()) 313 return readToken(); 314 315 if (eq(raw, "true")) return Token.of(TokenType.TRUE); 316 if (eq(raw, "false")) return Token.of(TokenType.FALSE); 317 if (eq(raw, "null")) return Token.of(TokenType.NULL); 318 319 // Whole-string JSON-number match (leading-zero-restricted grammar); span equals the full token on a match. 320 if (matchNumberPrefix(raw, false) == raw.length()) { 321 var numStr = raw; 322 try { 323 if (numStr.contains(".") || numStr.toLowerCase(Locale.ROOT).contains("e")) 324 return Token.number(Double.parseDouble(numStr)); 325 return parseIntegerOrLongToken(numStr); 326 } catch (@SuppressWarnings("unused") NumberFormatException e) { 327 // Number too large for Long or other parse failure; fall through to quoteless 328 } 329 } 330 331 return Token.quoteless(raw); 332 } 333 334 private static Token parseIntegerOrLongToken(String numStr) { 335 try { 336 return Token.number(Integer.parseInt(numStr)); 337 } catch (@SuppressWarnings("unused") NumberFormatException e) { 338 return Token.number(Long.parseLong(numStr)); 339 } 340 } 341 342 private int peekChar(int offset) throws IOException { 343 var buf = new int[offset]; 344 for (var i = 0; i < offset; i++) 345 buf[i] = readChar(); 346 for (var i = offset - 1; i >= 0; i--) 347 unread(buf[i]); 348 return offset > 0 ? buf[offset - 1] : -1; 349 } 350 351 /** 352 * Reads a quoted string ("..." or '...') with JSON-style escaping. 353 * 354 * @return The string value. 355 * @throws IOException If a read error occurs. 356 */ 357 public String readQuotedString() throws IOException { 358 var quote = readChar(); 359 if (quote != '"' && quote != '\'') 360 return ""; 361 var sb = new StringBuilder(); 362 var c = readChar(); 363 while (c >= 0 && c != quote) { 364 if (c == '\\') { 365 c = readChar(); 366 if (c >= 0) { 367 if (c == 'u') 368 sb.append(readUnicodeEscape()); 369 else 370 sb.append(unescape(c)); 371 } 372 } else { 373 sb.append((char) c); 374 } 375 c = readChar(); 376 } 377 return sb.toString(); 378 } 379 380 private static char unescape(int c) { 381 return switch (c) { 382 case '"' -> '"'; 383 case '\'' -> '\''; 384 case '\\' -> '\\'; 385 case '/' -> '/'; 386 case 'b' -> '\b'; 387 case 'f' -> '\f'; 388 case 'n' -> '\n'; 389 case 'r' -> '\r'; 390 case 't' -> '\t'; 391 default -> (char) c; 392 }; 393 } 394 395 private char readUnicodeEscape() throws IOException { 396 var hex = new StringBuilder(4); 397 for (var i = 0; i < 4; i++) { 398 var c = readChar(); 399 if (c < 0 || !isHexDigit(c)) 400 throw ioex("Invalid \\u escape sequence at line %s, column %s", line, column); 401 hex.append((char) c); 402 } 403 return (char) Integer.parseInt(hex.toString(), 16); 404 } 405 406 private static boolean isHexDigit(int c) { 407 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); 408 } 409 410 /** 411 * Reads a quoteless string (to end of line, trimmed). 412 * 413 * @return The string value. 414 * @throws IOException If a read error occurs. 415 */ 416 public String readQuotelessString() throws IOException { 417 var sb = new StringBuilder(); 418 var c = readChar(); 419 while (c >= 0 && c != '\n' && c != '\r') { 420 sb.append((char) c); 421 c = readChar(); 422 } 423 if (c >= 0) 424 unread(c); 425 return sb.toString().trim(); 426 } 427 428 /** 429 * Reads a multiline string ('''...'''). 430 * Caller must have already consumed the opening '''. 431 * 432 * @param indentBaseline The 1-based column of the first quote in '''; used to strip 433 * leading indentation from subsequent lines per Hjson spec. 434 * @return The string value (with \n line separators, last newline ignored per spec). 435 * @throws IOException If a read error occurs. 436 */ 437 public String readMultilineString(int indentBaseline) throws IOException { 438 var sb = new StringBuilder(); 439 var quoteCount = 0; 440 var c = readChar(); 441 while (c >= 0) { 442 if (c == '\'') { 443 quoteCount++; 444 if (quoteCount == 3) { 445 var s = sb.toString(); 446 if (s.endsWith("\n")) 447 s = s.substring(0, s.length() - 1); 448 s = s.replace("\r\n", "\n").replace("\r", "\n"); 449 return stripMultilineIndent(s, indentBaseline); 450 } 451 } else { 452 if (quoteCount == 1) 453 sb.append("'"); 454 else if (quoteCount == 2) 455 sb.append("''"); 456 quoteCount = 0; 457 if (c == '\r' && peekChar() == '\n') { 458 readChar(); 459 sb.append('\n'); 460 } else if (c != '\r') { 461 sb.append((char) c); 462 } 463 } 464 c = readChar(); 465 } 466 return stripMultilineIndent(sb.toString(), indentBaseline); 467 } 468 469 private static String stripMultilineIndent(String s, int indentBaseline) { 470 if (indentBaseline <= 0 || s.isEmpty()) 471 return s; 472 var stripCount = indentBaseline - 1; 473 if (stripCount <= 0) 474 return s; 475 var lines = s.split("\n", -1); 476 var result = new StringBuilder(); 477 for (var i = 0; i < lines.length; i++) { 478 if (i > 0) 479 result.append('\n'); 480 var line = lines[i]; 481 var j = 0; 482 while (j < stripCount && j < line.length() && (line.charAt(j) == ' ' || line.charAt(j) == '\t')) 483 j++; 484 result.append(line.substring(j)); 485 } 486 return result.toString(); 487 } 488}