diff --git a/prometeu-compiler/prometeu-deps/src/main/java/p/studio/compiler/workspaces/phases/StackPhase.java b/prometeu-compiler/prometeu-deps/src/main/java/p/studio/compiler/workspaces/phases/StackPhase.java index 6a99b68c..58027ff5 100644 --- a/prometeu-compiler/prometeu-deps/src/main/java/p/studio/compiler/workspaces/phases/StackPhase.java +++ b/prometeu-compiler/prometeu-deps/src/main/java/p/studio/compiler/workspaces/phases/StackPhase.java @@ -6,12 +6,9 @@ import p.studio.compiler.models.DependencyContext; import p.studio.compiler.models.ProjectInfoId; import p.studio.compiler.source.identifiers.ProjectId; import p.studio.compiler.workspaces.DependencyPhase; -import p.studio.utilities.structures.ReadOnlyList; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Deque; -import java.util.List; +import p.studio.utilities.structures.DependencyGraphAnaliser; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import static java.util.stream.Collectors.joining; @@ -21,62 +18,11 @@ public final class StackPhase implements DependencyPhase { */ @Override public BuildingIssueSink run(final DependencyContext ctx) { - final int n = ctx.projectTable.size(); - final int[] indeg = new int[n]; - for (int from = 0; from < n; from++) { - for (final ProjectId to : ctx.dependenciesByProject.get(from)) { - indeg[to.getIndex()]++; - } - } + final var analysis = new DependencyGraphAnaliser(java.util.Comparator.comparingInt(ProjectId::getIndex)) + .analyse(buildDependencyGraph(ctx)); - final Deque q = new ArrayDeque<>(); - for (int i = 0; i < n; i++) { - if (indeg[i] == 0) q.add(i); - } - - final List travesalOrder = new ArrayList<>(n); - // Performs topological sort using Kahn's algorithm - while (!q.isEmpty()) { - final int u = q.removeFirst(); - //travesalOrder.add(ctx.projectDescriptors.get(u).getProjectId()); - travesalOrder.add(new ProjectId(u)); - for (final ProjectId v : ctx.dependenciesByProject.get(u)) { - if (--indeg[v.getId()] == 0) { - q.addLast(v.getId()); - } - } - } - - if (travesalOrder.size() != n) { - boolean[] stuck = new boolean[n]; - for (int i = 0; i < n; i++) stuck[i] = indeg[i] > 0; - - var tarjan = new TarjanScc(n, ctx.dependenciesByProject); - final List> sccs = tarjan.run(stuck); - - final List> cycles = new ArrayList<>(); - for (var scc : sccs) { - if (scc.size() > 1) { - final var cycle = scc.stream().map(ProjectId::new).toList(); - cycles.add(cycle); - } else { - // size==1: cycle only if self-loop exists - final var u = scc.getFirst(); - final var projectId = new ProjectId(u); - boolean selfLoop = false; - for (final var pu : ctx.dependenciesByProject.get(u)) { - if (pu.getIndex() == u) { - selfLoop = true; - break; - } - } - if (selfLoop) { - cycles.add(List.of(projectId)); - } - } - } - - final var msg = "[DEPS]: cycle(s) detected:\n" + cycles.stream() + if (!analysis.acyclic()) { + final var msg = "[DEPS]: cycle(s) detected:\n" + analysis.cycleComponents().stream() .map(projectIds -> " * " + projectIds.stream() .map(pId -> ctx.projectInfoTable.get(new ProjectInfoId(pId.getIndex())).manifest.name()) .collect(joining(" -> ")) @@ -87,65 +33,16 @@ public final class StackPhase implements DependencyPhase { .report(builder -> builder.error(true).message(msg)); } - ctx.stack = new BuildStack(ReadOnlyList.wrap(travesalOrder)); + ctx.stack = new BuildStack(analysis.traversalOrder()); return BuildingIssueSink.empty(); } - static final class TarjanScc { - private final int n; - private final List> adj; - - private int time = 0; - private final int[] disc; - private final int[] low; - private final boolean[] inStack; - private final Deque stack = new ArrayDeque<>(); - - TarjanScc(int n, List> adj) { - this.n = n; - this.adj = adj; - this.disc = new int[n]; - this.low = new int[n]; - this.inStack = new boolean[n]; - java.util.Arrays.fill(disc, -1); - } - - List> run(boolean[] allowed) { - List> sccs = new ArrayList<>(); - for (int i = 0; i < n; i++) { - if (allowed[i] && disc[i] == -1) dfs(i, allowed, sccs); - } - return sccs; - } - - private void dfs(int u, boolean[] allowed, List> out) { - disc[u] = low[u] = time++; - stack.push(u); - inStack[u] = true; - - for (ProjectId pid : adj.get(u)) { - int v = pid.getIndex(); - if (!allowed[v]) continue; - - if (disc[v] == -1) { - dfs(v, allowed, out); - low[u] = Math.min(low[u], low[v]); - } else if (inStack[v]) { - low[u] = Math.min(low[u], disc[v]); - } - } - - if (low[u] == disc[u]) { - List scc = new ArrayList<>(); - while (true) { - int w = stack.pop(); - inStack[w] = false; - scc.add(w); - if (w == u) break; - } - out.add(scc); - } + private LinkedHashMap> buildDependencyGraph(final DependencyContext ctx) { + final var graph = new LinkedHashMap>(); + for (int index = 0; index < ctx.projectTable.size(); index++) { + graph.put(new ProjectId(index), new LinkedHashSet<>(ctx.dependenciesByProject.get(index))); } + return graph; } } diff --git a/prometeu-compiler/prometeu-deps/src/test/java/p/studio/compiler/workspaces/phases/StackPhaseTest.java b/prometeu-compiler/prometeu-deps/src/test/java/p/studio/compiler/workspaces/phases/StackPhaseTest.java new file mode 100644 index 00000000..523f759b --- /dev/null +++ b/prometeu-compiler/prometeu-deps/src/test/java/p/studio/compiler/workspaces/phases/StackPhaseTest.java @@ -0,0 +1,72 @@ +package p.studio.compiler.workspaces.phases; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import p.studio.compiler.messages.DependencyConfig; +import p.studio.compiler.models.DependencyContext; +import p.studio.compiler.models.ProjectDescriptor; +import p.studio.compiler.models.ProjectInfo; +import p.studio.compiler.models.PrometeuManifest; +import p.studio.compiler.source.identifiers.ProjectId; +import p.studio.utilities.structures.ReadOnlyList; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class StackPhaseTest { + + @TempDir + Path tempDir; + + @Test + void shouldBuildDeterministicTopologicalOrder() { + final var ctx = DependencyContext.basedOn(new DependencyConfig(false, tempDir)); + registerProject(ctx, "app"); + registerProject(ctx, "core"); + registerProject(ctx, "util"); + + ctx.dependenciesByProject.add(List.of(new ProjectId(1), new ProjectId(2))); + ctx.dependenciesByProject.add(List.of(new ProjectId(2))); + ctx.dependenciesByProject.add(List.of()); + + final var issues = new StackPhase().run(ctx); + + assertTrue(!issues.hasErrors()); + assertEquals(ReadOnlyList.wrap(List.of(new ProjectId(2), new ProjectId(1), new ProjectId(0))), ctx.stack.topologicalOrder); + } + + @Test + void shouldReportCycles() { + final var ctx = DependencyContext.basedOn(new DependencyConfig(false, tempDir)); + registerProject(ctx, "a"); + registerProject(ctx, "b"); + registerProject(ctx, "c"); + + ctx.dependenciesByProject.add(List.of(new ProjectId(1))); + ctx.dependenciesByProject.add(List.of(new ProjectId(2))); + ctx.dependenciesByProject.add(List.of(new ProjectId(0))); + + final var issues = new StackPhase().run(ctx); + + assertTrue(issues.hasErrors()); + assertTrue(issues.asCollection().stream().anyMatch(issue -> issue.getMessage().contains("a -> b -> c"))); + } + + private void registerProject(final DependencyContext ctx, final String name) { + final var root = tempDir.resolve(name); + ctx.projectTable.register(ProjectDescriptor.builder() + .rootPath(root) + .name(name) + .version("1.0.0") + .sourceRoots(ReadOnlyList.wrap(List.of())) + .build()); + ctx.projectInfoTable.register(ProjectInfo.builder() + .rootDirectory(root) + .manifestPath(root.resolve("prometeu.json")) + .manifest(new PrometeuManifest(name, "1.0.0", "pbs", 1, ReadOnlyList.wrap(List.of()))) + .build()); + } +} diff --git a/prometeu-infra/src/main/java/p/studio/utilities/structures/DependencyGraphAnaliser.java b/prometeu-infra/src/main/java/p/studio/utilities/structures/DependencyGraphAnaliser.java new file mode 100644 index 00000000..8ce46023 --- /dev/null +++ b/prometeu-infra/src/main/java/p/studio/utilities/structures/DependencyGraphAnaliser.java @@ -0,0 +1,154 @@ +package p.studio.utilities.structures; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import java.util.Set; + +public final class DependencyGraphAnaliser { + private final Comparator nodeOrder; + + public DependencyGraphAnaliser(final Comparator nodeOrder) { + this.nodeOrder = nodeOrder; + } + + public Analysis analyse(final Map> dependenciesByNode) { + final var canonicalGraph = canonicalize(dependenciesByNode); + final var indegree = new HashMap(); + final var reverse = new HashMap>(); + for (final var node : canonicalGraph.keySet()) { + indegree.put(node, 0); + reverse.put(node, new LinkedHashSet<>()); + } + + for (final var entry : canonicalGraph.entrySet()) { + for (final var dependency : entry.getValue()) { + indegree.put(entry.getKey(), indegree.get(entry.getKey()) + 1); + reverse.get(dependency).add(entry.getKey()); + } + } + + final var ready = new PriorityQueue(nodeOrder); + for (final var entry : indegree.entrySet()) { + if (entry.getValue() == 0) { + ready.add(entry.getKey()); + } + } + + final var traversalOrder = new ArrayList(canonicalGraph.size()); + while (!ready.isEmpty()) { + final var current = ready.remove(); + traversalOrder.add(current); + for (final var dependent : reverse.getOrDefault(current, new LinkedHashSet<>())) { + final var next = indegree.get(dependent) - 1; + indegree.put(dependent, next); + if (next == 0) { + ready.add(dependent); + } + } + } + + final var stronglyConnectedComponents = tarjan(canonicalGraph); + final var cycleComponents = new ArrayList>(); + for (final var scc : stronglyConnectedComponents) { + if (scc.size() > 1) { + cycleComponents.add(scc); + continue; + } + final var node = scc.getFirst(); + if (canonicalGraph.getOrDefault(node, new LinkedHashSet<>()).contains(node)) { + cycleComponents.add(scc); + } + } + + return new Analysis<>( + ReadOnlyList.wrap(traversalOrder), + ReadOnlyList.wrap(stronglyConnectedComponents), + ReadOnlyList.wrap(cycleComponents), + traversalOrder.size() == canonicalGraph.size()); + } + + private LinkedHashMap> canonicalize(final Map> dependenciesByNode) { + final var graph = new LinkedHashMap>(); + for (final var entry : dependenciesByNode.entrySet()) { + graph.computeIfAbsent(entry.getKey(), ignored -> new LinkedHashSet<>()); + for (final var dependency : entry.getValue()) { + graph.computeIfAbsent(dependency, ignored -> new LinkedHashSet<>()); + graph.get(entry.getKey()).add(dependency); + } + } + return graph; + } + + private List> tarjan(final LinkedHashMap> graph) { + final var indexByNode = new HashMap(); + final var lowLinkByNode = new HashMap(); + final var inStack = new HashSet(); + final var stack = new ArrayDeque(); + final var components = new ArrayList>(); + final var indexRef = new int[]{0}; + + for (final var node : graph.keySet()) { + if (!indexByNode.containsKey(node)) { + strongConnect(node, graph, indexByNode, lowLinkByNode, inStack, stack, components, indexRef); + } + } + + return components; + } + + private void strongConnect( + final N node, + final Map> graph, + final Map indexByNode, + final Map lowLinkByNode, + final Set inStack, + final Deque stack, + final List> components, + final int[] indexRef) { + indexByNode.put(node, indexRef[0]); + lowLinkByNode.put(node, indexRef[0]); + indexRef[0]++; + stack.push(node); + inStack.add(node); + + for (final var dependency : graph.getOrDefault(node, new LinkedHashSet<>())) { + if (!indexByNode.containsKey(dependency)) { + strongConnect(dependency, graph, indexByNode, lowLinkByNode, inStack, stack, components, indexRef); + lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), lowLinkByNode.get(dependency))); + } else if (inStack.contains(dependency)) { + lowLinkByNode.put(node, Math.min(lowLinkByNode.get(node), indexByNode.get(dependency))); + } + } + + if (lowLinkByNode.get(node).equals(indexByNode.get(node))) { + final var component = new ArrayList(); + while (true) { + final var current = stack.pop(); + inStack.remove(current); + component.add(current); + if (current.equals(node)) { + break; + } + } + component.sort(nodeOrder); + components.add(ReadOnlyList.wrap(component)); + } + } + + public record Analysis( + ReadOnlyList traversalOrder, + ReadOnlyList> stronglyConnectedComponents, + ReadOnlyList> cycleComponents, + boolean acyclic) { + } +} diff --git a/prometeu-infra/src/test/java/p/studio/utilities/structures/DependencyGraphAnaliserTest.java b/prometeu-infra/src/test/java/p/studio/utilities/structures/DependencyGraphAnaliserTest.java new file mode 100644 index 00000000..e4fc9343 --- /dev/null +++ b/prometeu-infra/src/test/java/p/studio/utilities/structures/DependencyGraphAnaliserTest.java @@ -0,0 +1,67 @@ +package p.studio.utilities.structures; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DependencyGraphAnaliserTest { + + @Test + void shouldTopologicallyOrderSimpleDag() { + final var graph = new LinkedHashMap>(); + graph.put("app", Set.of("core", "util")); + graph.put("core", Set.of("util")); + graph.put("util", Set.of()); + + final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph); + + assertTrue(analysis.acyclic()); + assertEquals(ReadOnlyList.wrap(List.of("util", "core", "app")), analysis.traversalOrder()); + assertTrue(analysis.cycleComponents().isEmpty()); + } + + @Test + void shouldDetectCycle() { + final var graph = new LinkedHashMap>(); + graph.put("a", Set.of("b")); + graph.put("b", Set.of("c")); + graph.put("c", Set.of("a")); + + final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph); + + assertFalse(analysis.acyclic()); + assertEquals(1, analysis.cycleComponents().size()); + assertEquals(ReadOnlyList.wrap(List.of("a", "b", "c")), analysis.cycleComponents().getFirst()); + } + + @Test + void shouldProduceDeterministicOrderForDisconnectedGraph() { + final var graph = new LinkedHashMap>(); + graph.put("zeta", Set.of()); + graph.put("alpha", Set.of()); + graph.put("beta", Set.of("alpha")); + + final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph); + + assertTrue(analysis.acyclic()); + assertEquals(ReadOnlyList.wrap(List.of("alpha", "beta", "zeta")), analysis.traversalOrder()); + } + + @Test + void shouldTreatSelfLoopAsCycle() { + final var graph = new LinkedHashMap>(); + graph.put("self", Set.of("self")); + + final var analysis = new DependencyGraphAnaliser<>(String::compareTo).analyse(graph); + + assertFalse(analysis.acyclic()); + assertEquals(1, analysis.cycleComponents().size()); + assertEquals(ReadOnlyList.wrap(List.of("self")), analysis.cycleComponents().getFirst()); + } +}