1212import java .io .InputStreamReader ;
1313import java .io .Reader ;
1414import java .net .URI ;
15+ import java .net .URISyntaxException ;
1516import java .net .URL ;
1617import java .nio .file .Files ;
1718import java .nio .file .Path ;
19+ import java .util .ArrayList ;
20+ import java .util .HashMap ;
21+ import java .util .HashSet ;
22+ import java .util .LinkedHashMap ;
1823import java .util .List ;
24+ import java .util .Map ;
25+ import java .util .Set ;
1926import java .util .logging .Logger ;
2027import org .yaml .snakeyaml .LoaderOptions ;
2128import org .yaml .snakeyaml .composer .Composer ;
2229import org .yaml .snakeyaml .error .YAMLException ;
30+ import org .yaml .snakeyaml .nodes .MappingNode ;
2331import org .yaml .snakeyaml .nodes .Node ;
32+ import org .yaml .snakeyaml .nodes .NodeTuple ;
33+ import org .yaml .snakeyaml .nodes .ScalarNode ;
34+ import org .yaml .snakeyaml .nodes .SequenceNode ;
2435import org .yaml .snakeyaml .parser .ParserImpl ;
2536import org .yaml .snakeyaml .reader .StreamReader ;
2637import org .yaml .snakeyaml .resolver .Resolver ;
@@ -35,9 +46,16 @@ public final class YamlUtils {
3546 public static Node merge (List <YamlSource > sources , ConfigurationContext context ) throws ConfiguratorException {
3647 Node root = null ;
3748 MergeStrategy mergeStrategy = MergeStrategyFactory .getMergeStrategyOrDefault (context .getMergeStrategy ());
49+ Map <String , Node > parsedCache = new HashMap <>();
50+
3851 for (YamlSource <?> source : sources ) {
3952 try (Reader reader = reader (source )) {
40- final Node node = read (source , reader , context );
53+ Node node = read (source , reader , context );
54+ if (node != null ) {
55+ Set <String > visited = new HashSet <>();
56+ visited .add (getCanonicalId (source ));
57+ node = resolveExtends (node , source , context , visited , parsedCache );
58+ }
4159
4260 if (root == null ) {
4361 root = node ;
@@ -128,4 +146,308 @@ public Node getSingleNode() {
128146 });
129147 return (Mapping ) constructor .getSingleData (Mapping .class );
130148 }
149+
150+ private static Node resolveExtends (
151+ Node node ,
152+ YamlSource <?> currentSource ,
153+ ConfigurationContext context ,
154+ Set <String > visited ,
155+ Map <String , Node > parsedCache )
156+ throws ConfiguratorException {
157+
158+ if (node instanceof MappingNode mapNode ) {
159+ List <NodeTuple > originalTuples = mapNode .getValue ();
160+ List <NodeTuple > resolvedTuples = new ArrayList <>();
161+
162+ List <String > extendsPaths = new ArrayList <>();
163+ boolean hasChanges = false ;
164+
165+ for (NodeTuple tuple : originalTuples ) {
166+ Node keyNode = tuple .getKeyNode ();
167+ Node valueNode = tuple .getValueNode ();
168+
169+ if (keyNode instanceof ScalarNode key && "_extends" .equals (key .getValue ())) {
170+ if (valueNode instanceof ScalarNode scalar ) {
171+ if (scalar .getValue () == null
172+ || scalar .getValue ().trim ().isEmpty ()) {
173+ throw new ConfiguratorException ("The '_extends' property cannot be empty." );
174+ }
175+ extendsPaths .add (scalar .getValue ());
176+ } else if (valueNode instanceof SequenceNode seq ) {
177+ if (seq .getValue ().isEmpty ()) {
178+ throw new ConfiguratorException ("The '_extends' list cannot be empty." );
179+ }
180+ for (Node item : seq .getValue ()) {
181+ if (item instanceof ScalarNode scalarItem ) {
182+ String path = scalarItem .getValue ();
183+ if (path == null || path .trim ().isEmpty ()) {
184+ throw new ConfiguratorException (
185+ "Items in the '_extends' list cannot be null or empty strings." );
186+ }
187+ extendsPaths .add (path );
188+ } else {
189+ throw new ConfiguratorException (String .format (
190+ "Invalid item in '_extends': expected string but got %s in %s" ,
191+ item .getNodeId (), currentSource ));
192+ }
193+ }
194+ } else {
195+ throw new ConfiguratorException (String .format (
196+ "Invalid value for '_extends' key. Expected string or list of strings, but found: %s" ,
197+ valueNode .getNodeId ()));
198+ }
199+ hasChanges = true ;
200+ continue ;
201+ }
202+
203+ Node resolvedValue =
204+ resolveExtends (valueNode , currentSource , context , Set .copyOf (visited ), parsedCache );
205+
206+ if (resolvedValue != valueNode ) {
207+ resolvedTuples .add (new NodeTuple (keyNode , resolvedValue ));
208+ hasChanges = true ;
209+ } else {
210+ resolvedTuples .add (tuple );
211+ }
212+ }
213+
214+ if (!hasChanges ) {
215+ return node ;
216+ }
217+
218+ MappingNode newMapNode = new MappingNode (
219+ mapNode .getTag (),
220+ true ,
221+ resolvedTuples ,
222+ mapNode .getStartMark (),
223+ mapNode .getEndMark (),
224+ mapNode .getFlowStyle ());
225+
226+ if (!extendsPaths .isEmpty ()) {
227+ Node baseNode = null ;
228+
229+ for (String path : extendsPaths ) {
230+ YamlSource <?> parentSource = resolveRelativeSource (currentSource , path );
231+ String parentId = getCanonicalId (parentSource );
232+
233+ if (visited .contains (parentId )) {
234+ throw new ConfiguratorException ("Circular _extends dependency detected: " + parentId );
235+ }
236+
237+ Node parentNode ;
238+ if (parsedCache .containsKey (parentId )) {
239+ parentNode = cloneNode (parsedCache .get (parentId ));
240+ } else {
241+ Set <String > newVisited = new HashSet <>(visited );
242+ newVisited .add (parentId );
243+
244+ try (Reader parentReader = reader (parentSource )) {
245+ parentNode = read (parentSource , parentReader , context );
246+ } catch (IOException e ) {
247+ throw new ConfiguratorException ("Failed to read extended config: " + path , e );
248+ }
249+
250+ parentNode =
251+ resolveExtends (parentNode , parentSource , context , Set .copyOf (newVisited ), parsedCache );
252+
253+ parentNode = cloneNode (parentNode );
254+ parsedCache .put (parentId , parentNode );
255+ }
256+
257+ if (baseNode == null ) {
258+ baseNode = parentNode ;
259+ } else {
260+ baseNode = deepMergeNodes (baseNode , parentNode );
261+ }
262+ }
263+
264+ return deepMergeNodes (baseNode , newMapNode );
265+ }
266+
267+ return newMapNode ;
268+
269+ } else if (node instanceof SequenceNode seqNode ) {
270+ List <Node > originalChildren = seqNode .getValue ();
271+ List <Node > resolvedChildren = new ArrayList <>();
272+ boolean hasChanges = false ;
273+
274+ for (Node child : originalChildren ) {
275+ Node resolvedChild = resolveExtends (child , currentSource , context , Set .copyOf (visited ), parsedCache );
276+ resolvedChildren .add (resolvedChild );
277+ if (resolvedChild != child ) {
278+ hasChanges = true ;
279+ }
280+ }
281+
282+ if (hasChanges ) {
283+ return new SequenceNode (
284+ seqNode .getTag (),
285+ true ,
286+ resolvedChildren ,
287+ seqNode .getStartMark (),
288+ seqNode .getEndMark (),
289+ seqNode .getFlowStyle ());
290+ }
291+ }
292+
293+ return node ;
294+ }
295+
296+ private static YamlSource <?> resolveRelativeSource (YamlSource <?> currentSource , String extendsPath )
297+ throws ConfiguratorException {
298+ if (ConfigurationAsCode .isSupportedURI (extendsPath )) {
299+ return YamlSource .of (extendsPath );
300+ }
301+
302+ Object src = currentSource .source ;
303+
304+ if (src instanceof Path currentPath ) {
305+ Path resolvedPath = currentPath .resolveSibling (extendsPath ).normalize ();
306+
307+ if (!Files .exists (resolvedPath )) {
308+ throw new ConfiguratorException ("Extended configuration file does not exist: " + resolvedPath );
309+ }
310+ return YamlSource .of (resolvedPath );
311+
312+ } else if (src instanceof String ) {
313+ try {
314+ URI currentUri = new URI ((String ) src );
315+ URI resolvedUri = currentUri .resolve (extendsPath ).normalize ();
316+ return YamlSource .of (resolvedUri .toString ());
317+ } catch (URISyntaxException e ) {
318+ throw new ConfiguratorException ("Invalid base URI to resolve against: " + src , e );
319+ }
320+
321+ } else if (src instanceof HttpServletRequest || src instanceof InputStream ) {
322+ throw new ConfiguratorException (
323+ "Relative `_extends` paths ('" + extendsPath + "') are not supported for inline configurations. "
324+ + "Use an absolute file: or http(s): URL instead." );
325+ }
326+
327+ throw new ConfiguratorException ("Cannot resolve relative path '" + extendsPath + "' for source type: "
328+ + src .getClass ().getSimpleName ());
329+ }
330+
331+ private static String extractKey (Node keyNode ) throws ConfiguratorException {
332+ if (keyNode instanceof ScalarNode scalarKey ) {
333+ return scalarKey .getValue ();
334+ }
335+ throw new ConfiguratorException (String .format (
336+ "Invalid YAML key type: %s. JCasC only supports scalar (string) keys." , keyNode .getNodeId ()));
337+ }
338+
339+ private static Node deepMergeNodes (Node base , Node override ) throws ConfiguratorException {
340+ if (base != null && override != null ) {
341+ boolean isBaseMap = base instanceof MappingNode ;
342+ boolean isBaseSeq = base instanceof SequenceNode ;
343+ boolean isOverrideMap = override instanceof MappingNode ;
344+ boolean isOverrideSeq = override instanceof SequenceNode ;
345+
346+ if ((isBaseMap && isOverrideSeq ) || (isBaseSeq && isOverrideMap )) {
347+ throw new ConfiguratorException (String .format (
348+ "Type mismatch during merge: Cannot merge a %s and a %s. "
349+ + "Check your '_extends' hierarchy for incompatible data structures." ,
350+ override .getNodeId (), base .getNodeId ()));
351+ }
352+ }
353+ if (base instanceof MappingNode baseMap && override instanceof MappingNode overrideMap ) {
354+ Map <String , NodeTuple > mergedTuples = new LinkedHashMap <>();
355+
356+ for (NodeTuple bTuple : baseMap .getValue ()) {
357+ String key = extractKey (bTuple .getKeyNode ());
358+ mergedTuples .put (key , bTuple );
359+ }
360+
361+ for (NodeTuple oTuple : overrideMap .getValue ()) {
362+ String key = extractKey (oTuple .getKeyNode ());
363+
364+ if (mergedTuples .containsKey (key )) {
365+ Node bValue = mergedTuples .get (key ).getValueNode ();
366+ Node oValue = oTuple .getValueNode ();
367+
368+ if (bValue instanceof MappingNode && oValue instanceof MappingNode ) {
369+ Node mergedValue = deepMergeNodes (bValue , oValue );
370+ mergedTuples .put (key , new NodeTuple (oTuple .getKeyNode (), mergedValue ));
371+ } else {
372+ mergedTuples .put (key , new NodeTuple (oTuple .getKeyNode (), cloneNode (oValue )));
373+ }
374+ } else {
375+ mergedTuples .put (key , new NodeTuple (oTuple .getKeyNode (), cloneNode (oTuple .getValueNode ())));
376+ }
377+ }
378+
379+ return new MappingNode (
380+ overrideMap .getTag (),
381+ true ,
382+ new ArrayList <>(mergedTuples .values ()),
383+ baseMap .getStartMark (),
384+ overrideMap .getEndMark (),
385+ overrideMap .getFlowStyle ());
386+ }
387+ return cloneNode (override );
388+ }
389+
390+ private static Node cloneNode (Node node ) {
391+ if (node == null ) {
392+ return null ;
393+ }
394+
395+ if (node instanceof MappingNode mapNode ) {
396+ List <NodeTuple > clonedTuples = new ArrayList <>();
397+ for (NodeTuple tuple : mapNode .getValue ()) {
398+ clonedTuples .add (new NodeTuple (cloneNode (tuple .getKeyNode ()), cloneNode (tuple .getValueNode ())));
399+ }
400+ return new MappingNode (
401+ mapNode .getTag (),
402+ true ,
403+ clonedTuples ,
404+ mapNode .getStartMark (),
405+ mapNode .getEndMark (),
406+ mapNode .getFlowStyle ());
407+
408+ } else if (node instanceof SequenceNode seqNode ) {
409+ List <Node > clonedChildren = new ArrayList <>();
410+ for (Node child : seqNode .getValue ()) {
411+ clonedChildren .add (cloneNode (child ));
412+ }
413+ return new SequenceNode (
414+ seqNode .getTag (),
415+ true ,
416+ clonedChildren ,
417+ seqNode .getStartMark (),
418+ seqNode .getEndMark (),
419+ seqNode .getFlowStyle ());
420+ }
421+
422+ if (node instanceof ScalarNode scalarNode ) {
423+ return new ScalarNode (
424+ scalarNode .getTag (),
425+ scalarNode .getValue (),
426+ scalarNode .getStartMark (),
427+ scalarNode .getEndMark (),
428+ scalarNode .getScalarStyle ());
429+ }
430+
431+ return node ;
432+ }
433+
434+ private static String getCanonicalId (YamlSource <?> source ) {
435+ Object src = source .source ;
436+
437+ if (src instanceof Path path ) {
438+ try {
439+ return path .toRealPath ().toString ();
440+ } catch (IOException e ) {
441+ return path .toAbsolutePath ().normalize ().toString ();
442+ }
443+ } else if (src instanceof String url ) {
444+ try {
445+ return new URI (url ).normalize ().toString ();
446+ } catch (URISyntaxException e ) {
447+ return url ;
448+ }
449+ }
450+
451+ return source .toString ();
452+ }
131453}
0 commit comments