JUnitMigrationPlan.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.internal.corext.fix.multifile;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_AFTER_EACH;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_ASSERTION_MESSAGE_FIRST;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_ASSERTION_QUALIFY;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_BEFORE_EACH;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_HIERARCHY_TYPE;
import static org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.ROLE_TEST_METHOD;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
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 java.util.stream.Collectors;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.sandbox.jdt.cleanup.multifile.SelectedCompilationUnitPlan;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.InvocationKind;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.InvocationMigration;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.MethodKind;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.MethodMigration;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.TypeMigration;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan.NodeKey;
import org.sandbox.jdt.triggerpattern.cleanup.PlanAwareHintFileFixCore;
/** Immutable project plan for coordinated JUnit migration edits. */
public record JUnitMigrationPlan(SelectedCompilationUnitPlan selectedScope,
List<ExternalResourceRuleMigration> externalResourceRules,
List<JUnit3HierarchyMigration> junit3Hierarchies,
JUnitTestTypeInventory testTypeInventory) {
private static final String JUNIT3_HINT_RESOURCE=
"org/sandbox/jdt/internal/corext/fix/hints/junit3-hierarchy-to-jupiter.sandbox-hint"; //$NON-NLS-1$
private static final String FACT_REMOVE_TEST_CASE_SUPERCLASS= "removeTestCaseSuperclass"; //$NON-NLS-1$
private static final String FACT_TEST_ORDER= "testOrder"; //$NON-NLS-1$
private static final String FACT_REMOVE_OVERRIDE= "removeOverride"; //$NON-NLS-1$
public JUnitMigrationPlan {
Objects.requireNonNull(selectedScope);
externalResourceRules= List.copyOf(externalResourceRules);
junit3Hierarchies= List.copyOf(junit3Hierarchies);
testTypeInventory= Objects.requireNonNull(testTypeInventory);
}
public JUnitMigrationPlan(SelectedCompilationUnitPlan selectedScope,
List<ExternalResourceRuleMigration> externalResourceRules) {
this(selectedScope, externalResourceRules, List.of(), new JUnitTestTypeInventory(List.of()));
}
public boolean contains(ICompilationUnit unit) {
return selectedScope.contains(unit);
}
public boolean hasCoordinatedChanges() {
return !externalResourceRules.isEmpty() || !junit3Hierarchies.isEmpty();
}
public void addOperationsFor(ICompilationUnit unit, CompilationUnit root,
Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesProcessed)
throws CoreException {
String unitHandle= unit.getPrimary().getHandleIdentifier();
addExternalResourceOperations(unit, unitHandle, root, operations, nodesProcessed);
addJUnit3HierarchyOperations(unit, unitHandle, root, operations, nodesProcessed);
}
private void addExternalResourceOperations(ICompilationUnit unit, String unitHandle, CompilationUnit root,
Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesProcessed)
throws CoreException {
List<ExternalResourceRuleMigration> fieldMigrations= externalResourceRules.stream()
.filter(migration -> unitHandle.equals(migration.ruleCompilationUnitHandle())).toList();
List<ExternalResourceRuleMigration> typeMigrations= externalResourceRules.stream()
.filter(migration -> unitHandle.equals(migration.resourceCompilationUnitHandle())).toList();
if (fieldMigrations.isEmpty() && typeMigrations.isEmpty()) {
return;
}
Set<String> expectedFieldKeys= fieldMigrations.stream().map(ExternalResourceRuleMigration::fieldBindingKey)
.collect(Collectors.toSet());
Set<String> expectedTypeKeys= typeMigrations.stream().map(ExternalResourceRuleMigration::resourceTypeBindingKey)
.collect(Collectors.toSet());
JUnitMultiFileRewriteOperation.ResolvedEdits resolved= resolveExternalResources(root, fieldMigrations,
typeMigrations, expectedFieldKeys, expectedTypeKeys);
if (!resolved.fieldKeys().equals(expectedFieldKeys) || !resolved.typeKeys().equals(expectedTypeKeys)) {
throw staleExternalResourcePlan(unit, expectedFieldKeys, expectedTypeKeys, resolved);
}
nodesProcessed.addAll(resolved.fields().keySet());
nodesProcessed.addAll(resolved.resourceTypes().keySet());
operations.add(new JUnitMultiFileRewriteOperation(resolved));
}
private void addJUnit3HierarchyOperations(ICompilationUnit unit, String unitHandle, CompilationUnit root,
Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesProcessed)
throws CoreException {
List<TypeMigration> plannedTypes= junit3Hierarchies.stream()
.flatMap(hierarchy -> hierarchy.types().stream())
.filter(type -> unitHandle.equals(type.compilationUnitHandle()))
.toList();
if (plannedTypes.isEmpty()) {
return;
}
Map<String, TypeMigration> expectedTypes= plannedTypes.stream()
.collect(Collectors.toMap(TypeMigration::typeBindingKey, type -> type));
Set<String> expectedMethodKeys= plannedTypes.stream().flatMap(type -> type.methods().stream())
.map(MethodMigration::methodBindingKey).collect(Collectors.toCollection(LinkedHashSet::new));
Map<NodeKey, InvocationMigration> expectedInvocations= new LinkedHashMap<>();
for (TypeMigration type : plannedTypes) {
for (InvocationMigration invocation : type.invocations()) {
NodeKey key= NodeKey.invocation(invocation.methodBindingKey(), invocation.sourceStart(),
invocation.sourceLength());
expectedInvocations.put(key, invocation);
}
}
Map<String, TypeDeclaration> resolvedTypes= new LinkedHashMap<>();
Map<String, MethodDeclaration> resolvedMethods= new LinkedHashMap<>();
Map<NodeKey, MethodInvocation> resolvedInvocations= new LinkedHashMap<>();
root.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
String key= typeKey(node.resolveBinding());
if (key != null && expectedTypes.containsKey(key)) {
resolvedTypes.put(key, node);
}
return true;
}
@Override
public boolean visit(MethodDeclaration node) {
IMethodBinding binding= node.resolveBinding();
String key= binding == null ? null : binding.getMethodDeclaration().getKey();
if (key != null && expectedMethodKeys.contains(key)) {
resolvedMethods.put(key, node);
}
return true;
}
@Override
public boolean visit(MethodInvocation node) {
NodeKey key= NodeKey.from(node);
if (key != null && expectedInvocations.containsKey(key)) {
resolvedInvocations.put(key, node);
}
return true;
}
});
if (!resolvedTypes.keySet().equals(expectedTypes.keySet())
|| !resolvedMethods.keySet().equals(expectedMethodKeys)
|| !resolvedInvocations.keySet().equals(expectedInvocations.keySet())) {
throw staleJUnit3Plan(unit, expectedTypes.keySet(), expectedMethodKeys, expectedInvocations.keySet(),
resolvedTypes.keySet(), resolvedMethods.keySet(), resolvedInvocations.keySet());
}
SemanticRewritePlan.Builder plan= SemanticRewritePlan.builder("junit3-hierarchy"); //$NON-NLS-1$
Set<NodeKey> expectedHintTargets= new LinkedHashSet<>();
for (TypeMigration planned : plannedTypes) {
NodeKey typeKey= NodeKey.type(planned.typeBindingKey());
plan.add(typeKey, ROLE_HIERARCHY_TYPE)
.putBoolean(typeKey, FACT_REMOVE_TEST_CASE_SUPERCLASS,
planned.removeTestCaseSuperclass());
if (planned.removeTestCaseSuperclass()) {
expectedHintTargets.add(typeKey);
}
for (MethodMigration method : planned.methods()) {
NodeKey key= NodeKey.method(method.methodBindingKey());
plan.add(key, methodRole(method.kind()));
if (method.kind() == MethodKind.TEST) {
if (method.executionOrder() <= 0) {
throw new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", //$NON-NLS-1$
"The planned JUnit 3 test order is missing for " + method.methodBindingKey())); //$NON-NLS-1$
}
plan.putInteger(key, FACT_TEST_ORDER, method.executionOrder());
} else {
plan.putBoolean(key, FACT_REMOVE_OVERRIDE, method.removeOverride());
}
expectedHintTargets.add(key);
}
for (InvocationMigration invocation : planned.invocations()) {
NodeKey key= NodeKey.invocation(invocation.methodBindingKey(), invocation.sourceStart(),
invocation.sourceLength());
plan.add(key, invocation.kind() == InvocationKind.MESSAGE_FIRST
? ROLE_ASSERTION_MESSAGE_FIRST : ROLE_ASSERTION_QUALIFY);
expectedHintTargets.add(key);
}
nodesProcessed.add(resolvedTypes.get(planned.typeBindingKey()));
planned.methods().stream().map(MethodMigration::methodBindingKey)
.map(resolvedMethods::get).forEach(nodesProcessed::add);
planned.invocations().stream()
.map(invocation -> NodeKey.invocation(invocation.methodBindingKey(), invocation.sourceStart(),
invocation.sourceLength()))
.map(resolvedInvocations::get).forEach(nodesProcessed::add);
}
Set<NodeKey> covered= PlanAwareHintFileFixCore.findOperationsFromContent(root, loadJUnit3HintProgram(),
plan.build(), unit.getJavaProject().getOptions(true), operations, nodesProcessed);
if (!covered.equals(expectedHintTargets)) {
throw new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", //$NON-NLS-1$
"The plan-aware JUnit hint program covered " + covered + " but expected " //$NON-NLS-1$ //$NON-NLS-2$
+ expectedHintTargets));
}
}
private static String methodRole(MethodKind kind) {
return switch (kind) {
case TEST -> ROLE_TEST_METHOD;
case BEFORE_EACH -> ROLE_BEFORE_EACH;
case AFTER_EACH -> ROLE_AFTER_EACH;
};
}
private static String loadJUnit3HintProgram() throws CoreException {
try (InputStream stream= JUnitMigrationPlan.class.getClassLoader().getResourceAsStream(JUNIT3_HINT_RESOURCE)) {
if (stream == null) {
throw new IOException("Missing resource " + JUNIT3_HINT_RESOURCE); //$NON-NLS-1$
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", //$NON-NLS-1$
"Cannot load plan-aware JUnit 3 hint program", e)); //$NON-NLS-1$
}
}
private static JUnitMultiFileRewriteOperation.ResolvedEdits resolveExternalResources(CompilationUnit root,
List<ExternalResourceRuleMigration> fieldMigrations,
List<ExternalResourceRuleMigration> typeMigrations, Set<String> expectedFieldKeys,
Set<String> expectedTypeKeys) {
JUnitMultiFileRewriteOperation.ResolvedEdits.Builder builder= JUnitMultiFileRewriteOperation.ResolvedEdits.builder(root);
root.accept(new ASTVisitor() {
@Override
public boolean visit(FieldDeclaration node) {
for (Object fragmentObject : node.fragments()) {
VariableDeclarationFragment fragment= (VariableDeclarationFragment) fragmentObject;
IVariableBinding binding= fragment.resolveBinding();
String key= binding == null ? null : binding.getVariableDeclaration().getKey();
if (key != null && expectedFieldKeys.contains(key)) {
ExternalResourceRuleMigration migration= fieldMigrations.stream()
.filter(candidate -> key.equals(candidate.fieldBindingKey())).findFirst().orElseThrow();
builder.addField(node, key, migration.classRule(), findRuleAnnotation(node, migration.classRule()));
}
}
return true;
}
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding binding= node.resolveBinding();
String key= typeKey(binding);
if (key != null && expectedTypeKeys.contains(key)) {
ExternalResourceRuleMigration migration= typeMigrations.stream()
.filter(candidate -> key.equals(candidate.resourceTypeBindingKey())).findFirst().orElseThrow();
builder.addResourceType(node, key, migration.classRule());
}
return true;
}
});
return builder.build();
}
private static Annotation findRuleAnnotation(FieldDeclaration field, boolean classRule) {
String expected= classRule ? "org.junit.ClassRule" : "org.junit.Rule"; //$NON-NLS-1$ //$NON-NLS-2$
for (Object modifier : field.modifiers()) {
if (modifier instanceof Annotation annotation) {
ITypeBinding binding= annotation.resolveTypeBinding();
if (binding != null && expected.equals(binding.getQualifiedName())) {
return annotation;
}
}
}
return null;
}
static String typeKey(ITypeBinding binding) {
if (binding == null) {
return null;
}
ITypeBinding declaration= binding.getErasure().getTypeDeclaration();
return declaration == null ? null : declaration.getKey();
}
private static CoreException staleExternalResourcePlan(ICompilationUnit unit, Set<String> expectedFieldKeys,
Set<String> expectedTypeKeys, JUnitMultiFileRewriteOperation.ResolvedEdits resolved) {
String message= "The coordinated JUnit migration plan is stale for " + unit.getElementName() //$NON-NLS-1$
+ ". Expected fields " + expectedFieldKeys + " and types " + expectedTypeKeys //$NON-NLS-1$ //$NON-NLS-2$
+ ", but resolved fields " + resolved.fieldKeys() + " and types " + resolved.typeKeys(); //$NON-NLS-1$ //$NON-NLS-2$
return new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", message)); //$NON-NLS-1$
}
private static CoreException staleJUnit3Plan(ICompilationUnit unit, Set<String> expectedTypeKeys,
Set<String> expectedMethodKeys, Set<NodeKey> expectedInvocationKeys, Set<String> resolvedTypeKeys,
Set<String> resolvedMethodKeys, Set<NodeKey> resolvedInvocationKeys) {
String message= "The coordinated JUnit 3 hierarchy plan is stale for " + unit.getElementName() //$NON-NLS-1$
+ ". Expected types " + expectedTypeKeys + ", methods " + expectedMethodKeys //$NON-NLS-1$ //$NON-NLS-2$
+ " and invocations " + expectedInvocationKeys + ", but resolved types " + resolvedTypeKeys //$NON-NLS-1$ //$NON-NLS-2$
+ ", methods " + resolvedMethodKeys + " and invocations " + resolvedInvocationKeys; //$NON-NLS-1$ //$NON-NLS-2$
return new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", message)); //$NON-NLS-1$
}
}