JUnit3HierarchyPlanner.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.multifile;

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnit3SemanticSupport.JUNIT3_TEST_CASE;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IType;
import org.eclipse.jdt.core.ITypeHierarchy;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.BodyDeclaration;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.Modifier;
import org.eclipse.jdt.core.dom.TypeDeclaration;

import org.sandbox.jdt.cleanup.multifile.MultiFileCandidateDiagnostic;
import org.sandbox.jdt.cleanup.multifile.RelatedCompilationUnitSearch;
import org.sandbox.jdt.internal.corext.fix.helper.lib.JUnit3MigrationExclusions;
import org.sandbox.jdt.internal.corext.fix.helper.lib.JUnit3SemanticSupport;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.MethodKind;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.MethodMigration;
import org.sandbox.jdt.internal.corext.fix.multifile.JUnit3HierarchyMigration.TypeMigration;

/** Classifies and plans ordinary closed JUnit 3 source hierarchies. */
final class JUnit3HierarchyPlanner {

	/** Mirrors JUnit 4's {@code org.junit.internal.MethodSorter.DEFAULT}. */
	private static final Comparator<MethodDeclaration> JUNIT3_METHOD_ORDER=
			Comparator.comparingInt((MethodDeclaration method) -> method.getName().getIdentifier().hashCode())
					.thenComparing(method -> method.getName().getIdentifier())
					.thenComparing(MethodDeclaration::toString);

	record Result(List<JUnit3HierarchyMigration> migrations,
			List<MultiFileCandidateDiagnostic> diagnostics, JUnitTestTypeInventory inventory) {
		Result {
			migrations= List.copyOf(migrations);
			diagnostics= List.copyOf(diagnostics);
		}
	}

	private record SourceType(String unitHandle, TypeDeclaration declaration,
			ITypeBinding binding, IType javaType) {
		String key() {
			return JUnitMigrationPlan.typeKey(binding);
		}

		String name() {
			return binding.getQualifiedName();
		}
	}

	private record Classification(boolean supported, String reasonCode, String message,
			List<TypeMigration> typeMigrations, List<String> baselineHandles) {
		static Classification rejected(String reasonCode, String message) {
			return new Classification(false, reasonCode, message, List.of(), List.of());
		}

		static Classification supported(List<TypeMigration> migrations, List<String> baselineHandles) {
			return new Classification(true, "SUPPORTED_HIERARCHY", //$NON-NLS-1$
					"The complete ordinary JUnit 3 hierarchy can be migrated atomically.", //$NON-NLS-1$
					migrations, baselineHandles);
		}
	}

	private enum OverrideRemoval {
		REMOVE,
		KEEP,
		UNRESOLVED
	}

	private JUnit3HierarchyPlanner() {
	}

	static Result create(IJavaProject project, ICompilationUnit[] selectedUnits,
			Map<String, CompilationUnit> rootsByHandle, boolean closedScope,
			IProgressMonitor monitor) throws CoreException {
		JUnitTestTypeInventory inventory= JUnitTestTypeInventory.capture(project, monitor);
		Map<String, SourceType> sourceTypes= collectSourceTypes(rootsByHandle);
		List<SourceType> roots= sourceTypes.values().stream()
				.filter(JUnit3HierarchyPlanner::directlyExtendsTestCase)
				.sorted(Comparator.comparing(SourceType::name))
				.toList();
		if (roots.isEmpty()) {
			return new Result(List.of(), List.of(), inventory);
		}

		Set<String> selectedHandles= Arrays.stream(selectedUnits)
				.map(ICompilationUnit::getPrimary)
				.map(IJavaElement::getHandleIdentifier)
				.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
		List<ICompilationUnit> selectedList= Arrays.asList(selectedUnits);
		List<JUnit3HierarchyMigration> migrations= new ArrayList<>();
		List<MultiFileCandidateDiagnostic> diagnostics= new ArrayList<>();
		for (SourceType root : roots) {
			List<SourceType> hierarchy= hierarchy(root, sourceTypes.values());
			// A direct leaf remains the responsibility of the established local,
			// fail-closed cleanup. Coordinated planning is only needed once JUnit 3
			// identity or execution order is inherited across source types.
			if (hierarchy.size() == 1) {
				continue;
			}
			List<String> relatedHandles= hierarchy.stream().map(SourceType::unitHandle).distinct().sorted().toList();
			String candidateId= "junit3-hierarchy:" + root.name(); //$NON-NLS-1$
			if (!closedScope || !containsAllSourceSubtypes(root, selectedHandles)) {
				String message= "The selected source scope does not contain every source or binary subtype of " //$NON-NLS-1$
						+ root.name() + "."; //$NON-NLS-1$
				diagnostics.add(MultiFileCandidateDiagnostic.rejected(candidateId, root.unitHandle(),
						"INCOMPLETE_JUNIT3_HIERARCHY", message, relatedHandles)); //$NON-NLS-1$
				continue;
			}
			RelatedCompilationUnitSearch.Result references= RelatedCompilationUnitSearch.findReferences(project,
					hierarchy.stream().map(SourceType::javaType).toList(),
					hierarchy.stream().map(SourceType::javaType).map(IType::getCompilationUnit).toList(),
					selectedList, monitor);
			if (!references.complete()) {
				String message= "The JUnit 3 hierarchy has unresolved, binary, or out-of-scope references: " //$NON-NLS-1$
						+ String.join("; ", references.rejectionReasons()); //$NON-NLS-1$
				diagnostics.add(MultiFileCandidateDiagnostic.rejected(candidateId, root.unitHandle(),
						"INCOMPLETE_JUNIT3_REFERENCES", message, relatedHandles)); //$NON-NLS-1$
				continue;
			}
			Set<String> hierarchyHandles= new HashSet<>(relatedHandles);
			List<String> externalReferenceHandles= references.compilationUnits().stream()
					.map(IJavaElement::getHandleIdentifier)
					.filter(handle -> !hierarchyHandles.contains(handle))
					.sorted()
					.toList();
			if (!externalReferenceHandles.isEmpty()) {
				List<String> allRelated= new ArrayList<>(relatedHandles);
				allRelated.addAll(externalReferenceHandles);
				String message= "The hierarchy participates in suite or harness code outside the hierarchy and requires an explicit framework migration."; //$NON-NLS-1$
				diagnostics.add(MultiFileCandidateDiagnostic.rejected(candidateId, root.unitHandle(),
						"CUSTOM_JUNIT3_HARNESS", message, allRelated)); //$NON-NLS-1$
				continue;
			}

			Classification classification= classify(root, hierarchy, inventory);
			if (!classification.supported()) {
				diagnostics.add(MultiFileCandidateDiagnostic.rejected(candidateId, root.unitHandle(),
						classification.reasonCode(), classification.message(), relatedHandles));
				continue;
			}
			migrations.add(new JUnit3HierarchyMigration(root.name(), classification.typeMigrations(),
					classification.baselineHandles()));
			diagnostics.add(MultiFileCandidateDiagnostic.transformed(candidateId, root.unitHandle(),
					classification.message(), relatedHandles));
		}
		return new Result(migrations, diagnostics, inventory);
	}

	private static Map<String, SourceType> collectSourceTypes(Map<String, CompilationUnit> rootsByHandle) {
		Map<String, SourceType> result= new LinkedHashMap<>();
		for (Map.Entry<String, CompilationUnit> entry : rootsByHandle.entrySet()) {
			entry.getValue().accept(new ASTVisitor() {
				@Override
				public boolean visit(TypeDeclaration node) {
					ITypeBinding binding= node.resolveBinding();
					String key= JUnitMigrationPlan.typeKey(binding);
					IJavaElement element= binding == null ? null : binding.getJavaElement();
					if (key != null && element instanceof IType type && type.getCompilationUnit() != null) {
						result.put(key, new SourceType(entry.getKey(), node, binding, type));
					}
					return true;
				}
			});
		}
		return result;
	}

	private static boolean directlyExtendsTestCase(SourceType type) {
		ITypeBinding superclass= type.binding().getSuperclass();
		return superclass != null && JUNIT3_TEST_CASE.equals(superclass.getErasure().getQualifiedName());
	}

	private static List<SourceType> hierarchy(SourceType root, Collection<SourceType> allTypes) {
		String rootKey= root.key();
		return allTypes.stream()
				.filter(candidate -> isSameOrSubtype(candidate.binding(), rootKey))
				.sorted(Comparator.comparing(SourceType::name))
				.toList();
	}

	private static boolean isSameOrSubtype(ITypeBinding binding, String rootKey) {
		ITypeBinding current= binding;
		while (current != null) {
			if (rootKey.equals(JUnitMigrationPlan.typeKey(current))) {
				return true;
			}
			current= current.getSuperclass();
		}
		return false;
	}

	private static int hierarchyDepth(SourceType type, String rootKey) {
		int depth= 0;
		ITypeBinding current= type.binding();
		while (current != null && !rootKey.equals(JUnitMigrationPlan.typeKey(current))) {
			depth++;
			current= current.getSuperclass();
		}
		return current == null ? -1 : depth;
	}

	private static boolean containsAllSourceSubtypes(SourceType root, Set<String> selectedHandles) {
		try {
			ITypeHierarchy hierarchy= root.javaType().newTypeHierarchy(null);
			for (IType subtype : hierarchy.getAllSubtypes(root.javaType())) {
				ICompilationUnit unit= subtype.getCompilationUnit();
				if (unit == null || !unit.exists()
						|| !selectedHandles.contains(unit.getPrimary().getHandleIdentifier())) {
					return false;
				}
			}
			return true;
		} catch (JavaModelException e) {
			return false;
		}
	}

	private static Classification classify(SourceType root, List<SourceType> hierarchy,
			JUnitTestTypeInventory inventory) {
		Map<String, String> testOwners= new HashMap<>();
		Map<String, Integer> lifecycleDeclarations= new HashMap<>();
		List<TypeMigration> typeMigrations= new ArrayList<>();
		List<String> baselineHandles= new ArrayList<>();
		String rootKey= root.key();
		int maxDepth= hierarchy.stream().mapToInt(type -> hierarchyDepth(type, rootKey)).max().orElse(0);
		int maxTestsPerType= hierarchy.stream()
				.mapToInt(type -> (int) Arrays.stream(type.declaration().getMethods())
						.filter(JUnit3HierarchyPlanner::isTestPrefixed).count())
				.max().orElse(0);
		int orderStride= Math.max(1, maxTestsPerType + 1);

		for (SourceType type : hierarchy) {
			String excludedSuperType= JUnit3MigrationExclusions.excludedSuperType(type.binding());
			if (excludedSuperType != null) {
				return Classification.rejected(JUnit3MigrationExclusions.EXCLUDED_BASE_TYPE_REASON,
						"The hierarchy type " + type.name() + " derives from " + excludedSuperType //$NON-NLS-1$ //$NON-NLS-2$
								+ ", whose JUnit 3 execution contract must not be migrated to Jupiter."); //$NON-NLS-1$
			}
			if (!type.declaration().isPackageMemberTypeDeclaration() || !hasSingleTopLevelType(type)
					|| hasJUnitAnnotation(type.declaration())) {
				return Classification.rejected("UNSUPPORTED_JUNIT3_TYPE_SHAPE", //$NON-NLS-1$
						"Each supported hierarchy type must be the only top-level type in its compilation unit and must not already use JUnit annotations."); //$NON-NLS-1$
			}
			if (!Modifier.isAbstract(type.binding().getModifiers()) && inventory.contains(type.javaType())) {
				baselineHandles.add(type.javaType().getHandleIdentifier());
			}
			int depth= hierarchyDepth(type, rootKey);
			if (depth < 0) {
				return Classification.rejected("INCOMPLETE_JUNIT3_HIERARCHY", //$NON-NLS-1$
						"A planned hierarchy type is no longer connected to its JUnit 3 root."); //$NON-NLS-1$
			}
			Map<String, Integer> executionOrders= plannedExecutionOrders(type, maxDepth, depth, orderStride);
			List<MethodMigration> methods= new ArrayList<>();
			for (MethodDeclaration method : type.declaration().getMethods()) {
				JUnit3HarnessSemantics.Rejection harnessRejection=
						JUnit3HarnessSemantics.rejection(method).orElse(null);
				if (harnessRejection != null) {
					return Classification.rejected(harnessRejection.reasonCode(),
							harnessRejection.explanation());
				}
				if (hasJUnitAnnotation(method)) {
					return Classification.rejected("MIXED_JUNIT_GENERATIONS", //$NON-NLS-1$
							"The hierarchy already mixes annotation-driven and JUnit 3 execution semantics."); //$NON-NLS-1$
				}
				String name= method.getName().getIdentifier();
				String bindingKey= methodKey(method);
				if (name.startsWith("test")) { //$NON-NLS-1$
					if (!JUnit3SemanticSupport.isExactTestMethod(method) || bindingKey == null) {
						return Classification.rejected("INVALID_JUNIT3_TEST_SIGNATURE", //$NON-NLS-1$
								"A test-prefixed method does not satisfy the exact public non-static void test*() contract."); //$NON-NLS-1$
					}
					String previousOwner= testOwners.putIfAbsent(name, type.name());
					if (previousOwner != null) {
						return Classification.rejected("OVERRIDDEN_JUNIT3_TEST", //$NON-NLS-1$
								"The test method " + name + " is overridden across the hierarchy."); //$NON-NLS-1$ //$NON-NLS-2$
					}
					Integer executionOrder= executionOrders.get(bindingKey);
					if (executionOrder == null) {
						return Classification.rejected("UNRESOLVED_JUNIT3_TEST_ORDER", //$NON-NLS-1$
								"The exact JUnit 3 discovery order could not be assigned to " + name + "()."); //$NON-NLS-1$ //$NON-NLS-2$
					}
					methods.add(new MethodMigration(bindingKey, MethodKind.TEST, executionOrder.intValue()));
				} else if ("setUp".equals(name) || "tearDown".equals(name)) { //$NON-NLS-1$ //$NON-NLS-2$
					if (!JUnit3SemanticSupport.isLifecycleMethod(method, name) || bindingKey == null) {
						return Classification.rejected("INVALID_JUNIT3_LIFECYCLE", //$NON-NLS-1$
								"A lifecycle method does not satisfy the supported non-static parameterless void contract."); //$NON-NLS-1$
					}
					int count= lifecycleDeclarations.merge(name, Integer.valueOf(1), Integer::sum).intValue();
					if (count > 1) {
						return Classification.rejected("JUNIT3_LIFECYCLE_OVERRIDE_CHAIN", //$NON-NLS-1$
								"Lifecycle override chains require explicit semantic migration."); //$NON-NLS-1$
					}
					OverrideRemoval overrideRemoval= plannedOverrideRemoval(method);
					if (overrideRemoval == OverrideRemoval.UNRESOLVED) {
						return Classification.rejected("UNRESOLVED_JUNIT3_OVERRIDE_ANNOTATION", //$NON-NLS-1$
								"The lifecycle method " + name //$NON-NLS-1$
										+ " has an unresolved simple @Override annotation; exact annotation identity is required."); //$NON-NLS-1$
					}
					methods.add(new MethodMigration(bindingKey,
							"setUp".equals(name) ? MethodKind.BEFORE_EACH : MethodKind.AFTER_EACH, //$NON-NLS-1$
							0, overrideRemoval == OverrideRemoval.REMOVE));
				}
			}
			JUnit3AssertionInventory.Result assertions= JUnit3AssertionInventory.analyze(type.declaration());
			if (!assertions.supported()) {
				return Classification.rejected("CUSTOM_JUNIT3_API_USAGE", assertions.rejectionReason()); //$NON-NLS-1$
			}
			typeMigrations.add(new TypeMigration(type.unitHandle(), type.key(), type == root,
					methods, assertions.invocations()));
		}
		if (baselineHandles.isEmpty()) {
			return Classification.rejected("JDT_JUNIT_FINDER_MISMATCH", //$NON-NLS-1$
					"The configured JDT JUnit finder does not expose a concrete test type from the hierarchy."); //$NON-NLS-1$
		}
		baselineHandles.sort(String::compareTo);
		return Classification.supported(typeMigrations, baselineHandles);
	}

	private static Map<String, Integer> plannedExecutionOrders(SourceType type, int maxDepth, int depth,
			int stride) {
		Map<String, Integer> result= new HashMap<>();
		List<MethodDeclaration> orderedTests= Arrays.stream(type.declaration().getMethods())
				.filter(JUnit3HierarchyPlanner::isTestPrefixed)
				.sorted(JUNIT3_METHOD_ORDER)
				.toList();
		for (int index= 0; index < orderedTests.size(); index++) {
			String key= methodKey(orderedTests.get(index));
			long order= (long) (maxDepth - depth) * stride + index + 1L;
			if (key != null && order <= Integer.MAX_VALUE) {
				result.put(key, Integer.valueOf((int) order));
			}
		}
		return result;
	}

	private static boolean isTestPrefixed(MethodDeclaration method) {
		return method.getName().getIdentifier().startsWith("test"); //$NON-NLS-1$
	}

	private static boolean hasSingleTopLevelType(SourceType type) {
		return type.declaration().getRoot() instanceof CompilationUnit root && root.types().size() == 1;
	}

	/**
	 * Captures exact {@code java.lang.Override} presence while the immutable
	 * hierarchy plan is built. A simple unresolved {@code @Override} is ambiguous
	 * with a user-defined annotation and therefore rejects the candidate.
	 */
	private static OverrideRemoval plannedOverrideRemoval(MethodDeclaration method) {
		for (Object modifier : method.modifiers()) {
			if (!(modifier instanceof Annotation annotation)) {
				continue;
			}
			ITypeBinding binding= annotation.resolveTypeBinding();
			String writtenName= annotation.getTypeName().getFullyQualifiedName();
			if (binding != null && !binding.isRecovered()) {
				if ("java.lang.Override".equals(binding.getQualifiedName())) { //$NON-NLS-1$
					return OverrideRemoval.REMOVE;
				}
				continue;
			}
			if ("java.lang.Override".equals(writtenName)) { //$NON-NLS-1$
				return OverrideRemoval.REMOVE;
			}
			if ("Override".equals(writtenName)) { //$NON-NLS-1$
				return OverrideRemoval.UNRESOLVED;
			}
		}
		return OverrideRemoval.KEEP;
	}

	private static boolean hasJUnitAnnotation(BodyDeclaration declaration) {
		for (Object modifier : declaration.modifiers()) {
			if (modifier instanceof Annotation annotation) {
				ITypeBinding binding= annotation.resolveTypeBinding();
				String name= binding == null ? annotation.getTypeName().getFullyQualifiedName()
						: binding.getQualifiedName();
				if (name.startsWith("org.junit.") || name.startsWith("junit.framework.")) { //$NON-NLS-1$ //$NON-NLS-2$
					return true;
				}
			}
		}
		return false;
	}

	private static String methodKey(MethodDeclaration method) {
		IMethodBinding binding= method.resolveBinding();
		return binding == null ? null : binding.getMethodDeclaration().getKey();
	}
}