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

import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RUNNERS_PARAMETERIZED;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RUNNERS_PARAMETERIZED_PARAMETERS;
import static org.sandbox.jdt.internal.corext.fix.helper.lib.JUnitConstants.ORG_JUNIT_RUNWITH;

import java.util.Objects;

import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.ArrayCreation;
import org.eclipse.jdt.core.dom.ArrayInitializer;
import org.eclipse.jdt.core.dom.ArrayType;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.FieldDeclaration;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MemberValuePair;
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.NormalAnnotation;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.Statement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.TypeLiteral;

/**
 * One shared fail-closed contract for the local JUnit 4 Parameterized rewrite
 * and its project-planning diagnostics.
 */
public final class ParameterizedMigrationEligibility {

	private static final String PARAMETER_ANNOTATION=
			"org.junit.runners.Parameterized.Parameter"; //$NON-NLS-1$
	private static final String JAVA_LANG_OBJECT= "java.lang.Object"; //$NON-NLS-1$
	private static final String JAVA_UTIL_ARRAYS= "java.util.Arrays"; //$NON-NLS-1$

	/** Immutable eligibility decision with a stable diagnostic reason code. */
	public record Assessment(boolean eligible, String reasonCode,
			String explanation) {

		/** Validate and normalize an assessment. */
		public Assessment {
			reasonCode= Objects.requireNonNull(reasonCode);
			explanation= Objects.requireNonNull(explanation);
		}
	}

	private ParameterizedMigrationEligibility() {
	}

	/**
	 * Return whether a type declares the JUnit 4 Parameterized runner.
	 *
	 * @param type
	 *            source type
	 * @return {@code true} when bindings prove
	 *         {@code @RunWith(Parameterized.class)}
	 */
	public static boolean hasParameterizedRunner(TypeDeclaration type) {
		return parameterizedRunnerAnnotation(type) != null;
	}

	/**
	 * Assess the exact local source contract understood by the existing rewrite.
	 *
	 * @param type
	 *            Parameterized test type
	 * @return eligibility or one stable fail-closed reason
	 */
	public static Assessment assess(TypeDeclaration type) {
		Objects.requireNonNull(type);
		int providers= 0;
		int constructors= 0;
		MethodDeclaration provider= null;
		MethodDeclaration constructor= null;
		for (MethodDeclaration method : type.getMethods()) {
			if (method.isConstructor()) {
				constructors++;
				constructor= method;
				continue;
			}
			if (hasParametersAnnotation(method)) {
				providers++;
				provider= method;
			}
		}
		if (providers == 0) {
			return rejected("PARAMETERIZED_PROVIDER_NOT_LOCAL", //$NON-NLS-1$
					"No local @Parameters provider is declared; inherited or external providers require a coordinated migration."); //$NON-NLS-1$
		}
		if (providers > 1) {
			return rejected("PARAMETERIZED_MULTIPLE_LOCAL_PROVIDERS", //$NON-NLS-1$
					"More than one local @Parameters provider is declared, so a unique MethodSource cannot be selected safely."); //$NON-NLS-1$
		}
		if (usesFieldInjection(type)) {
			return rejected("PARAMETERIZED_FIELD_INJECTION", //$NON-NLS-1$
					"@Parameterized.Parameter field injection is not represented by the constructor-based local rewrite."); //$NON-NLS-1$
		}
		if (!(parameterizedRunnerAnnotation(type) instanceof SingleMemberAnnotation)) {
			return rejected("PARAMETERIZED_RUNNER_SYNTAX_UNSUPPORTED", //$NON-NLS-1$
					"The local rewrite currently supports only @RunWith(Parameterized.class), not the explicit value = syntax."); //$NON-NLS-1$
		}
		if (provider == null || !hasSupportedProviderShape(provider)) {
			return rejected("PARAMETERIZED_PROVIDER_BODY_UNSUPPORTED", //$NON-NLS-1$
					"The local @Parameters provider is not the supported static Arrays.asList(new Object[][] { ... }) form and cannot be rewritten safely."); //$NON-NLS-1$
		}
		if (constructors != 1) {
			return rejected("PARAMETERIZED_CONSTRUCTOR_NOT_UNIQUE", //$NON-NLS-1$
					"Exactly one explicit constructor is required before constructor parameters can become test-method parameters."); //$NON-NLS-1$
		}
		if (constructor == null || constructor.parameters().isEmpty()) {
			return rejected("PARAMETERIZED_CONSTRUCTOR_HAS_NO_PARAMETERS", //$NON-NLS-1$
					"The unique constructor has no parameters to propagate to ParameterizedTest methods."); //$NON-NLS-1$
		}
		return new Assessment(true, "PARAMETERIZED_LOCAL_CONTRACT", //$NON-NLS-1$
				"One local provider with a supported body and one parameterized constructor form the supported local rewrite contract."); //$NON-NLS-1$
	}

	private static Annotation parameterizedRunnerAnnotation(TypeDeclaration type) {
		Objects.requireNonNull(type);
		for (Object modifier : type.modifiers()) {
			if (!(modifier instanceof Annotation annotation)) {
				continue;
			}
			ITypeBinding annotationBinding= annotation.resolveTypeBinding();
			Expression value= runnerValue(annotation);
			if (annotationBinding == null
					|| !ORG_JUNIT_RUNWITH.equals(annotationBinding.getQualifiedName())
					|| !(value instanceof TypeLiteral literal)) {
				continue;
			}
			ITypeBinding runnerBinding= literal.getType().resolveBinding();
			if (runnerBinding != null && ORG_JUNIT_RUNNERS_PARAMETERIZED
					.equals(runnerBinding.getErasure().getQualifiedName())) {
				return annotation;
			}
		}
		return null;
	}

	private static boolean hasSupportedProviderShape(MethodDeclaration provider) {
		if (!Modifier.isStatic(provider.getModifiers())
				|| !provider.parameters().isEmpty()
				|| !provider.typeParameters().isEmpty()
				|| !provider.thrownExceptionTypes().isEmpty()
				|| provider.getBody() == null
				|| provider.getBody().statements().size() != 1) {
			return false;
		}
		Statement statement= (Statement) provider.getBody().statements().get(0);
		if (!(statement instanceof ReturnStatement returnStatement)
				|| !(returnStatement.getExpression() instanceof MethodInvocation invocation)
				|| !"asList".equals(invocation.getName().getIdentifier()) //$NON-NLS-1$
				|| invocation.arguments().size() != 1) {
			return false;
		}
		IMethodBinding methodBinding= invocation.resolveMethodBinding();
		ITypeBinding declaringType= methodBinding == null
				? null : methodBinding.getDeclaringClass();
		if (declaringType == null || !JAVA_UTIL_ARRAYS.equals(
				declaringType.getErasure().getQualifiedName())) {
			return false;
		}
		Expression argument= (Expression) invocation.arguments().get(0);
		if (!(argument instanceof ArrayCreation arrayCreation)
				|| arrayCreation.getInitializer() == null
				|| arrayCreation.getInitializer().expressions().isEmpty()) {
			return false;
		}
		ArrayType arrayType= arrayCreation.getType();
		ITypeBinding elementType= arrayType.getElementType().resolveBinding();
		if (arrayType.getDimensions() != 2 || elementType == null
				|| !JAVA_LANG_OBJECT.equals(
						elementType.getErasure().getQualifiedName())) {
			return false;
		}
		for (Object row : arrayCreation.getInitializer().expressions()) {
			if (row instanceof ArrayInitializer) {
				continue;
			}
			if (row instanceof ArrayCreation nested
					&& nested.getInitializer() != null) {
				continue;
			}
			return false;
		}
		return true;
	}

	private static Expression runnerValue(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 hasParametersAnnotation(MethodDeclaration method) {
		for (Object modifier : method.modifiers()) {
			if (modifier instanceof Annotation annotation
					&& isAnnotation(annotation,
							ORG_JUNIT_RUNNERS_PARAMETERIZED_PARAMETERS,
							"Parameters")) { //$NON-NLS-1$
				return true;
			}
		}
		return false;
	}

	private static boolean usesFieldInjection(TypeDeclaration type) {
		for (FieldDeclaration field : type.getFields()) {
			for (Object modifier : field.modifiers()) {
				if (modifier instanceof Annotation annotation
						&& isAnnotation(annotation, PARAMETER_ANNOTATION,
								"Parameter")) { //$NON-NLS-1$
					return true;
				}
			}
		}
		return false;
	}

	private static boolean isAnnotation(Annotation annotation,
			String qualifiedName, String simpleName) {
		ITypeBinding binding= annotation.resolveTypeBinding();
		if (binding != null) {
			return qualifiedName.equals(binding.getQualifiedName());
		}
		String sourceName= annotation.getTypeName().getFullyQualifiedName();
		return simpleName.equals(sourceName) || qualifiedName.equals(sourceName);
	}

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