JUnitBestEffortSupport.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
*******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.multifile;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_CLASS_RULE;
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_RULES_EXTERNAL_RESOURCE;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RULES_TEMPORARY_FOLDER;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RUNWITH;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.ASTRequestor;
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.CompilationUnit;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import org.eclipse.jdt.core.dom.Javadoc;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.StringLiteral;
import org.eclipse.jdt.core.dom.TagElement;
import org.eclipse.jdt.core.dom.TextElement;
import org.eclipse.jdt.core.dom.ThrowStatement;
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.rewrite.ASTRewrite;
import org.eclipse.jdt.internal.corext.dom.IASTSharedValues;
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.jdt.internal.corext.refactoring.util.RefactoringASTParser;
import org.eclipse.text.edits.TextEditGroup;
import org.sandbox.jdt.internal.corext.fix.JUnitCleanUpFixCore;
import org.sandbox.jdt.internal.corext.fix.helper.ParameterizedMigrationEligibility;
import org.sandbox.jdt.internal.corext.fix.helper.RuleExpectedExceptionJUnitPlugin;
import org.sandbox.jdt.internal.corext.fix.helper.RuleTemporayFolderJUnitPlugin;
import org.sandbox.jdt.internal.corext.fix.helper.RunWithMigrationEligibility;
import org.sandbox.jdt.internal.corext.fix.helper.lib.TestNameRefactorer;
/**
* Explicit support for a deliberately non-atomic JUnit migration mode.
*
* <p>The normal cleanup remains fail closed. When best-effort mode is selected,
* independently safe rewrites may proceed while every known unsupported
* construct is represented by deterministic diagnostics and an actionable
* {@code @todo} in the affected type's Javadoc. A method scaffold is retained
* as a code example, not an executable member or a guessed implementation.</p>
*/
public final class JUnitBestEffortSupport {
private static final String TEST_NAME= "org.junit.rules.TestName"; //$NON-NLS-1$
private static final String TIMEOUT= "org.junit.rules.Timeout"; //$NON-NLS-1$
private static final String ERROR_COLLECTOR= "org.junit.rules.ErrorCollector"; //$NON-NLS-1$
private static final String MARKER_PREFIX= "Sandbox JUnit migration gap "; //$NON-NLS-1$
/** One unresolved construct and the exact source type where it must be completed. */
public record Gap(String ownerCompilationUnitHandle, String targetTypeBindingKey,
String targetTypeName, int targetTypeStart, String candidateId, String reasonCode,
String explanation, String remediation) {
/** Validates and normalizes a best-effort gap. */
public Gap {
ownerCompilationUnitHandle= Objects.requireNonNull(ownerCompilationUnitHandle);
targetTypeBindingKey= targetTypeBindingKey == null ? "" : targetTypeBindingKey; //$NON-NLS-1$
targetTypeName= Objects.requireNonNull(targetTypeName);
candidateId= Objects.requireNonNull(candidateId);
reasonCode= Objects.requireNonNull(reasonCode);
explanation= Objects.requireNonNull(explanation);
remediation= Objects.requireNonNull(remediation);
}
String markerText() {
return MARKER_PREFIX + candidateId + " (" + reasonCode + "): " + explanation //$NON-NLS-1$ //$NON-NLS-2$
+ " Manual completion: " + remediation; //$NON-NLS-1$
}
}
/** Deterministic analysis retained between project planning and per-file fixes. */
public record Analysis(List<Gap> gaps, boolean disableCoordinatedExternalResource) {
/** Copies and orders all gaps. */
public Analysis {
gaps= gaps == null ? List.of() : gaps.stream()
.sorted(Comparator.comparing(Gap::ownerCompilationUnitHandle)
.thenComparing(Gap::targetTypeName)
.thenComparing(Gap::candidateId)
.thenComparing(Gap::reasonCode))
.toList();
}
/** Returns an empty analysis for strict mode. */
public static Analysis empty() {
return new Analysis(List.of(), false);
}
/** Returns gaps belonging to one primary compilation unit. */
public List<Gap> gapsFor(ICompilationUnit unit) {
if (unit == null) {
return List.of();
}
String handle= unit.getPrimary().getHandleIdentifier();
return gaps.stream().filter(gap -> handle.equals(gap.ownerCompilationUnitHandle())).toList();
}
/** Wraps ordinary planner evidence with explicit manual-completion evidence. */
public String toJson(String plannerDiagnosticsJson) {
StringBuilder json= new StringBuilder(512);
json.append('{')
.append("\"bestEffort\":true,") //$NON-NLS-1$
.append("\"manualCompletionRequired\":").append(!gaps.isEmpty()).append(',') //$NON-NLS-1$
.append("\"coordinatedExternalResourceDeferred\":") //$NON-NLS-1$
.append(disableCoordinatedExternalResource).append(',')
.append("\"planner\":"); //$NON-NLS-1$
if (plannerDiagnosticsJson == null || plannerDiagnosticsJson.isBlank()) {
json.append("null"); //$NON-NLS-1$
} else {
json.append(plannerDiagnosticsJson);
}
json.append(",\"gaps\":["); //$NON-NLS-1$
for (int index= 0; index < gaps.size(); index++) {
if (index > 0) {
json.append(',');
}
Gap gap= gaps.get(index);
json.append('{')
.append("\"unitId\":\"").append(jsonEscape(opaqueId(gap.ownerCompilationUnitHandle()))) //$NON-NLS-1$
.append("\",\"type\":\"").append(jsonEscape(gap.targetTypeName())) //$NON-NLS-1$
.append("\",\"candidateId\":\"").append(jsonEscape(gap.candidateId())) //$NON-NLS-1$
.append("\",\"reasonCode\":\"").append(jsonEscape(gap.reasonCode())) //$NON-NLS-1$
.append("\",\"explanation\":\"").append(jsonEscape(gap.explanation())) //$NON-NLS-1$
.append("\",\"remediation\":\"").append(jsonEscape(gap.remediation())) //$NON-NLS-1$
.append("\"}"); //$NON-NLS-1$
}
return json.append("]}").toString(); //$NON-NLS-1$
}
}
private record SourceType(String unitHandle, String bindingKey, String name, int start,
TypeDeclaration declaration, ITypeBinding binding) {
}
private static final class ResourceUsage {
private final ITypeBinding binding;
private final List<SourceType> owners= new ArrayList<>();
private boolean instanceRule;
private boolean classRule;
ResourceUsage(ITypeBinding binding) {
this.binding= binding;
}
}
private JUnitBestEffortSupport() {
}
/** Replaces local-only gaps with the coordinated contract, keeping blocked files atomic. */
public static Analysis reconcileParameterized(Analysis analysis, JUnitMigrationPlan plan,
org.sandbox.jdt.cleanup.multifile.MultiFileCleanUpDiagnostics diagnostics, boolean executionAvailable) throws JavaModelException {
if (plan == null || !executionAvailable) {
return analysis;
}
List<JUnit4ParameterizedPlan> executable= plan.preparedParameterizedPlans().stream()
.filter(JUnit4ParameterizedPlan::executable).toList();
Set<String> authorized= executable.stream().map(item -> item.testClass().bindingKey())
.collect(java.util.stream.Collectors.toSet());
List<Gap> gaps= new ArrayList<>(analysis.gaps().stream()
.filter(gap -> !gap.candidateId().startsWith("parameterized:") //$NON-NLS-1$
|| !authorized.contains(gap.targetTypeBindingKey())).toList());
for (var diagnostic : diagnostics.candidates()) {
if (diagnostic.candidateId().startsWith("parameterized:") //$NON-NLS-1$
&& diagnostic.outcome() == org.sandbox.jdt.cleanup.multifile.MultiFileCandidateOutcome.REJECTED) {
// Quarantine the test hierarchy while allowing independent resource
// compatibility bridges and unrelated test classes to migrate.
for (String handle : diagnostic.relatedCompilationUnitHandles()) {
if (gaps.stream().anyMatch(gap -> handle.equals(gap.ownerCompilationUnitHandle())
&& gap.candidateId().startsWith("parameterized:"))) { //$NON-NLS-1$
continue;
}
gaps.add(parameterizedGap(handle, diagnostic.candidateId().substring("parameterized:".length()), diagnostic.candidateId(), //$NON-NLS-1$
diagnostic.reasonCode(), diagnostic.message(),
"Resolve the coordinated Parameterized diagnostic and rerun the complete selected scope.")); //$NON-NLS-1$
}
}
}
Set<String> blocked= gaps.stream().map(Gap::ownerCompilationUnitHandle)
.collect(java.util.stream.Collectors.toSet());
for (JUnit4ParameterizedPlan component : executable) {
if (component.compilationUnits().values().stream().anyMatch(blocked::contains)) {
for (String handle : new LinkedHashSet<>(component.compilationUnits().values())) {
gaps.add(parameterizedGap(handle, component.testClass().bindingKey(),
"parameterized:" + component.testClass().bindingKey(), "PARAMETERIZED_COMPONENT_BLOCKED", //$NON-NLS-1$ //$NON-NLS-2$
"A participating compilation unit has an unresolved migration gap.", //$NON-NLS-1$
"Resolve all gaps before changing this provider and test hierarchy.")); //$NON-NLS-1$
}
}
}
return new Analysis(gaps, analysis.disableCoordinatedExternalResource());
}
private static Gap parameterizedGap(String handle, String typeIdentity, String candidateId,
String reasonCode, String explanation, String remediation) throws JavaModelException {
String bindingKey= ""; //$NON-NLS-1$
String name= typeIdentity;
int start= -1;
if (JavaCore.create(handle) instanceof ICompilationUnit unit) {
IType[] types= unit.getAllTypes();
IType target= unit.findPrimaryType();
for (IType type : types) {
if (typeIdentity.equals(type.getKey()) || typeIdentity.equals(type.getFullyQualifiedName('.'))) {
target= type;
break;
}
}
if (target == null && types.length != 0) {
target= types[0];
}
if (target != null) {
bindingKey= target.getKey();
name= target.getFullyQualifiedName('.');
start= target.getSourceRange().getOffset();
}
}
return new Gap(handle, bindingKey, name, start, candidateId, reasonCode, explanation, remediation);
}
/**
* Returns rewrites that do not change test discovery, lifecycle, runners or
* extension contracts and may therefore proceed beside a quarantined gap.
*/
public static EnumSet<JUnitCleanUpFixCore> independentlySafeFixes(
EnumSet<JUnitCleanUpFixCore> fixes) {
EnumSet<JUnitCleanUpFixCore> result= fixes.clone();
result.retainAll(EnumSet.of(
JUnitCleanUpFixCore.ASSERT,
JUnitCleanUpFixCore.ASSERT_OPTIMIZATION,
JUnitCleanUpFixCore.ASSUME,
JUnitCleanUpFixCore.ASSUME_OPTIMIZATION));
return result;
}
/** Analyzes all enabled difficult JUnit 4 constructs before cleanup planning. */
public static Analysis analyze(IJavaProject project, ICompilationUnit[] units,
EnumSet<JUnitCleanUpFixCore> fixes, IProgressMonitor monitor) {
Objects.requireNonNull(project);
Objects.requireNonNull(units);
Objects.requireNonNull(fixes);
if (units.length == 0 || fixes.isEmpty()) {
return Analysis.empty();
}
Map<String, CompilationUnit> roots= parse(project, units, monitor);
Map<String, SourceType> sourceTypesByKey= new LinkedHashMap<>();
Map<String, Gap> gaps= new LinkedHashMap<>();
Map<String, ResourceUsage> usages= new LinkedHashMap<>();
for (Map.Entry<String, CompilationUnit> entry : roots.entrySet()) {
String handle= entry.getKey();
entry.getValue().accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding binding= node.resolveBinding();
String key= JUnitMigrationPlan.typeKey(binding);
SourceType type= new SourceType(handle, key == null ? "" : key, //$NON-NLS-1$
sourceTypeName(node), node.getStartPosition(), node, binding);
if (key != null) {
sourceTypesByKey.put(key, type);
}
boolean parameterizedRunner=
ParameterizedMigrationEligibility.hasParameterizedRunner(node);
if (parameterizedRunner && fixes.contains(JUnitCleanUpFixCore.PARAMETERIZED)) {
ParameterizedMigrationEligibility.Assessment assessment=
ParameterizedMigrationEligibility.assess(node);
if (!assessment.eligible()) {
addGap(gaps, type, "parameterized:" + type.name(), //$NON-NLS-1$
assessment.reasonCode(), assessment.explanation(),
remediationFor(assessment.reasonCode()));
}
} else if (parameterizedRunner && requiresParameterizedClosure(fixes)) {
String reasonCode= "PARAMETERIZED_COMPONENT_NOT_SELECTED"; //$NON-NLS-1$
addGap(gaps, type, "parameterized:" + type.name(), //$NON-NLS-1$
reasonCode,
"A selected rewrite would change JUnit discovery, lifecycle or Rule semantics while the JUnit 4 Parameterized runner remains.", //$NON-NLS-1$
remediationFor(reasonCode));
}
if (fixes.contains(JUnitCleanUpFixCore.RUNWITH)) {
String runner= runnerType(node);
if (runner != null && !runner.isBlank()
&& !parameterizedRunner
&& !RunWithMigrationEligibility.canMigrate(node, runner)) {
addGap(gaps, type, "runner:" + type.name(), //$NON-NLS-1$
"CUSTOM_JUNIT4_RUNNER", //$NON-NLS-1$
"The JUnit 4 runner " + runner //$NON-NLS-1$
+ " has no generally equivalent automatic Jupiter rewrite.", //$NON-NLS-1$
"Port the runner behavior to one or more Jupiter extensions, or retain the Vintage engine for this test type."); //$NON-NLS-1$
}
}
return true;
}
});
}
for (Map.Entry<String, CompilationUnit> entry : roots.entrySet()) {
String handle= entry.getKey();
entry.getValue().accept(new ASTVisitor() {
@Override
public boolean visit(FieldDeclaration field) {
TypeDeclaration ownerDeclaration= enclosingType(field);
if (ownerDeclaration == null) {
return true;
}
SourceType owner= sourceType(handle, ownerDeclaration);
ITypeBinding fieldType= field.getType().resolveBinding();
String qualifiedType= qualifiedName(fieldType);
if (fixes.contains(JUnitCleanUpFixCore.RULETEMPORARYFOLDER)
&& ORG_JUNIT_RULES_TEMPORARY_FOLDER.equals(qualifiedType)) {
RuleTemporayFolderJUnitPlugin.Assessment assessment=
RuleTemporayFolderJUnitPlugin.assess(field);
if (!assessment.eligible()) {
addGap(gaps, owner, fieldCandidate("temporary-folder", field), //$NON-NLS-1$
assessment.reasonCode(), assessment.explanation(),
remediationFor(assessment.reasonCode()));
}
}
if (fixes.contains(JUnitCleanUpFixCore.RULETESTNAME)
&& TEST_NAME.equals(qualifiedType)) {
TestNameRefactorer.Assessment assessment= TestNameRefactorer.assess(field);
if (!assessment.eligible()) {
addGap(gaps, owner, fieldCandidate("test-name", field), //$NON-NLS-1$
assessment.reasonCode(), assessment.explanation(),
remediationFor(assessment.reasonCode()));
}
}
if (fixes.contains(JUnitCleanUpFixCore.RULEEXPECTEDEXCEPTION)
&& ORG_JUNIT_RULES_EXPECTED_EXCEPTION.equals(qualifiedType)) {
RuleExpectedExceptionJUnitPlugin.Assessment assessment=
RuleExpectedExceptionJUnitPlugin.assess(field);
if (!assessment.eligible()) {
addGap(gaps, owner, fieldCandidate("expected-exception", field), //$NON-NLS-1$
assessment.reasonCode(), assessment.explanation(),
remediationFor(assessment.reasonCode()));
}
}
RuleKind ruleKind= ruleKind(field);
if (ruleKind != RuleKind.NONE) {
ITypeBinding resource= resourceTypeBinding(field);
if (extendsExternalResource(resource)) {
String resourceKey= JUnitMigrationPlan.typeKey(resource);
String usageKey= resourceKey == null ? qualifiedName(resource) : resourceKey;
if (usageKey != null && !usageKey.isBlank()) {
ResourceUsage usage= usages.computeIfAbsent(usageKey,
ignored -> new ResourceUsage(resource));
usage.owners.add(owner);
usage.instanceRule|= ruleKind == RuleKind.INSTANCE;
usage.classRule|= ruleKind == RuleKind.CLASS;
}
} else if (rulesEnabled(fixes) && !isKnownRuleType(qualifiedType)) {
addGap(gaps, owner, fieldCandidate("rule", field), //$NON-NLS-1$
"UNSUPPORTED_JUNIT4_RULE", //$NON-NLS-1$
"The rule type " + (qualifiedType == null ? "<unresolved>" : qualifiedType) //$NON-NLS-1$ //$NON-NLS-2$
+ " has no proven automatic Jupiter equivalent.", //$NON-NLS-1$
"Implement the rule contract as a Jupiter extension, register it explicitly, and then remove the JUnit 4 rule field."); //$NON-NLS-1$
}
}
return true;
}
});
}
boolean mixedLifecycle= false;
for (Map.Entry<String, ResourceUsage> entry : usages.entrySet()) {
ResourceUsage usage= entry.getValue();
if (!usage.instanceRule || !usage.classRule) {
continue;
}
mixedLifecycle= true;
SourceType target= sourceTypesByKey.get(entry.getKey());
if (target == null && !usage.owners.isEmpty()) {
target= usage.owners.get(0);
}
if (target != null) {
String resourceName= qualifiedName(usage.binding);
addGap(gaps, target, "external-resource:" //$NON-NLS-1$
+ (resourceName == null ? target.name() : resourceName),
"MIXED_RULE_LIFECYCLE", //$NON-NLS-1$
"The same ExternalResource fixture is used by both @Rule and @ClassRule, so one Jupiter callback lifecycle cannot preserve both contracts.", //$NON-NLS-1$
"Split the fixture into separate instance and class extensions, migrate each callback lifecycle, and update the corresponding fields."); //$NON-NLS-1$
}
}
return new Analysis(new ArrayList<>(gaps.values()), mixedLifecycle);
}
/** Adds one idempotent marker operation for the supplied compilation-unit gaps. */
public static void addMarkerOperation(CompilationUnit root, List<Gap> gaps,
Set<CompilationUnitRewriteOperationWithSourceRange> operations) {
if (root == null || gaps == null || gaps.isEmpty()) {
return;
}
Map<TypeDeclaration, List<Gap>> resolved= new LinkedHashMap<>();
List<TypeDeclaration> types= new ArrayList<>();
root.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
types.add(node);
return true;
}
});
for (Gap gap : gaps) {
TypeDeclaration target= types.stream().filter(type -> matches(type, gap)).findFirst().orElse(null);
if (target == null && !types.isEmpty()) {
target= types.get(0);
}
if (target != null && !hasMarker(target, gap)) {
resolved.computeIfAbsent(target, ignored -> new ArrayList<>()).add(gap);
}
}
if (!resolved.isEmpty()) {
operations.add(new GapMarkerOperation(resolved));
}
}
private enum RuleKind {
NONE,
INSTANCE,
CLASS
}
private static final class GapMarkerOperation extends CompilationUnitRewriteOperationWithSourceRange {
private final Map<TypeDeclaration, List<Gap>> gapsByType;
GapMarkerOperation(Map<TypeDeclaration, List<Gap>> gapsByType) {
this.gapsByType= gapsByType;
}
@Override
public void rewriteASTInternal(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel) {
TextEditGroup group= createTextEditGroup(
"Mark incomplete best-effort JUnit migrations", cuRewrite); //$NON-NLS-1$
ASTRewrite rewrite= cuRewrite.getASTRewrite();
AST ast= cuRewrite.getRoot().getAST();
for (Map.Entry<TypeDeclaration, List<Gap>> entry : gapsByType.entrySet()) {
TypeDeclaration type= entry.getKey();
Set<String> methodNames= new LinkedHashSet<>();
for (MethodDeclaration method : type.getMethods()) {
methodNames.add(method.getName().getIdentifier());
}
Javadoc documentation= type.getJavadoc() == null ? ast.newJavadoc()
: (Javadoc) ASTNode.copySubtree(ast, type.getJavadoc());
for (Gap gap : entry.getValue()) {
if (hasMarker(type, gap)) {
continue;
}
MethodDeclaration marker= markerMethod(ast, gap, methodNames);
methodNames.add(marker.getName().getIdentifier());
TagElement todo= (TagElement) ASTNode.copySubtree(ast, (ASTNode) marker.getJavadoc().tags().get(0));
marker.setJavadoc(null);
TextElement example= ast.newTextElement();
String sample= marker.toString().replace("*/", "* /") //$NON-NLS-1$ //$NON-NLS-2$
.replace("&", "&").replace("<", "<").replace(">", ">"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$ //$NON-NLS-6$
example.setText(" <pre>\n * " + sample.replace("\n", "\n * ") + "\n * </pre>"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
todo.fragments().add(example);
documentation.tags().add(todo);
}
rewrite.set(type, TypeDeclaration.JAVADOC_PROPERTY, documentation, group);
}
}
}
private static MethodDeclaration markerMethod(AST ast, Gap gap, Set<String> existingNames) {
MethodDeclaration method= ast.newMethodDeclaration();
String baseName= markerMethodName(gap.reasonCode());
String name= baseName;
for (int suffix= 2; existingNames.contains(name); suffix++) {
name= baseName + suffix;
}
method.setName(ast.newSimpleName(name));
method.setReturnType2(ast.newPrimitiveType(org.eclipse.jdt.core.dom.PrimitiveType.VOID));
method.modifiers().add(ast.newModifier(org.eclipse.jdt.core.dom.Modifier.ModifierKeyword.PRIVATE_KEYWORD));
method.modifiers().add(ast.newModifier(org.eclipse.jdt.core.dom.Modifier.ModifierKeyword.STATIC_KEYWORD));
Javadoc javadoc= ast.newJavadoc();
TagElement todo= ast.newTagElement();
todo.setTagName("@todo"); //$NON-NLS-1$
TextElement text= ast.newTextElement();
text.setText(javadocText(gap.markerText()));
todo.fragments().add(text);
javadoc.tags().add(todo);
method.setJavadoc(javadoc);
ClassInstanceCreation exception= ast.newClassInstanceCreation();
exception.setType(ast.newSimpleType(ast.newSimpleName("UnsupportedOperationException"))); //$NON-NLS-1$
StringLiteral message= ast.newStringLiteral();
message.setLiteralValue("Manual JUnit migration required: " + gap.reasonCode()); //$NON-NLS-1$
exception.arguments().add(message);
ThrowStatement throwStatement= ast.newThrowStatement();
throwStatement.setExpression(exception);
org.eclipse.jdt.core.dom.Block body= ast.newBlock();
body.statements().add(throwStatement);
method.setBody(body);
return method;
}
private static boolean hasMarker(TypeDeclaration type, Gap gap) {
String marker= MARKER_PREFIX + gap.candidateId() + " ("; //$NON-NLS-1$
if (type.getJavadoc() != null && type.getJavadoc().toString().contains(marker)) {
return true;
}
// Recognize earlier executable scaffolds without duplicating or editing them.
for (MethodDeclaration method : type.getMethods()) {
Javadoc javadoc= method.getJavadoc();
if (javadoc != null && javadoc.toString().contains(marker)) {
return true;
}
}
return false;
}
private static String markerMethodName(String reasonCode) {
StringBuilder name= new StringBuilder("sandboxJUnitMigrationTodo"); //$NON-NLS-1$
for (String part : reasonCode.toLowerCase(Locale.ROOT).split("[^a-z0-9]+")) { //$NON-NLS-1$
if (!part.isEmpty()) {
name.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
}
}
if (name.length() > 100) {
name.setLength(88);
name.append(Integer.toUnsignedString(reasonCode.hashCode(), 16));
}
return name.toString();
}
private static boolean matches(TypeDeclaration type, Gap gap) {
String key= JUnitMigrationPlan.typeKey(type.resolveBinding());
if (!gap.targetTypeBindingKey().isBlank() && gap.targetTypeBindingKey().equals(key)) {
return true;
}
return gap.targetTypeStart() == type.getStartPosition()
&& gap.targetTypeName().equals(sourceTypeName(type));
}
private static Map<String, CompilationUnit> parse(IJavaProject project, ICompilationUnit[] units,
IProgressMonitor monitor) {
Map<String, CompilationUnit> roots= new LinkedHashMap<>();
ASTParser parser= ASTParser.newParser(IASTSharedValues.SHARED_AST_LEVEL);
parser.setProject(project);
parser.setResolveBindings(true);
parser.setBindingsRecovery(IASTSharedValues.SHARED_BINDING_RECOVERY);
parser.setStatementsRecovery(IASTSharedValues.SHARED_AST_STATEMENT_RECOVERY);
parser.setCompilerOptions(RefactoringASTParser.getCompilerOptions(project));
parser.createASTs(units, new String[0], new ASTRequestor() {
@Override
public void acceptAST(ICompilationUnit source, CompilationUnit ast) {
roots.put(source.getPrimary().getHandleIdentifier(), ast);
}
}, monitor);
return roots;
}
private static SourceType sourceType(String handle, TypeDeclaration type) {
ITypeBinding binding= type.resolveBinding();
String key= JUnitMigrationPlan.typeKey(binding);
return new SourceType(handle, key == null ? "" : key, sourceTypeName(type), //$NON-NLS-1$
type.getStartPosition(), type, binding);
}
private static TypeDeclaration enclosingType(ASTNode node) {
for (ASTNode current= node.getParent(); current != null; current= current.getParent()) {
if (current instanceof TypeDeclaration type) {
return type;
}
}
return null;
}
private static String fieldCandidate(String kind, FieldDeclaration field) {
if (field.fragments().size() == 1
&& field.fragments().get(0) instanceof VariableDeclarationFragment fragment) {
return kind + ':' + fragment.getName().getIdentifier();
}
return kind + ":field@" + field.getStartPosition(); //$NON-NLS-1$
}
private static void addGap(Map<String, Gap> gaps, SourceType type, String candidateId,
String reasonCode, String explanation, String remediation) {
Gap gap= new Gap(type.unitHandle(), type.bindingKey(), type.name(), type.start(), candidateId,
reasonCode, explanation, remediation);
gaps.putIfAbsent(type.unitHandle() + '|' + candidateId + '|' + reasonCode, gap);
}
private static RuleKind ruleKind(FieldDeclaration field) {
for (Object modifier : field.modifiers()) {
if (!(modifier instanceof Annotation annotation)) {
continue;
}
ITypeBinding binding= annotation.resolveTypeBinding();
String name= qualifiedName(binding);
if (ORG_JUNIT_RULE.equals(name)) {
return RuleKind.INSTANCE;
}
if (ORG_JUNIT_CLASS_RULE.equals(name)) {
return RuleKind.CLASS;
}
}
return RuleKind.NONE;
}
private static ITypeBinding resourceTypeBinding(FieldDeclaration field) {
if (field.fragments().size() == 1
&& field.fragments().get(0) instanceof VariableDeclarationFragment fragment) {
if (fragment.getInitializer() instanceof ClassInstanceCreation creation) {
ITypeBinding created= creation.resolveTypeBinding();
if (created != null) {
return created;
}
}
IVariableBinding variable= fragment.resolveBinding();
if (variable != null) {
return variable.getType();
}
}
return field.getType().resolveBinding();
}
private static boolean extendsExternalResource(ITypeBinding binding) {
for (ITypeBinding current= binding; current != null; current= current.getSuperclass()) {
if (ORG_JUNIT_RULES_EXTERNAL_RESOURCE.equals(qualifiedName(current))) {
return true;
}
}
return false;
}
private static String runnerType(TypeDeclaration type) {
for (Object modifier : type.modifiers()) {
if (!(modifier instanceof Annotation annotation)) {
continue;
}
ITypeBinding annotationType= annotation.resolveTypeBinding();
if (!ORG_JUNIT_RUNWITH.equals(qualifiedName(annotationType))) {
continue;
}
org.eclipse.jdt.core.dom.Expression value= annotationValue(annotation);
if (value instanceof TypeLiteral literal) {
return qualifiedName(literal.getType().resolveBinding());
}
return "<unresolved>"; //$NON-NLS-1$
}
return null;
}
private static org.eclipse.jdt.core.dom.Expression annotationValue(Annotation annotation) {
if (annotation instanceof SingleMemberAnnotation single) {
return single.getValue();
}
if (annotation instanceof NormalAnnotation normal) {
for (Object value : normal.values()) {
if (value instanceof MemberValuePair pair
&& "value".equals(pair.getName().getIdentifier())) { //$NON-NLS-1$
return pair.getValue();
}
}
}
return null;
}
private static boolean requiresParameterizedClosure(EnumSet<JUnitCleanUpFixCore> fixes) {
EnumSet<JUnitCleanUpFixCore> structural= fixes.clone();
structural.removeAll(independentlySafeFixes(fixes));
return !structural.isEmpty();
}
private static boolean rulesEnabled(EnumSet<JUnitCleanUpFixCore> fixes) {
return fixes.contains(JUnitCleanUpFixCore.RULETEMPORARYFOLDER)
|| fixes.contains(JUnitCleanUpFixCore.RULETESTNAME)
|| fixes.contains(JUnitCleanUpFixCore.RULEEXTERNALRESOURCE)
|| fixes.contains(JUnitCleanUpFixCore.RULETIMEOUT)
|| fixes.contains(JUnitCleanUpFixCore.RULEEXPECTEDEXCEPTION)
|| fixes.contains(JUnitCleanUpFixCore.RULEERRORCOLLECTOR);
}
private static boolean isKnownRuleType(String qualifiedType) {
return ORG_JUNIT_RULES_TEMPORARY_FOLDER.equals(qualifiedType)
|| TEST_NAME.equals(qualifiedType)
|| ORG_JUNIT_RULES_EXPECTED_EXCEPTION.equals(qualifiedType)
|| TIMEOUT.equals(qualifiedType)
|| ERROR_COLLECTOR.equals(qualifiedType)
|| ORG_JUNIT_RULES_EXTERNAL_RESOURCE.equals(qualifiedType);
}
private static String sourceTypeName(TypeDeclaration type) {
ITypeBinding binding= type.resolveBinding();
String qualified= qualifiedName(binding);
return qualified == null || qualified.isBlank() ? type.getName().getIdentifier() : qualified;
}
private static String qualifiedName(ITypeBinding binding) {
return binding == null ? null : binding.getErasure().getQualifiedName();
}
private static String remediationFor(String reasonCode) {
if ("PARAMETERIZED_COMPONENT_NOT_SELECTED".equals(reasonCode)) { //$NON-NLS-1$
return "Enable the Parameterized migration for the same source component, or leave its JUnit 4 test, lifecycle and Rule annotations unchanged until that coordinated migration can run."; //$NON-NLS-1$
}
if (reasonCode.startsWith("PARAMETERIZED_")) { //$NON-NLS-1$
return "Replace field injection or the custom provider with explicit Jupiter method arguments/Arguments sources, then remove the Parameterized runner and constructor coupling."; //$NON-NLS-1$
}
if (reasonCode.startsWith("TEMPORARY_FOLDER_")) { //$NON-NLS-1$
return "Introduce @TempDir Path and translate the remaining custom folder semantics to java.nio.file.Files calls before removing TemporaryFolder."; //$NON-NLS-1$
}
if (reasonCode.startsWith("TEST_NAME_")) { //$NON-NLS-1$
return "Inject TestInfo in @BeforeEach and replace every remaining rule use with the intended method-name or display-name contract."; //$NON-NLS-1$
}
if (reasonCode.startsWith("EXPECTED_EXCEPTION_")) { //$NON-NLS-1$
return "Wrap only the throwing statements in assertThrows(), then express message, cause and matcher checks with explicit Jupiter assertions."; //$NON-NLS-1$
}
return "Complete this construct manually using the Jupiter extension and assertion APIs described by the reason code."; //$NON-NLS-1$
}
private static String javadocText(String value) {
return value.replace("*/", "* /").replace('\r', ' ').replace('\n', ' '); //$NON-NLS-1$ //$NON-NLS-2$
}
private static String opaqueId(String value) {
try {
byte[] digest= MessageDigest.getInstance("SHA-256") //$NON-NLS-1$
.digest(value.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest, 0, 8);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
private static String jsonEscape(String value) {
StringBuilder escaped= new StringBuilder(value.length() + 16);
for (int index= 0; index < value.length(); index++) {
char character= value.charAt(index);
switch (character) {
case '"' -> escaped.append("\\\""); //$NON-NLS-1$
case '\\' -> escaped.append("\\\\"); //$NON-NLS-1$
case '\b' -> escaped.append("\\b"); //$NON-NLS-1$
case '\f' -> escaped.append("\\f"); //$NON-NLS-1$
case '\n' -> escaped.append("\\n"); //$NON-NLS-1$
case '\r' -> escaped.append("\\r"); //$NON-NLS-1$
case '\t' -> escaped.append("\\t"); //$NON-NLS-1$
default -> {
if (character < 0x20) {
escaped.append(String.format(Locale.ROOT, "\\u%04x", Integer.valueOf(character))); //$NON-NLS-1$
} else {
escaped.append(character);
}
}
}
}
return escaped.toString();
}
}