From b6c01f13dec3ff12d61cbcb36a4ab802968f731d Mon Sep 17 00:00:00 2001 From: Jimin15 Date: Sun, 25 Jan 2026 17:27:14 +0900 Subject: [PATCH] Add CycleAwareDependencyGraphDumper to visualize cycles (resolves #1561) --- .../CycleAwareDependencyGraphDumper.java | 240 ++++++++++++++++++ .../CycleAwareDependencyGraphDumperTest.java | 103 ++++++++ 2 files changed, 343 insertions(+) create mode 100644 maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java create mode 100644 maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java new file mode 100644 index 000000000..d429ed80f --- /dev/null +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumper.java @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.util.graph.visitor; + +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.eclipse.aether.artifact.Artifact; +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.graph.DependencyVisitor; +import org.eclipse.aether.util.artifact.ArtifactIdUtils; + +import static java.util.Objects.requireNonNull; + +/** + * A dependency visitor that visualizes cycles in dependency graphs while preventing StackOverflow errors. + * This visitor wraps a {@link DependencyGraphDumper} and adds cycle detection and visualization capabilities. + *

+ * When a cycle is detected (a node with the same versionless artifact ID appears again in the current path), + * it is displayed with a reference notation like {@code ^N} where N is the index of the node in the path that + * it cycles back to. The visitor then stops traversing children of the cycle node to prevent infinite recursion. + *

+ *

+ * This visitor is particularly useful for visualizing dependency graphs in FULL verbosity mode where cycles + * are preserved in the graph structure. + *

+ * + * @since 2.0.0 + */ +public class CycleAwareDependencyGraphDumper implements DependencyVisitor { + + private final Consumer consumer; + private final DependencyGraphDumper dumper; + private final Deque currentPath; + private final Stack isCycleStack; + + /** + * Creates a new cycle-aware dependency graph dumper with the specified consumer. + * + * @param consumer the string consumer, must not be {@code null} + */ + public CycleAwareDependencyGraphDumper(Consumer consumer) { + this.consumer = requireNonNull(consumer, "consumer cannot be null"); + this.dumper = new DependencyGraphDumper(consumer); + this.currentPath = new ArrayDeque<>(); + this.isCycleStack = new Stack<>(); + } + + /** + * Creates a new cycle-aware dependency graph dumper with the specified consumer and decorators. + * + * @param consumer the string consumer, must not be {@code null} + * @param decorators the decorators to apply, must not be {@code null} + */ + public CycleAwareDependencyGraphDumper( + Consumer consumer, Collection> decorators) { + this.consumer = requireNonNull(consumer, "consumer cannot be null"); + this.dumper = new DependencyGraphDumper(consumer, decorators); + this.currentPath = new ArrayDeque<>(); + this.isCycleStack = new Stack<>(); + } + + @Override + public boolean visitEnter(DependencyNode node) { + requireNonNull(node, "node cannot be null"); + + // Check for cycle BEFORE adding current node to path + int cycleIndex = findCycleInPath(node); + boolean isCycle = cycleIndex >= 0; + + // Add node to path after checking (for formatting purposes) + currentPath.push(node); + isCycleStack.push(isCycle); + + if (isCycle) { + // Format the cycle line with reference notation + // Use custom formatting for cycle nodes since they might be references + String indentation = formatCycleIndentation(currentPath); + String nodeStr = dumper.formatNode(currentPath); + String line = indentation + nodeStr + " ^" + cycleIndex; + consumer.accept(line); + return false; // Stop traversing children to prevent infinite recursion + } + + // Delegate to the wrapped dumper for normal nodes + return dumper.visitEnter(node); + } + + @Override + public boolean visitLeave(DependencyNode node) { + if (!currentPath.isEmpty() && currentPath.peek() == node) { + currentPath.pop(); + } + + Boolean isCycle = isCycleStack.pop(); + + // Only call dumper.visitLeave if we called dumper.visitEnter + // (i.e., if it's not a cycle node) + if (isCycle) { + return true; + } + + return dumper.visitLeave(node); + } + + /** + * Finds if the given node creates a cycle in the current path by checking if a node with the same + * versionless artifact ID already exists in the path. + * + * @param node the node to check, must not be {@code null} + * @return the index of the node in the current path that has the same versionless ID, or {@code -1} if no cycle + */ + private int findCycleInPath(DependencyNode node) { + Artifact currentArtifact = node.getArtifact(); + if (currentArtifact == null) { + return -1; + } + + int index = 0; + for (DependencyNode pathNode : currentPath) { + Artifact pathArtifact = pathNode.getArtifact(); + if (pathArtifact != null + && ArtifactIdUtils.equalsVersionlessId(currentArtifact, pathArtifact)) { + return index; // Return the index of the node in the path (0-based, root is 0) + } + index++; + } + return -1; // No cycle found + } + + /** + * Formats the indentation for a cycle node. This is needed because cycle nodes + * might not be in the parent's children list (they are references), so we need + * to manually calculate the indentation based on the path structure. + *

+ * This method uses the same logic as {@link DependencyGraphDumper#formatIndentation(Deque)}, + * but additionally handles cycle nodes that might be references by using artifact ID + * comparison when identity comparison fails. + *

+ * + * @param path the current path including the cycle node + * @return the indentation string for the cycle node + */ + private String formatCycleIndentation(Deque path) { + if (path.size() < 2) { + return ""; + } + + StringBuilder buffer = new StringBuilder(128); + Iterator iter = path.descendingIterator(); + DependencyNode parent = iter.hasNext() ? iter.next() : null; + DependencyNode child = iter.hasNext() ? iter.next() : null; + DependencyNode cycleNode = path.peekFirst(); // The cycle node is at the top + + while (parent != null && child != null) { + boolean isLast = isLastChild(parent, child); + boolean end = child == cycleNode; + + String indent = formatIndentString(isLast, end); + buffer.append(indent); + + parent = child; + child = iter.hasNext() ? iter.next() : null; + } + + return buffer.toString(); + } + + /** + * Determines if the given child is the last child of its parent. + * For cycle nodes that might be references, uses artifact ID comparison + * when identity comparison fails. + * + * @param parent the parent node + * @param child the child node to check + * @return {@code true} if the child is the last child, {@code false} otherwise + */ + private boolean isLastChild(DependencyNode parent, DependencyNode child) { + List children = parent.getChildren(); + if (children.isEmpty()) { + return false; + } + + DependencyNode lastChild = children.get(children.size() - 1); + + // Try identity comparison first (same as DependencyGraphDumper) + if (lastChild == child) { + return true; + } + + // If identity fails, try artifact ID comparison (for cycle nodes that are references) + Artifact childArtifact = child.getArtifact(); + if (childArtifact != null) { + Artifact lastArtifact = lastChild.getArtifact(); + if (lastArtifact != null + && ArtifactIdUtils.equalsVersionlessId(childArtifact, lastArtifact)) { + return true; + } + } + + return false; + } + + /** + * Formats the indentation string for a single level. + * + * @param isLast whether this is the last child + * @param isEnd whether this is the final node (cycle node) + * @return the indentation string ("+- ", "\\- ", "| ", or " ") + */ + private String formatIndentString(boolean isLast, boolean isEnd) { + if (isEnd) { + return isLast ? "\\- " : "+- "; + } else { + return isLast ? " " : "| "; + } + } +} + diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java new file mode 100644 index 000000000..f9f1b2e0b --- /dev/null +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/visitor/CycleAwareDependencyGraphDumperTest.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.eclipse.aether.util.graph.visitor; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.aether.graph.DependencyNode; +import org.eclipse.aether.internal.test.util.DependencyGraphParser; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class CycleAwareDependencyGraphDumperTest { + + private DependencyNode parse(String resource) throws Exception { + return new DependencyGraphParser("visitor/ordered-list/").parseResource(resource); + } + + @Test + void dumpSimple() throws Exception { + DependencyNode root = parse("simple.txt"); + + List cycleAwareOutput = new ArrayList<>(); + root.accept(new CycleAwareDependencyGraphDumper(cycleAwareOutput::add)); + + List standardOutput = new ArrayList<>(); + root.accept(new DependencyGraphDumper(standardOutput::add)); + + // For graphs without cycles, output should be identical + assertEquals(standardOutput.size(), cycleAwareOutput.size()); + for (int i = 0; i < standardOutput.size(); i++) { + assertEquals(standardOutput.get(i), cycleAwareOutput.get(i)); + } + } + + @Test + void dumpCycles() throws Exception { + DependencyNode root = parse("cycles.txt"); + + List output = new ArrayList<>(); + root.accept(new CycleAwareDependencyGraphDumper(output::add)); + + assertFalse(output.isEmpty()); + assertTrue(output.stream().anyMatch(line -> line.contains("^"))); + assertTrue(output.stream().anyMatch(line -> line.matches(".*\\^\\d+.*"))); + } + + @Test + void dumpCyclesNoStackOverflow() throws Exception { + DependencyNode root = parse("cycles.txt"); + List output = new ArrayList<>(); + assertDoesNotThrow(() -> root.accept(new CycleAwareDependencyGraphDumper(output::add))); + assertFalse(output.isEmpty()); + } + + @Test + void cycleReferencePointsToCorrectIndex() throws Exception { + DependencyNode root = parse("cycles.txt"); + List output = new ArrayList<>(); + root.accept(new CycleAwareDependencyGraphDumper(output::add)); + + String cycleLine = output.stream() + .filter(line -> line.contains("^")) + .findFirst() + .orElse(null); + + assertNotNull(cycleLine); + int cycleIndex = extractCycleIndex(cycleLine); + assertTrue(cycleIndex >= 0); + assertTrue(cycleIndex < output.size()); + } + + private int extractCycleIndex(String line) { + int caretIndex = line.indexOf('^'); + if (caretIndex < 0) { + return -1; + } + String afterCaret = line.substring(caretIndex + 1).trim(); + try { + return Integer.parseInt(afterCaret.split("\\s")[0]); + } catch (NumberFormatException e) { + return -1; + } + } +} +