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.toml;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.isEmpty;
020import static org.apache.juneau.commons.utils.Shorts.*;
021import static org.apache.juneau.commons.utils.StringUtils.isEmpty;
022
023import java.io.*;
024import java.time.temporal.*;
025import java.util.*;
026import java.util.Map.*;
027
028import org.apache.juneau.commons.bean.*;
029import org.apache.juneau.commons.reflect.*;
030import org.apache.juneau.marshall.*;
031import org.apache.juneau.marshall.collections.*;
032import org.apache.juneau.marshall.parser.*;
033import org.apache.juneau.marshall.stream.*;
034
035/**
036 * Session for parsing TOML into POJOs.
037 */
038/*
039 * Suppression rationale:
040 * - rawtypes, unchecked: TOML maps use Map<String,Object> with dynamic values requiring casts.
041 * - java:S3776: Cognitive complexity; parser logic is inherently branched for TOML syntax.
042 * - java:S6541: Brain method; parser orchestration spans many concerns.
043 * - java:S135: Multiple break/continue per loop needed for TOML state-machine parsing.
044 * - java:S1192: Duplicated literals (argument/property names) read more clearly inline than as constants.
045 */
046@SuppressWarnings({
047   "java:S135", // Multiple breaks acceptable in parse loop
048   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
049   "java:S1612", // Class.isAssignableFrom necessary when checking target type (Class<?>), not object instance
050   "java:S3776", // Cognitive complexity acceptable for readMessage
051   "java:S6541", // Brain method acceptable for readMessage
052   "rawtypes", // Raw types necessary for generic Map/List handling
053   "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design.
054   "unchecked" // Type erasure requires unchecked casts in convertValue
055})
056public class TomlParserSession extends ReaderParserSession implements RecordReadable {
057
058   private static final String MSG_INVALID_NUMBER = "Invalid number: ";
059
060   /**
061    * Builder for TOML parser session.
062    */
063   public static class Builder extends ReaderParserSession.Builder<Builder> {
064
065      private final TomlParser ctx;
066
067      protected Builder(TomlParser ctx) {
068         super(reqnn("ctx", ctx));
069         this.ctx = ctx;
070      }
071
072      @Override
073      public TomlParserSession build() {
074         return new TomlParserSession(this);
075      }
076   }
077
078   /**
079    * Creates a session builder.
080    *
081    * @param ctx The parser context.
082    *    <br>Cannot be <jk>null</jk>.
083    * @return The builder.
084    */
085   public static Builder create(TomlParser ctx) {
086      return new Builder(reqnn("ctx", ctx));
087   }
088
089   private final TomlParser ctx;
090
091   protected TomlParserSession(Builder builder) {
092      super(builder);
093      ctx = builder.ctx;
094   }
095
096   @Override /* RecordReadable */
097   public RecordReader readRecords(Object input) throws IOException {
098      return RecordAdapter.reader(this, input);
099   }
100
101   @Override /* RecordReadable */
102   public boolean isRecordStreaming() {
103      return false;
104   }
105
106   @Override
107   protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException, ExecutableException {
108      try (Reader r = pipe.getParserReader()) {
109         if (r == null)
110            return null;
111         Map<String,Object> root = readTomlDocument(new TomlTokenizer(r));
112         if (isEmpty(root))
113            return type.canCreateNewBean(getOuter()) ? type.newInstance(getOuter()) : null;
114         // Root _value wrapper: document has exactly one key _value
115         if (root.size() == 1 && root.containsKey("_value")) {
116            Object inner = root.get("_value");
117            return (T) convertValue(inner, type);
118         }
119         return convertMapToType(root, type);
120      }
121   }
122
123   private Map<String,Object> readTomlDocument(TomlTokenizer t) throws IOException, ParseException {
124      Map<String,Object> root = m();
125      Map<String,Object> currentTable = root;
126
127      while (!t.isEof()) {
128         t.skipWhitespaceAndComments();
129         if (t.isEof())
130            break;
131
132         int c = t.peek();
133         if (c == '[') {
134            t.read();
135            if (t.peek() == '[') {
136               t.read();
137               String path = readTablePath(t);
138               Object existing = getOrCreateAt(root, path);
139               if (!(existing instanceof List)) {
140                  if (existing != null)
141                     throw t.parseException("Key " + path + " redefined");
142                  setAt(root, path, l());
143                  existing = getOrCreateAt(root, path);
144               }
145               Map<String,Object> newTable = m();
146               ((List)existing).add(newTable);
147               currentTable = newTable;
148            } else {
149               String path = readTablePath(t);
150               currentTable = getOrCreateTableAt(root, path);
151            }
152         } else if (c == '=' || Character.isLetterOrDigit(c) || c == '"' || c == '\'' || c == '_' || c == '-') {
153            List<String> keyPath = readKey(t);
154            if (keyPath.isEmpty())
155               throw t.parseException("Expected key");
156            t.skipWhitespace();
157            if (t.read() != '=')
158               throw t.parseException("Expected '='");
159            t.skipWhitespace();
160            Object value = readValue(t);
161            setValueAt(currentTable, keyPath, value);
162         } else {
163            break;
164         }
165      }
166      return root;
167   }
168
169   private static String readTablePath(TomlTokenizer t) throws IOException, ParseException {
170      t.skipWhitespace();
171      var parts = new ArrayList<String>();
172      while (true) {
173         String key = t.readBareKey();
174         if (key == null)
175            break;
176         parts.add(key);
177         t.skipWhitespace();
178         if (t.peek() != '.')
179            break;
180         t.read();
181         t.skipWhitespace();
182      }
183      if (t.peek() == ']')
184         t.read();
185      if (t.peek() == ']')
186         t.read();
187      return String.join(".", parts);
188   }
189
190   private static List<String> readKey(TomlTokenizer t) throws IOException, ParseException {
191      var parts = new ArrayList<String>();
192      while (true) {
193         String key = t.readBareKey();
194         if (key == null)
195            break;
196         parts.add(key);
197         t.skipWhitespace();
198         if (t.peek() != '.')
199            break;
200         t.read();
201         t.skipWhitespace();
202      }
203      return parts;
204   }
205
206   private Object readValue(TomlTokenizer t) throws IOException, ParseException {
207      int c = t.peek();
208      if (c == '"') {
209         if (isTripleQuote(t, '"'))
210            return t.readMultiLineBasicString();
211         return t.readBasicString();
212      }
213      if (c == '\'') {
214         if (isTripleQuote(t, '\''))
215            return t.readMultiLineLiteralString();
216         return t.readLiteralString();
217      }
218      if (c == 't' || c == 'f')
219         return t.readBoolean();
220      if (c == '[') {
221         t.read();
222         t.skipWhitespaceAndComments();
223         var list = l();
224         while (t.peek() != ']') {
225            list.add(readValue(t));
226            t.skipWhitespaceAndComments();
227            if (t.peek() == ',') {
228               t.read();
229               t.skipWhitespaceAndComments();
230            }
231         }
232         t.read();
233         return list;
234      }
235      if (c == '{') {
236         t.read();
237         t.skipWhitespace();
238         var map = new LinkedHashMap<String,Object>();
239         while (t.peek() != '}') {
240            t.skipWhitespace();
241            List<String> k = readKey(t);
242            if (k.isEmpty())
243               throw t.parseException("Expected key in inline table");
244            t.skipWhitespace();
245            if (t.read() != '=')
246               throw t.parseException("Expected '='");
247            t.skipWhitespace();
248            Object v = readValue(t);
249            map.put(k.get(0), v);
250            t.skipWhitespace();
251            if (t.peek() == ',') {
252               t.read();
253               t.skipWhitespace();
254            }
255         }
256         t.read();
257         return map;
258      }
259      if (Character.isDigit(c) || c == '+' || c == '-') {
260         String s = readUntilValueEnd(t);
261         if (isEmpty(s))
262            throw t.parseException("Expected value");
263         Number special = readSpecialFloat(s);
264         if (special != null)
265            return special;
266         Number radix = readRadixInteger(s, t);
267         if (radix != null)
268            return radix;
269         // TOML permits a space in place of 'T' between the date and time portions of an
270         // offset/local date-time (e.g. "2024-01-15 10:30:00"). readUntilValueEnd() above hard-stops
271         // at the first space -- a legitimate terminator for every other TOML value shape -- so
272         // without this, the time portion is silently truncated and the bare date string is returned
273         // instead. If what's been read so far is exactly a bare date and it's immediately followed by
274         // " <digit>", that space belongs to *this* value: consume it and keep reading the time part.
275         if (s.length() == 10 && s.charAt(4) == '-' && s.charAt(7) == '-' && t.peek() == ' ') {
276            t.read();
277            if (Character.isDigit(t.peek())) {
278               s = s + " " + readUntilValueEnd(t);
279            } else {
280               t.unread(' ');
281            }
282         }
283         String noUnderscore = s.replace("_", "");
284         if (s.length() >= 10 && s.charAt(4) == '-' && (s.contains("T") || s.contains(":") || s.contains(" ")))
285            return TomlTokenizer.parseDateTimeString(s);
286         // Date-like patterns kept as string for convertValue to parse as LocalDate, YearMonth, Year
287         if (isDateLikeString(noUnderscore))
288            return s;
289         if (s.contains(".") || s.toLowerCase(Locale.ROOT).contains("e"))
290            return Double.parseDouble(noUnderscore);
291         try {
292            return Long.parseLong(noUnderscore);
293         } catch (@SuppressWarnings("unused") NumberFormatException e) {
294            // May be date/time string that slipped through (e.g. LocalDate, LocalTime)
295            if (s.contains("-") || s.contains(":"))
296               return s;
297            throw t.parseException(MSG_INVALID_NUMBER + s);
298         }
299      }
300      if (c == 'i' || c == 'n') {
301         String s = readUntilValueEnd(t);
302         Number special = readSpecialFloat(s);
303         if (special != null)
304            return special;
305         throw t.parseException("Expected inf or nan");
306      }
307      throw t.parseException("Unexpected character: " + (char)c);
308   }
309
310   private static boolean isTripleQuote(TomlTokenizer t, char q) throws IOException {
311      if (t.peek() != q) return false;
312      int c1 = t.read();
313      int c2 = t.read();
314      int c3 = t.peek();
315      if (c2 == q && c3 == q) {
316         t.read();
317         return true;
318      }
319      if (c2 >= 0)
320         t.unread(c2);
321      t.unread(c1);
322      return false;
323   }
324
325   private static boolean isDateLikeString(String s) {
326      if (s == null || s.length() < 7)
327         return false;
328      // yyyy-MM-dd (LocalDate) | yyyy-MM (YearMonth) - exclude plain 4-digit (e.g. port numbers)
329      return (s.length() == 10 && s.charAt(4) == '-' && s.charAt(7) == '-')
330         || (s.length() == 7 && s.charAt(4) == '-');
331   }
332
333   private static String readUntilValueEnd(TomlTokenizer t) throws IOException {
334      var sb = new StringBuilder();
335      int c;
336      while ((c = t.peek()) >= 0 && c != ' ' && c != '\t' && c != '\n' && c != '\r' && c != '#' && c != ',' && c != ']' && c != '}') {
337         sb.append((char) t.read());
338      }
339      return sb.isEmpty() ? null : sb.toString().trim();
340   }
341
342   private static Number readSpecialFloat(String s) {
343      if (s == null)
344         return null;
345      if (eqa(s, "inf", "+inf"))
346         return Double.POSITIVE_INFINITY;
347      if (eq(s, "-inf"))
348         return Double.NEGATIVE_INFINITY;
349      if (eqa(s, "nan", "+nan", "-nan"))
350         return Double.NaN;
351      return null;
352   }
353
354   private static Number readRadixInteger(String s, TomlTokenizer t) throws ParseException {
355      if (s == null || s.length() < 3 || s.charAt(0) != '0')
356         return null;
357      char p = s.charAt(1);
358      int radix;
359      if (p == 'x' || p == 'X')
360         radix = 16;
361      else if (p == 'o' || p == 'O')
362         radix = 8;
363      else if (p == 'b' || p == 'B')
364         radix = 2;
365      else
366         return null;
367      String digits = s.substring(2).replace("_", "");
368      if (digits.isEmpty())
369         throw t.parseException(MSG_INVALID_NUMBER + s);
370      try {
371         return Long.parseUnsignedLong(digits, radix);
372      } catch (@SuppressWarnings("unused") NumberFormatException e) {
373         throw t.parseException(MSG_INVALID_NUMBER + s);
374      }
375   }
376
377   // Steps into a child table.  Per TOML, a header path that traverses an array of tables refers to its most recent element.
378   private static Object stepInto(Object current, String key) {
379      if (current instanceof List l && !l.isEmpty())
380         current = l.get(l.size() - 1);
381      return ((Map)current).computeIfAbsent(key, k -> new LinkedHashMap<String,Object>());
382   }
383
384   private static Map<String,Object> lastTable(Object current) {
385      if (current instanceof List l && !l.isEmpty())
386         current = l.get(l.size() - 1);
387      return (Map<String,Object>)current;
388   }
389
390   private static Object getOrCreateAt(Map<String,Object> root, String path) {
391      String[] parts = path.split("\\.");
392      Object current = root;
393      for (int i = 0; i < parts.length - 1; i++)
394         current = stepInto(current, parts[i]);
395      return lastTable(current).get(parts[parts.length - 1]);
396   }
397
398   private static Map<String,Object> getOrCreateTableAt(Map<String,Object> root, String path) {
399      String[] parts = path.split("\\.");
400      Object current = root;
401      for (String part : parts)
402         current = stepInto(current, part);
403      return lastTable(current);
404   }
405
406   private static void setAt(Map<String,Object> root, String path, Object value) {
407      String[] parts = path.split("\\.");
408      Object current = root;
409      for (int i = 0; i < parts.length - 1; i++)
410         current = stepInto(current, parts[i]);
411      lastTable(current).put(parts[parts.length - 1], value);
412   }
413
414   private static void setValueAt(Map<String,Object> root, List<String> keyPath, Object value) {
415      if (keyPath.size() == 1) {
416         root.put(keyPath.get(0), value);
417         return;
418      }
419      Object current = root;
420      for (int i = 0; i < keyPath.size() - 1; i++) {
421         current = ((Map)current).computeIfAbsent(keyPath.get(i), k -> new LinkedHashMap<String,Object>());
422      }
423      ((Map)current).put(keyPath.get(keyPath.size() - 1), value);
424   }
425
426   private <T> T convertMapToType(Map<String,Object> map, ClassMeta<T> type) throws ParseException, ExecutableException {
427      if (type.isMap()) {
428         var keyType = type.getKeyType();
429         var valueType = type.getValueType();
430         Map m = type.canCreateNewInstance(getOuter()) ? (Map) type.newInstance(getOuter()) : newGenericMap(type);
431         for (var e : map.entrySet()) {
432            String keyStr = eq(e.getKey(), "null") ? null : e.getKey();
433            Object key = convertAttrToType(m, keyStr, keyType);
434            Object val = convertValue(e.getValue(), valueType);
435            m.put(key, val);
436         }
437         return (T) m;
438      }
439      BeanMap<?> bm = toBeanMap(type.newInstance(getOuter()));
440      populateBeanMap(bm, map);
441      return (T) checkRequired(bm).getBean();
442   }
443
444   private JsonMap toJsonMap(Map<?,?> map) throws ParseException, ExecutableException {
445      var jm = new JsonMap();
446      for (Entry<?,?> e : map.entrySet()) {
447         Object k = e.getKey();
448         Object v = e.getValue();
449         Object converted = v instanceof Map v2 ? toJsonMap(v2) : convertValue(v, object());
450         jm.put(k == null ? "null" : k.toString(), converted);
451      }
452      return jm;
453   }
454
455   private void populateBeanMap(BeanMap<?> bm, Map<String,Object> map) throws ParseException, ExecutableException {
456      for (Entry<String,Object> e : map.entrySet()) {
457         String key = e.getKey();
458         Object val = e.getValue();
459         BeanPropertyMeta pMeta = bm.getMeta().getProperties().get(key);
460         if (pMeta == null && isIgnoreUnknownBeanProperties())
461            continue;
462         if (pMeta == null)
463            throw new ParseException(this, "Unknown property '%s'", key);
464         ClassMeta<?> targetType = (ClassMeta<?>) pMeta.getBeanInfo();
465         Object converted = convertValue(val, targetType);
466         bm.put(key, converted);
467      }
468   }
469
470   @SuppressWarnings({
471      "java:S9391" // Hot path; avoids stream allocation (and loop body throws checked exceptions).
472   })
473   private Object convertValue(Object val, ClassMeta<?> targetType) throws ParseException, ExecutableException {
474      if (val == null)
475         return null;
476      String nv = ctx != null ? ctx.getNullValue() : null;
477      if (val instanceof String val2 && nv != null && val2.equals(nv))
478         return null;
479      if (val instanceof Map val2) {
480         if (JsonMap.class.isAssignableFrom(targetType.inner()))
481            return toJsonMap(val2);
482         if (targetType.isBean()) {
483            BeanMap<?> child = toBeanMap(targetType.newInstance(getOuter()));
484            populateBeanMap(child, val2);
485            return checkRequired(child).getBean();
486         }
487      }
488      if (val instanceof List val2 && targetType.isCollectionOrArray()) {
489         ClassMeta<?> elType = targetType.getElementType();
490         List result = new ArrayList();
491         for (Object item : val2) {
492            result.add(convertValue(item, elType));
493         }
494         return targetType.isArray() ? toArray(targetType, result) : result;
495      }
496      // Bare numeric wire literals (e.g. "2024", "8100000000000") arrive here as Number values
497      // from the TOML tokenizer.  Route them through the format-aware parsers so the configured
498      // MarshallingContext.get<Format>() hint (NANOS, ISO_YEAR, MILLIS, …) reaches the coercion.
499      // Without this routing the generic Number → T coercion below would silently drop the hint
500      // (e.g. treating Long(2024) as epoch-millis → 1970-01-01T00:00:02.024Z instead of year 2024).
501      if (val instanceof Number val2) {
502         if (targetType.isNumber())
503            return convertToMemberType(null, val2, targetType);
504         if (targetType.isDuration())
505            return readDuration(val2.toString());
506         if (targetType.isPeriod())
507            return readPeriod(val2.toString());
508         if (targetType.isDate())
509            return readDate(val2.toString(), targetType);
510         if (targetType.isCalendar())
511            return readCalendar(val2.toString(), targetType);
512         if (targetType.isTemporal())
513            return readTemporal(val2.toString(), targetType);
514      }
515      if (val instanceof String val2) {
516         if (targetType.isDate())
517            return readDate(val2, targetType);
518         if (targetType.isCalendar())
519            return readCalendar(val2, targetType);
520         if (targetType.isTemporal())
521            return readTemporal(val2, targetType);
522         if (targetType.isDuration())
523            return readDuration(val2);
524         if (targetType.isPeriod())
525            return readPeriod(val2);
526      }
527      // Native TOML datetime literals (Z-zoned / offset / local) are returned by TomlTokenizer as
528      // java.time.OffsetDateTime / LocalDateTime / LocalDate / LocalTime objects.  Re-stringify
529      // through the temporal's own ISO toString() and route through readTemporal so the
530      // configured TemporalFormat hint (e.g. ISO_INSTANT) is honored — otherwise the generic
531      // Temporal → T coercion below applies the local zone and we lose the wire's UTC anchor.
532      if (val instanceof TemporalAccessor val2 && targetType.isTemporal())
533         return readTemporal(val2.toString(), targetType);
534      // Route String-shaped byte[] values (collection-element + top-level dispatch sites) through
535      // the configured BinaryFormat's variant binarySwap before falling through to the default
536      // String → byte[] coercion.  The bean-property path is unaffected because the per-property
537      // MPP install hides the type-level default swap behind the per-property swap.
538      var binBytes = tryUnswapByteArray(val, targetType);
539      if (binBytes != null)
540         return binBytes;
541      return convertToMemberType(null, val, targetType);
542   }
543
544   /**
545    * Routes a {@link String}-shaped {@code byte[]} value through the session-aware
546    * {@link org.apache.juneau.marshall.swaps.BinarySwap} when one is matched.
547    *
548    * <p>Collection-element and top-level dispatch sites don't go through
549    * {@code MarshalledPropertyPostProcessor}'s per-property swap install, so they fall back to the
550    * {@link org.apache.juneau.marshall.swap.spi.DefaultSwaps} registry.  Returns {@code null} when the target isn't
551    * {@code byte[]}, the value isn't a {@link String}, or no swap is matched on the current session
552    * (e.g. {@link BinaryFormat#NOT_SET} is configured).
553    */
554   @SuppressWarnings({
555      "java:S1168" // Null is a meaningful 'no byte[] unswap applies' signal; callers fall through on null (an empty array would wrongly mean 'unswapped to empty bytes').
556   })
557   private byte[] tryUnswapByteArray(Object val, ClassMeta<?> targetType) throws ParseException {
558      if (!(val instanceof String val2) || targetType == null || targetType.inner() != byte[].class)
559         return null;
560      var swap = targetType.getSwap(this);
561      if (swap == null)
562         return null;
563      return (byte[]) unswap(swap, val2, targetType);
564   }
565}