ThrowingRunnableJUnitPlugin.java
/*******************************************************************************
* Copyright (c) 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
*
* Contributors:
* Carsten Hammer
*******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.*;
/*-
* #%L
* Sandbox junit cleanup
* %%
* Copyright (C) 2026 hammer
* %%
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0.
*
* This Source Code may also be made available under the following Secondary
* Licenses when the conditions for such availability set forth in the Eclipse
* Public License, v. 2.0 are satisfied: GNU General Public License, version 2
* with the GNU Classpath Exception which is
* available at https://www.gnu.org/software/classpath/license.html.
*
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
* #L%
*/
import java.util.HashSet;
import java.util.Set;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.ParameterizedType;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.internal.corext.dom.ASTNodes;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.common.AstProcessorBuilder;
import org.sandbox.jdt.internal.common.ReferenceHolder;
import org.sandbox.jdt.internal.corext.fix.JUnitCleanUpFixCore;
import org.sandbox.jdt.internal.corext.fix.helper.lib.AbstractTool;
import org.sandbox.jdt.internal.corext.fix.helper.lib.JunitHolder;
/**
* Migrates JUnit 4 ThrowingRunnable to JUnit 5 Executable.
*
* <p>
* Transforms:
* </p>
* <ul>
* <li>org.junit.function.ThrowingRunnable →
* org.junit.jupiter.api.function.Executable</li>
* <li>ThrowingRunnable.run() → Executable.execute()</li>
* </ul>
*
* <p>
* Handles:
* </p>
* <ul>
* <li>Variable declarations with ThrowingRunnable type</li>
* <li>Method parameters with ThrowingRunnable type</li>
* <li>Generic type parameters: AtomicReference<ThrowingRunnable></li>
* <li>Method invocations: throwingRunnable.run()</li>
* <li>Import statements</li>
* </ul>
*/
public class ThrowingRunnableJUnitPlugin extends AbstractTool<ReferenceHolder<Integer, JunitHolder>> {
private static final String THROWING_RUNNABLE_SIMPLE = "ThrowingRunnable";
private static final String EXECUTABLE_SIMPLE = "Executable";
private static final String RUN_METHOD = "run";
private static final String EXECUTE_METHOD = "execute";
@Override
public void find(JUnitCleanUpFixCore fixcore, CompilationUnit compilationUnit,
Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesprocessed) {
ReferenceHolder<Integer, JunitHolder> dataHolder = ReferenceHolder.createIndexed();
Set<ASTNode> found = new HashSet<>();
// Visit the compilation unit to find ThrowingRunnable usages
// Each visitor type must be a separate AstProcessorBuilder call because
// chaining multiple onXxx calls creates sequential/scoped visitors via ASTProcessor,
// but these visitors need to run independently on the full compilation unit.
AstProcessorBuilder.with(dataHolder, nodesprocessed)
.onImportDeclaration((node, h) -> {
String importName = node.getName().getFullyQualifiedName();
if (ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(importName)) {
if (!nodesprocessed.contains(node)) {
found.add(node);
addStandardRewriteOperation(fixcore, operations, node, dataHolder);
}
}
return true;
})
.build(compilationUnit);
AstProcessorBuilder.with(dataHolder, nodesprocessed)
.onSimpleType((node, h) -> {
// Check if this is a ThrowingRunnable type reference
ITypeBinding binding = node.resolveBinding();
if (binding != null && ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(binding.getQualifiedName())) {
// Store the SimpleType node directly for replacement
if (!nodesprocessed.contains(node) && !found.contains(node)) {
found.add(node);
addStandardRewriteOperation(fixcore, operations, node, dataHolder);
}
}
return true;
})
.build(compilationUnit);
AstProcessorBuilder.with(dataHolder, nodesprocessed)
.onParameterizedType((node, h) -> {
// Check if this parameterized type contains ThrowingRunnable
// e.g., AtomicReference<ThrowingRunnable>
if (containsThrowingRunnable(node)) {
if (!nodesprocessed.contains(node) && !found.contains(node)) {
found.add(node);
addStandardRewriteOperation(fixcore, operations, node, dataHolder);
}
}
// Don't visit children - we handle the whole parameterized type
return false;
})
.build(compilationUnit);
AstProcessorBuilder.with(dataHolder, nodesprocessed)
.onMethodInvocation((node, h) -> {
// Check if this is a .run() call on a ThrowingRunnable
if (RUN_METHOD.equals(node.getName().getIdentifier()) && node.arguments().isEmpty()) {
if (isThrowingRunnableRunCall(node)) {
if (!nodesprocessed.contains(node) && !found.contains(node)) {
found.add(node);
addStandardRewriteOperation(fixcore, operations, node, dataHolder);
}
}
}
return true;
})
.build(compilationUnit);
nodesprocessed.addAll(found);
}
@Override
protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast, ImportRewrite importRewriter,
JunitHolder junitHolder) {
ASTNode node = junitHolder.getMinv();
if (node instanceof ImportDeclaration) {
processImportDeclaration(importRewriter, (ImportDeclaration) node);
} else if (node instanceof MethodInvocation) {
processMethodInvocation(group, rewriter, ast, (MethodInvocation) node);
} else if (node instanceof SimpleType) {
processSimpleType(group, rewriter, ast, importRewriter, (SimpleType) node);
} else if (node instanceof ParameterizedType) {
processParameterizedType(group, rewriter, ast, importRewriter, (ParameterizedType) node);
}
}
/**
* Processes import declarations, replacing ThrowingRunnable import with
* Executable import.
*/
private void processImportDeclaration(ImportRewrite importRewriter, ImportDeclaration importDecl) {
ensureImports(importRewriter);
}
/**
* Processes method invocations, replacing .run() with .execute().
*/
private void processMethodInvocation(TextEditGroup group, ASTRewrite rewriter, AST ast, MethodInvocation node) {
if (RUN_METHOD.equals(node.getName().getIdentifier())) {
SimpleName newName = ast.newSimpleName(EXECUTE_METHOD);
ASTNodes.replaceButKeepComment(rewriter, node.getName(), newName, group);
}
}
/**
* Processes simple type nodes, replacing ThrowingRunnable with Executable.
*/
private void processSimpleType(TextEditGroup group, ASTRewrite rewriter, AST ast, ImportRewrite importRewriter,
SimpleType node) {
if (THROWING_RUNNABLE_SIMPLE.equals(node.getName().getFullyQualifiedName())) {
SimpleType newType = ast.newSimpleType(ast.newName(EXECUTABLE_SIMPLE));
ASTNodes.replaceButKeepComment(rewriter, node, newType, group);
ensureImports(importRewriter);
}
}
/**
* Processes parameterized type nodes, replacing ThrowingRunnable in type
* arguments with Executable. e.g., AtomicReference<ThrowingRunnable>
* -> AtomicReference<Executable>
*/
private void processParameterizedType(TextEditGroup group, ASTRewrite rewriter, AST ast,
ImportRewrite importRewriter, ParameterizedType node) {
Type newType = createExecutableType(ast, node);
if (newType != null) {
ASTNodes.replaceButKeepComment(rewriter, node, newType, group);
ensureImports(importRewriter);
}
}
/**
* Ensures the correct imports are present (removes old, adds new).
* ImportRewrite handles deduplication automatically.
*/
private void ensureImports(ImportRewrite importRewriter) {
importRewriter.removeImport(ORG_JUNIT_FUNCTION_THROWING_RUNNABLE);
importRewriter.addImport(ORG_JUNIT_JUPITER_API_FUNCTION_EXECUTABLE);
}
/**
* Creates a new Executable type, handling both simple and parameterized types.
* Recursively processes nested parameterized types.
*/
private Type createExecutableType(AST ast, Type originalType) {
if (originalType instanceof SimpleType) {
SimpleType simpleType = (SimpleType) originalType;
if (THROWING_RUNNABLE_SIMPLE.equals(simpleType.getName().getFullyQualifiedName())) {
return ast.newSimpleType(ast.newName(EXECUTABLE_SIMPLE));
}
} else if (originalType instanceof ParameterizedType) {
// Handle generic types like AtomicReference<ThrowingRunnable>
// or nested types like Map<String, AtomicReference<ThrowingRunnable>>
ParameterizedType paramType = (ParameterizedType) originalType;
Type baseType = paramType.getType();
// Check if any type argument needs transformation (recursive check)
boolean needsTransformation = false;
for (Object arg : paramType.typeArguments()) {
if (containsThrowingRunnable((Type) arg)) {
needsTransformation = true;
break;
}
}
if (needsTransformation) {
// Create new parameterized type with transformed arguments
ParameterizedType newParamType = ast.newParameterizedType((Type) ASTNode.copySubtree(ast, baseType));
for (Object arg : paramType.typeArguments()) {
Type argType = (Type) arg;
Type transformedArg = createExecutableType(ast, argType);
if (transformedArg != null) {
// Argument was transformed
newParamType.typeArguments().add(transformedArg);
} else {
// Argument doesn't need transformation, copy as-is
newParamType.typeArguments().add(ASTNode.copySubtree(ast, argType));
}
}
return newParamType;
}
}
return null;
}
/**
* Checks if a type contains ThrowingRunnable anywhere in its structure.
*/
private boolean containsThrowingRunnable(Type type) {
if (type instanceof SimpleType) {
SimpleType simpleType = (SimpleType) type;
return THROWING_RUNNABLE_SIMPLE.equals(simpleType.getName().getFullyQualifiedName());
} else if (type instanceof ParameterizedType) {
ParameterizedType paramType = (ParameterizedType) type;
for (Object arg : paramType.typeArguments()) {
if (containsThrowingRunnable((Type) arg)) {
return true;
}
}
}
return false;
}
/**
* Checks if a type binding represents ThrowingRunnable, handling generics, type variables,
* and erasure. This is needed because when calling .run() on the result of a generic method
* like AtomicReference<ThrowingRunnable>.get(), the type binding may be a type variable
* or a parameterized type that requires erasure or interface checking.
*/
private static boolean isThrowingRunnableType(ITypeBinding typeBinding) {
if (typeBinding == null) {
return false;
}
// Direct match
if (ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(typeBinding.getQualifiedName())) {
return true;
}
// Check erasure (handles parameterized types)
ITypeBinding erasure = typeBinding.getErasure();
if (erasure != null && ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(erasure.getQualifiedName())) {
return true;
}
// Check if this is a type variable — check its bounds
if (typeBinding.isTypeVariable()) {
for (ITypeBinding bound : typeBinding.getTypeBounds()) {
if (isThrowingRunnableType(bound)) {
return true;
}
}
}
// Check if this is a capture binding — check wildcard bound
if (typeBinding.isCapture()) {
ITypeBinding wildcard = typeBinding.getWildcard();
if (wildcard != null) {
ITypeBinding bound = wildcard.getBound();
if (bound != null && isThrowingRunnableType(bound)) {
return true;
}
}
}
// Check all implemented interfaces
for (ITypeBinding iface : typeBinding.getInterfaces()) {
if (ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(iface.getQualifiedName())) {
return true;
}
}
// Check superclass hierarchy
ITypeBinding superclass = typeBinding.getSuperclass();
if (superclass != null && ORG_JUNIT_FUNCTION_THROWING_RUNNABLE.equals(superclass.getQualifiedName())) {
return true;
}
return false;
}
/**
* Comprehensive check for whether a .run() MethodInvocation is called on a ThrowingRunnable.
* Uses multiple strategies to handle direct types, generic type parameters, and method chaining.
*/
private static boolean isThrowingRunnableRunCall(MethodInvocation node) {
// Strategy 1: Check via method binding's declaring class
org.eclipse.jdt.core.dom.IMethodBinding methodBinding = node.resolveMethodBinding();
if (methodBinding != null) {
ITypeBinding declaringClass = methodBinding.getDeclaringClass();
if (declaringClass != null && isThrowingRunnableType(declaringClass)) {
return true;
}
// Also check the generic method declaration's declaring class
org.eclipse.jdt.core.dom.IMethodBinding methodDecl = methodBinding.getMethodDeclaration();
if (methodDecl != null && methodDecl != methodBinding) {
ITypeBinding declClass = methodDecl.getDeclaringClass();
if (declClass != null && isThrowingRunnableType(declClass)) {
return true;
}
}
}
// Strategy 2: Check the receiver expression type
if (node.getExpression() != null) {
ITypeBinding receiverType = node.getExpression().resolveTypeBinding();
if (receiverType != null && isThrowingRunnableType(receiverType)) {
return true;
}
// Strategy 3: If receiver is a method invocation (e.g., ref.get()),
// check if the return type's type arguments contain ThrowingRunnable
if (node.getExpression() instanceof MethodInvocation) {
MethodInvocation receiverInvocation = (MethodInvocation) node.getExpression();
org.eclipse.jdt.core.dom.IMethodBinding receiverMethodBinding = receiverInvocation.resolveMethodBinding();
if (receiverMethodBinding != null) {
ITypeBinding returnType = receiverMethodBinding.getReturnType();
if (returnType != null && isThrowingRunnableType(returnType)) {
return true;
}
}
// Strategy 4: Check if the receiver's receiver (e.g., ref in ref.get())
// has a type with ThrowingRunnable as a type argument
if (receiverInvocation.getExpression() != null) {
ITypeBinding outerReceiverType = receiverInvocation.getExpression().resolveTypeBinding();
if (outerReceiverType != null && hasThrowingRunnableTypeArgument(outerReceiverType)) {
return true;
}
}
}
}
return false;
}
/**
* Checks if a type binding has ThrowingRunnable as one of its type arguments.
* e.g., AtomicReference<ThrowingRunnable> → true
*/
private static boolean hasThrowingRunnableTypeArgument(ITypeBinding typeBinding) {
if (typeBinding == null) {
return false;
}
ITypeBinding[] typeArgs = typeBinding.getTypeArguments();
if (typeArgs != null) {
for (ITypeBinding arg : typeArgs) {
if (isThrowingRunnableType(arg)) {
return true;
}
}
}
return false;
}
@Override
public String getPreview(boolean afterRefactoring) {
if (afterRefactoring) {
return """
import org.junit.jupiter.api.function.Executable;
Executable runnable = () -> {};
runnable.execute();
"""; //$NON-NLS-1$
}
return """
import org.junit.function.ThrowingRunnable;
ThrowingRunnable runnable = () -> {};
runnable.run();
"""; //$NON-NLS-1$
}
@Override
public String toString() {
return "ThrowingRunnable"; //$NON-NLS-1$
}
}