ContainerFlowComponentAssembler.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer.
*
* This program and the accompanying materials
* are made available under the terms of the Eclipse Public License 2.0
* which accompanies this distribution, and is available at
* https://www.eclipse.org/legal/epl-2.0.
*
* SPDX-License-Identifier: EPL-2.0
*******************************************************************************/
package org.sandbox.jdt.container.analysis;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.sandbox.jdt.container.api.ContainerFlowComponent;
import org.sandbox.jdt.container.api.ContainerFlowComponent.LocatedFlowDiagnostic;
import org.sandbox.jdt.container.api.ContainerFlowComponent.LocatedFlowEdge;
import org.sandbox.jdt.container.api.ContainerFlowGraph;
import org.sandbox.jdt.container.api.ContainerFlowGraph.ClosureStatus;
import org.sandbox.jdt.container.api.ContainerFlowGraph.DiagnosticKind;
import org.sandbox.jdt.container.api.ContainerFlowGraph.FlowDiagnostic;
import org.sandbox.jdt.container.api.ContainerFlowGraph.FlowEdge;
import org.sandbox.jdt.container.api.ContainerFlowGraph.FlowNode;
import org.sandbox.jdt.container.api.ContainerFlowGraph.NodeKind;
/**
* Assembles compilation-unit-local flow fragments into one canonical component.
*
* <p>Signature boundary nodes and their later source declarations use different local
* binding keys. They are therefore coalesced by semantic signature identity rather
* than by fragment-local node identifier.</p>
*/
public final class ContainerFlowComponentAssembler {
/** Assembles fragments in deterministic input order. */
public ContainerFlowComponent assemble(List<ContainerFlowGraph> fragments) {
Objects.requireNonNull(fragments, "fragments"); //$NON-NLS-1$
if (fragments.isEmpty()) {
throw new IllegalArgumentException("At least one flow fragment is required"); //$NON-NLS-1$
}
Map<String, MutableNode> nodes= new LinkedHashMap<>();
List<LocatedFlowEdge> edges= new ArrayList<>();
Set<String> edgeKeys= new LinkedHashSet<>();
List<LocatedFlowDiagnostic> diagnostics= new ArrayList<>();
Set<String> diagnosticKeys= new LinkedHashSet<>();
ClosureStatus status= ClosureStatus.LOCAL_CLOSED;
String componentRoot= null;
for (int fragmentIndex= 0; fragmentIndex < fragments.size(); fragmentIndex++) {
ContainerFlowGraph fragment= Objects.requireNonNull(
fragments.get(fragmentIndex), "fragment"); //$NON-NLS-1$
String fragmentUnit= compilationUnitHandle(fragment);
Map<String, String> remappedIds= new LinkedHashMap<>();
for (FlowNode node : fragment.nodes()) {
String canonicalId= canonicalId(node);
remappedIds.put(node.stableId(), canonicalId);
MutableNode merged= nodes.computeIfAbsent(
canonicalId, ignored -> new MutableNode(canonicalId, node));
if (merged.merge(node)) {
status= ClosureStatus.REJECTED;
addDiagnostic(
diagnostics,
diagnosticKeys,
fragmentUnit,
DiagnosticKind.UNCLASSIFIED_FLOW,
"Conflicting declarations map to the same canonical flow node: " //$NON-NLS-1$
+ canonicalId,
node.sourceStart(),
node.sourceLength());
}
}
if (fragmentIndex == 0) {
componentRoot= remappedIds.get(fragment.rootNodeId());
}
for (FlowEdge edge : fragment.edges()) {
String source= remappedIds.get(edge.sourceNodeId());
String target= remappedIds.get(edge.targetNodeId());
if (source == null || target == null) {
status= ClosureStatus.REJECTED;
addDiagnostic(
diagnostics,
diagnosticKeys,
fragmentUnit,
DiagnosticKind.UNCLASSIFIED_FLOW,
"A fragment edge cannot be mapped to canonical component nodes.", //$NON-NLS-1$
edge.sourceStart(),
edge.sourceLength());
continue;
}
String edgeUnit= edgeCompilationUnit(fragment, edge, fragmentUnit);
LocatedFlowEdge located= new LocatedFlowEdge(
edgeUnit,
source,
target,
edge.kind(),
edge.sourceStart(),
edge.sourceLength());
String key= edgeKey(located);
if (edgeKeys.add(key)) {
edges.add(located);
}
}
for (FlowDiagnostic diagnostic : fragment.diagnostics()) {
addDiagnostic(
diagnostics,
diagnosticKeys,
fragmentUnit,
diagnostic.kind(),
diagnostic.message(),
diagnostic.sourceStart(),
diagnostic.sourceLength());
}
status= moreSevere(status, fragment.closureStatus());
}
List<FlowNode> canonicalNodes= nodes.values().stream()
.map(MutableNode::toFlowNode)
.toList();
for (FlowNode node : canonicalNodes) {
if (node.kind() == NodeKind.EXTERNAL_PARAMETER) {
status= moreSevere(status, ClosureStatus.EXTERNAL_BOUNDARY);
} else if ((node.kind() == NodeKind.PARAMETER
|| node.kind() == NodeKind.RETURN_POSITION)
&& !node.sourceResolved()) {
status= moreSevere(status, ClosureStatus.REQUIRES_SCOPE_EXPANSION);
}
}
if (componentRoot == null || !nodes.containsKey(componentRoot)) {
throw new IllegalArgumentException("The first fragment root cannot be canonicalized"); //$NON-NLS-1$
}
return new ContainerFlowComponent(
componentRoot,
canonicalNodes,
edges,
status,
diagnostics);
}
private static String canonicalId(FlowNode node) {
return switch (node.kind()) {
case PARAMETER, EXTERNAL_PARAMETER ->
canonicalParameterId(node);
case RETURN_POSITION -> node.ownerKey().isBlank()
? "return-local:" + node.stableId() //$NON-NLS-1$
: "return:" + node.ownerKey(); //$NON-NLS-1$
case FIELD -> "field:" + preferredIdentity(node); //$NON-NLS-1$
case LOCAL_VARIABLE -> "local:" + preferredIdentity(node); //$NON-NLS-1$
case UNKNOWN_BOUNDARY -> "unknown:" + node.stableId(); //$NON-NLS-1$
};
}
private static String canonicalParameterId(FlowNode node) {
if (node.ownerKey().isBlank() || node.signatureIndex() < 0) {
return "parameter-local:" + node.stableId(); //$NON-NLS-1$
}
return "parameter:" + node.ownerKey() + ':' + node.signatureIndex(); //$NON-NLS-1$
}
private static String preferredIdentity(FlowNode node) {
if (!node.javaElementHandle().isBlank()) {
return node.javaElementHandle();
}
if (!node.bindingKey().isBlank()) {
return node.bindingKey();
}
return node.compilationUnitHandle() + ':' + node.stableId();
}
private static String compilationUnitHandle(ContainerFlowGraph fragment) {
FlowNode root= fragment.node(fragment.rootNodeId()).orElseThrow();
if (!root.compilationUnitHandle().isBlank()) {
return root.compilationUnitHandle();
}
return fragment.nodes().stream()
.map(FlowNode::compilationUnitHandle)
.filter(handle -> !handle.isBlank())
.findFirst()
.orElseThrow(() -> new IllegalArgumentException(
"A multi-file flow fragment requires a compilation-unit handle")); //$NON-NLS-1$
}
private static String edgeCompilationUnit(
ContainerFlowGraph fragment,
FlowEdge edge,
String fallback) {
return fragment.node(edge.sourceNodeId())
.map(FlowNode::compilationUnitHandle)
.filter(handle -> !handle.isBlank())
.orElse(fallback);
}
private static String edgeKey(LocatedFlowEdge edge) {
return edge.compilationUnitHandle() + '|' + edge.sourceNodeId() + '|'
+ edge.targetNodeId() + '|' + edge.kind() + '|' + edge.sourceStart();
}
private static void addDiagnostic(
List<LocatedFlowDiagnostic> diagnostics,
Set<String> keys,
String compilationUnitHandle,
DiagnosticKind kind,
String message,
int sourceStart,
int sourceLength) {
LocatedFlowDiagnostic diagnostic= new LocatedFlowDiagnostic(
compilationUnitHandle,
kind,
message,
sourceStart,
sourceLength);
String key= compilationUnitHandle + '|' + kind + '|' + message + '|' + sourceStart;
if (keys.add(key)) {
diagnostics.add(diagnostic);
}
}
private static ClosureStatus moreSevere(ClosureStatus left, ClosureStatus right) {
return rank(right) > rank(left) ? right : left;
}
private static int rank(ClosureStatus status) {
return switch (status) {
case LOCAL_CLOSED -> 0;
case REQUIRES_SCOPE_EXPANSION -> 1;
case EXTERNAL_BOUNDARY -> 2;
case REJECTED -> 3;
};
}
private static final class MutableNode {
private final String canonicalId;
private NodeKind kind;
private String bindingKey;
private String ownerKey;
private String compilationUnitHandle;
private String javaElementHandle;
private int signatureIndex;
private boolean sourceResolved;
private int sourceStart;
private int sourceLength;
MutableNode(String canonicalId, FlowNode initial) {
this.canonicalId= canonicalId;
kind= initial.kind();
bindingKey= initial.bindingKey();
ownerKey= initial.ownerKey();
compilationUnitHandle= initial.compilationUnitHandle();
javaElementHandle= initial.javaElementHandle();
signatureIndex= initial.signatureIndex();
sourceResolved= initial.sourceResolved();
sourceStart= initial.sourceStart();
sourceLength= initial.sourceLength();
}
boolean merge(FlowNode incoming) {
boolean conflict= false;
NodeKind mergedKind= mergeKind(kind, incoming.kind());
if (mergedKind == null) {
conflict= true;
} else {
kind= mergedKind;
}
TextMerge binding= mergeText(bindingKey, incoming.bindingKey());
TextMerge owner= mergeText(ownerKey, incoming.ownerKey());
TextMerge element= mergeText(javaElementHandle, incoming.javaElementHandle());
conflict|= binding.conflict() || owner.conflict() || element.conflict();
bindingKey= binding.value();
ownerKey= owner.value();
javaElementHandle= element.value();
if (signatureIndex < 0) {
signatureIndex= incoming.signatureIndex();
} else if (incoming.signatureIndex() >= 0
&& signatureIndex != incoming.signatureIndex()) {
conflict= true;
}
if (!sourceResolved && incoming.sourceResolved()) {
compilationUnitHandle= incoming.compilationUnitHandle();
sourceStart= incoming.sourceStart();
sourceLength= incoming.sourceLength();
}
sourceResolved|= incoming.sourceResolved();
return conflict;
}
FlowNode toFlowNode() {
return new FlowNode(
canonicalId,
kind,
bindingKey,
ownerKey,
compilationUnitHandle,
javaElementHandle,
signatureIndex,
sourceResolved,
sourceStart,
sourceLength);
}
private static NodeKind mergeKind(NodeKind current, NodeKind incoming) {
if (current == incoming) {
return current;
}
if ((current == NodeKind.PARAMETER && incoming == NodeKind.EXTERNAL_PARAMETER)
|| (current == NodeKind.EXTERNAL_PARAMETER && incoming == NodeKind.PARAMETER)) {
return NodeKind.EXTERNAL_PARAMETER;
}
return null;
}
private static TextMerge mergeText(String current, String incoming) {
if (current.isBlank()) {
return new TextMerge(incoming, false);
}
if (incoming.isBlank() || current.equals(incoming)) {
return new TextMerge(current, false);
}
return new TextMerge(current, true);
}
}
private record TextMerge(String value, boolean conflict) {
}
}