RuleExpectedExceptionJUnitPlugin.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;

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

import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.List;
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.Block;
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.InfixExpression;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypeLiteral;
import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
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.TriggerPatternCleanupPlugin;
import org.sandbox.jdt.triggerpattern.api.CleanupPattern;
import org.sandbox.jdt.triggerpattern.api.Match;
import org.sandbox.jdt.triggerpattern.api.PatternKind;

/**
 * Fail-closed migration of the supported JUnit 4 ExpectedException contract.
 * Unsupported matchers or non-local rule references leave the complete field
 * unchanged instead of silently dropping expectations.
 */
@CleanupPattern(value = "@Rule public ExpectedException $name", kind = PatternKind.FIELD,
		qualifiedType = ORG_JUNIT_RULES_EXPECTED_EXCEPTION,
		cleanupId = "cleanup.junit.ruleexpectedexception",
		description = "Migrate a closed ExpectedException contract to assertThrows()",
		displayName = "JUnit 4 @Rule ExpectedException → JUnit 5 assertThrows()")
public class RuleExpectedExceptionJUnitPlugin extends TriggerPatternCleanupPlugin {

	/** Stable fail-closed decision exposed to regression tests. */
	public record Assessment(boolean eligible, String reasonCode, String explanation) {
		public Assessment {
			reasonCode= Objects.requireNonNull(reasonCode);
			explanation= Objects.requireNonNull(explanation);
		}
	}

	private record MethodPlan(MethodDeclaration method, int firstIndex, int lastIndex,
			TypeLiteral exceptionType, Expression messageSubstring, TypeLiteral causeType) {
	}

	@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 reference can be represented without losing semantics. */
	public static Assessment assess(FieldDeclaration field) {
		if (field == null || field.fragments().size() != 1
				|| !(field.fragments().get(0) instanceof VariableDeclarationFragment fragment)) {
			return rejected("EXPECTED_EXCEPTION_FIELD_SHAPE_UNSUPPORTED", //$NON-NLS-1$
					"ExpectedException 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_EXPECTED_EXCEPTION.equals(type.getErasure().getQualifiedName())) {
			return rejected("EXPECTED_EXCEPTION_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The ExpectedException field binding could not be resolved."); //$NON-NLS-1$
		}
		TypeDeclaration owner= ASTNodes.getParent(field, TypeDeclaration.class);
		CompilationUnitView view= CompilationUnitView.of(field);
		if (owner == null || view.root() == null) {
			return rejected("EXPECTED_EXCEPTION_OWNER_UNRESOLVED", //$NON-NLS-1$
					"The declaring source type could not be resolved."); //$NON-NLS-1$
		}
		if (countExpectedExceptionFields(view.root()) != 1) {
			return rejected("EXPECTED_EXCEPTION_MULTIPLE_FIELDS", //$NON-NLS-1$
					"Multiple ExpectedException fields require a coordinated migration."); //$NON-NLS-1$
		}
		String fieldKey= variableKey(fieldBinding);
		if (fieldKey == null) {
			return rejected("EXPECTED_EXCEPTION_BINDING_UNRESOLVED", //$NON-NLS-1$
					"The field has no stable binding key."); //$NON-NLS-1$
		}

		List<MethodPlan> plans= new ArrayList<>();
		Set<MethodInvocation> allowedInvocations= new LinkedHashSet<>();
		for (MethodDeclaration method : owner.getMethods()) {
			MethodAnalysis analysis= analyzeMethod(method, fieldKey);
			if (!analysis.valid()) {
				return rejected(analysis.reasonCode(), analysis.explanation());
			}
			if (analysis.plan() != null) {
				plans.add(analysis.plan());
				allowedInvocations.addAll(analysis.configurationInvocations());
			}
		}
		if (plans.isEmpty()) {
			return rejected("EXPECTED_EXCEPTION_UNUSED_RULE", //$NON-NLS-1$
					"No supported ExpectedException contract was found in a JUnit 4 test method."); //$NON-NLS-1$
		}
		if (hasUnsupportedReference(view.root(), fragment, fieldKey, allowedInvocations)) {
			return rejected("EXPECTED_EXCEPTION_UNSUPPORTED_USE", //$NON-NLS-1$
					"The rule field is referenced outside the supported top-level expectation statements."); //$NON-NLS-1$
		}
		if (hasReferencesOutsideCompilationUnit(fieldBinding)) {
			return rejected("EXPECTED_EXCEPTION_EXTERNAL_REFERENCE", //$NON-NLS-1$
					"The rule field is referenced outside its compilation unit."); //$NON-NLS-1$
		}
		return new Assessment(true, "EXPECTED_EXCEPTION_CLOSED_CONTRACT", //$NON-NLS-1$
				"Every expectation is local, ordered and exactly representable with Jupiter assertions."); //$NON-NLS-1$
	}

	@Override
	protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast,
			ImportRewrite importRewriter, JunitHolder junitHolder) {
		FieldDeclaration field= junitHolder.getFieldDeclaration();
		Assessment assessment= assess(field);
		if (!assessment.eligible()) {
			return;
		}
		VariableDeclarationFragment fragment= (VariableDeclarationFragment) field.fragments().get(0);
		String fieldKey= variableKey(fragment.resolveBinding());
		TypeDeclaration owner= ASTNodes.getParent(field, TypeDeclaration.class);

		rewriter.remove(field, group);
		importRewriter.removeImport(ORG_JUNIT_RULES_EXPECTED_EXCEPTION);
		if (!hasOtherRuleAnnotation((org.eclipse.jdt.core.dom.CompilationUnit) field.getRoot(), field)) {
			importRewriter.removeImport(ORG_JUNIT_RULE);
		}
		importRewriter.addStaticImport(ORG_JUNIT_JUPITER_API_ASSERTIONS, "assertThrows", false); //$NON-NLS-1$

		for (MethodDeclaration method : owner.getMethods()) {
			MethodAnalysis analysis= analyzeMethod(method, fieldKey);
			if (analysis.plan() != null) {
				applyPlan(analysis.plan(), rewriter, ast, group, importRewriter);
			}
		}
	}

	private static void applyPlan(MethodPlan plan, ASTRewrite rewriter, AST ast, TextEditGroup group,
			ImportRewrite imports) {
		Block body= plan.method().getBody();
		@SuppressWarnings("unchecked")
		List<Statement> statements= body.statements();
		boolean capture= plan.messageSubstring() != null || plan.causeType() != null;
		String exceptionName= capture
				? uniqueVariableName("exception", getUsedVariableNames(plan.method())) //$NON-NLS-1$
				: null;

		MethodInvocation assertThrows= ast.newMethodInvocation();
		assertThrows.setName(ast.newSimpleName("assertThrows")); //$NON-NLS-1$
		assertThrows.arguments().add(ASTNode.copySubtree(ast, plan.exceptionType()));
		LambdaExpression lambda= ast.newLambdaExpression();
		lambda.setParentheses(true);
		Block lambdaBody= ast.newBlock();
		for (int index= plan.lastIndex() + 1; index < statements.size(); index++) {
			lambdaBody.statements().add(ASTNode.copySubtree(ast, statements.get(index)));
		}
		lambda.setBody(lambdaBody);
		assertThrows.arguments().add(lambda);

		Statement replacement;
		if (capture) {
			VariableDeclarationFragment declaration= ast.newVariableDeclarationFragment();
			declaration.setName(ast.newSimpleName(exceptionName));
			declaration.setInitializer(assertThrows);
			VariableDeclarationStatement variable= ast.newVariableDeclarationStatement(declaration);
			variable.setType((Type) ASTNode.copySubtree(ast, plan.exceptionType().getType()));
			replacement= variable;
		} else {
			replacement= ast.newExpressionStatement(assertThrows);
		}

		for (int index= statements.size() - 1; index >= plan.firstIndex(); index--) {
			rewriter.remove(statements.get(index), group);
		}
		ListRewriteFacade append= new ListRewriteFacade(rewriter, body, group);
		append.add(replacement);
		if (plan.messageSubstring() != null) {
			imports.addStaticImport(ORG_JUNIT_JUPITER_API_ASSERTIONS, "assertTrue", false); //$NON-NLS-1$
			append.add(messageAssertion(ast, exceptionName, plan.messageSubstring()));
		}
		if (plan.causeType() != null) {
			imports.addStaticImport(ORG_JUNIT_JUPITER_API_ASSERTIONS, "assertInstanceOf", false); //$NON-NLS-1$
			append.add(causeAssertion(ast, exceptionName, plan.causeType()));
		}
	}

	private static Statement messageAssertion(AST ast, String exceptionName, Expression expectedSubstring) {
		MethodInvocation firstMessage= messageCall(ast, exceptionName);
		InfixExpression notNull= ast.newInfixExpression();
		notNull.setLeftOperand(firstMessage);
		notNull.setOperator(InfixExpression.Operator.NOT_EQUALS);
		notNull.setRightOperand(ast.newNullLiteral());

		MethodInvocation contains= ast.newMethodInvocation();
		contains.setExpression(messageCall(ast, exceptionName));
		contains.setName(ast.newSimpleName("contains")); //$NON-NLS-1$
		contains.arguments().add(ASTNode.copySubtree(ast, expectedSubstring));
		InfixExpression condition= ast.newInfixExpression();
		condition.setLeftOperand(notNull);
		condition.setOperator(InfixExpression.Operator.CONDITIONAL_AND);
		condition.setRightOperand(contains);

		MethodInvocation assertion= ast.newMethodInvocation();
		assertion.setName(ast.newSimpleName("assertTrue")); //$NON-NLS-1$
		assertion.arguments().add(condition);
		return ast.newExpressionStatement(assertion);
	}

	private static MethodInvocation messageCall(AST ast, String exceptionName) {
		MethodInvocation result= ast.newMethodInvocation();
		result.setExpression(ast.newSimpleName(exceptionName));
		result.setName(ast.newSimpleName("getMessage")); //$NON-NLS-1$
		return result;
	}

	private static Statement causeAssertion(AST ast, String exceptionName, TypeLiteral causeType) {
		MethodInvocation cause= ast.newMethodInvocation();
		cause.setExpression(ast.newSimpleName(exceptionName));
		cause.setName(ast.newSimpleName("getCause")); //$NON-NLS-1$
		MethodInvocation assertion= ast.newMethodInvocation();
		assertion.setName(ast.newSimpleName("assertInstanceOf")); //$NON-NLS-1$
		assertion.arguments().add(ASTNode.copySubtree(ast, causeType));
		assertion.arguments().add(cause);
		return ast.newExpressionStatement(assertion);
	}

	private record MethodAnalysis(boolean valid, String reasonCode, String explanation,
			MethodPlan plan, Set<MethodInvocation> configurationInvocations) {
		static MethodAnalysis empty() {
			return new MethodAnalysis(true, "", "", null, Set.of()); //$NON-NLS-1$ //$NON-NLS-2$
		}
		static MethodAnalysis rejected(String code, String explanation) {
			return new MethodAnalysis(false, code, explanation, null, Set.of());
		}
	}

	private static MethodAnalysis analyzeMethod(MethodDeclaration method, String fieldKey) {
		Block body= method.getBody();
		if (body == null) {
			return MethodAnalysis.empty();
		}
		@SuppressWarnings("unchecked")
		List<Statement> statements= body.statements();
		List<Integer> indices= new ArrayList<>();
		Set<MethodInvocation> invocations= new LinkedHashSet<>();
		TypeLiteral exceptionType= null;
		Expression message= null;
		TypeLiteral cause= null;
		for (int index= 0; index < statements.size(); index++) {
			MethodInvocation invocation= configurationInvocation(statements.get(index), fieldKey);
			if (invocation == null) {
				continue;
			}
			indices.add(Integer.valueOf(index));
			invocations.add(invocation);
			String name= invocation.getName().getIdentifier();
			if ("expect".equals(name)) { //$NON-NLS-1$
				if (exceptionType != null || invocation.arguments().size() != 1
						|| !(invocation.arguments().get(0) instanceof TypeLiteral literal)) {
					return MethodAnalysis.rejected("EXPECTED_EXCEPTION_EXPECT_UNSUPPORTED", //$NON-NLS-1$
							"Only one expect(ExceptionType.class) call is supported per test method."); //$NON-NLS-1$
				}
				exceptionType= literal;
			} else if ("expectMessage".equals(name)) { //$NON-NLS-1$
				if (message != null || invocation.arguments().size() != 1
						|| !(invocation.arguments().get(0) instanceof Expression expression)
						|| !isJavaLangString(expression.resolveTypeBinding())) {
					return MethodAnalysis.rejected("EXPECTED_EXCEPTION_MESSAGE_MATCHER_UNSUPPORTED", //$NON-NLS-1$
							"Only one String substring expectation is supported; matcher overloads are rejected."); //$NON-NLS-1$
				}
				message= expression;
			} else if ("expectCause".equals(name)) { //$NON-NLS-1$
				TypeLiteral literal= causeType(invocation);
				if (cause != null || literal == null) {
					return MethodAnalysis.rejected("EXPECTED_EXCEPTION_CAUSE_MATCHER_UNSUPPORTED", //$NON-NLS-1$
							"Only instanceOf/isA(ExceptionType.class) cause matchers are supported."); //$NON-NLS-1$
				}
				cause= literal;
			} else {
				return MethodAnalysis.rejected("EXPECTED_EXCEPTION_UNSUPPORTED_USE", //$NON-NLS-1$
						"An unsupported ExpectedException method is used."); //$NON-NLS-1$
			}
		}
		if (indices.isEmpty()) {
			return MethodAnalysis.empty();
		}
		if (!isJUnit4Test(method)) {
			return MethodAnalysis.rejected("EXPECTED_EXCEPTION_NON_TEST_METHOD", //$NON-NLS-1$
					"Expectation calls in helper or lifecycle methods cannot be moved safely."); //$NON-NLS-1$
		}
		int first= indices.get(0).intValue();
		int last= indices.get(indices.size() - 1).intValue();
		if (last - first + 1 != indices.size()) {
			return MethodAnalysis.rejected("EXPECTED_EXCEPTION_INTERLEAVED_CONFIGURATION", //$NON-NLS-1$
					"Expectation calls must be consecutive top-level statements."); //$NON-NLS-1$
		}
		if (exceptionType == null) {
			return MethodAnalysis.rejected("EXPECTED_EXCEPTION_TYPE_MISSING", //$NON-NLS-1$
					"A supported expect(ExceptionType.class) call is required."); //$NON-NLS-1$
		}
		if (last + 1 >= statements.size()) {
			return MethodAnalysis.rejected("EXPECTED_EXCEPTION_NO_THROWING_BODY", //$NON-NLS-1$
					"No statements remain for the assertThrows executable."); //$NON-NLS-1$
		}
		return new MethodAnalysis(true, "", "", //$NON-NLS-1$ //$NON-NLS-2$
				new MethodPlan(method, first, last, exceptionType, message, cause), Set.copyOf(invocations));
	}

	private static MethodInvocation configurationInvocation(Statement statement, String fieldKey) {
		if (!(statement instanceof ExpressionStatement expressionStatement)
				|| !(expressionStatement.getExpression() instanceof MethodInvocation invocation)
				|| invocation.getExpression() == null
				|| !fieldKey.equals(expressionVariableKey(invocation.getExpression()))) {
			return null;
		}
		IMethodBinding binding= invocation.resolveMethodBinding();
		ITypeBinding declaring= binding == null ? null : binding.getDeclaringClass();
		return declaring != null && ORG_JUNIT_RULES_EXPECTED_EXCEPTION
				.equals(declaring.getErasure().getQualifiedName()) ? invocation : null;
	}

	private static TypeLiteral causeType(MethodInvocation invocation) {
		if (invocation.arguments().size() != 1
				|| !(invocation.arguments().get(0) instanceof MethodInvocation matcher)
				|| !("instanceOf".equals(matcher.getName().getIdentifier()) //$NON-NLS-1$
						|| "isA".equals(matcher.getName().getIdentifier())) //$NON-NLS-1$
				|| matcher.arguments().size() != 1
				|| !(matcher.arguments().get(0) instanceof TypeLiteral literal)) {
			return null;
		}
		IMethodBinding binding= matcher.resolveMethodBinding();
		ITypeBinding declaring= binding == null ? null : binding.getDeclaringClass();
		String packageName= declaring == null || declaring.getPackage() == null
				? "" : declaring.getPackage().getName(); //$NON-NLS-1$
		return packageName.startsWith("org.hamcrest") ? literal : null; //$NON-NLS-1$
	}

	private static boolean hasUnsupportedReference(org.eclipse.jdt.core.dom.CompilationUnit root,
			VariableDeclarationFragment declaration, String fieldKey,
			Set<MethodInvocation> allowedInvocations) {
		AtomicBoolean unsupported= new AtomicBoolean();
		root.accept(new ASTVisitor() {
			@Override
			public boolean visit(SimpleName node) {
				IBinding binding= node.resolveBinding();
				if (!(binding instanceof IVariableBinding variable)
						|| !fieldKey.equals(variableKey(variable)) || node == declaration.getName()) {
					return true;
				}
				MethodInvocation invocation= ASTNodes.getParent(node, MethodInvocation.class);
				if (invocation == null || !allowedInvocations.contains(invocation)
						|| invocation.getExpression() == null
						|| !isDescendantOf(node, invocation.getExpression())) {
					unsupported.set(true);
				}
				return true;
			}
		});
		return unsupported.get();
	}

	private static boolean isJUnit4Test(MethodDeclaration method) {
		for (Object modifier : method.modifiers()) {
			if (modifier instanceof Annotation annotation) {
				ITypeBinding binding= annotation.resolveTypeBinding();
				if (binding != null && ORG_JUNIT_TEST.equals(binding.getQualifiedName())) {
					return true;
				}
			}
		}
		return false;
	}

	private static boolean isJavaLangString(ITypeBinding binding) {
		return binding != null && "java.lang.String".equals(binding.getErasure().getQualifiedName()); //$NON-NLS-1$
	}

	private static int countExpectedExceptionFields(org.eclipse.jdt.core.dom.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_EXPECTED_EXCEPTION
						.equals(binding.getErasure().getQualifiedName())) {
					count[0]+= field.fragments().size();
				}
				return true;
			}
		});
		return count[0];
	}

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

	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 String uniqueVariableName(String base, Collection<String> used) {
		if (!used.contains(base)) {
			return base;
		}
		for (int suffix= 2; ; suffix++) {
			String candidate= base + suffix;
			if (!used.contains(candidate)) {
				return candidate;
			}
		}
	}

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

	private record CompilationUnitView(org.eclipse.jdt.core.dom.CompilationUnit root) {
		static CompilationUnitView of(ASTNode node) {
			return new CompilationUnitView(node.getRoot() instanceof org.eclipse.jdt.core.dom.CompilationUnit root
					? root : null);
		}
	}

	private static final class ListRewriteFacade {
		private final org.eclipse.jdt.core.dom.rewrite.ListRewrite rewrite;
		private final TextEditGroup group;

		ListRewriteFacade(ASTRewrite astRewrite, Block body, TextEditGroup group) {
			this.rewrite= astRewrite.getListRewrite(body, Block.STATEMENTS_PROPERTY);
			this.group= group;
		}

		void add(Statement statement) {
			rewrite.insertLast(statement, group);
		}
	}

	@Override
	public String getPreview(boolean afterRefactoring) {
		if (afterRefactoring) {
			return """
				import static org.junit.jupiter.api.Assertions.assertThrows;
				import static org.junit.jupiter.api.Assertions.assertTrue;

				@Test
				void testException() {
					IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> {
						throw new IllegalArgumentException("Invalid argument details");
					});
					assertTrue(exception.getMessage() != null
							&& exception.getMessage().contains("Invalid argument"));
				}
				"""; //$NON-NLS-1$
		}
		return """
				@Rule
				public ExpectedException thrown = ExpectedException.none();

				@Test
				public void testException() {
					thrown.expect(IllegalArgumentException.class);
					thrown.expectMessage("Invalid argument");
					throw new IllegalArgumentException("Invalid argument details");
				}
				"""; //$NON-NLS-1$
	}

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