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.util.*;
023import java.util.function.*;
024import java.util.stream.*;
025
026/**
027 * Resolves ${var} and ${?var} substitutions in the parsed HOCON tree.
028 *
029 * <p>
030 * Resolution algorithm: single-pass depth-first over the tree. Substitutions reference paths in the
031 * root object (e.g., ${database.host}). Optional substitutions (${?var}) are silently removed if
032 * the path does not exist. Required substitutions (${var}) throw if the path is missing.
033 * Circular references are detected and cause an error.
034 *
035 * <h5 class='section'>See Also:</h5><ul>
036 *    <li class='link'><a class="doclink" href="https://github.com/lightbend/config/blob/main/HOCON.md">HOCON Specification</a>
037 * </ul>
038 */
039public class HoconResolver {
040
041   private final HoconValue.HoconObject root;
042   private final Set<String> inProgress = new HashSet<>();
043
044   /**
045    * Constructor.
046    *
047    * @param root The root HOCON object (after parsing, before resolution).
048    *    <br>Must not be <jk>null</jk> ({@link #resolve()} dereferences it).
049    */
050   public HoconResolver(HoconValue.HoconObject root) {
051      this.root = root;
052   }
053
054   /**
055    * Resolves all substitutions in-place in the tree.
056    *
057    * @throws RuntimeException If a required substitution path is missing or circular.
058    */
059   public void resolve() {
060      resolveObject(root);
061   }
062
063   private void resolveObject(HoconValue.HoconObject obj) {
064      var it = obj.getMembers().entrySet().iterator();
065      while (it.hasNext()) {
066         var e = it.next();
067         var resolved = resolveValue(e.getValue());
068         if (resolved == null)
069            it.remove();
070         else
071            e.setValue(resolved);
072      }
073   }
074
075   private HoconValue resolveValue(HoconValue val) {
076      if (val == null)
077         return null;
078      return switch (val.getType()) {
079         case STRING, NUMBER, BOOLEAN, NULL -> val;
080         case OBJECT -> {
081            resolveObject((HoconValue.HoconObject) val);
082            yield val;
083         }
084         case ARRAY -> {
085            var arr = (HoconValue.HoconArray) val;
086            var resolved = arr.getElements().stream().map(this::resolveValue).filter(Objects::nonNull).collect(Collectors.toCollection(ArrayList::new));
087            arr.setElements(resolved);
088            yield val;
089         }
090         case SUBSTITUTION -> resolveSubstitution((HoconValue.HoconSubstitution) val, false);
091         case OPT_SUBSTITUTION -> resolveSubstitution((HoconValue.HoconSubstitution) val, true);
092         case CONCAT -> resolveConcat((HoconValue.HoconConcat) val);
093         default -> val;
094      };
095   }
096
097   private HoconValue resolveSubstitution(HoconValue.HoconSubstitution sub, boolean optional) {
098      var path = sub.getPath();
099      if (inProgress.contains(path))
100         throw rex("Circular substitution: %s", path);
101      inProgress.add(path);
102      try {
103         var found = lookup(path);
104         if (found == null) {
105            if (optional)
106               return null;
107            throw rex("Substitution '$%s' not found", path);
108         }
109         var resolved = resolveValue(found);
110         if (resolved != null)
111            return resolved;
112         return optional ? null : found;
113      } finally {
114         inProgress.remove(path);
115      }
116   }
117
118   private HoconValue resolveConcat(HoconValue.HoconConcat concat) {
119      var sb = new StringBuilder();
120      for (var p : concat.getParts()) {
121         var resolved = resolveValue(p);
122         if (resolved != null)
123            sb.append(resolveConcatPart(resolved));
124      }
125      return new HoconValue.HoconString(sb.toString());
126   }
127
128   private static String resolveConcatPart(HoconValue val) {
129      if (val == null)
130         return "";
131      return switch (val.getType()) {
132         case STRING -> ((HoconValue.HoconString) val).getValue();
133         case NUMBER -> ((HoconValue.HoconNumber) val).getValue().toString();
134         case BOOLEAN -> String.valueOf(((HoconValue.HoconBoolean) val).getValue());
135         case NULL -> "null";
136         default -> val.toString();
137      };
138   }
139
140   /**
141    * Looks up a dotted path in the root object.
142    *
143    * @param path The path (e.g., "database.host").
144    *    <br>Can be <jk>null</jk> (returns <jk>null</jk>).
145    * @return The value at that path, or <jk>null</jk> if the path is <jk>null</jk>, blank, or does not resolve to an
146    *    existing value.
147    */
148   public HoconValue lookup(String path) {
149      if (isEmpty(path))
150         return null;
151      var parts = path.split("\\.", -1);
152      return root.getPath(parts);
153   }
154
155   /**
156    * Resolves a concatenation value with a custom lookup (for self-referential substitution).
157    * When the concat contains ${path}, the lookup is used instead of the root object.
158    * This allows <code>path = ${path}"/bin"</code> to append to the previous value.
159    *
160    * @param concat The concatenation to resolve.
161    *    <br>Must not be <jk>null</jk>.
162    * @param lookup Custom lookup; for path X returns the value to use (e.g. previous value for self-ref).
163    *    <br>Must not be <jk>null</jk>.
164    * @return The resolved string value.
165    */
166   public HoconValue resolveConcatWithLookup(HoconValue.HoconConcat concat, Function<String,HoconValue> lookup) {
167      var sb = new StringBuilder();
168      for (var p : concat.getParts()) {
169         var resolved = resolveValueWithLookup(p, lookup);
170         if (resolved != null)
171            sb.append(resolveConcatPart(resolved));
172      }
173      return new HoconValue.HoconString(sb.toString());
174   }
175
176   private HoconValue resolveValueWithLookup(HoconValue val, Function<String,HoconValue> lookup) {
177      if (val == null)
178         return null;
179      return switch (val.getType()) {
180         case STRING, NUMBER, BOOLEAN, NULL -> val;
181         case OBJECT -> {
182            resolveObject((HoconValue.HoconObject) val);
183            yield val;
184         }
185         case ARRAY -> {
186            var arr = (HoconValue.HoconArray) val;
187            var resolved = arr.getElements().stream().map(el -> resolveValueWithLookup(el, lookup)).collect(Collectors.toCollection(ArrayList::new));
188            arr.setElements(resolved);
189            yield val;
190         }
191         case SUBSTITUTION -> resolveSubstitutionWithLookup((HoconValue.HoconSubstitution) val, false, lookup);
192         case OPT_SUBSTITUTION -> resolveSubstitutionWithLookup((HoconValue.HoconSubstitution) val, true, lookup);
193         case CONCAT -> resolveConcatWithLookup((HoconValue.HoconConcat) val, lookup);
194         default -> val;
195      };
196   }
197
198   private HoconValue resolveSubstitutionWithLookup(HoconValue.HoconSubstitution sub, boolean optional,
199         Function<String,HoconValue> lookup) {
200      var path = sub.getPath();
201      if (inProgress.contains(path))
202         throw rex("Circular substitution: %s", path);
203      inProgress.add(path);
204      try {
205         var found = lookup.apply(path);
206         if (found == null) {
207            if (optional)
208               return null;
209            throw rex("Substitution '$%s' not found", path);
210         }
211         var resolved = resolveValueWithLookup(found, lookup);
212         if (resolved != null)
213            return resolved;
214         return optional ? null : found;
215      } finally {
216         inProgress.remove(path);
217      }
218   }
219
220}