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}