RuleTemporayFolderJUnitPlugin.java

/*******************************************************************************
 * Copyright (c) 2021, 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;

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

import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
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.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
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.MethodInvocation;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
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;

import org.sandbox.jdt.internal.corext.fix.helper.lib.JunitHolder;
import org.sandbox.jdt.internal.corext.fix.helper.lib.TestNameRefactorer;
import org.sandbox.jdt.internal.corext.fix.helper.lib.TriggerPatternCleanupPlugin;
import org.sandbox.jdt.triggerpattern.api.CleanupPattern;
import org.sandbox.jdt.triggerpattern.api.Match;
import org.sandbox.jdt.triggerpattern.api.PatternKind;

/** Binding-proven migration of JUnit 4 TemporaryFolder to Jupiter TempDir. */
@CleanupPattern(value = "@Rule public TemporaryFolder $name", kind = PatternKind.FIELD,
		qualifiedType = ORG_JUNIT_RULES_TEMPORARY_FOLDER,
		cleanupId = "cleanup.junit.ruletemporaryfolder",
		description = "Migrate a closed TemporaryFolder contract to @TempDir",
		displayName = "JUnit 4 @Rule TemporaryFolder → JUnit 5 @TempDir")
public class RuleTemporayFolderJUnitPlugin extends TriggerPatternCleanupPlugin {

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

	private record InvocationPlan(MethodInvocation invocation, Operation operation) {
	}

	private enum Operation {
		GET_ROOT,
		NEW_FILE_RANDOM,
		NEW_FILE_NAMED,
		NEW_FOLDER_RANDOM,
		NEW_FOLDER_NAMED
	}

	@Override
	protected JunitHolder createHolder(Match match) {
		FieldDeclaration field= (FieldDeclaration) match.getMatchedNode();
		if (!assess(field).eligible()) {
			return null;
		}
		JunitHolder holder= new JunitHolder();
		holder.setMinv(field);
		return holder;
	}

	/** Proves that every use has an exact TempDir expression equivalent. */
	public static Assessment assess(FieldDeclaration field) {
		if (field == null || field.fragments().size() != 1
				|| !(field.fragments().get(0) instanceof VariableDeclarationFragment fragment)) {
			return rejected("TEMPORARY_FOLDER_FIELD_SHAPE_UNSUPPORTED", //$NON-NLS-1$
					"TemporaryFolder must be declared as one rule field."); //$NON-NLS-1$
		}
		IVariableBinding fieldBinding= fragment.resolveBinding();
		ITypeBinding type= fieldBinding == null ? null : fieldBinding.getType();
		if (fieldBinding == null || type == null || !ORG_JUNIT_RULES_TEMPORARY_FOLDER
				.equals(type.getErasure().getQualifiedName())) {
			return rejected("TEMPORARY_FOLDER_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The TemporaryFolder field binding could not be resolved."); //$NON-NLS-1$
		}
		if (Modifier.isStatic(field.getModifiers())) {
			return rejected("TEMPORARY_FOLDER_STATIC_RULE", //$NON-NLS-1$
					"A JUnit 4 @Rule TemporaryFolder is instance-scoped."); //$NON-NLS-1$
		}
		Expression initializer= fragment.getInitializer();
		if (!(initializer instanceof ClassInstanceCreation creation)
				|| creation.getAnonymousClassDeclaration() != null || !creation.arguments().isEmpty()) {
			return rejected("TEMPORARY_FOLDER_INITIALIZER_UNSUPPORTED", //$NON-NLS-1$
					"Only new TemporaryFolder() without custom builder or subclass semantics is supported."); //$NON-NLS-1$
		}
		org.eclipse.jdt.core.dom.CompilationUnit root= field.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit unit
				? unit : null;
		if (root == null || ASTNodes.getParent(field, TypeDeclaration.class) == null) {
			return rejected("TEMPORARY_FOLDER_OWNER_UNRESOLVED", //$NON-NLS-1$
					"The declaring source type could not be resolved."); //$NON-NLS-1$
		}
		if (countTemporaryFolderFields(root) != 1) {
			return rejected("TEMPORARY_FOLDER_MULTIPLE_FIELDS", //$NON-NLS-1$
					"Multiple TemporaryFolder fields require a coordinated migration."); //$NON-NLS-1$
		}
		String fieldKey= variableKey(fieldBinding);
		if (fieldKey == null) {
			return rejected("TEMPORARY_FOLDER_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The TemporaryFolder field has no stable binding key."); //$NON-NLS-1$
		}

		Set<MethodInvocation> allowed= new LinkedHashSet<>();
		AtomicBoolean unsupported= new AtomicBoolean();
		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation invocation) {
				if (fieldKey.equals(expressionVariableKey(invocation.getExpression()))) {
					Operation operation= operation(invocation);
					if (operation == null) {
						unsupported.set(true);
					} else {
						allowed.add(invocation);
					}
				}
				return true;
			}

			@Override
			public boolean visit(SimpleName node) {
				IBinding binding= node.resolveBinding();
				if (!(binding instanceof IVariableBinding variable)
						|| !fieldKey.equals(variableKey(variable)) || node == fragment.getName()) {
					return true;
				}
				MethodInvocation invocation= ASTNodes.getParent(node, MethodInvocation.class);
				if (invocation == null || !allowed.contains(invocation)
						|| invocation.getExpression() == null
						|| !isDescendantOf(node, invocation.getExpression())) {
					unsupported.set(true);
				}
				return true;
			}
		});
		if (unsupported.get()) {
			return rejected("TEMPORARY_FOLDER_UNSUPPORTED_USE", //$NON-NLS-1$
					"The rule is passed, assigned, referenced externally, or uses an unsupported TemporaryFolder operation."); //$NON-NLS-1$
		}
		if (allowed.isEmpty()) {
			return rejected("TEMPORARY_FOLDER_UNUSED_RULE", //$NON-NLS-1$
					"No supported TemporaryFolder operation was found."); //$NON-NLS-1$
		}
		if (hasReferencesOutsideCompilationUnit(fieldBinding)) {
			return rejected("TEMPORARY_FOLDER_EXTERNAL_REFERENCE", //$NON-NLS-1$
					"The TemporaryFolder field is referenced outside its compilation unit."); //$NON-NLS-1$
		}
		return new Assessment(true, "TEMPORARY_FOLDER_CLOSED_CONTRACT", //$NON-NLS-1$
				"Every reference has an exact binding-proven TempDir equivalent."); //$NON-NLS-1$
	}

	@Override
	protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite imports, JunitHolder holder) {
		FieldDeclaration field= holder.getFieldDeclaration();
		if (!assess(field).eligible()) {
			return;
		}
		VariableDeclarationFragment fragment= (VariableDeclarationFragment) field.fragments().get(0);
		String fieldKey= variableKey(fragment.resolveBinding());
		String fieldName= fragment.getName().getIdentifier();
		org.eclipse.jdt.core.dom.CompilationUnit root= (org.eclipse.jdt.core.dom.CompilationUnit) field.getRoot();

		removeRuleAnnotation(field, rewriter, group);
		String pathName= imports.addImport("java.nio.file.Path"); //$NON-NLS-1$
		rewriter.replace(field.getType(), ast.newSimpleType(ast.newName(pathName)), group);
		rewriter.set(fragment, VariableDeclarationFragment.INITIALIZER_PROPERTY, null, group);
		for (Object modifierObject : field.modifiers()) {
			if (modifierObject instanceof Modifier modifier && modifier.isFinal()) {
				rewriter.remove(modifier, group);
			}
		}
		MarkerAnnotation tempDir= ast.newMarkerAnnotation();
		tempDir.setTypeName(ast.newName(imports.addImport(ORG_JUNIT_JUPITER_API_IO_TEMP_DIR)));
		rewriter.getListRewrite(field, FieldDeclaration.MODIFIERS2_PROPERTY).insertFirst(tempDir, group);
		imports.removeImport(ORG_JUNIT_RULES_TEMPORARY_FOLDER);
		if (!hasOtherRuleAnnotation(root, field)) {
			imports.removeImport(ORG_JUNIT_RULE);
		}

		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodInvocation invocation) {
				if (!fieldKey.equals(expressionVariableKey(invocation.getExpression()))) {
					return true;
				}
				Operation operation= operation(invocation);
				if (operation == null) {
					return false;
				}
				if (operation != Operation.GET_ROOT) {
					imports.addImport("java.nio.file.Files"); //$NON-NLS-1$
				}
				rewriter.replace(invocation, replacement(invocation, operation, fieldName, ast), group);
				return false;
			}
		});
	}

	private static Expression replacement(MethodInvocation invocation, Operation operation,
			String fieldName, AST ast) {
		return switch (operation) {
		case GET_ROOT -> toFile(ast, ast.newSimpleName(fieldName));
		case NEW_FILE_RANDOM -> toFile(ast,
				filesCall(ast, "createTempFile", ast.newSimpleName(fieldName), //$NON-NLS-1$
						stringLiteral(ast, "junit"), ast.newNullLiteral())); //$NON-NLS-1$
		case NEW_FILE_NAMED -> toFile(ast,
				filesCall(ast, "createFile", resolve(ast, fieldName, //$NON-NLS-1$
						(Expression) invocation.arguments().get(0))));
		case NEW_FOLDER_RANDOM -> toFile(ast,
				filesCall(ast, "createTempDirectory", ast.newSimpleName(fieldName), //$NON-NLS-1$
						stringLiteral(ast, "junit"))); //$NON-NLS-1$
		case NEW_FOLDER_NAMED -> toFile(ast,
				filesCall(ast, "createDirectory", resolve(ast, fieldName, //$NON-NLS-1$
						(Expression) invocation.arguments().get(0))));
		};
	}

	private static MethodInvocation resolve(AST ast, String fieldName, Expression argument) {
		MethodInvocation result= ast.newMethodInvocation();
		result.setExpression(ast.newSimpleName(fieldName));
		result.setName(ast.newSimpleName("resolve")); //$NON-NLS-1$
		result.arguments().add(TestNameRefactorer.copyAndTransformTestNameReferences(argument, ast));
		return result;
	}

	private static MethodInvocation filesCall(AST ast, String name, Expression... arguments) {
		MethodInvocation result= ast.newMethodInvocation();
		result.setExpression(ast.newSimpleName("Files")); //$NON-NLS-1$
		result.setName(ast.newSimpleName(name));
		for (Expression argument : arguments) {
			result.arguments().add(argument);
		}
		return result;
	}

	private static MethodInvocation toFile(AST ast, Expression expression) {
		MethodInvocation result= ast.newMethodInvocation();
		result.setExpression(expression);
		result.setName(ast.newSimpleName("toFile")); //$NON-NLS-1$
		return result;
	}

	private static org.eclipse.jdt.core.dom.StringLiteral stringLiteral(AST ast, String value) {
		org.eclipse.jdt.core.dom.StringLiteral result= ast.newStringLiteral();
		result.setLiteralValue(value);
		return result;
	}

	private static Operation operation(MethodInvocation invocation) {
		IMethodBinding binding= invocation.resolveMethodBinding();
		ITypeBinding declaring= binding == null ? null : binding.getDeclaringClass();
		if (declaring == null || !ORG_JUNIT_RULES_TEMPORARY_FOLDER
				.equals(declaring.getErasure().getQualifiedName())) {
			return null;
		}
		String name= invocation.getName().getIdentifier();
		int arguments= invocation.arguments().size();
		if ("getRoot".equals(name) && arguments == 0) { //$NON-NLS-1$
			return Operation.GET_ROOT;
		}
		if ("newFile".equals(name)) { //$NON-NLS-1$
			return arguments == 0 ? Operation.NEW_FILE_RANDOM
					: arguments == 1 ? Operation.NEW_FILE_NAMED : null;
		}
		if ("newFolder".equals(name)) { //$NON-NLS-1$
			return arguments == 0 ? Operation.NEW_FOLDER_RANDOM
					: arguments == 1 ? Operation.NEW_FOLDER_NAMED : null;
		}
		return null;
	}

	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(org.eclipse.jdt.core.dom.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 countTemporaryFolderFields(org.eclipse.jdt.core.dom.CompilationUnit root) {
		int[] count= { 0 };
		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(FieldDeclaration candidate) {
				ITypeBinding binding= candidate.getType().resolveBinding();
				if (binding != null && ORG_JUNIT_RULES_TEMPORARY_FOLDER
						.equals(binding.getErasure().getQualifiedName())) {
					count[0]+= candidate.fragments().size();
				}
				return true;
			}
		});
		return count[0];
	}

	private static boolean hasReferencesOutsideCompilationUnit(IVariableBinding binding) {
		IJavaElement element= binding.getJavaElement();
		IJavaElement ownerElement= element == null ? null : element.getAncestor(IJavaElement.COMPILATION_UNIT);
		if (!(element 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 matchElement= match.getElement();
				if (!(matchElement instanceof IJavaElement javaElement)) {
					external.set(true);
					return;
				}
				IJavaElement matchedOwner= javaElement.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();
	}

	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 Assessment rejected(String code, String explanation) {
		return new Assessment(false, code, explanation);
	}

	@Override
	public String getPreview(boolean afterRefactoring) {
		if (afterRefactoring) {
			return """
					@TempDir
					Path tempFolder;

					@Test
					public void test3() throws IOException {
						File newFile = Files.createFile(tempFolder.resolve("myfile.txt")).toFile();
					}
					"""; //$NON-NLS-1$
		}
		return """
					@Rule
					public TemporaryFolder tempFolder = new TemporaryFolder();

					@Test
					public void test3() throws IOException {
						File newFile = tempFolder.newFile("myfile.txt");
					}
					"""; //$NON-NLS-1$
	}

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