TestNameRefactorer.java

/*******************************************************************************
 * Copyright (c) 2025, 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
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper.lib;

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_BEFORE_EACH;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_TEST_INFO;
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_TEST_NAME;

import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.NullProgressMonitor;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IField;
import org.eclipse.jdt.core.IJavaElement;
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.Assignment;
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.FieldAccess;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IBinding;
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.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.QualifiedName;
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.ThisExpression;
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.core.search.IJavaSearchConstants;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.core.search.SearchMatch;
import org.eclipse.jdt.core.search.SearchParticipant;
import org.eclipse.jdt.core.search.SearchPattern;
import org.eclipse.jdt.core.search.SearchRequestor;
import org.eclipse.jdt.internal.corext.dom.ASTNodes;
import org.eclipse.text.edits.TextEditGroup;

/**
 * Fail-closed migration of JUnit 4 {@code TestName} rules.
 *
 * <p>{@code TestName.getMethodName()} is equivalent to the Java method name,
 * not Jupiter's display name. The migration therefore derives the value from
 * {@code TestInfo.getTestMethod().orElseThrow().getName()} and rejects every
 * other use of the rule field.</p>
 */
public final class TestNameRefactorer {

	/** Stable eligibility decision used by the cleanup and its tests. */
	public record Assessment(boolean eligible, String reasonCode, String explanation) {
		public Assessment {
			reasonCode= Objects.requireNonNull(reasonCode);
			explanation= Objects.requireNonNull(explanation);
		}
	}

	private record SourceRange(int start, int length) {
	}

	private TestNameRefactorer() {
		throw new UnsupportedOperationException("Utility class"); //$NON-NLS-1$
	}

	/** Proves that all references are local, bound and exact getMethodName calls. */
	public static Assessment assess(FieldDeclaration field) {
		if (field == null || field.fragments().size() != 1
				|| !(field.fragments().get(0) instanceof VariableDeclarationFragment fragment)) {
			return rejected("TEST_NAME_FIELD_SHAPE_UNSUPPORTED", //$NON-NLS-1$
					"The TestName rule must contain exactly one field fragment."); //$NON-NLS-1$
		}
		IVariableBinding binding= fragment.resolveBinding();
		ITypeBinding fieldType= binding == null ? null : binding.getType();
		if (binding == null || fieldType == null
				|| !ORG_JUNIT_RULES_TEST_NAME.equals(fieldType.getErasure().getQualifiedName())) {
			return rejected("TEST_NAME_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The TestName field binding could not be resolved exactly."); //$NON-NLS-1$
		}
		if (Modifier.isStatic(field.getModifiers())) {
			return rejected("TEST_NAME_STATIC_RULE", //$NON-NLS-1$
					"A JUnit 4 @Rule TestName field must remain instance-scoped."); //$NON-NLS-1$
		}
		TypeDeclaration owner= ASTNodes.getParent(field, TypeDeclaration.class);
		CompilationUnit root= field.getRoot() instanceof CompilationUnit unit ? unit : null;
		if (owner == null || root == null) {
			return rejected("TEST_NAME_OWNER_UNRESOLVED", //$NON-NLS-1$
					"The declaring source type could not be resolved."); //$NON-NLS-1$
		}
		if (countTestNameFields(root) != 1) {
			return rejected("TEST_NAME_MULTIPLE_FIELDS", //$NON-NLS-1$
					"Multiple TestName fields in one compilation unit require a coordinated migration."); //$NON-NLS-1$
		}

		String fieldKey= variableKey(binding);
		if (fieldKey == null) {
			return rejected("TEST_NAME_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The TestName field has no stable binding key."); //$NON-NLS-1$
		}
		AtomicBoolean unsupported= new AtomicBoolean();
		int[] supportedInvocations= { 0 };
		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(SimpleName node) {
				IBinding resolved= node.resolveBinding();
				if (!(resolved instanceof IVariableBinding variable)
						|| !fieldKey.equals(variableKey(variable))) {
					return true;
				}
				if (node == fragment.getName()) {
					return true;
				}
				MethodInvocation invocation= ASTNodes.getParent(node, MethodInvocation.class);
				if (invocation != null && invocation.getExpression() != null
						&& isDescendantOf(node, invocation.getExpression())
						&& isSupportedInvocation(invocation, fieldKey)) {
					supportedInvocations[0]++;
					return true;
				}
				unsupported.set(true);
				return true;
			}
		});
		if (unsupported.get()) {
			return rejected("TEST_NAME_UNSUPPORTED_USE", //$NON-NLS-1$
					"The rule field is used for something other than getMethodName()."); //$NON-NLS-1$
		}
		if (hasReferencesOutsideCompilationUnit(binding, root)) {
			return rejected("TEST_NAME_EXTERNAL_REFERENCE", //$NON-NLS-1$
					"The TestName field is referenced outside its compilation unit."); //$NON-NLS-1$
		}
		if (supportedInvocations[0] == 0) {
			return new Assessment(true, "TEST_NAME_UNUSED_RULE", //$NON-NLS-1$
					"The TestName field has no workspace references and can be migrated without blocking the surrounding lifecycle."); //$NON-NLS-1$
		}
		return new Assessment(true, "TEST_NAME_LOCAL_GET_METHOD_NAME", //$NON-NLS-1$
				"Every workspace reference is a local bound getMethodName() call."); //$NON-NLS-1$
	}

	/** Performs the proven local migration. */
	public static void refactorTestnameInClass(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewrite, FieldDeclaration field) {
		Assessment assessment= assess(field);
		if (!assessment.eligible()) {
			return;
		}
		VariableDeclarationFragment fragment= (VariableDeclarationFragment) field.fragments().get(0);
		IVariableBinding binding= fragment.resolveBinding();
		String fieldKey= variableKey(binding);
		String fieldName= fragment.getName().getIdentifier();
		TypeDeclaration owner= ASTNodes.getParent(field, TypeDeclaration.class);
		CompilationUnit root= (CompilationUnit) field.getRoot();

		removeRuleAnnotation(field, rewriter, group);
		rewriter.replace(field.getType(), ast.newSimpleType(ast.newSimpleName("String")), group); //$NON-NLS-1$
		rewriter.set(fragment, VariableDeclarationFragment.INITIALIZER_PROPERTY, null, group);
		for (Object modifierObject : field.modifiers()) {
			if (modifierObject instanceof Modifier modifier && modifier.isFinal()) {
				rewriter.remove(modifier, group);
			}
		}

		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation invocation) {
				if (isSupportedInvocation(invocation, fieldKey)) {
					Expression replacement= (Expression) ASTNode.copySubtree(ast, invocation.getExpression());
					rewriter.replace(invocation, replacement, group);
					return false;
				}
				return true;
			}
		});

		String beforeEachName= importRewrite.addImport(ORG_JUNIT_JUPITER_API_BEFORE_EACH);
		String testInfoName= importRewrite.addImport(ORG_JUNIT_JUPITER_API_TEST_INFO);
		importRewrite.removeImport(ORG_JUNIT_RULES_TEST_NAME);
		if (!hasOtherRuleAnnotation(root, field)) {
			importRewrite.removeImport(ORG_JUNIT_RULE);
		}
		MethodDeclaration initializer= createInitializer(owner, fieldName, beforeEachName, testInfoName);
		rewriter.getListRewrite(owner, TypeDeclaration.BODY_DECLARATIONS_PROPERTY)
				.insertAfter(initializer, field, group);
	}

	/**
	 * Historical entry point. Project-search eligibility proves there are no
	 * subclass or cross-file references, so the safe operation is deliberately
	 * local rather than mutating independently parsed ASTs.
	 */
	public static void refactorTestnameInClassAndSubclasses(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewrite, FieldDeclaration field) {
		refactorTestnameInClass(group, rewriter, ast, importRewrite, field);
	}

	private static MethodDeclaration createInitializer(TypeDeclaration owner, String fieldName,
			String beforeEachName, String testInfoName) {
		AST ast= owner.getAST();
		MethodDeclaration method= ast.newMethodDeclaration();
		method.setName(ast.newSimpleName(uniqueMethodName(owner,
				"initialize" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1) //$NON-NLS-1$
						+ "FromTestInfo"))); //$NON-NLS-1$
		method.setReturnType2(ast.newPrimitiveType(PrimitiveType.VOID));
		MarkerAnnotation beforeEach= ast.newMarkerAnnotation();
		beforeEach.setTypeName(ast.newName(beforeEachName));
		method.modifiers().add(beforeEach);

		SingleVariableDeclaration parameter= ast.newSingleVariableDeclaration();
		parameter.setType(ast.newSimpleType(ast.newName(testInfoName)));
		parameter.setName(ast.newSimpleName("testInfo")); //$NON-NLS-1$
		method.parameters().add(parameter);

		MethodInvocation getTestMethod= ast.newMethodInvocation();
		getTestMethod.setExpression(ast.newSimpleName("testInfo")); //$NON-NLS-1$
		getTestMethod.setName(ast.newSimpleName("getTestMethod")); //$NON-NLS-1$
		MethodInvocation orElseThrow= ast.newMethodInvocation();
		orElseThrow.setExpression(getTestMethod);
		orElseThrow.setName(ast.newSimpleName("orElseThrow")); //$NON-NLS-1$
		MethodInvocation getName= ast.newMethodInvocation();
		getName.setExpression(orElseThrow);
		getName.setName(ast.newSimpleName("getName")); //$NON-NLS-1$

		ThisExpression thisExpression= ast.newThisExpression();
		FieldAccess target= ast.newFieldAccess();
		target.setExpression(thisExpression);
		target.setName(ast.newSimpleName(fieldName));
		Assignment assignment= ast.newAssignment();
		assignment.setLeftHandSide(target);
		assignment.setRightHandSide(getName);
		Block body= ast.newBlock();
		body.statements().add(ast.newExpressionStatement(assignment));
		method.setBody(body);
		return method;
	}

	private static String uniqueMethodName(TypeDeclaration owner, String base) {
		List<String> names= new ArrayList<>();
		for (MethodDeclaration method : owner.getMethods()) {
			names.add(method.getName().getIdentifier());
		}
		if (!names.contains(base)) {
			return base;
		}
		for (int suffix= 2; ; suffix++) {
			String candidate= base + suffix;
			if (!names.contains(candidate)) {
				return candidate;
			}
		}
	}

	private static void removeRuleAnnotation(FieldDeclaration field, ASTRewrite rewriter,
			TextEditGroup group) {
		for (Object modifier : field.modifiers()) {
			if (modifier instanceof Annotation annotation) {
				ITypeBinding binding= annotation.resolveTypeBinding();
				if (binding != null && ORG_JUNIT_RULE.equals(binding.getQualifiedName())) {
					rewriter.remove(annotation, group);
					return;
				}
			}
		}
	}

	private static boolean hasOtherRuleAnnotation(CompilationUnit root, FieldDeclaration migratedField) {
		AtomicBoolean other= new AtomicBoolean();
		root.accept(new ASTVisitor() {
			@Override
			public boolean preVisit2(ASTNode node) {
				if (!(node instanceof Annotation annotation)) {
					return !other.get();
				}
				if (ASTNodes.getParent(annotation, FieldDeclaration.class) == migratedField) {
					return true;
				}
				ITypeBinding binding= annotation.resolveTypeBinding();
				if (binding != null && ORG_JUNIT_RULE.equals(binding.getQualifiedName())) {
					other.set(true);
				}
				return !other.get();
			}
		});
		return other.get();
	}

	private static int countTestNameFields(CompilationUnit root) {
		int[] count= { 0 };
		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(FieldDeclaration field) {
				ITypeBinding binding= field.getType().resolveBinding();
				if (binding != null && ORG_JUNIT_RULES_TEST_NAME.equals(binding.getErasure().getQualifiedName())) {
					count[0]+= field.fragments().size();
				}
				return true;
			}
		});
		return count[0];
	}

	private static boolean isSupportedInvocation(MethodInvocation invocation, String fieldKey) {
		if (!"getMethodName".equals(invocation.getName().getIdentifier()) //$NON-NLS-1$
				|| !invocation.arguments().isEmpty() || invocation.getExpression() == null
				|| !fieldKey.equals(expressionVariableKey(invocation.getExpression()))) {
			return false;
		}
		IMethodBinding binding= invocation.resolveMethodBinding();
		ITypeBinding declaring= binding == null ? null : binding.getDeclaringClass();
		return declaring != null && ORG_JUNIT_RULES_TEST_NAME.equals(declaring.getErasure().getQualifiedName());
	}

	private static String expressionVariableKey(Expression expression) {
		if (expression instanceof SimpleName name && name.resolveBinding() instanceof IVariableBinding variable) {
			return variableKey(variable);
		}
		if (expression instanceof FieldAccess access) {
			return variableKey(access.resolveFieldBinding());
		}
		if (expression instanceof QualifiedName name && name.resolveBinding() instanceof IVariableBinding variable) {
			return variableKey(variable);
		}
		return null;
	}

	private static String variableKey(IVariableBinding binding) {
		if (binding == null) {
			return null;
		}
		IVariableBinding declaration= binding.getVariableDeclaration();
		return declaration == null ? null : declaration.getKey();
	}

	private static boolean isDescendantOf(ASTNode node, ASTNode ancestor) {
		for (ASTNode current= node; current != null; current= current.getParent()) {
			if (current == ancestor) {
				return true;
			}
		}
		return false;
	}

	private static boolean hasReferencesOutsideCompilationUnit(IVariableBinding binding, CompilationUnit root) {
		IJavaElement javaElement= binding.getJavaElement();
		IJavaElement ownerElement= javaElement == null ? null
				: javaElement.getAncestor(IJavaElement.COMPILATION_UNIT);
		if (!(javaElement instanceof IField field) || !(ownerElement instanceof ICompilationUnit owner)) {
			return true;
		}
		SearchPattern pattern= SearchPattern.createPattern(field, IJavaSearchConstants.REFERENCES);
		if (pattern == null) {
			return true;
		}
		AtomicBoolean external= new AtomicBoolean();
		SearchRequestor requestor= new SearchRequestor() {
			@Override
			public void acceptSearchMatch(SearchMatch match) {
				Object element= match.getElement();
				if (!(element instanceof IJavaElement matched)) {
					external.set(true);
					return;
				}
				IJavaElement matchedOwner= matched.getAncestor(IJavaElement.COMPILATION_UNIT);
				if (!(matchedOwner instanceof ICompilationUnit matchedUnit)
						|| !owner.getPrimary().equals(matchedUnit.getPrimary())) {
					external.set(true);
				}
			}
		};
		try {
			new SearchEngine().search(pattern,
					new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() },
					SearchEngine.createWorkspaceScope(), requestor, new NullProgressMonitor());
		} catch (CoreException exception) {
			return true;
		}
		return external.get();
	}

	/**
	 * Copies an expression and rewrites only binding-proven TestName getMethodName
	 * invocations. This remains available to the TemporaryFolder migration.
	 */
	public static ASTNode copyAndTransformTestNameReferences(ASTNode originalNode, AST ast) {
		if (originalNode == null || ast == null) {
			return originalNode;
		}
		Map<SourceRange, Expression> replacements= new LinkedHashMap<>();
		originalNode.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation invocation) {
				IMethodBinding method= invocation.resolveMethodBinding();
				ITypeBinding declaring= method == null ? null : method.getDeclaringClass();
				if (declaring != null && ORG_JUNIT_RULES_TEST_NAME.equals(declaring.getErasure().getQualifiedName())
						&& "getMethodName".equals(invocation.getName().getIdentifier()) //$NON-NLS-1$
						&& invocation.arguments().isEmpty() && invocation.getExpression() != null) {
					replacements.put(new SourceRange(invocation.getStartPosition(), invocation.getLength()),
							invocation.getExpression());
				}
				return true;
			}
		});
		ASTNode copy= ASTNode.copySubtree(ast, originalNode);
		if (replacements.isEmpty()) {
			return copy;
		}
		copy.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation invocation) {
				Expression replacement= replacements.get(
						new SourceRange(invocation.getStartPosition(), invocation.getLength()));
				if (replacement != null) {
					replaceInParent(invocation, ASTNode.copySubtree(ast, replacement));
					return false;
				}
				return true;
			}
		});
		return copy;
	}

	/** Copied nodes without original bindings are deliberately left unchanged. */
	@Deprecated
	public static ASTNode transformTestNameReferencesInCopy(ASTNode copiedNode, AST ast) {
		return copiedNode;
	}

	@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);
			}
		}
	}

	private static Assessment rejected(String reasonCode, String explanation) {
		return new Assessment(false, reasonCode, explanation);
	}
}