JUnit4ParameterizedPlanner.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 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.JUnit4ParameterizedPlan.*;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.compiler.IProblem;
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.ArrayCreation;
import org.eclipse.jdt.core.dom.ArrayInitializer;
import org.eclipse.jdt.core.dom.BodyDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IAnnotationBinding;
import org.eclipse.jdt.core.dom.IBinding;
import org.eclipse.jdt.core.dom.IMemberValuePairBinding;
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.Initializer;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NullLiteral;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.sandbox.jdt.cleanup.multifile.MultiFileCandidateDiagnostic;
import org.sandbox.jdt.cleanup.multifile.MultiFileCandidateOutcome;
import org.sandbox.jdt.cleanup.multifile.MultiFilePlanningBudget;
import org.sandbox.jdt.internal.corext.fix.helper.ParameterizedMigrationEligibility;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan.NodeKey;
/** Discovers closed source components; never executes a provider or changes source. */
final class JUnit4ParameterizedPlanner {
private static final String PARAMETERS= "org.junit.runners.Parameterized.Parameters"; //$NON-NLS-1$
private static final String PARAMETER_ANNOTATION= "org.junit.runners.Parameterized.Parameter"; //$NON-NLS-1$
private static final String JUNIT_TEST= "org.junit.Test"; //$NON-NLS-1$
private static final String OBJECT= "java.lang.Object"; //$NON-NLS-1$
record Result(List<JUnit4ParameterizedPlan> plans, List<MultiFileCandidateDiagnostic> diagnostics) {
Result {
plans= List.copyOf(plans);
diagnostics= List.copyOf(diagnostics);
}
}
private final Map<String, CompilationUnit> roots;
private final Map<String, TypeDeclaration> types= new LinkedHashMap<>();
private final Map<String, MethodDeclaration> methods= new LinkedHashMap<>();
private final Map<ASTNode, String> handlesByRoot= new IdentityHashMap<>();
private final Map<String, String> fingerprints;
private final boolean sourcesStable;
private final boolean execute;
private final IProgressMonitor monitor;
private JUnit4ParameterizedPlanner(Map<String, CompilationUnit> roots, Map<String, String> snapshots,
boolean execute, IProgressMonitor monitor)
throws CoreException {
this.roots= new TreeMap<>(roots);
this.monitor= monitor;
this.execute= execute;
fingerprints= Map.copyOf(snapshots);
boolean stable= snapshots.keySet().equals(roots.keySet());
for (Map.Entry<String, CompilationUnit> entry : this.roots.entrySet()) {
MultiFilePlanningBudget.checkCanceled(monitor);
handlesByRoot.put(entry.getValue(), entry.getKey());
if (entry.getValue().getJavaElement() instanceof ICompilationUnit unit) {
String source= unit.getSource();
stable &= source != null
&& JUnit4ParameterizedPlan.fingerprint(source).equals(snapshots.get(entry.getKey()));
} else {
stable= false;
}
entry.getValue().accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding binding= node.resolveBinding();
if (resolved(binding)) {
types.put(binding.getTypeDeclaration().getKey(), node);
}
return true;
}
@Override
public boolean visit(MethodDeclaration node) {
IMethodBinding binding= node.resolveBinding();
if (resolved(binding)) {
methods.put(binding.getMethodDeclaration().getKey(), node);
}
return true;
}
});
}
sourcesStable= stable;
}
static Result discover(Map<String, CompilationUnit> roots, Map<String, String> sourceFingerprints,
boolean closedScope, IProgressMonitor monitor)
throws CoreException {
return discover(roots, sourceFingerprints, closedScope, false, monitor);
}
static Result discover(Map<String, CompilationUnit> roots, Map<String, String> sourceFingerprints,
boolean closedScope, boolean execute, IProgressMonitor monitor) throws CoreException {
return new JUnit4ParameterizedPlanner(roots, sourceFingerprints, execute, monitor).discover(closedScope);
}
/** Capture before parsing so an old AST cannot be paired with a newer source fingerprint. */
static Map<String, String> captureSources(ICompilationUnit[] units, IProgressMonitor monitor)
throws CoreException {
Map<String, String> snapshots= new LinkedHashMap<>();
for (ICompilationUnit unit : units) {
MultiFilePlanningBudget.checkCanceled(monitor);
String source= unit.getPrimary().getSource();
if (source != null) {
snapshots.put(unit.getPrimary().getHandleIdentifier(), JUnit4ParameterizedPlan.fingerprint(source));
}
}
return Map.copyOf(snapshots);
}
private Result discover(boolean closedScope) {
List<JUnit4ParameterizedPlan> plans= new ArrayList<>();
List<MultiFileCandidateDiagnostic> diagnostics= new ArrayList<>();
for (Map.Entry<String, CompilationUnit> entry : roots.entrySet()) {
entry.getValue().accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
MultiFilePlanningBudget.checkCanceled(monitor);
if (!candidate(node) && !(execute && !closedScope && hasJUnit4ExecutionConsumers(node))) {
return true;
}
if (execute && Arrays.stream(node.getMethods()).anyMatch(method -> annotation(method, "org.junit.jupiter.api.Test") != null) //$NON-NLS-1$
&& Arrays.stream(node.getMethods()).noneMatch(method -> annotation(method, JUNIT_TEST) != null)
&& ParameterizedMigrationEligibility.assess(node).eligible()) {
return true; // Preserve the explicit repair path for already-staged local migrations.
}
ITypeBinding binding= node.resolveBinding();
String identity= resolved(binding) ? binding.getQualifiedName()
: entry.getKey() + "#" + node.getName().getIdentifier(); //$NON-NLS-1$
String id= "parameterized:" + identity; //$NON-NLS-1$
try {
require(closedScope, "INCOMPLETE_SCOPE", //$NON-NLS-1$
"Select the complete editable provider and test hierarchy before planning."); //$NON-NLS-1$
require(sourcesStable, "SOURCE_CHANGED", //$NON-NLS-1$
"The selected sources changed or were unavailable during AST creation. Retry planning on a stable source scope."); //$NON-NLS-1$
JUnit4ParameterizedPlan plan= plan(node);
plans.add(plan);
if (plan.executable()) {
diagnostics.add(new MultiFileCandidateDiagnostic(id, entry.getKey(),
MultiFileCandidateOutcome.FOUND, "PARAMETERIZED_CLASS_READY", //$NON-NLS-1$
"The closed constant-row component can use the runtime-verified ParameterizedClass strategy.", //$NON-NLS-1$
new ArrayList<>(plan.compilationUnits().values())));
return true;
}
diagnostics.add(new MultiFileCandidateDiagnostic(id, entry.getKey(),
MultiFileCandidateOutcome.FOUND, "PARAMETERIZED_RUNTIME_VERIFICATION_REQUIRED", //$NON-NLS-1$
"Parameterized source roles and ordered injection relations were discovered. " //$NON-NLS-1$
+ "A coordinated executor must verify identity, multiplicity, names and results " //$NON-NLS-1$
+ "with the JDT JUnit runtime before this prepared plan can be applied.", //$NON-NLS-1$
new ArrayList<>(plan.compilationUnits().values())));
} catch (Rejected e) {
diagnostics.add(MultiFileCandidateDiagnostic.rejected(id, entry.getKey(), e.code,
"No coordinated Parameterized plan was produced: " + e.getMessage(), //$NON-NLS-1$
executionClosureHandles(node)));
}
return true;
}
});
}
return new Result(plans, diagnostics);
}
private List<String> executionClosureHandles(TypeDeclaration leaf) {
Set<String> handles= new java.util.LinkedHashSet<>();
handles.add(handlesByRoot.get(leaf.getRoot()));
List<ITypeBinding> hierarchy= new ArrayList<>();
Set<String> seen= new HashSet<>();
for (ITypeBinding binding= leaf.resolveBinding(); resolved(binding)
&& !OBJECT.equals(binding.getQualifiedName()) && seen.add(binding.getKey()); binding= binding.getSuperclass()) {
hierarchy.add(binding);
}
for (TypeDeclaration type : types.values()) {
if (hierarchy.isEmpty() ? hasJUnit4ExecutionMembers(type)
: hierarchy.stream().anyMatch(base -> type.resolveBinding().isSubTypeCompatible(base))) {
handles.add(handlesByRoot.get(type.getRoot()));
}
}
return List.copyOf(handles);
}
private JUnit4ParameterizedPlan plan(TypeDeclaration testClass) {
require(ParameterizedMigrationEligibility.hasParameterizedRunner(testClass), "RUNNER_UNRESOLVED", //$NON-NLS-1$
"Resolve @RunWith to the standard JUnit 4 Parameterized runner."); //$NON-NLS-1$
require(testClass.isPackageMemberTypeDeclaration() && !testClass.isInterface()
&& Modifier.isPublic(testClass.getModifiers()) && !Modifier.isAbstract(testClass.getModifiers()),
"TEST_CLASS_UNSUPPORTED", "Select a public concrete top-level Parameterized test class."); //$NON-NLS-1$ //$NON-NLS-2$
List<TypeDeclaration> hierarchy= hierarchy(testClass);
SemanticRewritePlan.Builder builder= SemanticRewritePlan.builder(CONTRACT);
Map<NodeKey, String> owners= new LinkedHashMap<>();
NodeKey testKey= add(builder, owners, testClass, RUNNER_CLASS);
if (execute) {
validateExecutionHierarchy(testClass, hierarchy, builder, owners);
builder.putString(testKey, TARGET_STRATEGY, PARAMETERIZED_CLASS);
}
List<MethodDeclaration> providers= new ArrayList<>();
List<MethodDeclaration> tests= new ArrayList<>();
Map<Integer, VariableDeclarationFragment> fields= new TreeMap<>();
Set<String> testNames= new HashSet<>();
for (TypeDeclaration type : hierarchy) {
validateSource(type);
checkAnnotations(type);
require(type.typeParameters().isEmpty(), "GENERIC_HIERARCHY", //$NON-NLS-1$
"Resolve generic test hierarchy substitutions before migration."); //$NON-NLS-1$
// JUnit 4's default MethodSorter orders each declaring class by name hash,
// with a lexical tie-breaker. The hierarchy itself is visited leaf first.
List<MethodDeclaration> declared= Arrays.asList(type.getMethods());
if (execute) {
declared.sort(Comparator.comparingInt((MethodDeclaration method) -> method.getName().getIdentifier().hashCode())
.thenComparing(method -> method.getName().getIdentifier()));
}
for (MethodDeclaration method : declared) {
checkAnnotations(method);
if (annotation(method, PARAMETERS) != null) {
providers.add(method);
}
if (annotation(method, JUNIT_TEST) != null) {
if (execute) {
Annotation test= annotation(method, JUNIT_TEST);
Object expected= value(test, "expected"); //$NON-NLS-1$
require(Long.valueOf(0).equals(value(test, "timeout")) //$NON-NLS-1$
&& expected instanceof ITypeBinding exception
&& "org.junit.Test.None".equals(exception.getQualifiedName()), //$NON-NLS-1$
"TEST_CONTRACT_UNSUPPORTED", "Expected exceptions and timeouts require a separate parameterized execution contract."); //$NON-NLS-1$ //$NON-NLS-2$
}
require(testNames.add(method.getName().getIdentifier()), "TEST_OVERRIDE", //$NON-NLS-1$
"Resolve overridden or hidden parameterized test methods before migration."); //$NON-NLS-1$
require(Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers())
&& method.parameters().isEmpty() && method.getBody() != null
&& resolved(method.resolveBinding())
&& "void".equals(method.resolveBinding().getReturnType().getName()), //$NON-NLS-1$
"TEST_METHOD_UNSUPPORTED", "JUnit 4 test methods must be public void instance methods without parameters."); //$NON-NLS-1$ //$NON-NLS-2$
tests.add(method);
}
}
for (FieldDeclaration field : type.getFields()) {
checkAnnotations(field);
Annotation parameter= annotation(field, PARAMETER_ANNOTATION);
if (parameter == null) {
continue;
}
require(field.fragments().size() == 1 && Modifier.isPublic(field.getModifiers())
&& !Modifier.isStatic(field.getModifiers()) && !Modifier.isFinal(field.getModifiers()),
"FIELD_UNSUPPORTED", "Use one public non-final instance field per @Parameter declaration."); //$NON-NLS-1$ //$NON-NLS-2$
Object index= value(parameter, "value"); //$NON-NLS-1$
require(index instanceof Integer && ((Integer) index).intValue() >= 0, "FIELD_INDEX", //$NON-NLS-1$
"Resolve each @Parameter index to a non-negative integer."); //$NON-NLS-1$
require(fields.put((Integer) index, (VariableDeclarationFragment) field.fragments().get(0)) == null,
"DUPLICATE_FIELD_INDEX", "Give each injected field a unique contiguous index starting at zero."); //$NON-NLS-1$ //$NON-NLS-2$
}
}
require(providers.size() == 1, "PROVIDER_NOT_UNIQUE", //$NON-NLS-1$
"Select exactly one unambiguous local or inherited @Parameters provider."); //$NON-NLS-1$
require(!tests.isEmpty(), "NO_TESTS", "No supported JUnit 4 test methods were found."); //$NON-NLS-1$ //$NON-NLS-2$
List<ITypeBinding> parameterTypes= injection(testClass, fields, builder, owners, testKey);
MethodDeclaration provider= providers.get(0);
if (execute) {
long declarations= hierarchy.stream().flatMap(type -> Arrays.stream(type.getMethods()))
.filter(method -> method.getName().getIdentifier().equals(provider.getName().getIdentifier())
&& method.parameters().isEmpty()).count();
require(declarations == 1, "PROVIDER_OVERRIDE", //$NON-NLS-1$
"Resolve hidden or overridden provider methods before selecting a Jupiter method source."); //$NON-NLS-1$
}
require(Modifier.isPublic(provider.getModifiers()), "PROVIDER_NOT_PUBLIC", //$NON-NLS-1$
"JUnit 4 requires a public @Parameters provider."); //$NON-NLS-1$
String displayPattern= validateDisplayName(provider);
NodeKey providerKey= add(builder, owners, provider, PROVIDER);
builder.relate(testKey, HAS_PROVIDER, providerKey);
MethodDeclaration data= providerData(provider, builder, owners, new HashSet<>());
validateRows(data, parameterTypes, displayPattern);
builder.putString(providerKey, "conversionStrategy", "OBJECT_ARRAY_ROWS"); //$NON-NLS-1$ //$NON-NLS-2$
builder.putString(testKey, "displayNameSemantics", "JUNIT4_MESSAGE_FORMAT_ZERO_BASED"); //$NON-NLS-1$ //$NON-NLS-2$
for (MethodDeclaration method : tests) {
if (execute) {
long declarations= hierarchy.stream().flatMap(type -> Arrays.stream(type.getMethods()))
.filter(other -> other.getName().getIdentifier().equals(method.getName().getIdentifier())
&& other.parameters().isEmpty()).count();
require(declarations == 1, "TEST_OVERRIDE", //$NON-NLS-1$
"An unannotated override changes JUnit 4 test dispatch; retain the complete hierarchy."); //$NON-NLS-1$
}
NodeKey methodKey= add(builder, owners, method, TEST);
builder.relate(testKey, HAS_TEST, methodKey);
if (execute) {
builder.putInteger(methodKey, "testOrder", tests.indexOf(method)); //$NON-NLS-1$
}
}
return new JUnit4ParameterizedPlan(testKey, builder.build(), owners, fingerprints);
}
private static String validateDisplayName(MethodDeclaration provider) {
Object name= value(annotation(provider, PARAMETERS), "name"); //$NON-NLS-1$
require(name instanceof String, "DISPLAY_NAME_UNRESOLVED", //$NON-NLS-1$
"Resolve the @Parameters display-name pattern before migration."); //$NON-NLS-1$
try {
new MessageFormat(((String) name).replace("{index}", "0")); //$NON-NLS-1$ //$NON-NLS-2$
} catch (IllegalArgumentException e) {
throw new Rejected("PARAMETERIZED_DISPLAY_NAME_UNSUPPORTED", //$NON-NLS-1$
"The @Parameters name is not a valid JUnit 4 MessageFormat pattern; correct it before migration."); //$NON-NLS-1$
}
return (String) name;
}
private List<ITypeBinding> injection(TypeDeclaration type, Map<Integer, VariableDeclarationFragment> fields,
SemanticRewritePlan.Builder builder, Map<NodeKey, String> owners, NodeKey testKey) {
List<MethodDeclaration> constructors= java.util.Arrays.stream(type.getMethods())
.filter(MethodDeclaration::isConstructor).toList();
List<ITypeBinding> parameterTypes= new ArrayList<>();
if (!fields.isEmpty()) {
require(constructors.isEmpty() || constructors.size() == 1
&& constructors.get(0).parameters().isEmpty() && Modifier.isPublic(constructors.get(0).getModifiers()),
"MIXED_INJECTION", "Field injection requires one public zero-argument constructor."); //$NON-NLS-1$ //$NON-NLS-2$
for (int index= 0; index < fields.size(); index++) {
VariableDeclarationFragment field= fields.get(Integer.valueOf(index));
require(field != null, "FIELD_INDEX_GAP", "Parameter field indices must be contiguous starting at zero."); //$NON-NLS-1$ //$NON-NLS-2$
IVariableBinding binding= field.resolveBinding();
require(resolved(binding), "BINDING_UNRESOLVED", "Resolve every injected field binding."); //$NON-NLS-1$ //$NON-NLS-2$
parameterTypes.add(binding.getType());
builder.relate(testKey, HAS_FIELD, add(builder, owners, field, FIELD));
}
} else {
require(constructors.size() == 1 && Modifier.isPublic(constructors.get(0).getModifiers())
&& !constructors.get(0).parameters().isEmpty(), "CONSTRUCTOR_NOT_UNIQUE", //$NON-NLS-1$
"Constructor injection requires exactly one public constructor with parameters."); //$NON-NLS-1$
MethodDeclaration constructor= constructors.get(0);
NodeKey constructorKey= add(builder, owners, constructor, CONSTRUCTOR);
builder.relate(testKey, HAS_CONSTRUCTOR, constructorKey);
for (Object item : constructor.parameters()) {
SingleVariableDeclaration parameter= (SingleVariableDeclaration) item;
checkAnnotations(parameter.modifiers());
IVariableBinding binding= parameter.resolveBinding();
require(!parameter.isVarargs() && resolved(binding), "PARAMETER_UNSUPPORTED", //$NON-NLS-1$
"Resolve each constructor parameter; varargs require a dedicated conversion strategy."); //$NON-NLS-1$
parameterTypes.add(binding.getType());
builder.relate(constructorKey, HAS_PARAMETER, add(builder, owners, parameter, PARAMETER));
}
}
return parameterTypes;
}
private List<TypeDeclaration> hierarchy(TypeDeclaration leaf) {
List<TypeDeclaration> hierarchy= new ArrayList<>();
Set<String> seen= new HashSet<>();
ITypeBinding binding= leaf.resolveBinding();
while (binding != null && !OBJECT.equals(binding.getQualifiedName())) {
MultiFilePlanningBudget.checkCanceled(monitor);
require(resolved(binding) && seen.add(binding.getKey()), "HIERARCHY_UNRESOLVED", //$NON-NLS-1$
"Resolve the complete test superclass chain."); //$NON-NLS-1$
TypeDeclaration source= types.get(binding.getTypeDeclaration().getKey());
require(source != null, "HIERARCHY_OUTSIDE_SCOPE", //$NON-NLS-1$
"Include every editable test superclass; binary hierarchies cannot be migrated."); //$NON-NLS-1$
hierarchy.add(source);
binding= binding.getSuperclass();
}
return hierarchy;
}
private MethodDeclaration providerData(MethodDeclaration provider, SemanticRewritePlan.Builder builder,
Map<NodeKey, String> owners, Set<String> visited) {
MultiFilePlanningBudget.checkCanceled(monitor);
validateSource(provider);
if (execute) {
require(provider.getParent() instanceof TypeDeclaration, "PROVIDER_BODY_UNSUPPORTED", //$NON-NLS-1$
"Select a provider declared in an editable class."); //$NON-NLS-1$
for (TypeDeclaration type : hierarchy((TypeDeclaration) provider.getParent())) {
validateInitialization(type);
}
require(provider.thrownExceptionTypes().isEmpty(), "PROVIDER_THROWS", //$NON-NLS-1$
"Remove the provider's throws clause or migrate its exception contract explicitly."); //$NON-NLS-1$
}
require(resolved(provider.resolveBinding()) && visited.add(provider.resolveBinding().getKey()),
"PROVIDER_CYCLE", "Resolve provider delegation without recursive cycles."); //$NON-NLS-1$ //$NON-NLS-2$
require(Modifier.isStatic(provider.getModifiers()) && provider.parameters().isEmpty()
&& provider.typeParameters().isEmpty() && provider.getBody() != null
&& provider.getBody().statements().size() == 1
&& provider.getBody().statements().get(0) instanceof ReturnStatement,
"PROVIDER_BODY_UNSUPPORTED", "Use a static no-argument provider returning a literal Object[][] matrix or a source provider delegate."); //$NON-NLS-1$ //$NON-NLS-2$
Expression expression= ((ReturnStatement) provider.getBody().statements().get(0)).getExpression();
if (expression instanceof MethodInvocation invocation && !arraysAsList(invocation)) {
IMethodBinding binding= invocation.resolveMethodBinding();
Expression receiver= invocation.getExpression();
boolean typeReceiver= receiver == null
|| receiver instanceof Name name && name.resolveBinding() instanceof ITypeBinding;
require(invocation.arguments().isEmpty() && typeReceiver
&& (!resolved(binding) || Modifier.isStatic(binding.getModifiers())),
"PROVIDER_BODY_UNSUPPORTED", "Only static no-argument calls can delegate to a source provider."); //$NON-NLS-1$ //$NON-NLS-2$
require(resolved(binding),
"PROVIDER_DELEGATE_UNRESOLVED", "Resolve the static no-argument provider delegation."); //$NON-NLS-1$ //$NON-NLS-2$
MethodDeclaration delegate= methods.get(binding.getMethodDeclaration().getKey());
require(delegate != null, "PROVIDER_OUTSIDE_SCOPE", //$NON-NLS-1$
"Include the editable delegated provider; binary or unselected providers are rejected."); //$NON-NLS-1$
NodeKey delegateKey= add(builder, owners, delegate, PROVIDER);
builder.relate(NodeKey.from(provider), DELEGATES_TO, delegateKey);
return providerData(delegate, builder, owners, visited);
}
return provider;
}
private void validateRows(MethodDeclaration provider, List<ITypeBinding> parameters, String displayPattern) {
Expression expression= ((ReturnStatement) provider.getBody().statements().get(0)).getExpression();
if (expression instanceof MethodInvocation invocation && arraysAsList(invocation)) {
require(invocation.arguments().size() == 1, "PROVIDER_BODY_UNSUPPORTED", //$NON-NLS-1$
"Arrays.asList must wrap exactly one Object[][] initializer."); //$NON-NLS-1$
expression= (Expression) invocation.arguments().get(0);
}
require(expression instanceof ArrayCreation, "PROVIDER_BODY_UNSUPPORTED", //$NON-NLS-1$
"Only a literal Object[][] matrix has a proven row conversion in this planner."); //$NON-NLS-1$
ArrayCreation matrix= (ArrayCreation) expression;
ITypeBinding type= matrix.resolveTypeBinding();
require(resolved(type) && type.getDimensions() == 2 && OBJECT.equals(type.getElementType().getQualifiedName())
&& matrix.getInitializer() != null, "PROVIDER_BODY_UNSUPPORTED", //$NON-NLS-1$
"The provider must return an initialized Object[][] matrix."); //$NON-NLS-1$
if (execute) {
require(!matrix.getInitializer().expressions().isEmpty(), "EMPTY_ROWS", //$NON-NLS-1$
"Empty parameter sets have different class-lifecycle semantics; retain the JUnit 4 runner."); //$NON-NLS-1$
}
Set<String> displayNames= new HashSet<>();
int rowIndex= 0;
for (Object row : matrix.getInitializer().expressions()) {
MultiFilePlanningBudget.checkCanceled(monitor);
ArrayInitializer values= row instanceof ArrayInitializer initializer ? initializer
: row instanceof ArrayCreation creation ? creation.getInitializer() : null;
require(values != null && values.expressions().size() == parameters.size(), "ROW_ARITY", //$NON-NLS-1$
"Every parameter row must have exactly one value per injection parameter."); //$NON-NLS-1$
for (int index= 0; index < parameters.size(); index++) {
Expression argument= (Expression) values.expressions().get(index);
ITypeBinding target= parameters.get(index);
ITypeBinding source= argument.resolveTypeBinding();
boolean compatible= resolved(target) && (argument instanceof NullLiteral ? !target.isPrimitive()
: argument.resolveConstantExpressionValue() != null && resolved(source)
&& source.isAssignmentCompatible(target));
require(compatible, "CONVERSION_UNPROVEN", //$NON-NLS-1$
"Use resolved constant arguments assignable to their injection types; implicit Jupiter conversions are not assumed equivalent."); //$NON-NLS-1$
}
if (execute) {
Object[] arguments= values.expressions().stream()
.map(item -> ((Expression) item).resolveConstantExpressionValue()).toArray();
String displayName;
try {
displayName= MessageFormat.format(displayPattern.replace("{index}", Integer.toString(rowIndex)), arguments); //$NON-NLS-1$
} catch (IllegalArgumentException e) {
throw new Rejected("PARAMETERIZED_DISPLAY_NAME_UNSUPPORTED", //$NON-NLS-1$
"The name pattern cannot format every constant row."); //$NON-NLS-1$
}
require(displayNames.add(displayName), "DISPLAY_NAME_COLLISION", //$NON-NLS-1$
"The JDT JUnit 4 loader merges duplicate row identities; use distinct row names before migration."); //$NON-NLS-1$
}
rowIndex++;
}
}
private NodeKey add(SemanticRewritePlan.Builder builder, Map<NodeKey, String> owners, ASTNode node, String role) {
NodeKey key= NodeKey.from(node);
require(key != null, "BINDING_UNRESOLVED", "Resolve every planned declaration binding."); //$NON-NLS-1$ //$NON-NLS-2$
String owner= handlesByRoot.get(node.getRoot());
require(owner != null, "SOURCE_OUTSIDE_SCOPE", "Include every planned source declaration."); //$NON-NLS-1$ //$NON-NLS-2$
owners.put(key, owner);
builder.add(key, role);
return key;
}
private void validateExecutionHierarchy(TypeDeclaration leaf, List<TypeDeclaration> hierarchy,
SemanticRewritePlan.Builder builder, Map<NodeKey, String> owners) {
Set<String> lifecycleNames= new HashSet<>();
Set<String> allowedTypes= hierarchy.stream().map(type -> type.resolveBinding().getJavaElement().getHandleIdentifier())
.collect(java.util.stream.Collectors.toSet());
for (TypeDeclaration type : hierarchy) {
add(builder, owners, type, HIERARCHY_TYPE);
validateInitialization(type);
if (type.resolveBinding().getJavaElement() instanceof IType modelType) {
try {
for (IType subtype : modelType.newTypeHierarchy(monitor).getAllSubtypes(modelType)) {
require(allowedTypes.contains(subtype.getHandleIdentifier()), "SHARED_HIERARCHY", //$NON-NLS-1$
"A binary, generated or unselected subtype consumes this test hierarchy; keep its JUnit 4 annotations."); //$NON-NLS-1$
}
} catch (JavaModelException e) {
throw new Rejected("PARAMETERIZED_HIERARCHY_UNRESOLVED", //$NON-NLS-1$
"The complete JDT subtype hierarchy could not be resolved: " + e.getMessage()); //$NON-NLS-1$
}
}
require(type.superInterfaceTypes().isEmpty(), "INTERFACE_HOOKS", //$NON-NLS-1$
"Interface test and lifecycle methods need a dedicated discovery contract."); //$NON-NLS-1$
require(type == leaf || Modifier.isAbstract(type.getModifiers())
&& annotation(type, "org.junit.runner.RunWith") == null, "SHARED_HIERARCHY", //$NON-NLS-1$ //$NON-NLS-2$
"Select an abstract test superclass without its own runner."); //$NON-NLS-1$
for (TypeDeclaration other : types.values()) {
if (!hierarchy.contains(other) && resolved(other.resolveBinding())) {
require(!other.resolveBinding().isSubTypeCompatible(type.resolveBinding()), "SHARED_HIERARCHY", //$NON-NLS-1$
"A superclass is shared with another source test type; its annotations must stay on JUnit 4."); //$NON-NLS-1$
}
}
Set<String> lifecycleKinds= new HashSet<>();
for (MethodDeclaration method : type.getMethods()) {
for (String hook : List.of("Before", "After", "BeforeClass", "AfterClass")) { //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
if (annotation(method, "org.junit." + hook) == null) { //$NON-NLS-1$
continue;
}
require(annotation(method, JUNIT_TEST) == null, "LIFECYCLE_SIGNATURE", //$NON-NLS-1$
"A method cannot be both a parameterized test and a lifecycle callback in this contract."); //$NON-NLS-1$
require(lifecycleKinds.add(hook) && lifecycleNames.add(method.getName().getIdentifier()),
"LIFECYCLE_ORDER", "Multiple or overridden lifecycle methods need an explicit invocation-order contract."); //$NON-NLS-1$ //$NON-NLS-2$
require(Modifier.isPublic(method.getModifiers())
&& Modifier.isStatic(method.getModifiers()) == hook.endsWith("Class") //$NON-NLS-1$
&& method.parameters().isEmpty() && resolved(method.resolveBinding())
&& "void".equals(method.resolveBinding().getReturnType().getName()), //$NON-NLS-1$
"LIFECYCLE_SIGNATURE", "Resolve a valid public void JUnit 4 lifecycle method."); //$NON-NLS-1$ //$NON-NLS-2$
add(builder, owners, method, "JUNIT4_PARAMETERIZED_" + hook); //$NON-NLS-1$
}
}
}
// An unannotated override also changes virtual dispatch in JUnit 4.
for (String name : lifecycleNames) {
long declarations= hierarchy.stream().flatMap(type -> Arrays.stream(type.getMethods()))
.filter(method -> method.getName().getIdentifier().equals(name) && method.parameters().isEmpty()).count();
require(declarations == 1, "LIFECYCLE_OVERRIDE", //$NON-NLS-1$
"Resolve lifecycle overrides before migrating the parameterized hierarchy."); //$NON-NLS-1$
}
}
private static void validateInitialization(TypeDeclaration type) {
for (Object body : type.bodyDeclarations()) {
require(!(body instanceof Initializer initializer && Modifier.isStatic(initializer.getModifiers())),
"INITIALIZATION_ORDER", "JUnit 4 initializes providers before class callbacks; executable static initializers are not equivalent in Jupiter."); //$NON-NLS-1$ //$NON-NLS-2$
if (body instanceof FieldDeclaration field && Modifier.isStatic(field.getModifiers())) {
for (Object item : field.fragments()) {
Expression initializer= ((VariableDeclarationFragment) item).getInitializer();
require(initializer == null || initializer instanceof NullLiteral
|| initializer.resolveConstantExpressionValue() != null,
"INITIALIZATION_ORDER", "Keep providers with executable static field initialization on JUnit 4."); //$NON-NLS-1$ //$NON-NLS-2$
}
}
}
}
private static void validateSource(ASTNode node) {
CompilationUnit root= (CompilationUnit) node.getRoot();
for (IProblem problem : root.getProblems()) {
require(!problem.isError(), "SOURCE_ERRORS", "Resolve compilation errors in every participating source file."); //$NON-NLS-1$ //$NON-NLS-2$
}
require(root.getJavaElement() instanceof ICompilationUnit, "SOURCE_UNAVAILABLE", //$NON-NLS-1$
"A workspace compilation unit is required for source snapshot validation."); //$NON-NLS-1$
ICompilationUnit unit= (ICompilationUnit) root.getJavaElement();
require(!unit.isReadOnly() && unit.getResource() != null
&& (unit.getResource().getResourceAttributes() == null
|| !unit.getResource().getResourceAttributes().isReadOnly()), "SOURCE_READ_ONLY", //$NON-NLS-1$
"Make every participating source file editable before migration."); //$NON-NLS-1$
}
private static boolean candidate(TypeDeclaration type) {
if (ParameterizedMigrationEligibility.hasParameterizedRunner(type)) {
return true;
}
for (Object item : type.modifiers()) {
if (item instanceof Annotation annotation) {
String name= annotation.getTypeName().getFullyQualifiedName();
if (("RunWith".equals(name) || "org.junit.runner.RunWith".equals(name)) //$NON-NLS-1$ //$NON-NLS-2$
&& annotation.toString().contains("Parameterized")) { //$NON-NLS-1$
return true;
}
}
}
return false;
}
private boolean hasJUnit4ExecutionConsumers(TypeDeclaration type) {
if (!hasJUnit4ExecutionMembers(type)) {
return false;
}
ITypeBinding binding= type.resolveBinding();
if (resolved(binding) && binding.getJavaElement() instanceof IType modelType) {
try {
return modelType.newTypeHierarchy(monitor).getAllSubtypes(modelType).length != 0;
} catch (JavaModelException e) {
// Without a complete hierarchy, changing inherited execution annotations is unsafe.
}
}
return true;
}
private static boolean hasJUnit4ExecutionMembers(TypeDeclaration type) {
for (MethodDeclaration method : type.getMethods()) {
for (String name : List.of(JUNIT_TEST, PARAMETERS, "org.junit.Before", "org.junit.After", //$NON-NLS-1$ //$NON-NLS-2$
"org.junit.BeforeClass", "org.junit.AfterClass")) { //$NON-NLS-1$ //$NON-NLS-2$
if (annotation(method, name) != null) {
return true;
}
}
}
return Arrays.stream(type.getFields()).anyMatch(field -> annotation(field, PARAMETER_ANNOTATION) != null);
}
private static Annotation annotation(BodyDeclaration declaration, String name) {
for (Object item : declaration.modifiers()) {
if (item instanceof Annotation annotation && resolved(annotation.resolveTypeBinding())
&& name.equals(annotation.resolveTypeBinding().getQualifiedName())) {
return annotation;
}
}
return null;
}
private void checkAnnotations(BodyDeclaration declaration) {
checkAnnotations(declaration.modifiers());
}
private void checkAnnotations(List<?> modifiers) {
for (Object item : modifiers) {
if (!(item instanceof Annotation annotation)) {
continue;
}
ITypeBinding binding= annotation.resolveTypeBinding();
require(resolved(binding), "BINDING_UNRESOLVED", "Resolve all annotations before classifying execution hooks."); //$NON-NLS-1$ //$NON-NLS-2$
String name= binding.getQualifiedName();
if (execute) {
require(!jupiterMetaAnnotation(binding, new HashSet<>()), "EXECUTION_HOOK_UNSUPPORTED", //$NON-NLS-1$
"Composed Jupiter annotations change execution or argument conversion and need a dedicated contract."); //$NON-NLS-1$
}
if (name.startsWith("org.junit.")) { //$NON-NLS-1$
require(Set.of(PARAMETERS, PARAMETER_ANNOTATION, JUNIT_TEST, "org.junit.runner.RunWith", //$NON-NLS-1$
"org.junit.Before", "org.junit.After", "org.junit.BeforeClass", "org.junit.AfterClass") //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
.contains(name), "EXECUTION_HOOK_UNSUPPORTED", //$NON-NLS-1$
"Custom runners, rules, parameter hooks and mixed JUnit generations need a dedicated semantic contract."); //$NON-NLS-1$
}
}
}
private static boolean jupiterMetaAnnotation(ITypeBinding type, Set<String> seen) {
if (!resolved(type) || !seen.add(type.getKey()) || type.getQualifiedName().startsWith("java.lang.annotation.")) { //$NON-NLS-1$
return false;
}
for (IAnnotationBinding annotation : type.getAnnotations()) {
ITypeBinding meta= annotation.getAnnotationType();
if (meta.getQualifiedName().startsWith("org.junit.jupiter.") || jupiterMetaAnnotation(meta, seen)) { //$NON-NLS-1$
return true;
}
}
return false;
}
private static Object value(Annotation annotation, String member) {
IAnnotationBinding binding= annotation.resolveAnnotationBinding();
if (binding != null) {
for (IMemberValuePairBinding pair : binding.getAllMemberValuePairs()) {
if (member.equals(pair.getName())) {
return pair.getValue();
}
}
}
return null;
}
private static boolean arraysAsList(MethodInvocation invocation) {
IMethodBinding binding= invocation.resolveMethodBinding();
return resolved(binding) && resolved(binding.getDeclaringClass())
&& "java.util.Arrays".equals(binding.getDeclaringClass().getQualifiedName()) //$NON-NLS-1$
&& "asList".equals(binding.getName()); //$NON-NLS-1$
}
private static boolean resolved(IBinding binding) {
return binding != null && !binding.isRecovered() && binding.getKey() != null;
}
private static void require(boolean condition, String code, String message) {
if (!condition) {
throw new Rejected("PARAMETERIZED_" + code, message); //$NON-NLS-1$
}
}
private static final class Rejected extends RuntimeException {
private static final long serialVersionUID= 1L;
private final String code;
Rejected(String code, String message) {
super(message);
this.code= code;
}
}
}