ExternalResourceCompatibilityRefactorer.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 static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_AFTER;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_AFTER_ALL;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_AFTER_EACH;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_BEFORE;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_BEFORE_ALL;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.METHOD_BEFORE_EACH;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_EXTENSION_AFTER_ALL_CALLBACK;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_EXTENSION_AFTER_EACH_CALLBACK;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_EXTENSION_BEFORE_ALL_CALLBACK;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_EXTENSION_BEFORE_EACH_CALLBACK;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_JUPITER_API_EXTENSION_EXTENSION_CONTEXT;

import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.Block;
import org.eclipse.jdt.core.dom.CatchClause;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.ExpressionStatement;
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.SingleVariableDeclaration;
import org.eclipse.jdt.core.dom.ThrowStatement;
import org.eclipse.jdt.core.dom.TryStatement;
import org.eclipse.jdt.core.dom.Type;
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.text.edits.TextEditGroup;

/**
 * Adds Jupiter callback bridges while retaining the JUnit 4
 * {@code ExternalResource} contract.
 *
 * <p>This staged form is required when a shared fixture has both migrated
 * Jupiter consumers and a strict-mode consumer that must stay on JUnit 4. The
 * callbacks delegate to the original virtual {@code before()}/{@code after()}
 * methods, so source subclasses keep their established override and explicit
 * {@code super} semantics.</p>
 */
public final class ExternalResourceCompatibilityRefactorer {

	private record CallbackConfig(String beforeInterface, String afterInterface,
			String beforeMethod, String afterMethod) {
	}

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

	/** Adds compatibility callbacks to a direct {@code ExternalResource} subclass. */
	public static void addJupiterCallbacks(TypeDeclaration node, boolean classRule,
			ASTRewrite rewrite, AST ast, TextEditGroup group, ImportRewrite imports) {
		if (node == null || !ExternalResourceRefactorer.isDirectlyExtendingExternalResource(node.resolveBinding())) {
			return;
		}
		CallbackConfig config= classRule
				? new CallbackConfig(ORG_JUNIT_JUPITER_API_EXTENSION_BEFORE_ALL_CALLBACK,
						ORG_JUNIT_JUPITER_API_EXTENSION_AFTER_ALL_CALLBACK,
						METHOD_BEFORE_ALL, METHOD_AFTER_ALL)
				: new CallbackConfig(ORG_JUNIT_JUPITER_API_EXTENSION_BEFORE_EACH_CALLBACK,
						ORG_JUNIT_JUPITER_API_EXTENSION_AFTER_EACH_CALLBACK,
						METHOD_BEFORE_EACH, METHOD_AFTER_EACH);

		addInterfaceIfMissing(node, config.beforeInterface(), rewrite, ast, group, imports);
		addInterfaceIfMissing(node, config.afterInterface(), rewrite, ast, group, imports);

		ListRewrite members= rewrite.getListRewrite(node, TypeDeclaration.BODY_DECLARATIONS_PROPERTY);
		if (!declaresCallback(node, config.beforeMethod())) {
			members.insertLast(createBeforeBridge(ast, config.beforeMethod(), imports), group);
		}
		if (!declaresCallback(node, config.afterMethod())) {
			members.insertLast(createAfterBridge(ast, config.afterMethod(), imports), group);
		}
	}

	private static void addInterfaceIfMissing(TypeDeclaration node, String qualifiedName,
			ASTRewrite rewrite, AST ast, TextEditGroup group, ImportRewrite imports) {
		ITypeBinding binding= node.resolveBinding();
		if (binding != null) {
			for (ITypeBinding interfaceBinding : binding.getInterfaces()) {
				if (qualifiedName.equals(interfaceBinding.getErasure().getQualifiedName())) {
					return;
				}
			}
		}
		for (Object interfaceObject : node.superInterfaceTypes()) {
			if (interfaceObject instanceof Type interfaceType) {
				ITypeBinding interfaceBinding= interfaceType.resolveBinding();
				if (interfaceBinding != null
						&& qualifiedName.equals(interfaceBinding.getErasure().getQualifiedName())) {
					return;
				}
			}
		}
		String interfaceName= imports.addImport(qualifiedName);
		rewrite.getListRewrite(node, TypeDeclaration.SUPER_INTERFACE_TYPES_PROPERTY)
				.insertLast(ast.newSimpleType(ast.newName(interfaceName)), group);
	}

	private static boolean declaresCallback(TypeDeclaration node, String methodName) {
		for (MethodDeclaration method : node.getMethods()) {
			if (!methodName.equals(method.getName().getIdentifier()) || method.parameters().size() != 1
					|| !(method.parameters().get(0) instanceof SingleVariableDeclaration parameter)) {
				continue;
			}
			ITypeBinding parameterType= parameter.getType().resolveBinding();
			if (parameterType != null && ORG_JUNIT_JUPITER_API_EXTENSION_EXTENSION_CONTEXT
					.equals(parameterType.getErasure().getQualifiedName())) {
				return true;
			}
			String sourceType= parameter.getType().toString();
			if ("ExtensionContext".equals(sourceType) //$NON-NLS-1$
					|| ORG_JUNIT_JUPITER_API_EXTENSION_EXTENSION_CONTEXT.equals(sourceType)) {
				return true;
			}
		}
		return false;
	}

	private static MethodDeclaration createBeforeBridge(AST ast, String methodName, ImportRewrite imports) {
		MethodDeclaration method= createCallbackMethod(ast, methodName, imports);
		method.thrownExceptionTypes().add(ast.newSimpleType(ast.newSimpleName("Exception"))); //$NON-NLS-1$

		TryStatement guardedCall= ast.newTryStatement();
		Block body= ast.newBlock();
		body.statements().add(invokeLifecycle(ast, METHOD_BEFORE));
		guardedCall.setBody(body);
		guardedCall.catchClauses().add(rethrow(ast, "Exception", "exception")); //$NON-NLS-1$ //$NON-NLS-2$
		guardedCall.catchClauses().add(rethrow(ast, "Error", "error")); //$NON-NLS-1$ //$NON-NLS-2$
		guardedCall.catchClauses().add(wrapUnexpectedThrowable(ast));

		Block callbackBody= ast.newBlock();
		callbackBody.statements().add(guardedCall);
		method.setBody(callbackBody);
		return method;
	}

	private static MethodDeclaration createAfterBridge(AST ast, String methodName, ImportRewrite imports) {
		MethodDeclaration method= createCallbackMethod(ast, methodName, imports);
		Block body= ast.newBlock();
		body.statements().add(invokeLifecycle(ast, METHOD_AFTER));
		method.setBody(body);
		return method;
	}

	private static MethodDeclaration createCallbackMethod(AST ast, String methodName, ImportRewrite imports) {
		MethodDeclaration method= ast.newMethodDeclaration();
		MarkerAnnotation override= ast.newMarkerAnnotation();
		override.setTypeName(ast.newSimpleName("Override")); //$NON-NLS-1$
		method.modifiers().add(override);
		method.modifiers().add(ast.newModifier(Modifier.ModifierKeyword.PUBLIC_KEYWORD));
		method.setName(ast.newSimpleName(methodName));
		method.setReturnType2(ast.newPrimitiveType(PrimitiveType.VOID));

		SingleVariableDeclaration context= ast.newSingleVariableDeclaration();
		String contextType= imports.addImport(ORG_JUNIT_JUPITER_API_EXTENSION_EXTENSION_CONTEXT);
		context.setType(ast.newSimpleType(ast.newName(contextType)));
		context.setName(ast.newSimpleName("context")); //$NON-NLS-1$
		method.parameters().add(context);
		return method;
	}

	private static ExpressionStatement invokeLifecycle(AST ast, String methodName) {
		MethodInvocation invocation= ast.newMethodInvocation();
		invocation.setName(ast.newSimpleName(methodName));
		return ast.newExpressionStatement(invocation);
	}

	private static CatchClause rethrow(AST ast, String typeName, String variableName) {
		CatchClause clause= ast.newCatchClause();
		SingleVariableDeclaration exception= ast.newSingleVariableDeclaration();
		exception.setType(ast.newSimpleType(ast.newSimpleName(typeName)));
		exception.setName(ast.newSimpleName(variableName));
		clause.setException(exception);
		ThrowStatement rethrow= ast.newThrowStatement();
		rethrow.setExpression(ast.newSimpleName(variableName));
		Block body= ast.newBlock();
		body.statements().add(rethrow);
		clause.setBody(body);
		return clause;
	}

	private static CatchClause wrapUnexpectedThrowable(AST ast) {
		CatchClause clause= ast.newCatchClause();
		SingleVariableDeclaration throwable= ast.newSingleVariableDeclaration();
		throwable.setType(ast.newSimpleType(ast.newSimpleName("Throwable"))); //$NON-NLS-1$
		throwable.setName(ast.newSimpleName("throwable")); //$NON-NLS-1$
		clause.setException(throwable);

		ClassInstanceCreation wrapper= ast.newClassInstanceCreation();
		wrapper.setType(ast.newSimpleType(ast.newSimpleName("RuntimeException"))); //$NON-NLS-1$
		wrapper.arguments().add(ast.newSimpleName("throwable")); //$NON-NLS-1$
		ThrowStatement throwWrapper= ast.newThrowStatement();
		throwWrapper.setExpression(wrapper);
		Block body= ast.newBlock();
		body.statements().add(throwWrapper);
		clause.setBody(body);
		return clause;
	}
}