LostTestFinderJUnitPlugin.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
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ANNOTATION_TEST;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_TEST;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_TEST;

import java.util.List;
import java.util.Set;

import org.eclipse.jdt.core.dom.AST;
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.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.common.ReferenceHolder;
import org.sandbox.jdt.internal.corext.fix.JUnitCleanUpFixCore;
import org.sandbox.jdt.internal.corext.fix.helper.lib.AbstractTool;
import org.sandbox.jdt.internal.corext.fix.helper.lib.JunitHolder;

/**
 * Plugin to detect and fix "lost" JUnit 3 tests that were not properly migrated.
 * A method is considered a lost test when:
 * - The class (or its superclasses) contains @Test annotated methods
 * - Method name starts with "test"
 * - No @Test annotation present
 * - Public void signature with no parameters
 * - Not annotated with lifecycle annotations
 */
public class LostTestFinderJUnitPlugin extends AbstractTool<ReferenceHolder<Integer, JunitHolder>> {

	private static final Set<String> LIFECYCLE_ANNOTATIONS = Set.of(
			"Before", "After", "BeforeClass", "AfterClass",  // JUnit 4
			"BeforeEach", "AfterEach", "BeforeAll", "AfterAll",  // JUnit 5
			"Ignore", "Disabled"  // Skip annotations
	);

	@Override
	public void find(JUnitCleanUpFixCore fixcore, CompilationUnit compilationUnit,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesprocessed) {
		
		// Visit all type declarations to find classes that have @Test methods
		compilationUnit.accept(new ASTVisitor() {
			@Override
			public boolean visit(TypeDeclaration node) {
				// Check if this class has any @Test methods (including inherited)
				if (classHasTestMethods(node)) {
					// Find lost test methods in this class
					findLostTestMethods(fixcore, node, operations, nodesprocessed);
				}
				return true;
			}
		});
	}

	/**
	 * Checks if the class or any of its superclasses contains @Test annotated methods
	 */
	private boolean classHasTestMethods(TypeDeclaration typeDecl) {
		// Check current class methods
		for (MethodDeclaration method : typeDecl.getMethods()) {
			if (hasTestAnnotation(method)) {
				return true;
			}
		}
		
		// Check superclass hierarchy using ITypeBinding
		ITypeBinding binding = typeDecl.resolveBinding();
		if (binding != null) {
			ITypeBinding superclass = binding.getSuperclass();
			while (superclass != null) {
				for (IMethodBinding method : superclass.getDeclaredMethods()) {
					if (hasTestAnnotationOnBinding(method)) {
						return true;
					}
				}
				superclass = superclass.getSuperclass();
			}
		}
		
		return false;
	}

	/**
	 * Checks if a method has a @Test annotation
	 */
	private boolean hasTestAnnotation(MethodDeclaration method) {
		for (Object modifier : method.modifiers()) {
			if (modifier instanceof Annotation) {
				Annotation ann = (Annotation) modifier;
				String name = ann.getTypeName().getFullyQualifiedName();
				if (name.equals("Test") || 
					name.equals("org.junit.Test") || 
					name.equals("org.junit.jupiter.api.Test")) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Checks if a method binding has a @Test annotation
	 */
	private boolean hasTestAnnotationOnBinding(IMethodBinding methodBinding) {
		for (org.eclipse.jdt.core.dom.IAnnotationBinding annotation : methodBinding.getAnnotations()) {
			String annotationName = annotation.getAnnotationType().getQualifiedName();
			if (annotationName.equals("org.junit.Test") || 
				annotationName.equals("org.junit.jupiter.api.Test")) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Find methods that look like lost tests in the given type
	 */
	private void findLostTestMethods(JUnitCleanUpFixCore fixcore, TypeDeclaration typeDecl,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesprocessed) {
		
		for (MethodDeclaration method : typeDecl.getMethods()) {
			if (isLostTestMethod(method) && !nodesprocessed.contains(method)) {
				nodesprocessed.add(method);
				
				ReferenceHolder<Integer, JunitHolder> dataHolder = new ReferenceHolder<>();
				JunitHolder mh = new JunitHolder();
				mh.minv = method;
				dataHolder.put(0, mh);
				operations.add(fixcore.rewrite(dataHolder));
			}
		}
	}

	/**
	 * Determines if a method is a lost test method
	 */
	private boolean isLostTestMethod(MethodDeclaration method) {
		// Must start with "test"
		String methodName = method.getName().getIdentifier();
		if (!methodName.startsWith("test")) {
			return false;
		}
		
		// Must not already have @Test annotation
		if (hasTestAnnotation(method)) {
			return false;
		}
		
		// Must be public void with no parameters
		if (!Modifier.isPublic(method.getModifiers())) {
			return false;
		}
		
		// Check for void return type using bindings when available, with a safe AST fallback
		org.eclipse.jdt.core.dom.ITypeBinding returnBinding = null;
		org.eclipse.jdt.core.dom.IMethodBinding methodBinding = method.resolveBinding();
		if (methodBinding != null) {
			returnBinding = methodBinding.getReturnType();
		}

		if (returnBinding != null) {
			// Binding-based check: require primitive void
			if (!returnBinding.isPrimitive() || !"void".equals(returnBinding.getName())) {
				return false;
			}
		} else {
			// Fallback: inspect the AST return type node defensively
			org.eclipse.jdt.core.dom.Type astReturnType = method.getReturnType2();
			if (astReturnType == null) {
				return false;
			}
			if (astReturnType.isPrimitiveType()) {
				org.eclipse.jdt.core.dom.PrimitiveType primitiveType = (org.eclipse.jdt.core.dom.PrimitiveType) astReturnType;
				if (primitiveType.getPrimitiveTypeCode() != org.eclipse.jdt.core.dom.PrimitiveType.VOID) {
					return false;
				}
			} else {
				// Only PrimitiveType with VOID is valid - any other return type is not a lost test
				return false;
			}
		}
		
		if (!method.parameters().isEmpty()) {
			return false;
		}
		
		// Must not have lifecycle annotations
		if (hasLifecycleAnnotation(method)) {
			return false;
		}
		
		return true;
	}

	/**
	 * Checks if a method has a lifecycle annotation that would prevent it from being a test
	 */
	private boolean hasLifecycleAnnotation(MethodDeclaration method) {
		for (Object modifier : method.modifiers()) {
			if (modifier instanceof Annotation) {
				Annotation ann = (Annotation) modifier;
				String name = ann.getTypeName().getFullyQualifiedName();
				int lastDot = name.lastIndexOf('.');
				String simpleName = lastDot == -1 ? name : name.substring(lastDot + 1);
				// Check simple name and fully qualified JUnit names
				if (LIFECYCLE_ANNOTATIONS.contains(name)
						|| ((name.startsWith("org.junit.") || name.startsWith("org.junit.jupiter.api."))
								&& LIFECYCLE_ANNOTATIONS.contains(simpleName))) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Determines which @Test annotation to add based on imports in the compilation unit
	 */
	private boolean shouldUseJUnit5(CompilationUnit compilationUnit) {
		final boolean[] hasJUnit5Import = {false};
		final boolean[] hasJUnit4Import = {false};
		
		// Check imports
		List<?> imports = compilationUnit.imports();
		for (Object obj : imports) {
			if (obj instanceof ImportDeclaration) {
				ImportDeclaration imp = (ImportDeclaration) obj;
				String importName = imp.getName().getFullyQualifiedName();
				if (importName.startsWith("org.junit.jupiter.api")) {
					hasJUnit5Import[0] = true;
				} else if (importName.equals("org.junit.Test")
						|| (importName.equals("org.junit") && imp.isOnDemand())) {
					hasJUnit4Import[0] = true;
				}
			}
		}
		
		// Prefer JUnit 5 if both are imported or neither
		return hasJUnit5Import[0] || !hasJUnit4Import[0];
	}

	@Override
	protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast, ImportRewrite importRewriter,
			JunitHolder junitHolder) {
		MethodDeclaration method = (MethodDeclaration) junitHolder.minv;
		CompilationUnit cu = (CompilationUnit) method.getRoot();
		
		// Determine which @Test to use
		boolean useJUnit5 = shouldUseJUnit5(cu);
		
		// Add @Test annotation
		ListRewrite modifiers = rewriter.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
		MarkerAnnotation testAnnotation = ast.newMarkerAnnotation();
		testAnnotation.setTypeName(ast.newSimpleName(ANNOTATION_TEST));
		modifiers.insertFirst(testAnnotation, group);
		
		// Add import
		if (useJUnit5) {
			importRewriter.addImport(ORG_JUNIT_JUPITER_TEST);
		} else {
			importRewriter.addImport(ORG_JUNIT_TEST);
		}
	}

	@Override
	public String getPreview(boolean afterRefactoring) {
		if (afterRefactoring) {
			return """
					@Test
					public void testEdgeCase() {
						assertEquals(0, calc.divide(0, 1));
					}
					"""; //$NON-NLS-1$
		}
		return """
				public void testEdgeCase() {
					assertEquals(0, calc.divide(0, 1));
				}
				"""; //$NON-NLS-1$
	}

	@Override
	public String toString() {
		return "LostTests"; //$NON-NLS-1$
	}
}