JUnit3LegacyShape.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.helper.lib;
import java.util.List;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.Initializer;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.SuperConstructorInvocation;
import org.eclipse.jdt.core.dom.SuperMethodInvocation;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
/**
* Fail-closed recognition of common JUnit 3 compatibility members that become
* redundant after an ordinary Jupiter migration.
*
* <p>The accepted shapes deliberately have no user-visible construction state or
* custom control flow: a constructor may only delegate its optional test name
* to {@code TestCase}, a self suite may only select its declaring class, and a
* lifecycle super call may only invoke the empty {@code TestCase} hook from the
* matching lifecycle method.</p>
*/
public final class JUnit3LegacyShape {
/** Fully qualified JUnit 3 base type. */
public static final String JUNIT3_TEST_CASE= "junit.framework.TestCase"; //$NON-NLS-1$
private static final String JAVA_LANG_STRING= "java.lang.String"; //$NON-NLS-1$
private JUnit3LegacyShape() {
}
/** Returns whether the type directly extends {@code junit.framework.TestCase}. */
public static boolean directlyExtendsTestCase(TypeDeclaration type) {
if (type == null || type.getSuperclassType() == null) {
return false;
}
ITypeBinding binding= type.resolveBinding();
ITypeBinding superclass= binding == null ? null : binding.getSuperclass();
if (superclass != null && !superclass.isRecovered()) {
return JUNIT3_TEST_CASE.equals(superclass.getErasure().getQualifiedName());
}
return isWrittenType(type.getSuperclassType(), JUNIT3_TEST_CASE);
}
/**
* Returns whether an explicit constructor can be removed without changing any
* user-defined construction behavior.
*/
public static boolean isRemovableConstructor(MethodDeclaration method) {
if (method == null || !method.isConstructor() || hasAnnotation(method)
|| !method.typeParameters().isEmpty() || !method.thrownExceptionTypes().isEmpty()
|| method.getReceiverType() != null) {
return false;
}
Block body= method.getBody();
if (body == null) {
return false;
}
@SuppressWarnings("unchecked")
List<Statement> statements= body.statements();
if (method.parameters().isEmpty()) {
return statements.isEmpty()
|| statements.size() == 1 && statements.get(0) instanceof SuperConstructorInvocation invocation
&& invocation.arguments().isEmpty() && invocation.typeArguments().isEmpty();
}
if (method.parameters().size() != 1 || statements.size() != 1
|| !(method.parameters().get(0) instanceof SingleVariableDeclaration parameter)
|| parameter.isVarargs() || !isString(parameter.getType())
|| !(statements.get(0) instanceof SuperConstructorInvocation invocation)
|| !invocation.typeArguments().isEmpty() || invocation.arguments().size() != 1) {
return false;
}
Expression argument= (Expression) invocation.arguments().get(0);
return argument instanceof SimpleName name
&& parameter.getName().getIdentifier().equals(name.getIdentifier());
}
/**
* Returns whether {@code suite()} is the ordinary redundant JUnit 3 wrapper
* {@code new TestSuite(ThisType.class)}.
*/
public static boolean isSelfSuite(MethodDeclaration method, TypeDeclaration owner) {
if (method == null || owner == null) {
return false;
}
JUnit3SuiteModel.Result model= JUnit3SuiteModel.analyze(method);
if (!model.supported() || model.selectedTypes().size() != 1) {
return false;
}
String selected= model.selectedTypes().get(0);
ITypeBinding binding= owner.resolveBinding();
String qualifiedName= binding == null || binding.isRecovered() ? null : binding.getQualifiedName();
String ownerSimpleName= owner.getName().getIdentifier();
return ownerSimpleName.equals(selected) || qualifiedName != null && qualifiedName.equals(selected)
|| ownerSimpleName.equals(simpleName(selected));
}
/**
* Returns whether a type is a pure suite aggregator, optionally retaining a
* harmless direct {@code TestCase} superclass, delegating constructors and one
* static initialization block. The block is migrated to one
* {@code @BeforeSuite} method by the suite cleanup.
*/
public static boolean isPureSuiteAggregator(TypeDeclaration owner, MethodDeclaration suite) {
if (owner == null || suite == null || !(owner.getParent() instanceof CompilationUnit root)
|| root.types().size() != 1 || owner.isInterface() || owner.getFields().length != 0
|| owner.getTypes().length != 0 || !JUnit3SuiteModel.analyze(suite).supported()
|| !hasSupportedSuiteInitializer(owner)) {
return false;
}
if (owner.getSuperclassType() != null && !directlyExtendsTestCase(owner)) {
return false;
}
for (MethodDeclaration method : owner.getMethods()) {
if (method != suite && !isRemovableConstructor(method)) {
return false;
}
}
return true;
}
/**
* Returns the single static initializer of a pure suite aggregator, or
* {@code null} when none exists or the initializer shape is unsupported.
*/
public static Initializer suiteInitializer(TypeDeclaration owner) {
List<Initializer> initializers= initializers(owner);
if (initializers.size() != 1 || !Modifier.isStatic(initializers.get(0).getModifiers())) {
return null;
}
return initializers.get(0);
}
/**
* Returns whether an invocation is a redundant direct call to the matching
* {@code TestCase.setUp()} or {@code TestCase.tearDown()} hook.
*/
public static boolean isRedundantLifecycleSuperCall(SuperMethodInvocation invocation) {
if (invocation == null || !invocation.arguments().isEmpty() || !invocation.typeArguments().isEmpty()
|| !(invocation.getParent() instanceof ExpressionStatement)) {
return false;
}
String name= invocation.getName().getIdentifier();
if (!("setUp".equals(name) || "tearDown".equals(name))) { //$NON-NLS-1$ //$NON-NLS-2$
return false;
}
MethodDeclaration enclosing= enclosingMethod(invocation);
if (enclosing == null || !name.equals(enclosing.getName().getIdentifier())) {
return false;
}
IMethodBinding binding= invocation.resolveMethodBinding();
ITypeBinding declaring= binding == null ? null : binding.getDeclaringClass();
return declaring != null && JUNIT3_TEST_CASE.equals(declaring.getErasure().getQualifiedName());
}
private static boolean hasSupportedSuiteInitializer(TypeDeclaration owner) {
List<Initializer> initializers= initializers(owner);
return initializers.size() <= 1
&& initializers.stream().allMatch(initializer -> Modifier.isStatic(initializer.getModifiers()));
}
private static List<Initializer> initializers(TypeDeclaration owner) {
if (owner == null) {
return List.of();
}
return owner.bodyDeclarations().stream()
.filter(Initializer.class::isInstance)
.map(Initializer.class::cast)
.toList();
}
private static MethodDeclaration enclosingMethod(ASTNode node) {
for (ASTNode current= node.getParent(); current != null; current= current.getParent()) {
if (current instanceof MethodDeclaration method) {
return method;
}
}
return null;
}
private static boolean hasAnnotation(MethodDeclaration method) {
return method.modifiers().stream().anyMatch(Annotation.class::isInstance);
}
private static boolean isString(Type type) {
ITypeBinding binding= type.resolveBinding();
return binding != null && !binding.isRecovered()
? JAVA_LANG_STRING.equals(binding.getErasure().getQualifiedName())
: isWrittenType(type, JAVA_LANG_STRING);
}
private static boolean isWrittenType(Type type, String qualifiedName) {
String written= type.toString();
return qualifiedName.equals(written) || simpleName(qualifiedName).equals(written);
}
private static String simpleName(String qualifiedName) {
int separator= qualifiedName.lastIndexOf('.');
return separator < 0 ? qualifiedName : qualifiedName.substring(separator + 1);
}
}