TestNameRefactorer.java

/*******************************************************************************
 * Copyright (c) 2025 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.lib;

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.*;

import java.util.ArrayList;
import java.util.List;

import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.ITypeHierarchy;
import org.eclipse.jdt.core.JavaModelException;
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.Assignment;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldAccess;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
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.PrimitiveType;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.StructuralPropertyDescriptor;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
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.dom.ASTNodes;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.corext.util.ASTNavigationUtils;

/**
 * Helper class for refactoring JUnit 4 TestName rule to JUnit 5 TestInfo parameter.
 * Replaces @Rule TestName fields with a @BeforeEach method that captures test information.
 */
public final class TestNameRefactorer {

	// Private constructor to prevent instantiation
	private TestNameRefactorer() {
		throw new UnsupportedOperationException("Utility class");
	}

	/**
	 * Refactors TestName field usage in a class and optionally in its subclasses.
	 * Replaces JUnit 4 @Rule TestName with a @BeforeEach method that captures test info.
	 * 
	 * @param group the text edit group
	 * @param rewriter the AST rewriter
	 * @param ast the AST instance
	 * @param importRewrite the import rewriter
	 * @param node the TestName field declaration to replace
	 */
	public static void refactorTestnameInClass(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewrite, FieldDeclaration node) {
		if (node == null || rewriter == null || ast == null || importRewrite == null) {
			return;
		}

		// Remove the old @Rule TestName field
		rewriter.remove(node, group);

		// Add new infrastructure: @BeforeEach init method and private String testName field
		TypeDeclaration parentClass = ASTNodes.getParent(node, TypeDeclaration.class);
		addBeforeEachInitMethod(parentClass, rewriter, group);
		addTestNameField(parentClass, rewriter, group);

		// Update method references from testNameField.getMethodName() to just testName
		updateMethodReferences(parentClass, ast, rewriter, group);

		// Update imports
		importRewrite.addImport(ORG_JUNIT_JUPITER_API_TEST_INFO);
		importRewrite.addImport(ORG_JUNIT_JUPITER_API_BEFORE_EACH);
		importRewrite.removeImport(ORG_JUNIT_RULE);
		importRewrite.removeImport(ORG_JUNIT_RULES_TEST_NAME);
	}

	/**
	 * Refactors TestName usage in a class and all its subclasses.
	 * 
	 * @param group the text edit group
	 * @param rewriter the AST rewriter
	 * @param ast the AST instance
	 * @param importRewrite the import rewriter
	 * @param node the TestName field declaration to replace
	 */
	public static void refactorTestnameInClassAndSubclasses(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewrite, FieldDeclaration node) {
		refactorTestnameInClass(group, rewriter, ast, importRewrite, node);

		TypeDeclaration parentClass = ASTNodes.getParent(node, TypeDeclaration.class);
		if (parentClass == null) {
			return;
		}
		ITypeBinding typeBinding = parentClass.resolveBinding();
		List<ITypeBinding> subclasses = getAllSubclasses(typeBinding);

		for (ITypeBinding subclassBinding : subclasses) {
			IType subclassType = (IType) subclassBinding.getJavaElement();

			CompilationUnit subclassUnit = ASTNavigationUtils.parseCompilationUnit(subclassType.getCompilationUnit());
			subclassUnit.accept(new ASTVisitor() {
				@Override
				public boolean visit(TypeDeclaration subclassNode) {
					if (subclassNode.resolveBinding().equals(subclassBinding)) {
						refactorTestnameInClass(group, rewriter, subclassNode.getAST(), importRewrite, node);
					}
					return false;
				}
			});
		}
	}

	/**
	 * Adds a @BeforeEach init method that captures the test name from TestInfo.
	 * 
	 * @param parentClass the class to add the method to
	 * @param rewriter the AST rewriter
	 * @param group the text edit group
	 */
	public static void addBeforeEachInitMethod(TypeDeclaration parentClass, ASTRewrite rewriter, TextEditGroup group) {
		AST ast = parentClass.getAST();

		MethodDeclaration methodDeclaration = createInitMethod(ast);
		MarkerAnnotation beforeEachAnnotation = createBeforeEachAnnotation(ast);

		addMethodToClass(parentClass, methodDeclaration, beforeEachAnnotation, rewriter, group);
	}

	/**
	 * Adds a private String field named 'testName' to the class.
	 * Used when migrating JUnit 4 TestName rule to JUnit 5 TestInfo parameter.
	 * 
	 * @param parentClass the class to add the field to
	 * @param rewriter the AST rewriter
	 * @param group the text edit group
	 */
	public static void addTestNameField(TypeDeclaration parentClass, ASTRewrite rewriter, TextEditGroup group) {
		AST ast = parentClass.getAST();
		VariableDeclarationFragment fragment = ast.newVariableDeclarationFragment();
		fragment.setName(ast.newSimpleName(TEST_NAME));

		FieldDeclaration fieldDeclaration = ast.newFieldDeclaration(fragment);
		fieldDeclaration.setType(ast.newSimpleType(ast.newName("String")));
		fieldDeclaration.modifiers().add(ast.newModifier(Modifier.ModifierKeyword.PRIVATE_KEYWORD));

		ListRewrite listRewrite = rewriter.getListRewrite(parentClass, TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
		listRewrite.insertFirst(fieldDeclaration, group);
	}

	/**
	 * Updates method references from testNameField.getMethodName() to just testName.
	 * 
	 * @param parentClass the class containing methods to update
	 * @param ast the AST instance
	 * @param rewriter the AST rewriter
	 * @param group the text edit group
	 */
	public static void updateMethodReferences(TypeDeclaration parentClass, AST ast, ASTRewrite rewriter,
			TextEditGroup group) {
		for (MethodDeclaration method : parentClass.getMethods()) {
			if (method.getBody() != null) {
				method.getBody().accept(new ASTVisitor() {
					@Override
					public boolean visit(MethodInvocation node) {
						if (node.getExpression() != null) {
							ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
							if (typeBinding != null && ORG_JUNIT_RULES_TEST_NAME.equals(typeBinding.getQualifiedName())) {
								SimpleName newFieldAccess = ast.newSimpleName(TEST_NAME);
								rewriter.replace(node, newFieldAccess, group);
							}
						}
						return super.visit(node);
					}
				});
			}
		}
	}

	/**
	 * Copies an AST subtree and transforms TestName.getMethodName() calls to just the field name.
	 * This is useful when copying arguments that may contain TestName usages, such as in TemporaryFolder migration.
	 * 
	 * The method checks the ORIGINAL node for type bindings (which are available on the original AST),
	 * and applies transformations to the copy.
	 * 
	 * @param originalNode the original AST node to copy and transform
	 * @param ast the target AST instance for creating the copy
	 * @return the transformed copy of the node
	 */
	public static ASTNode copyAndTransformTestNameReferences(ASTNode originalNode, AST ast) {
		if (originalNode == null || ast == null) {
			return originalNode;
		}
		
		// First, find all TestName.getMethodName() calls in the original and record their positions
		final java.util.Set<MethodInvocation> testNameCalls = new java.util.HashSet<>();
		originalNode.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation node) {
				if (node.getExpression() != null && "getMethodName".equals(node.getName().getIdentifier())) {
					ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
					if (typeBinding != null && ORG_JUNIT_RULES_TEST_NAME.equals(typeBinding.getQualifiedName())) {
						testNameCalls.add(node);
					}
				}
				return super.visit(node);
			}
		});
		
		// If no TestName calls found, just return a simple copy
		if (testNameCalls.isEmpty()) {
			return ASTNode.copySubtree(ast, originalNode);
		}
		
		// Create the copy and transform TestName references
		ASTNode copiedNode = ASTNode.copySubtree(ast, originalNode);
		
		// Now transform the copy - we need to find the corresponding nodes in the copy
		// by matching the pattern (since we know from the original which ones need transformation)
		copiedNode.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation node) {
				if (node.getExpression() != null && "getMethodName".equals(node.getName().getIdentifier())) {
					// In the copy, we can't check type bindings, but we know from the original
					// that getMethodName() calls on SimpleName expressions should be transformed
					if (node.getExpression() instanceof SimpleName) {
						SimpleName replacement = ast.newSimpleName(TEST_NAME);
						replaceInParent(node, replacement);
					}
				}
				return super.visit(node);
			}
		});
		
		return copiedNode;
	}
	
	/**
	 * Transforms a copied AST subtree by replacing TestName.getMethodName() calls with just the field name.
	 * This is useful when copying arguments that may contain TestName usages, such as in TemporaryFolder migration.
	 * 
	 * Note: Copied AST nodes from ASTNode.copySubtree may not have resolved type bindings, so this method
	 * also checks by pattern (SimpleName expression + "getMethodName" method name).
	 * 
	 * @param copiedNode the copied AST node to transform
	 * @param ast the AST instance
	 * @return the transformed node (same reference as input, modified in place)
	 * @deprecated Use {@link #copyAndTransformTestNameReferences(ASTNode, AST)} instead which checks bindings on the original
	 */
	@Deprecated
	public static ASTNode transformTestNameReferencesInCopy(ASTNode copiedNode, AST ast) {
		if (copiedNode == null || ast == null) {
			return copiedNode;
		}
		
		copiedNode.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation node) {
				// Check if this is a getMethodName() call on a TestName-typed expression
				if (node.getExpression() != null && "getMethodName".equals(node.getName().getIdentifier())) {
					ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
					boolean isTestNameType = typeBinding != null && ORG_JUNIT_RULES_TEST_NAME.equals(typeBinding.getQualifiedName());
					
					// Also check by pattern: if expression is a SimpleName, it might be a TestName field
					// This handles copied nodes where type bindings are not resolved
					boolean isTestNamePattern = node.getExpression() instanceof SimpleName;
					
					if (isTestNameType || isTestNamePattern) {
						// Replace the entire method invocation with just the field name
						SimpleName replacement = ast.newSimpleName(TEST_NAME);
						// We need to replace node in its parent
						replaceInParent(node, replacement);
					}
				}
				return super.visit(node);
			}
		});
		
		return copiedNode;
	}
	
	/**
	 * Replaces a node in its parent with a replacement node.
	 * Handles common parent types like InfixExpression, MethodInvocation arguments, etc.
	 * 
	 * @param oldNode the node to replace
	 * @param newNode the replacement node
	 */
	@SuppressWarnings("unchecked")
	private static void replaceInParent(ASTNode oldNode, ASTNode newNode) {
		ASTNode parent = oldNode.getParent();
		if (parent == null) {
			return;
		}
		
		StructuralPropertyDescriptor location = oldNode.getLocationInParent();
		if (location.isChildProperty()) {
			parent.setStructuralProperty(location, newNode);
		} else if (location.isChildListProperty()) {
			List<ASTNode> list = (List<ASTNode>) parent.getStructuralProperty(location);
			int index = list.indexOf(oldNode);
			if (index >= 0) {
				list.set(index, newNode);
			}
		}
	}

	/**
	 * Gets all direct and indirect subclasses of the given type.
	 * Uses the JDT type hierarchy to discover subclasses in the project.
	 * 
	 * @param typeBinding the type binding to find subclasses for
	 * @return list of type bindings for all subclasses
	 */
	public static List<ITypeBinding> getAllSubclasses(ITypeBinding typeBinding) {
		List<ITypeBinding> subclasses = new ArrayList<>();

		try {
			// Create the corresponding IType of the given ITypeBinding
			IType type = (IType) typeBinding.getJavaElement();

			// Create the type hierarchy for the given type within the project (null uses entire project)
			ITypeHierarchy typeHierarchy = type.newTypeHierarchy(null);

			// Iterate through all direct and indirect subtypes and add them to the list
			for (IType subtype : typeHierarchy.getAllSubtypes(type)) {
				ITypeBinding subtypeBinding = subtype.getAdapter(ITypeBinding.class);
				if (subtypeBinding != null) {
					subclasses.add(subtypeBinding);
				}
			}
		} catch (JavaModelException e) {
			System.err.println("Failed to get subclasses for type: " + typeBinding.getQualifiedName());
			e.printStackTrace();
			return new ArrayList<>();
		}
		return subclasses;
	}

	/**
	 * Creates the init method that assigns testInfo.getDisplayName() to this.testName.
	 * 
	 * @param ast the AST instance
	 * @return the method declaration
	 */
	private static MethodDeclaration createInitMethod(AST ast) {
		MethodDeclaration methodDeclaration = ast.newMethodDeclaration();
		methodDeclaration.setName(ast.newSimpleName("init"));
		methodDeclaration.setReturnType2(ast.newPrimitiveType(PrimitiveType.VOID));

		// Add parameter: TestInfo testInfo
		SingleVariableDeclaration param = ast.newSingleVariableDeclaration();
		param.setType(ast.newSimpleType(ast.newName("TestInfo")));
		param.setName(ast.newSimpleName("testInfo"));
		methodDeclaration.parameters().add(param);

		// Create method body
		Block body = createInitMethodBody(ast);
		methodDeclaration.setBody(body);

		return methodDeclaration;
	}

	/**
	 * Creates the body of the init method: this.testName = testInfo.getDisplayName();
	 * 
	 * @param ast the AST instance
	 * @return the method body block
	 */
	private static Block createInitMethodBody(AST ast) {
		Block body = ast.newBlock();

		// Create assignment: this.testName = testInfo.getDisplayName()
		Assignment assignment = ast.newAssignment();

		// Left side: this.testName
		FieldAccess fieldAccess = ast.newFieldAccess();
		fieldAccess.setExpression(ast.newThisExpression());
		fieldAccess.setName(ast.newSimpleName(TEST_NAME));
		assignment.setLeftHandSide(fieldAccess);

		// Right side: testInfo.getDisplayName()
		MethodInvocation methodInvocation = ast.newMethodInvocation();
		methodInvocation.setExpression(ast.newSimpleName("testInfo"));
		methodInvocation.setName(ast.newSimpleName("getDisplayName"));
		assignment.setRightHandSide(methodInvocation);

		body.statements().add(ast.newExpressionStatement(assignment));
		return body;
	}

	/**
	 * Creates a @BeforeEach annotation.
	 * 
	 * @param ast the AST instance
	 * @return the annotation
	 */
	private static MarkerAnnotation createBeforeEachAnnotation(AST ast) {
		MarkerAnnotation annotation = ast.newMarkerAnnotation();
		annotation.setTypeName(ast.newName("BeforeEach"));
		return annotation;
	}

	/**
	 * Adds a method with its annotation to a class.
	 * 
	 * @param parentClass the class to add to
	 * @param method the method to add
	 * @param annotation the annotation to add to the method
	 * @param rewriter the AST rewriter
	 * @param group the text edit group
	 */
	private static void addMethodToClass(TypeDeclaration parentClass, MethodDeclaration method,
			MarkerAnnotation annotation, ASTRewrite rewriter, TextEditGroup group) {
		// Add method to class
		ListRewrite classBodyRewrite = rewriter.getListRewrite(parentClass, TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
		classBodyRewrite.insertFirst(method, group);

		// Add annotation to method
		ListRewrite modifierRewrite = rewriter.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
		modifierRewrite.insertFirst(annotation, group);
	}
}