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.ObjectUtils.*;
020import static org.apache.juneau.commons.utils.Shorts.*;
021
022import java.io.*;
023import java.util.*;
024
025import org.apache.juneau.commons.bean.*;
026import org.apache.juneau.commons.reflect.*;
027import org.apache.juneau.marshall.*;
028import org.apache.juneau.marshall.collections.*;
029import org.apache.juneau.marshall.parser.*;
030import org.apache.juneau.marshall.stream.*;
031
032/**
033 * Session for parsing HOCON format into POJOs.
034 */
035@SuppressWarnings({
036   "java:S110", // Inheritance depth acceptable for this class hierarchy.
037   "java:S135", // readArrayOrConcat uses two distinct breaks — one for standard HOCON terminators, one for unquoted keys after arrays/objects; merging them would obscure intent
038   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
039   "java:S3776", // Cognitive complexity acceptable for this specific logic.
040   "java:S6541", // Brain method acceptable for this parser state machine.
041   "resource", // Closeable resources are owned by the caller's parser session; Eclipse JDT @Owning warning is by design.
042   "unchecked" // Type erasure on reflective/generic cast; element type is verified at call site.
043})
044public class HoconParserSession extends ReaderParserSession implements RecordReadable {
045
046   private static final String[] EMPTY_PATH = new String[0];
047
048   /**
049    * Builder for HOCON parser session.
050    */
051   public static class Builder extends ReaderParserSession.Builder<Builder> {
052
053      private final HoconParser hoconParser;
054
055      protected Builder(HoconParser ctx) {
056         super(reqnn("ctx", ctx));
057         this.hoconParser = ctx;
058      }
059
060      @Override
061      public HoconParserSession build() {
062         return new HoconParserSession(this);
063      }
064   }
065
066   /**
067    * Creates a session builder.
068    *
069    * @param ctx The parser context.
070    *    <br>Cannot be <jk>null</jk>.
071    * @return The builder.
072    */
073   public static Builder create(HoconParser ctx) {
074      return new Builder(reqnn("ctx", ctx));
075   }
076
077   private final HoconParser hoconParser;
078
079   protected HoconParserSession(Builder builder) {
080      super(builder);
081      hoconParser = builder.hoconParser;
082   }
083
084   @Override /* RecordReadable */
085   public RecordReader readRecords(Object input) throws IOException {
086      return RecordAdapter.reader(this, input);
087   }
088
089   @Override /* RecordReadable */
090   public boolean isRecordStreaming() {
091      return false;
092   }
093
094   @Override
095   protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException, ExecutableException {
096      String inputStr;
097      try {
098         inputStr = pipe.asString();
099      } catch (IOException e) {
100         throw new ParseException(e);
101      }
102      if (inputStr == null)
103         return null;
104      var trimmed = inputStr.trim();
105      if (trimmed.isEmpty())
106         return null;
107      var tokenizer = new HoconTokenizer(new StringReader(inputStr));
108      var root = readRoot(tokenizer);
109      if (root == null)
110         return null;
111      if (hoconParser.resolveSubstitutions) {
112         var resolver = new HoconResolver(root);
113         resolver.resolve();
114      }
115      var map = hoconToMap(root, type);
116      return (T) convertToBean(map, type);
117   }
118
119   private HoconValue.HoconObject readRoot(HoconTokenizer t) throws IOException, ParseException {
120      t.skipWhitespaceAndComments();
121      var peek = t.peek();
122      if (peek.type() == HoconTokenizer.TokenType.EOF)
123         return null;
124      if (peek.type() == HoconTokenizer.TokenType.LBRACE) {
125         t.read();
126         return readObject(t, null, new String[0]);
127      }
128      return readRootBraceless(t);
129   }
130
131   private HoconValue.HoconObject readRootBraceless(HoconTokenizer t) throws IOException, ParseException {
132      var root = new HoconValue.HoconObject();
133      while (t.peek().type() != HoconTokenizer.TokenType.EOF) {
134         var path = readPath(t);
135         if (path == null || path.length == 0)
136            break;
137         t.skipWhitespaceAndComments();
138         var next = t.peek();
139         if (next.type() == HoconTokenizer.TokenType.LBRACE) {
140            t.read();
141            var nested = readObject(t, root, path);
142            var existing = root.getPath(path);
143            if (existing instanceof HoconValue.HoconObject existingObj)
144               existingObj.merge(nested);
145            else
146               root.setPath(path, nested);
147         } else if (next.type() == HoconTokenizer.TokenType.PLUS_EQUALS) {
148            t.read();
149            t.skipWhitespaceAndComments();
150            var value = readValueOrConcat(t);
151            var existing = root.getPath(path);
152            if (existing instanceof HoconValue.HoconArray arr)
153               arr.add(value);
154            else {
155               var newArr = new HoconValue.HoconArray();
156               if (existing != null)
157                  newArr.add(existing);
158               newArr.add(value);
159               root.setPath(path, newArr);
160            }
161         } else if (next.type() == HoconTokenizer.TokenType.EQUALS || next.type() == HoconTokenizer.TokenType.COLON) {
162            t.read();
163            t.skipWhitespaceAndComments();
164            var value = readValueOrConcat(t);
165            value = resolveSelfRefConcatIfNeeded(value, path, root);
166            root.setPath(path, value);
167         } else {
168            throw new ParseException(this, "Expected =, : or brace at line %s", t.getLine());
169         }
170         t.skipWhitespaceAndComments();
171         if (t.peek().type() == HoconTokenizer.TokenType.NEWLINE)
172            t.read();
173      }
174      return root;
175   }
176
177   private String[] readPath(HoconTokenizer t) throws IOException, ParseException {
178      var tok = t.read();
179      String first = switch (tok.type()) {
180         case EOF -> null;
181         case UNQUOTED_STRING -> isTrimStrings() ? tok.stringValue().trim() : tok.stringValue();
182         case QUOTED_STRING, TRIPLE_QUOTED -> isTrimStrings() ? tok.stringValue().trim() : tok.stringValue();
183         case NULL -> null;
184         case TRUE -> "true";
185         case FALSE -> "false";
186         case NUMBER -> tok.numberValue().toString();
187         default -> throw new ParseException(this, "Expected key at line %s", t.getLine());
188      };
189      if (first == null)
190         return EMPTY_PATH;
191      var path = new ArrayList<String>();
192      if (tok.type() == HoconTokenizer.TokenType.UNQUOTED_STRING && first.contains(".") && !first.startsWith("."))
193         path.addAll(Arrays.asList(first.split("\\.", -1)));
194      else {
195         var comp = first.startsWith(".") ? first.substring(1) : first;
196         if (!comp.isEmpty())
197            path.add(comp);
198         t.skipWhitespaceAndComments();
199         while (t.peek().type() == HoconTokenizer.TokenType.UNQUOTED_STRING
200               || t.peek().type() == HoconTokenizer.TokenType.QUOTED_STRING
201               || t.peek().type() == HoconTokenizer.TokenType.TRIPLE_QUOTED) {
202            var next = t.read().stringValue();
203            var part = next.startsWith(".") ? next.substring(1) : next;
204            if (!part.isEmpty())
205               path.add(part);
206            t.skipWhitespaceAndComments();
207         }
208      }
209      return path.toArray(String[]::new);
210   }
211
212   /**
213    * Parses a value, supporting HOCON concatenation: adjacent values on the same line
214    * are concatenated (strings) or merged (objects/arrays).
215    */
216   private HoconValue readValueOrConcat(HoconTokenizer t) throws IOException, ParseException {
217      var values = new ArrayList<HoconValue>();
218      values.add(readValue(t));
219      while (true) {
220         t.skipWhitespaceAndCommentsExceptNewlines();
221         var peek = t.peekNoSkip();
222         if (peek.type() == HoconTokenizer.TokenType.NEWLINE
223               || peek.type() == HoconTokenizer.TokenType.COMMA
224               || peek.type() == HoconTokenizer.TokenType.RBRACE
225               || peek.type() == HoconTokenizer.TokenType.RBRACKET
226               || peek.type() == HoconTokenizer.TokenType.EQUALS
227               || peek.type() == HoconTokenizer.TokenType.PLUS_EQUALS
228               || peek.type() == HoconTokenizer.TokenType.EOF)
229            break;
230         // HOCON: newline ends concatenation; unquoted string after array/object is next key, not concat
231         var last = values.isEmpty() ? null : values.get(values.size() - 1);
232         if (peek.type() == HoconTokenizer.TokenType.UNQUOTED_STRING
233               && (last instanceof HoconValue.HoconArray || last instanceof HoconValue.HoconObject))
234            break;
235         values.add(readValue(t));
236      }
237      if (values.size() == 1)
238         return values.get(0);
239      return combineConcat(values);
240   }
241
242   private static HoconValue combineConcat(List<HoconValue> values) {
243      if (values.size() == 1)
244         return values.get(0);
245      var allArrays = values.stream().allMatch(v -> v instanceof HoconValue.HoconArray);
246      var allObjects = values.stream().allMatch(v -> v instanceof HoconValue.HoconObject);
247      if (allArrays) {
248         var first = (HoconValue.HoconArray) values.get(0);
249         for (var i = 1; i < values.size(); i++)
250            first.concat((HoconValue.HoconArray) values.get(i));
251         return first;
252      }
253      if (allObjects) {
254         var first = (HoconValue.HoconObject) values.get(0);
255         for (var i = 1; i < values.size(); i++)
256            first.merge((HoconValue.HoconObject) values.get(i));
257         return first;
258      }
259      var concat = new HoconValue.HoconConcat();
260      for (var v : values)
261         concat.add(v);
262      return concat;
263   }
264
265   private static String[] concatenatePaths(String[] prefix, String[] path) {
266      var result = new String[prefix.length + path.length];
267      System.arraycopy(prefix, 0, result, 0, prefix.length);
268      System.arraycopy(path, 0, result, prefix.length, path.length);
269      return result;
270   }
271
272   private static HoconValue resolveSelfRefConcatIfNeeded(HoconValue value, String[] fullPath,
273         HoconValue.HoconObject root) throws ParseException {
274      if (!(value instanceof HoconValue.HoconConcat concat))
275         return value;
276      var pathStr = String.join(".", fullPath);
277      if (!concat.referencesPath(pathStr))
278         return value;
279      var existing = root.getPath(fullPath);
280      if (existing == null)
281         return value;
282      var resolver = new HoconResolver(root);
283      java.util.function.Function<String,HoconValue> lookup = p -> p.equals(pathStr) ? existing : resolver.lookup(p);
284      return resolver.resolveConcatWithLookup(concat, lookup);
285   }
286
287   private HoconValue readValue(HoconTokenizer t) throws IOException, ParseException {
288      var tok = t.peek();
289      return switch (tok.type()) {
290         case UNQUOTED_STRING, QUOTED_STRING, TRIPLE_QUOTED -> {
291            t.read();
292            var s = isTrimStrings() ? tok.stringValue().trim() : tok.stringValue();
293            yield new HoconValue.HoconString(s);
294         }
295         case NUMBER -> {
296            t.read();
297            yield new HoconValue.HoconNumber(tok.numberValue());
298         }
299         case TRUE -> {
300            t.read();
301            yield new HoconValue.HoconBoolean(true);
302         }
303         case FALSE -> {
304            t.read();
305            yield new HoconValue.HoconBoolean(false);
306         }
307         case NULL -> {
308            t.read();
309            yield new HoconValue.HoconNull();
310         }
311         case LBRACE -> {
312            t.read();
313            yield readObject(t, null, new String[0]);
314         }
315         case LBRACKET -> {
316            t.read();
317            yield readArray(t);
318         }
319         case SUBSTITUTION, OPT_SUBSTITUTION -> {
320            var st = t.read();
321            yield new HoconValue.HoconSubstitution(st.stringValue(), tok.type() == HoconTokenizer.TokenType.OPT_SUBSTITUTION);
322         }
323         case EOF -> null;
324         default -> throw new ParseException(this, "Unexpected token %s at line %s", tok.type(), t.getLine());
325      };
326   }
327
328   private HoconValue.HoconObject readObject(HoconTokenizer t, HoconValue.HoconObject root, String[] pathPrefix)
329         throws IOException, ParseException {
330      var obj = new HoconValue.HoconObject();
331      var effectiveRoot = or(root, obj);
332      t.skipWhitespaceAndComments();
333      while (t.peek().type() != HoconTokenizer.TokenType.RBRACE && t.peek().type() != HoconTokenizer.TokenType.EOF) {
334         var path = readPath(t);
335         if (path == null || path.length == 0)
336            break;
337         t.skipWhitespaceAndComments();
338         var next = t.peek();
339         if (next.type() == HoconTokenizer.TokenType.LBRACE) {
340            t.read();
341            var nested = readObject(t, effectiveRoot, concatenatePaths(pathPrefix, path));
342            var existing = obj.getPath(path);
343            if (existing instanceof HoconValue.HoconObject existingObj)
344               existingObj.merge(nested);
345            else
346               obj.setPath(path, nested);
347         } else if (next.type() == HoconTokenizer.TokenType.PLUS_EQUALS) {
348            t.read();
349            t.skipWhitespaceAndComments();
350            var value = readValueOrConcat(t);
351            var existing = obj.getPath(path);
352            if (existing instanceof HoconValue.HoconArray arr)
353               arr.add(value);
354            else {
355               var newArr = new HoconValue.HoconArray();
356               if (existing != null)
357                  newArr.add(existing);
358               newArr.add(value);
359               obj.setPath(path, newArr);
360            }
361         } else if (next.type() == HoconTokenizer.TokenType.EQUALS || next.type() == HoconTokenizer.TokenType.COLON) {
362            t.read();
363            t.skipWhitespaceAndComments();
364            var value = readValueOrConcat(t);
365            var fullPath = concatenatePaths(pathPrefix, path);
366            value = resolveSelfRefConcatIfNeeded(value, fullPath, effectiveRoot);
367            obj.setPath(path, value);
368         } else {
369            throw new ParseException(this, "Expected =, : or brace at line %s", t.getLine());
370         }
371         // skipWhitespaceAndComments is a no-op while a token is cached (see HoconTokenizer), so a
372         // closing `}` already peeked by readArrayOrConcat's concat loop is preserved here and the
373         // loop terminates naturally via the while-condition above rather than chewing through a
374         // newline that belongs to an enclosing scope.
375         t.skipWhitespaceAndComments();
376         if (t.peek().type() == HoconTokenizer.TokenType.COMMA || t.peek().type() == HoconTokenizer.TokenType.NEWLINE)
377            t.read();
378         t.skipWhitespaceAndComments();
379      }
380      if (t.peek().type() == HoconTokenizer.TokenType.RBRACE)
381         t.read();
382      return obj;
383   }
384
385   private HoconValue.HoconArray readArray(HoconTokenizer t) throws IOException, ParseException {
386      var arr = new HoconValue.HoconArray();
387      t.skipWhitespaceAndComments();
388      while (t.peek().type() != HoconTokenizer.TokenType.RBRACKET && t.peek().type() != HoconTokenizer.TokenType.EOF) {
389         // Elements inside [ ... ] are separated by COMMA or NEWLINE (handled below), so each
390         // readArrayOrConcat call returns a single element.  HOCON array-concatenation
391         // (`[a,b] [c,d]` ≡ `[a,b,c,d]`) is performed inside readArrayOrConcat when adjacent
392         // arrays appear without a separator; the result is already a flattened HoconArray at
393         // that point.  Here we must add the element as-is so nested arrays like
394         // `[[1,2,3], [4,5,6]]` (with separators) stay nested rather than flattening.
395         arr.add(readValueOrConcat(t));
396         // readArrayOrConcat's internal concat loop calls peekNoSkip(), which eagerly consumes the
397         // closing-bracket char from the underlying reader and stashes it as peeked=RBRACKET.
398         // skipWhitespaceAndComments is a no-op while a token is cached (see HoconTokenizer), so it
399         // will NOT read past the cached `]` and eat a following newline — a newline that is either
400         // the in-array separator for the NEXT element of an enclosing array or a meaningful boundary
401         // for an outer scope.  This prevents adjacent newline-separated inner arrays like
402         // `[[1,2]\n[3,4]]` from being silently re-merged via HOCON array-concatenation above us.
403         // The loop terminates on the cached `]` via the while-condition above.
404         t.skipWhitespaceAndComments();
405         if (t.peek().type() == HoconTokenizer.TokenType.COMMA || t.peek().type() == HoconTokenizer.TokenType.NEWLINE)
406            t.read();
407         t.skipWhitespaceAndComments();
408      }
409      if (t.peek().type() == HoconTokenizer.TokenType.RBRACKET)
410         t.read();
411      return arr;
412   }
413
414   private Object hoconToMap(HoconValue val, ClassMeta<?> type) throws ParseException {
415      if (val == null)
416         return null;
417      // STRING values targeting byte[] consult the configured BinaryFormat's variant BinarySwap
418      // before falling back to the default String → byte[] UTF-8 coercion that the bean binder
419      // would otherwise apply at the collection-element / top-level dispatch site.  The
420      // bean-property path is unaffected because the per-property MPP install hides the type-level
421      // default swap behind the per-property swap on the BeanPropertyMeta.
422      //
423      // At BinaryFormat.NOT_SET BinarySwap.match returns 0 and no swap fires, but
424      // HoconSerializerSession emits byte[] at NOT_SET as a base64 string (to sidestep HOCON's
425      // array-concatenation flattening for nested int-array wire forms).  Decode that here as the
426      // symmetric fallback so List<byte[]> and top-level byte[] round-trip at NOT_SET.
427      if (val.getType() == HoconValue.Type.STRING && type != null && type.inner() == byte[].class) {
428         var s = ((HoconValue.HoconString) val).getValue();
429         var swap = type.getSwap(this);
430         if (swap != null)
431            return unswap(swap, s, type);
432         return Base64.getDecoder().decode(s);
433      }
434      // Look up bean property metas so nested typed-map properties (Map<K,V> with non-String K) can have
435      // their keys coerced via the converter's Map→Map path before the bean binds them.  Collection/Array
436      // property types are also surfaced here so the ARRAY branch can thread the parent's element type
437      // into recursion and the byte[] swap dispatch above can fire at the right depth.  Other property
438      // shapes (Optional, primitive, bean) still pass through as object() so the lazy-typed conversion
439      // path stays in place.
440      var beanMeta = (type != null && type.isBean()) ? type.getBeanMeta() : null;
441      var result = switch (val.getType()) {
442         case STRING -> ((HoconValue.HoconString) val).getValue();
443         case NUMBER -> ((HoconValue.HoconNumber) val).getValue();
444         case BOOLEAN -> ((HoconValue.HoconBoolean) val).getValue();
445         case NULL -> null;
446         case OBJECT -> {
447            var obj = (HoconValue.HoconObject) val;
448            var map = newGenericMap();
449            for (var e : obj.getMembers().entrySet()) {
450               var key = e.getKey();
451               ClassMeta<?> childType = object();
452               if (beanMeta != null && key != null && !key.equals(getBeanTypePropertyName(type))) {
453                  var pMeta = beanMeta.getPropertyMeta(key);
454                  if (pMeta != null) {
455                     var cm = (ClassMeta<?>) pMeta.getBeanInfo();
456                     if (cm != null && (cm.isMap() || cm.isCollectionOrArray()))
457                        childType = cm;
458                  }
459               }
460               map.put(key, hoconToMap(e.getValue(), childType));
461            }
462            yield map;
463         }
464         case ARRAY -> {
465            var arr = (HoconValue.HoconArray) val;
466            var list = newGenericList();
467            var elType = (type != null && type.isCollectionOrArray()) ? coalesce(type.getElementType(), object()) : object();
468            for (var el : arr.getElements()) {
469               list.add(hoconToMap(el, elType));
470            }
471            yield list;
472         }
473         default -> null;
474      };
475      // If the enclosing target is a typed Map (e.g. Map<TestEnum,String>), coerce keys / values
476      // via the converter's findMapConversion so the bean property binder gets a properly-keyed map.
477      if (result instanceof Map<?,?> result2 && type != null && type.isMap()
478            && type.getKeyType() != null && !type.getKeyType().isObject())
479         return convertToMemberType(null, result2, type);
480      return result;
481   }
482
483   private Object convertToBean(Object map, ClassMeta<?> type) throws ExecutableException, ParseException {
484      if (map == null)
485         return null;
486      if (type == null)
487         type = object();
488      var casted = cast((MarshalledMap) map, null, type);
489      if (casted != map)
490         return casted;
491      if (!type.isObject()) {
492         var swap = type.getSwap(this);
493         if (nn(swap)) {
494            var sType = swap.getSwapClassMeta(this);
495            var swapped = convertToMemberType(null, map, sType);
496            return unswap(swap, swapped, type);
497         }
498         var result = convertToMemberType(null, map, type);
499         if (type.isBean() && result != null)
500            injectAnnotations((MarshalledMap) map, result);
501         return result;
502      }
503      return map;
504   }
505
506   private void injectAnnotations(MarshalledMap map, Object bean) throws ExecutableException {
507      var bm = toBeanMap(bean);
508      for (var entry : map.entrySet()) {
509         var key = entry.getKey();
510         var pm = bm.getPropertyMeta(key);
511         var val = pm == null ? null : getBeanValueSafely(bm, key);
512         if (pm == null || val == null)
513            continue;
514         var cm = (ClassMeta<?>) pm.getBeanInfo();
515         if (cm.getNameProperty() != null)
516            setName(cm, val, key);
517         injectParentAnnotations(cm, val, entry.getValue(), bean);
518      }
519   }
520
521   /*
522    * Recursively injects @ParentProperty (and @NameProperty on map values / nested beans) into val.
523    * Intermediate collections and maps are transparent: a collection/map element's parent is
524    * parentBean (the nearest enclosing bean), skipping all intermediate containers.  This keeps HOCON
525    * consistent with the streaming parsers.
526    */
527   private void injectParentAnnotations(ClassMeta<?> cm, Object val, Object node, Object parentBean) throws ExecutableException {
528      if (val == null || cm == null)
529         return;
530      if (cm.getParentProperty() != null)
531         setParent(cm, val, parentBean);
532      if (cm.isCollectionOrArray()) {
533         var et = cm.getElementType();
534         if (et == null || et.isObject())
535            return;
536         var nodeList = node instanceof List<?> nl ? nl : null;
537         var i = 0;
538         for (var element : toIterable(val)) {
539            injectParentAnnotations(et, element, nodeList != null && i < nodeList.size() ? nodeList.get(i) : null, parentBean);
540            i++;
541         }
542      } else if (cm.isMap() && val instanceof Map<?,?> valMap) {
543         var vt = cm.getValueType();
544         if (vt == null || vt.isObject())
545            return;
546         var nodeMap = node instanceof Map<?,?> nm ? nm : null;
547         for (Map.Entry<?,?> e : valMap.entrySet()) {
548            if (vt.getNameProperty() != null)
549               setName(vt, e.getValue(), e.getKey());
550            injectParentAnnotations(vt, e.getValue(), nodeMap != null ? nodeMap.get(e.getKey()) : null, parentBean);
551         }
552      } else if (cm.isBean() && !(val instanceof Map) && node instanceof MarshalledMap nodeMap) {
553         injectAnnotations(nodeMap, val);
554      }
555   }
556
557   private static Iterable<?> toIterable(Object val) {
558      if (val instanceof Collection<?> c)
559         return c;
560      if (val instanceof Object[] a)
561         return Arrays.asList(a);
562      return List.of();
563   }
564
565   private static Object getBeanValueSafely(BeanMap<?> bm, String key) {
566      try {
567         return bm.get(key);
568      } catch (@SuppressWarnings("unused") Exception e) {
569         return null;
570      }
571   }
572}