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}