InheritedLifecycleMethodRefactorer.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.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.IAnnotationBinding;
import org.eclipse.jdt.core.dom.IMethodBinding;
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.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.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;

import org.eclipse.text.edits.TextEditGroup;

/** Preserves inherited JUnit lifecycle semantics through unannotated source overrides. */
public final class InheritedLifecycleMethodRefactorer {

	private static final String DESCRIPTION= "Preserve inherited JUnit lifecycle semantics"; //$NON-NLS-1$

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

	/**
	 * Adds a lifecycle annotation to methods that override an inherited lifecycle
	 * method without declaring a lifecycle annotation themselves.
	 *
	 * <p>JUnit 4 invokes the reflected superclass method virtually, so an
	 * unannotated override is executed. Jupiter suppresses an overridden lifecycle
	 * method unless the override is itself annotated. This method is used both to
	 * propagate Jupiter annotations when migrating a class and to add JUnit 4
	 * compatibility annotations when a strict-blocked class must remain on JUnit 4
	 * while its superclass has been migrated to Jupiter. Binding-proven propagation
	 * is therefore required to retain the original dispatch semantics.</p>
	 */
	public static void addInheritedLifecycleOverrides(CompilationUnit compilationUnit,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations,
			Set<ASTNode> nodesProcessed, String sourceAnnotation, String targetAnnotation) {
		addInheritedLifecycleOverrides(compilationUnit, operations, nodesProcessed,
				Set.of(sourceAnnotation), targetAnnotation);
	}

	/**
	 * Adds {@code targetAnnotation} when an override inherits any accepted source
	 * lifecycle annotation. Accepting both the JUnit 4 and Jupiter forms keeps
	 * the operation correct while a coordinated rewrite still exposes the old
	 * bindings and when a later cleanup starts from already migrated sources.
	 */
	public static void addInheritedLifecycleOverrides(CompilationUnit compilationUnit,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations,
			Set<ASTNode> nodesProcessed, Set<String> sourceAnnotations, String targetAnnotation) {
		Set<String> acceptedSourceAnnotations= Set.copyOf(sourceAnnotations);
		compilationUnit.accept(new ASTVisitor() {
			@Override
			public boolean visit(MethodDeclaration node) {
				if (node.isConstructor() || nodesProcessed.contains(node)
						|| hasAnyAnnotation(node, acceptedSourceAnnotations)
						|| hasAnnotation(node, targetAnnotation)) {
					return true;
				}
				IMethodBinding binding= node.resolveBinding();
				if (binding == null || !overridesAnnotatedLifecycle(binding, acceptedSourceAnnotations)) {
					return true;
				}
				nodesProcessed.add(node);
				operations.add(addAnnotation(node, targetAnnotation));
				return true;
			}
		});
	}

	private static boolean overridesAnnotatedLifecycle(IMethodBinding binding,
			Set<String> sourceAnnotations) {
		IMethodBinding declaration= binding.getMethodDeclaration();
		ITypeBinding declaringType= declaration.getDeclaringClass();
		for (ITypeBinding superType= declaringType == null ? null : declaringType.getSuperclass();
				superType != null; superType= superType.getSuperclass()) {
			for (IMethodBinding candidate : superType.getDeclaredMethods()) {
				IMethodBinding candidateDeclaration= candidate.getMethodDeclaration();
				if (declaration.overrides(candidateDeclaration)
						&& hasAnyAnnotation(candidateDeclaration, sourceAnnotations)) {
					return true;
				}
			}
		}
		return false;
	}

	private static boolean hasAnyAnnotation(MethodDeclaration method, Set<String> qualifiedNames) {
		for (String qualifiedName : qualifiedNames) {
			if (hasAnnotation(method, qualifiedName)) {
				return true;
			}
		}
		return false;
	}

	private static boolean hasAnyAnnotation(IMethodBinding method, Set<String> qualifiedNames) {
		for (String qualifiedName : qualifiedNames) {
			if (hasAnnotation(method, qualifiedName)) {
				return true;
			}
		}
		return false;
	}

	private static boolean hasAnnotation(MethodDeclaration method, String qualifiedName) {
		if (hasAnnotation(method.resolveBinding(), qualifiedName)) {
			return true;
		}
		String simpleName= qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1);
		for (Object modifier : method.modifiers()) {
			if (!(modifier instanceof Annotation annotation)) {
				continue;
			}
			ITypeBinding annotationType= annotation.resolveTypeBinding();
			if (annotationType != null && qualifiedName.equals(annotationType.getQualifiedName())) {
				return true;
			}
			String sourceName= annotation.getTypeName().getFullyQualifiedName();
			if (qualifiedName.equals(sourceName) || simpleName.equals(sourceName)) {
				return true;
			}
		}
		return false;
	}

	private static boolean hasAnnotation(IMethodBinding method, String qualifiedName) {
		if (method == null) {
			return false;
		}
		for (IAnnotationBinding annotation : method.getAnnotations()) {
			ITypeBinding annotationType= annotation.getAnnotationType();
			if (annotationType != null && qualifiedName.equals(annotationType.getQualifiedName())) {
				return true;
			}
		}
		return false;
	}

	private static CompilationUnitRewriteOperationWithSourceRange addAnnotation(
			MethodDeclaration method, String targetAnnotation) {
		return new CompilationUnitRewriteOperationWithSourceRange() {
			@Override
			public void rewriteASTInternal(CompilationUnitRewrite cuRewrite,
					LinkedProposalModelCore linkedModel) {
				TextEditGroup group= createTextEditGroup(DESCRIPTION, cuRewrite);
				ASTRewrite rewrite= cuRewrite.getASTRewrite();
				AST ast= cuRewrite.getRoot().getAST();
				ImportRewrite imports= cuRewrite.getImportRewrite();
				MarkerAnnotation annotation= ast.newMarkerAnnotation();
				annotation.setTypeName(ast.newName(imports.addImport(targetAnnotation)));

				Annotation lastAnnotation= null;
				for (Object modifier : method.modifiers()) {
					if (modifier instanceof Annotation existing) {
						lastAnnotation= existing;
					}
				}
				ListRewrite modifiers= rewrite.getListRewrite(method,
						MethodDeclaration.MODIFIERS2_PROPERTY);
				if (lastAnnotation == null) {
					modifiers.insertFirst(annotation, group);
				} else {
					modifiers.insertAfter(annotation, lastAnnotation, group);
				}
			}
		};
	}
}