JUnit4ParameterizedMigration.java
/*******************************************************************************
* Copyright (c) 2026 Carsten Hammer and others.
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which 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.JUnit4ParameterizedPlan.*;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.IJavaProject;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.*;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.cleanup.multifile.JavaProjectCompilationUnits;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan.NodeKey;
import org.sandbox.jdt.triggerpattern.cleanup.PlanAwareHintFileFixCore;
/** Applies the closed, constant-row ParameterizedClass execution contract. */
final class JUnit4ParameterizedMigration {
private JUnit4ParameterizedMigration() {
}
static void addOperations(JUnit4ParameterizedPlan plan, ICompilationUnit unit, CompilationUnit root,
Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> processed) throws CoreException {
if (!plan.executable() || !plan.compilationUnits().containsValue(unit.getPrimary().getHandleIdentifier())) {
return;
}
validateCurrentSources(plan);
Map<NodeKey, ASTNode> nodes= new LinkedHashMap<>();
root.accept(new ASTVisitor() {
@Override
public void preVisit(ASTNode node) {
NodeKey key= NodeKey.from(node);
if (key != null && plan.compilationUnits().containsKey(key)
&& !(node instanceof SimpleName)) {
nodes.put(key, node);
}
}
});
Set<NodeKey> expected= new HashSet<>();
plan.compilationUnits().forEach((key, owner) -> {
if (owner.equals(unit.getPrimary().getHandleIdentifier())) {
expected.add(key);
}
});
if (!nodes.keySet().equals(expected)) {
throw failure("The Parameterized declaration bindings changed; retry the complete migration."); //$NON-NLS-1$
}
Set<NodeKey> hintTargets= new HashSet<>();
for (Map.Entry<NodeKey, ASTNode> entry : nodes.entrySet()) {
Set<String> roles= plan.semanticPlan().rolesByNode().get(entry.getKey());
if (roles.contains(TEST) || roles.stream().anyMatch(role -> role.startsWith("JUNIT4_PARAMETERIZED_Before") //$NON-NLS-1$
|| role.startsWith("JUNIT4_PARAMETERIZED_After"))) { //$NON-NLS-1$
hintTargets.add(entry.getKey());
}
ASTNode declaration= entry.getValue();
processed.add(declaration);
if (declaration instanceof VariableDeclarationFragment) {
declaration= declaration.getParent();
}
if (declaration instanceof BodyDeclaration body) {
for (Object modifier : body.modifiers()) {
if (modifier instanceof Annotation annotation) {
processed.add(annotation);
}
}
}
}
Set<NodeKey> covered= PlanAwareHintFileFixCore.findOperationsFromContent(root, hintProgram(),
plan.semanticPlan(), unit.getJavaProject().getOptions(true), operations, processed);
if (!covered.equals(hintTargets)) {
throw failure("The Parameterized hint program did not cover every planned test and lifecycle method."); //$NON-NLS-1$
}
NodeKey providerKey= plan.semanticPlan().outgoing(plan.testClass(), HAS_PROVIDER).get(0).target();
if (nodes.entrySet().stream().noneMatch(entry -> entry.getKey().equals(providerKey)
|| plan.semanticPlan().rolesByNode().get(entry.getKey()).stream()
.anyMatch(Set.of(RUNNER_CLASS, FIELD, TEST)::contains))) {
return; // Delegated provider sources are validated but have no declaration edits.
}
operations.add(new CompilationUnitRewriteOperationWithSourceRange() {
@Override
public void rewriteASTInternal(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel)
throws CoreException {
TextEditGroup group= createTextEditGroup("Migrate the coordinated Parameterized class", cuRewrite); //$NON-NLS-1$
for (Map.Entry<NodeKey, ASTNode> entry : nodes.entrySet()) {
Set<String> roles= plan.semanticPlan().rolesByNode().get(entry.getKey());
if (roles.contains(RUNNER_CLASS)) {
rewriteClass((TypeDeclaration) entry.getValue(), plan, cuRewrite, group);
} else if (roles.contains(FIELD)) {
FieldDeclaration field= (FieldDeclaration) entry.getValue().getParent();
int index= plan.semanticPlan().outgoing(plan.testClass(), HAS_FIELD).stream()
.map(relation -> relation.target()).toList().indexOf(entry.getKey());
removeAnnotation(field, "org.junit.runners.Parameterized.Parameter", cuRewrite, group); //$NON-NLS-1$
addAnnotation(field, "org.junit.jupiter.params.Parameter", //$NON-NLS-1$
Map.of("value", root.getAST().newNumberLiteral(Integer.toString(index))), cuRewrite, group); //$NON-NLS-1$
} else if (roles.contains(PROVIDER) && entry.getKey().equals(
plan.semanticPlan().outgoing(plan.testClass(), HAS_PROVIDER).get(0).target())) {
removeAnnotation((MethodDeclaration) entry.getValue(), "org.junit.runners.Parameterized.Parameters", cuRewrite, group); //$NON-NLS-1$
} else if (roles.contains(TEST)) {
MethodDeclaration method= (MethodDeclaration) entry.getValue();
addAnnotation(method, "org.junit.jupiter.api.DisplayName", //$NON-NLS-1$
Map.of("value", literal(root.getAST(), method.getName().getIdentifier())), cuRewrite, group); //$NON-NLS-1$
}
}
}
});
}
private static void validateCurrentSources(JUnit4ParameterizedPlan plan) throws CoreException {
Map<String, String> sources= new LinkedHashMap<>();
Set<IJavaProject> projects= new HashSet<>();
for (String handle : plan.sourceFingerprints().keySet()) {
if (!(JavaCore.create(handle) instanceof ICompilationUnit unit) || !unit.exists() || unit.isReadOnly()) {
throw failure("A Parameterized source is missing or read-only; retry with the complete editable scope."); //$NON-NLS-1$
}
projects.add(unit.getJavaProject());
}
for (IJavaProject project : projects) {
for (ICompilationUnit unit : JavaProjectCompilationUnits.collect(project)) {
sources.put(unit.getPrimary().getHandleIdentifier(), unit.getPrimary().getSource());
}
}
if (!plan.isCurrent(sources)) {
throw failure("The Parameterized sources or source-scope membership changed; retry the complete migration."); //$NON-NLS-1$
}
}
private static void rewriteClass(TypeDeclaration type, JUnit4ParameterizedPlan plan,
CompilationUnitRewrite cuRewrite, TextEditGroup group) throws CoreException {
AST ast= type.getAST();
ASTRewrite rewrite= cuRewrite.getASTRewrite();
NodeKey providerKey= plan.semanticPlan().outgoing(plan.testClass(), HAS_PROVIDER).get(0).target();
ICompilationUnit providerUnit= (ICompilationUnit) JavaCore.create(plan.compilationUnits().get(providerKey));
ASTParser parser= ASTParser.newParser(AST.getJLSLatest());
parser.setSource(providerUnit);
parser.setResolveBindings(true);
CompilationUnit providerRoot= (CompilationUnit) parser.createAST(null);
List<MethodDeclaration> providers= new ArrayList<>();
providerRoot.accept(new ASTVisitor() {
@Override
public boolean visit(MethodDeclaration method) {
if (providerKey.equals(NodeKey.from(method))) {
providers.add(method);
}
return true;
}
});
if (providers.size() != 1) {
throw failure("The Parameterized provider binding changed before rewrite."); //$NON-NLS-1$
}
MethodDeclaration provider= providers.get(0);
String pattern= parametersName(provider);
Set<String> names= new HashSet<>();
for (ITypeBinding binding= type.resolveBinding(); binding != null; binding= binding.getSuperclass()) {
for (IMethodBinding method : binding.getDeclaredMethods()) {
names.add(method.getName());
}
}
String adapter= "jupiterArguments"; //$NON-NLS-1$
for (int suffix= 2; names.contains(adapter); suffix++) {
adapter= "jupiterArguments" + suffix; //$NON-NLS-1$
}
removeAnnotation(type, "org.junit.runner.RunWith", cuRewrite, group); //$NON-NLS-1$
addAnnotation(type, "org.junit.jupiter.params.ParameterizedClass", //$NON-NLS-1$
Map.of("name", literal(ast, "{argumentSetName}"), "autoCloseArguments", ast.newBooleanLiteral(false)), cuRewrite, group); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
addAnnotation(type, "org.junit.jupiter.params.provider.MethodSource", //$NON-NLS-1$
Map.of("value", literal(ast, adapter)), cuRewrite, group); //$NON-NLS-1$
TypeLiteral order= ast.newTypeLiteral();
order.setType(ast.newSimpleType(ast.newName("org.junit.jupiter.api.MethodOrderer.OrderAnnotation"))); //$NON-NLS-1$
addAnnotation(type, "org.junit.jupiter.api.TestMethodOrder", Map.of("value", order), cuRewrite, group); //$NON-NLS-1$ //$NON-NLS-2$
addAnnotation(type, "org.junit.jupiter.api.TestInstance", //$NON-NLS-1$
Map.of("value", ast.newName("org.junit.jupiter.api.TestInstance.Lifecycle.PER_METHOD")), cuRewrite, group); //$NON-NLS-1$ //$NON-NLS-2$
addAnnotation(type, "org.junit.jupiter.api.parallel.Execution", //$NON-NLS-1$
Map.of("value", ast.newName("org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD")), cuRewrite, group); //$NON-NLS-1$ //$NON-NLS-2$
// This fixed adapter preserves JUnit 4's zero-based MessageFormat names and
// evaluates the original provider once. Constructor bodies and fields stay intact.
String member= """
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> %s() {
java.lang.Object source = %s();
java.lang.Iterable<?> rows = source instanceof java.lang.Object[][] ? java.util.Arrays.asList((java.lang.Object[][]) source) : (java.lang.Iterable<?>) source;
java.util.List<org.junit.jupiter.params.provider.Arguments> result = new java.util.ArrayList<>();
int index = 0;
for (java.lang.Object row : rows) {
java.lang.Object[] arguments = (java.lang.Object[]) row;
java.lang.String name = "[" + java.text.MessageFormat.format(%s.replace("{index}", java.lang.Integer.toString(index++)), arguments) + "]";
result.add(org.junit.jupiter.params.provider.Arguments.argumentSet(name, arguments));
}
return result.stream();
}
""".replace("\n", "%n").formatted(adapter, provider.getName().getIdentifier(), literal(ast, pattern)); //$NON-NLS-1$ //$NON-NLS-2$
parser= ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_CLASS_BODY_DECLARATIONS);
parser.setSource(member.toCharArray());
TypeDeclaration parsed= (TypeDeclaration) parser.createAST(null);
if (parsed.getMethods().length != 1 || (parsed.getFlags() & ASTNode.MALFORMED) != 0) {
throw failure("Cannot materialize the typed Parameterized provider adapter."); //$NON-NLS-1$
}
rewrite.getListRewrite(type, TypeDeclaration.BODY_DECLARATIONS_PROPERTY)
.insertLast(ASTNode.copySubtree(ast, parsed.getMethods()[0]), group);
}
private static String parametersName(MethodDeclaration provider) throws CoreException {
for (Object item : provider.modifiers()) {
if (item instanceof Annotation annotation && annotation.resolveAnnotationBinding() != null
&& "org.junit.runners.Parameterized.Parameters".equals(annotation.resolveTypeBinding().getQualifiedName())) { //$NON-NLS-1$
for (IMemberValuePairBinding pair : annotation.resolveAnnotationBinding().getAllMemberValuePairs()) {
if ("name".equals(pair.getName()) && pair.getValue() instanceof String value) { //$NON-NLS-1$
return value;
}
}
}
}
throw failure("The Parameterized provider name is unresolved."); //$NON-NLS-1$
}
private static StringLiteral literal(AST ast, String value) {
StringLiteral result= ast.newStringLiteral();
result.setLiteralValue(value);
return result;
}
@SuppressWarnings("unchecked")
private static void addAnnotation(BodyDeclaration target, String name, Map<String, Expression> values,
CompilationUnitRewrite cuRewrite, TextEditGroup group) {
AST ast= target.getAST();
NormalAnnotation annotation= ast.newNormalAnnotation();
annotation.setTypeName(ast.newName(name));
values.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
MemberValuePair pair= ast.newMemberValuePair();
pair.setName(ast.newSimpleName(entry.getKey()));
pair.setValue(entry.getValue());
annotation.values().add(pair);
});
cuRewrite.getASTRewrite().getListRewrite(target, target.getModifiersProperty()).insertFirst(annotation, group);
}
private static void removeAnnotation(BodyDeclaration target, String name,
CompilationUnitRewrite cuRewrite, TextEditGroup group) {
for (Object item : target.modifiers()) {
if (item instanceof Annotation annotation && annotation.resolveTypeBinding() != null
&& name.equals(annotation.resolveTypeBinding().getQualifiedName())) {
cuRewrite.getImportRemover().registerRemovedNode(annotation);
cuRewrite.getASTRewrite().remove(annotation, group);
}
}
}
private static String hintProgram() throws CoreException {
String resource= "org/sandbox/jdt/internal/corext/fix/hints/junit4-parameterized-to-jupiter.sandbox-hint"; //$NON-NLS-1$
try (InputStream stream= JUnit4ParameterizedMigration.class.getClassLoader().getResourceAsStream(resource)) {
if (stream == null) {
throw new IOException(resource);
}
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", "Cannot load Parameterized hints", e)); //$NON-NLS-1$ //$NON-NLS-2$
}
}
private static CoreException failure(String message) {
return new CoreException(new Status(IStatus.ERROR, "sandbox_junit_cleanup", message)); //$NON-NLS-1$
}
}