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.hocon; 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 HOCON (Human-Optimized Config Object Notation) format. 027 * 028 * <p> 029 * Handles unquoted strings, quoted strings, triple-quoted strings, numbers, booleans, null, 030 * structural tokens, substitutions ({@code ${var}} and {@code ${?var}}), and comments. 031 * 032 * <h5 class='section'>See Also:</h5><ul> 033 * <li class='link'><a class="doclink" href="https://github.com/lightbend/config/blob/main/HOCON.md">HOCON 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 HOCON 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 HoconTokenizer { 042 043 /** Characters that cannot appear in unquoted strings per HOCON spec (includes . for path parsing). Note: / is allowed for paths like /usr/local. */ 044 private static final String UNQUOTED_FORBIDDEN = "$\"{}[]:=,+#^\\?!@*&\t\n\r "; 045 046 /** Token types produced by the tokenizer. */ 047 public enum TokenType { 048 /** Unquoted string (key or value). */ 049 UNQUOTED_STRING, 050 /** Double or single-quoted string. */ 051 QUOTED_STRING, 052 /** Triple-quoted string ("""..."""). */ 053 TRIPLE_QUOTED, 054 /** Number. */ 055 NUMBER, 056 /** Boolean true. */ 057 TRUE, 058 /** Boolean false. */ 059 FALSE, 060 /** Null value. */ 061 NULL, 062 /** Left brace {. */ 063 LBRACE, 064 /** Right brace }. */ 065 RBRACE, 066 /** Left bracket [. */ 067 LBRACKET, 068 /** Right bracket ]. */ 069 RBRACKET, 070 /** Colon :. */ 071 COLON, 072 /** Equals =. */ 073 EQUALS, 074 /** Plus-equals +=. */ 075 PLUS_EQUALS, 076 /** Comma ,. */ 077 COMMA, 078 /** Significant newline (member separator). */ 079 NEWLINE, 080 /** Substitution ${var}. */ 081 SUBSTITUTION, 082 /** Optional substitution ${?var}. */ 083 OPT_SUBSTITUTION, 084 /** End of input. */ 085 EOF 086 } 087 088 /** 089 * A token with optional string or number value. 090 * 091 * @param type Token type. 092 * @param stringValue String value for string tokens; <jk>null</jk> otherwise. 093 * @param numberValue Number value for NUMBER tokens; <jk>null</jk> otherwise. 094 */ 095 public record Token(TokenType type, String stringValue, Number numberValue) { 096 /** 097 * Creates a structural or literal token with no value. 098 * 099 * @param type Token type. 100 * @return A new token. 101 */ 102 public static Token of(TokenType type) { 103 return new Token(type, null, null); 104 } 105 /** 106 * Creates a string token. 107 * 108 * @param value The string value. 109 * @return A new token. 110 */ 111 public static Token string(String value) { 112 return new Token(TokenType.UNQUOTED_STRING, value, null); 113 } 114 /** 115 * Creates a quoted-string token. 116 * 117 * @param value The string value. 118 * @return A new token. 119 */ 120 public static Token quoted(String value) { 121 return new Token(TokenType.QUOTED_STRING, value, null); 122 } 123 /** 124 * Creates a triple-quoted string token. 125 * 126 * @param value The string value. 127 * @return A new token. 128 */ 129 public static Token tripleQuoted(String value) { 130 return new Token(TokenType.TRIPLE_QUOTED, value, null); 131 } 132 /** 133 * Creates a number token. 134 * 135 * @param value The number value. 136 * @return A new token. 137 */ 138 public static Token number(Number value) { 139 return new Token(TokenType.NUMBER, null, value); 140 } 141 /** 142 * Creates a substitution token. 143 * 144 * @param path The reference path. 145 * @param optional Whether it is optional (${?var}). 146 * @return A new token. 147 */ 148 public static Token substitution(String path, boolean optional) { 149 return new Token(optional ? TokenType.OPT_SUBSTITUTION : TokenType.SUBSTITUTION, path, null); 150 } 151 } 152 153 private final PushbackReader reader; 154 private Token peeked; 155 private int line = 1; 156 private int column = 1; 157 158 /** 159 * Constructor. 160 * 161 * @param reader The reader to tokenize. 162 * <br>Must not be <jk>null</jk>. 163 */ 164 public HoconTokenizer(Reader reader) { 165 var in = reader instanceof BufferedReader reader2 ? reader2 : new BufferedReader(reader); 166 this.reader = new PushbackReader(in, 8); 167 } 168 169 /** 170 * Returns the current line number (1-based). 171 * 172 * @return The line number. 173 */ 174 public int getLine() { 175 return line; 176 } 177 178 /** 179 * Returns the current column number (1-based). 180 * 181 * @return The column number. 182 */ 183 public int getColumn() { 184 return column; 185 } 186 187 /** 188 * Peeks at the next token without consuming it. 189 * 190 * @return The next token. 191 * @throws IOException If a read error occurs. 192 */ 193 public Token peek() throws IOException { 194 if (peeked == null) 195 peeked = readToken(); 196 return peeked; 197 } 198 199 /** 200 * Peeks at the next token without skipping whitespace first. 201 * Caller must call {@link #skipWhitespaceAndCommentsExceptNewlines()} first when checking for value concatenation. 202 * 203 * @return The next token. 204 * @throws IOException If a read error occurs. 205 */ 206 public Token peekNoSkip() throws IOException { 207 if (peeked == null) 208 peeked = readTokenImmediate(); 209 return peeked; 210 } 211 212 /** 213 * Reads and consumes the next token. 214 * 215 * @return The next token. 216 * @throws IOException If a read error occurs. 217 */ 218 public Token read() throws IOException { 219 if (peeked != null) { 220 var t = peeked; 221 peeked = null; 222 return t; 223 } 224 return readToken(); 225 } 226 227 private int readChar() throws IOException { 228 var c = reader.read(); 229 if (c == '\n' || (c == '\r' && peekChar() != '\n')) { 230 line++; 231 column = 1; 232 } else if (c >= 0) { 233 column++; 234 } 235 return c; 236 } 237 238 private int peekChar() throws IOException { 239 var c = reader.read(); 240 if (c >= 0) 241 reader.unread(c); 242 return c; 243 } 244 245 private void unread(int c) throws IOException { 246 if (c >= 0) { 247 reader.unread(c); 248 column--; 249 if (c == '\n') 250 line--; 251 } 252 } 253 254 /** 255 * Skips whitespace and comments. 256 * 257 * <p> 258 * No-op when a token has already been peeked (i.e. {@link #peek()} or {@link #peekNoSkip()} 259 * cached a token). Once a token is cached, the underlying reader is positioned <em>after</em> 260 * that token, so skipping whitespace here would consume characters that semantically come after 261 * the cached token rather than before it. The leading whitespace before the cached token was 262 * already consumed when the token was produced, so this method has nothing left to do until the 263 * cached token is read. This invariant keeps callers from accidentally reading past a peeked 264 * structural token (such as a closing {@code ]} or {@code }}) and swallowing a separator that 265 * belongs to an enclosing scope. 266 * 267 * @throws IOException If a read error occurs. 268 */ 269 public void skipWhitespaceAndComments() throws IOException { 270 if (peeked != null) 271 return; 272 while (true) { 273 var c = readChar(); 274 if (c < 0) 275 return; 276 if (c == ' ' || c == '\t' || c == '\r' || c == '\n') 277 continue; 278 if (c == '#') { 279 skipLineComment(); 280 continue; 281 } 282 if (c == '/' && peekChar() == '/') { 283 readChar(); 284 skipLineComment(); 285 continue; 286 } 287 unread(c); 288 return; 289 } 290 } 291 292 private void skipLineComment() throws IOException { 293 var c = readChar(); 294 while (c >= 0 && c != '\n' && c != '\r') 295 c = readChar(); 296 if (c == '\r' && peekChar() == '\n') 297 readChar(); 298 } 299 300 /** Skips a line comment but stops before the newline (does not consume it). */ 301 private void skipLineCommentExceptNewline() throws IOException { 302 var c = readChar(); 303 while (c >= 0 && c != '\n' && c != '\r') { 304 c = readChar(); 305 } 306 if (c >= 0) 307 unread(c); 308 } 309 310 /** 311 * Skips whitespace and comments but stops at newlines. 312 * Used when parsing value concatenation so newlines end the value. 313 * 314 * @throws IOException If a read error occurs. 315 */ 316 public void skipWhitespaceAndCommentsExceptNewlines() throws IOException { 317 while (true) { 318 var c = readChar(); 319 if (c < 0) 320 return; 321 if (c == '\n' || c == '\r') { 322 unread(c); 323 return; 324 } 325 if (c == ' ' || c == '\t') 326 continue; 327 if (c == '#') { 328 skipLineCommentExceptNewline(); 329 continue; 330 } 331 if (c == '/' && peekChar() == '/') { 332 readChar(); 333 skipLineCommentExceptNewline(); 334 continue; 335 } 336 unread(c); 337 return; 338 } 339 } 340 341 private Token readToken() throws IOException { 342 skipWhitespaceAndComments(); 343 return readTokenImmediate(); 344 } 345 346 private Token readTokenImmediate() throws IOException { 347 var c = readChar(); 348 if (c < 0) 349 return Token.of(TokenType.EOF); 350 351 if (c == '{') return Token.of(TokenType.LBRACE); 352 if (c == '}') return Token.of(TokenType.RBRACE); 353 if (c == '[') return Token.of(TokenType.LBRACKET); 354 if (c == ']') return Token.of(TokenType.RBRACKET); 355 if (c == ',') return Token.of(TokenType.COMMA); 356 if (c == '\n' || c == '\r') { 357 if (c == '\r' && peekChar() == '\n') 358 readChar(); 359 return Token.of(TokenType.NEWLINE); 360 } 361 362 if (c == ':') { 363 return Token.of(TokenType.COLON); 364 } 365 // An equals sign always produces the EQUALS token regardless of the character that follows it. 366 // The plus-equals operator is recognized by the separate check below that reads the leading 367 // plus first, so an equals immediately followed by a plus is never mistaken for a partial 368 // plus-equals operator. 369 if (c == '=') 370 return Token.of(TokenType.EQUALS); 371 if (c == '+' && peekChar() == '=') { 372 readChar(); 373 return Token.of(TokenType.PLUS_EQUALS); 374 } 375 376 if (c == '"') { 377 if (peekChar() == '"' && peekChar(2) == '"') { 378 readChar(); 379 readChar(); 380 return Token.tripleQuoted(readTripleQuotedString()); 381 } 382 unread(c); 383 return Token.quoted(readQuotedString()); 384 } 385 if (c == '\'') { 386 unread(c); 387 return Token.quoted(readQuotedString()); 388 } 389 390 if (c == '$' && peekChar() == '{') { 391 readChar(); 392 return readSubstitutionToken(); 393 } 394 395 unread(c); 396 return readUnquotedOrNumber(); 397 } 398 399 private Token readSubstitutionToken() throws IOException { 400 var sb = new StringBuilder(); 401 var c = readChar(); 402 var optional = c == '?'; 403 if (optional) 404 c = readChar(); 405 while (c >= 0 && c != '}') { 406 if (c == ' ' || c == '\t' || c == '\n' || c == '\r') 407 break; 408 sb.append((char) c); 409 c = readChar(); 410 } 411 if (c == '}') 412 return Token.substitution(sb.toString().trim(), optional); 413 // Malformed ${...} - treat as string 414 return Token.string("${" + (optional ? "?" : "") + sb); 415 } 416 417 private Token readUnquotedOrNumber() throws IOException { 418 var sb = new StringBuilder(); 419 var c = readChar(); 420 while (c >= 0 && UNQUOTED_FORBIDDEN.indexOf(c) < 0) { 421 sb.append((char) c); 422 c = readChar(); 423 } 424 if (c >= 0) 425 unread(c); 426 var raw = sb.toString().trim(); 427 // Guard against infinite recursion: if we haven't consumed any chars and the next char is 428 // a forbidden one that wasn't handled as a structural / operator token by readTokenImmediate 429 // (e.g. orphan `+`, `$`, or `=` not followed by their expected continuation), we'd loop 430 // forever calling back into readToken(). Surface this as a clean IOException instead so the 431 // parser converts it to a ParseException rather than a StackOverflowError. 432 if (raw.isEmpty()) { 433 if (c >= 0) 434 throw ioex("Unexpected character '%s' at line %s, column %s", (char) c, line, column); 435 return Token.of(TokenType.EOF); 436 } 437 438 if (eq(raw, "true")) return Token.of(TokenType.TRUE); 439 if (eq(raw, "false")) return Token.of(TokenType.FALSE); 440 if (eq(raw, "null")) return Token.of(TokenType.NULL); 441 442 // Whole-string JSON-number match (leading-zero-restricted grammar); span equals the full token on a match. 443 if (matchNumberPrefix(raw, false) == raw.length()) { 444 var numStr = raw; 445 try { 446 if (numStr.contains(".") || numStr.toLowerCase(Locale.ROOT).contains("e")) 447 return Token.number(Double.parseDouble(numStr)); 448 return parseIntegerOrLongToken(numStr); 449 } catch (@SuppressWarnings("unused") NumberFormatException e) { 450 // Number too large for Long or other parse failure; fall through to unquoted 451 } 452 } 453 454 return Token.string(raw); 455 } 456 457 private static Token parseIntegerOrLongToken(String numStr) { 458 try { 459 return Token.number(Integer.parseInt(numStr)); 460 } catch (@SuppressWarnings("unused") NumberFormatException e) { 461 return Token.number(Long.parseLong(numStr)); 462 } 463 } 464 465 private int peekChar(int offset) throws IOException { 466 var buf = new int[offset]; 467 for (var i = 0; i < offset; i++) 468 buf[i] = readChar(); 469 for (var i = offset - 1; i >= 0; i--) 470 unread(buf[i]); 471 return offset > 0 ? buf[offset - 1] : -1; 472 } 473 474 /** 475 * Reads a quoted string ("..." or '...') with JSON-style escaping. 476 * 477 * @return The string value. 478 * @throws IOException If a read error occurs. 479 */ 480 public String readQuotedString() throws IOException { 481 var quote = readChar(); 482 if (quote != '"' && quote != '\'') 483 return ""; 484 var sb = new StringBuilder(); 485 var c = readChar(); 486 while (c >= 0 && c != quote) { 487 if (c == '\\') { 488 c = readChar(); 489 if (c >= 0) { 490 if (c == 'u') 491 sb.append(readUnicodeEscape()); 492 else 493 sb.append(unescape(c)); 494 } 495 } else { 496 sb.append((char) c); 497 } 498 c = readChar(); 499 } 500 return sb.toString(); 501 } 502 503 private static char unescape(int c) { 504 return switch (c) { 505 case '"' -> '"'; 506 case '\'' -> '\''; 507 case '\\' -> '\\'; 508 case '/' -> '/'; 509 case 'b' -> '\b'; 510 case 'f' -> '\f'; 511 case 'n' -> '\n'; 512 case 'r' -> '\r'; 513 case 't' -> '\t'; 514 default -> (char) c; 515 }; 516 } 517 518 private char readUnicodeEscape() throws IOException { 519 var hex = new StringBuilder(4); 520 for (var i = 0; i < 4; i++) { 521 var c = readChar(); 522 if (c < 0 || !isHexDigit(c)) 523 throw ioex("Invalid \\u escape sequence at line %s, column %s", line, column); 524 hex.append((char) c); 525 } 526 return (char) Integer.parseInt(hex.toString(), 16); 527 } 528 529 private static boolean isHexDigit(int c) { 530 return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); 531 } 532 533 /** 534 * Reads an unquoted string until a forbidden character. 535 * 536 * @return The string value. 537 * @throws IOException If a read error occurs. 538 */ 539 public String readUnquotedString() throws IOException { 540 var sb = new StringBuilder(); 541 var c = readChar(); 542 while (c >= 0 && UNQUOTED_FORBIDDEN.indexOf(c) < 0) { 543 sb.append((char) c); 544 c = readChar(); 545 } 546 if (c >= 0) 547 unread(c); 548 return sb.toString().trim(); 549 } 550 551 /** 552 * Reads a triple-quoted string ("""..."""). No escape processing inside per HOCON spec. 553 * 554 * @return The string value. 555 * @throws IOException If a read error occurs. 556 */ 557 public String readTripleQuotedString() throws IOException { 558 var sb = new StringBuilder(); 559 var quoteCount = 0; 560 var c = readChar(); 561 while (c >= 0) { 562 if (c == '"') { 563 quoteCount++; 564 if (quoteCount == 3) { 565 var s = sb.toString(); 566 if (s.endsWith("\n")) 567 s = s.substring(0, s.length() - 1); 568 return s.replace("\r\n", "\n").replace("\r", "\n"); 569 } 570 } else { 571 if (quoteCount == 1) 572 sb.append('"'); 573 else if (quoteCount == 2) 574 sb.append("\"\""); 575 quoteCount = 0; 576 if (c == '\r' && peekChar() == '\n') { 577 readChar(); 578 sb.append('\n'); 579 } else if (c != '\r') { 580 sb.append((char) c); 581 } 582 } 583 c = readChar(); 584 } 585 return sb.toString(); 586 } 587}