JUnitScopeCandidateDetector.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.helper.lib.JUnitConstants.ORG_JUNIT_CLASS_RULE;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RULE;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RULES_EXTERNAL_RESOURCE;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_SUITE_SUITECLASSES;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.OperationCanceledException;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.ASTRequestor;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayInitializer;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypeLiteral;
import org.eclipse.jdt.internal.corext.dom.IASTSharedValues;
import org.eclipse.jdt.internal.corext.refactoring.util.RefactoringASTParser;
/** Lightweight selected-scope detector for coordinated JUnit migration. */
public final class JUnitScopeCandidateDetector {
/** Binding-derived elements and direct source dependencies defining the required closure. */
public record SearchSeeds(boolean candidateFound, boolean complete, List<IJavaElement> elements,
List<ICompilationUnit> directCompilationUnits) {
public SearchSeeds {
elements= List.copyOf(elements);
directCompilationUnits= List.copyOf(directCompilationUnits);
}
/** Compatibility constructor for the original ExternalResource-only detector. */
public SearchSeeds(boolean candidateFound, boolean complete, List<IJavaElement> elements) {
this(candidateFound, complete, elements, List.of());
}
}
private JUnitScopeCandidateDetector() {
// utility class
}
/** Returns whether the selection contains either side of an ExternalResource migration. */
public static boolean containsCandidate(IJavaProject project, Collection<ICompilationUnit> currentScope,
IProgressMonitor monitor) {
return findSearchSeeds(project, currentScope, monitor).candidateFound();
}
/** Finds ExternalResource migration seeds using the historical detector contract. */
public static SearchSeeds findSearchSeeds(IJavaProject project, Collection<ICompilationUnit> currentScope,
IProgressMonitor monitor) {
return findSearchSeeds(project, currentScope, true, false, monitor);
}
/**
* Finds source dependencies required by the enabled coordinated JUnit components.
* ExternalResource declarations use reverse-reference search seeds; JUnit 4 suite
* annotations add their directly referenced source test classes.
*/
public static SearchSeeds findSearchSeeds(IJavaProject project, Collection<ICompilationUnit> currentScope,
boolean migrateExternalResourceRules, boolean migrateSuites, IProgressMonitor monitor) {
if (project == null || currentScope == null || currentScope.isEmpty()
|| !migrateExternalResourceRules && !migrateSuites) {
return new SearchSeeds(false, true, List.of(), List.of());
}
checkCanceled(monitor);
Set<ICompilationUnit> units= new LinkedHashSet<>();
for (ICompilationUnit unit : currentScope) {
if (unit != null && unit.exists() && project.equals(unit.getJavaProject())) {
units.add(unit.getPrimary());
}
}
if (units.isEmpty()) {
return new SearchSeeds(false, true, List.of(), List.of());
}
boolean[] candidateFound= { false };
boolean[] complete= { true };
Set<IJavaElement> elements= new LinkedHashSet<>();
Set<ICompilationUnit> directUnits= new LinkedHashSet<>();
ASTParser parser= ASTParser.newParser(IASTSharedValues.SHARED_AST_LEVEL);
parser.setProject(project);
parser.setResolveBindings(true);
parser.setBindingsRecovery(IASTSharedValues.SHARED_BINDING_RECOVERY);
parser.setStatementsRecovery(IASTSharedValues.SHARED_AST_STATEMENT_RECOVERY);
parser.setCompilerOptions(RefactoringASTParser.getCompilerOptions(project));
parser.createASTs(units.toArray(ICompilationUnit[]::new), new String[0], new ASTRequestor() {
@Override
public void acceptAST(ICompilationUnit source, CompilationUnit ast) {
ast.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
if (!migrateExternalResourceRules) {
return true;
}
ITypeBinding binding= node.resolveBinding();
if (binding != null && extendsExternalResource(binding)) {
candidateFound[0]= true;
complete[0]&= addJavaElement(binding, elements);
complete[0]&= addExternalResourceSuperTypes(binding, elements);
return false;
}
if (binding == null && node.getSuperclassType() != null
&& "ExternalResource".equals(simpleName(node.getSuperclassType().toString()))) { //$NON-NLS-1$
candidateFound[0]= true;
complete[0]= false;
}
return true;
}
@Override
public boolean visit(FieldDeclaration node) {
if (!migrateExternalResourceRules) {
return true;
}
boolean ruleField= false;
boolean unresolvedRuleAnnotation= false;
for (Object modifier : node.modifiers()) {
if (!(modifier instanceof Annotation annotation)) {
continue;
}
ITypeBinding annotationBinding= annotation.resolveTypeBinding();
if (annotationBinding != null) {
String qualifiedName= annotationBinding.getQualifiedName();
ruleField|= ORG_JUNIT_RULE.equals(qualifiedName)
|| ORG_JUNIT_CLASS_RULE.equals(qualifiedName);
} else if (isSyntacticRuleName(annotation.getTypeName().getFullyQualifiedName())) {
ruleField= true;
unresolvedRuleAnnotation= true;
}
}
if (!ruleField) {
return true;
}
candidateFound[0]= true;
if (unresolvedRuleAnnotation) {
complete[0]= false;
}
ITypeBinding fieldType= node.getType().resolveBinding();
complete[0]&= addJavaElement(fieldType, elements);
if (fieldType != null && extendsExternalResource(fieldType)) {
complete[0]&= addExternalResourceSuperTypes(fieldType, elements);
}
return true;
}
@Override
public boolean visit(SingleMemberAnnotation node) {
if (migrateSuites && isSuiteClasses(node)) {
candidateFound[0]= true;
complete[0]&= collectSuiteTargets(node.getValue(), directUnits);
}
return true;
}
@Override
public boolean visit(NormalAnnotation node) {
if (!migrateSuites || !isSuiteClasses(node)) {
return true;
}
candidateFound[0]= true;
boolean valueFound= false;
for (Object valueObject : node.values()) {
MemberValuePair pair= (MemberValuePair) valueObject;
if ("value".equals(pair.getName().getIdentifier())) { //$NON-NLS-1$
valueFound= true;
complete[0]&= collectSuiteTargets(pair.getValue(), directUnits);
}
}
complete[0]&= valueFound;
return true;
}
});
}
}, monitor);
checkCanceled(monitor);
return new SearchSeeds(candidateFound[0], complete[0], new ArrayList<>(elements),
new ArrayList<>(directUnits));
}
private static boolean isSuiteClasses(Annotation annotation) {
ITypeBinding binding= annotation.resolveTypeBinding();
if (binding != null) {
return ORG_JUNIT_SUITE_SUITECLASSES.equals(binding.getQualifiedName());
}
return "SuiteClasses".equals(simpleName(annotation.getTypeName().getFullyQualifiedName())); //$NON-NLS-1$
}
private static boolean collectSuiteTargets(Expression expression, Set<ICompilationUnit> units) {
if (expression instanceof TypeLiteral literal) {
return addCompilationUnit(literal.getType().resolveBinding(), units);
}
if (expression instanceof ArrayInitializer initializer) {
boolean complete= true;
for (Object expressionObject : initializer.expressions()) {
complete&= expressionObject instanceof Expression nested && collectSuiteTargets(nested, units);
}
return complete;
}
return false;
}
private static boolean isSyntacticRuleName(String name) {
String simple= simpleName(name);
return "Rule".equals(simple) || "ClassRule".equals(simple); //$NON-NLS-1$ //$NON-NLS-2$
}
/** Returns whether the superclass chain reaches {@code ExternalResource}. */
private static boolean extendsExternalResource(ITypeBinding binding) {
for (ITypeBinding current= binding == null ? null : binding.getSuperclass(); current != null;
current= current.getSuperclass()) {
if (ORG_JUNIT_RULES_EXTERNAL_RESOURCE.equals(current.getErasure().getQualifiedName())) {
return true;
}
}
return false;
}
/**
* Seeds every source type between a fixture and {@code ExternalResource}, because
* the inherited callbacks are renamed together with the fixture.
*/
private static boolean addExternalResourceSuperTypes(ITypeBinding binding, Set<IJavaElement> elements) {
boolean complete= true;
for (ITypeBinding current= binding.getSuperclass(); current != null; current= current.getSuperclass()) {
if (ORG_JUNIT_RULES_EXTERNAL_RESOURCE.equals(current.getErasure().getQualifiedName())) {
return complete;
}
complete&= addJavaElement(current, elements);
}
return false;
}
private static String simpleName(String name) {
int separator= name.lastIndexOf('.');
return separator < 0 ? name : name.substring(separator + 1);
}
private static boolean addJavaElement(ITypeBinding binding, Set<IJavaElement> elements) {
IJavaElement element= binding == null ? null : binding.getErasure().getJavaElement();
if (element == null || !element.exists()) {
return false;
}
elements.add(element);
return true;
}
private static boolean addCompilationUnit(ITypeBinding binding, Set<ICompilationUnit> units) {
IJavaElement element= binding == null ? null : binding.getErasure().getJavaElement();
if (element == null || !element.exists()) {
return false;
}
if (element instanceof IType type) {
ICompilationUnit compilationUnit= type.getCompilationUnit();
if (compilationUnit != null && compilationUnit.exists()) {
units.add(compilationUnit.getPrimary());
}
}
return true;
}
private static void checkCanceled(IProgressMonitor monitor) {
if (monitor != null && monitor.isCanceled()) {
throw new OperationCanceledException();
}
}
}