TestTimeoutJUnitPlugin.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.*;

import java.util.List;
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.Expression;
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.NumberLiteral;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
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.HelperVisitor;
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;

/**
 * Plugin to migrate JUnit 4 @Test(timeout=...) to JUnit 5 @Timeout.
 */
public class TestTimeoutJUnitPlugin extends AbstractTool<ReferenceHolder<Integer, JunitHolder>> {

	@Override
	public void find(JUnitCleanUpFixCore fixcore, CompilationUnit compilationUnit,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesprocessed) {
		ReferenceHolder<Integer, JunitHolder> dataHolder = new ReferenceHolder<>();
		HelperVisitor.forAnnotation(ORG_JUNIT_TEST)
			.in(compilationUnit)
			.excluding(nodesprocessed)
			.processEach(dataHolder, (visited, aholder) -> {
				if (visited instanceof NormalAnnotation) {
					return processFoundNode(fixcore, operations, (NormalAnnotation) visited, aholder);
				}
				return true;
			});
	}

	private boolean processFoundNode(JUnitCleanUpFixCore fixcore,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations, NormalAnnotation node,
			ReferenceHolder<Integer, JunitHolder> dataHolder) {
		
		// Check if this @Test annotation has a timeout parameter
		Long timeoutValue = null;
		MemberValuePair timeoutPair = null;
		
		@SuppressWarnings("unchecked")
		List<MemberValuePair> values = node.values();
		for (MemberValuePair pair : values) {
			if ("timeout".equals(pair.getName().getIdentifier())) {
				timeoutPair = pair;
				Expression value = pair.getValue();
				if (value instanceof NumberLiteral) {
					try {
						timeoutValue = Long.parseLong(((NumberLiteral) value).getToken());
					} catch (NumberFormatException e) {
						// Skip invalid timeout values
						return false;
					}
				} else {
					// Timeout value is not a simple number literal (could be a constant or expression)
					// Skip this case as it requires more complex analysis
					return false;
				}
				break;
			}
		}
		
		// Only process if we found a timeout parameter with a valid numeric value
		if (timeoutValue != null && timeoutPair != null) {
			JunitHolder mh = new JunitHolder();
			mh.minv = node;
			mh.minvname = node.getTypeName().getFullyQualifiedName();
			mh.value = String.valueOf(timeoutValue);
			mh.additionalInfo = timeoutPair; // Store the timeout pair for removal
			dataHolder.put(dataHolder.size(), mh);
			operations.add(fixcore.rewrite(dataHolder));
		}
		
		// Return true to continue processing other annotations
		// The fluent API interprets false as "stop processing all nodes"
		return true;
	}

	@Override
	protected void process2Rewrite(TextEditGroup group, ASTRewrite rewriter, AST ast, ImportRewrite importRewriter,
			JunitHolder junitHolder) {
		NormalAnnotation testAnnotation = (NormalAnnotation) junitHolder.getAnnotation();
		MemberValuePair timeoutPair = (MemberValuePair) junitHolder.additionalInfo;
		
		String timeoutString = junitHolder.value;
		if (timeoutString == null) {
			// Cannot determine timeout value, skip refactoring for this method
			return;
		}
		
		final long timeoutMillis;
		try {
			timeoutMillis = Long.parseLong(timeoutString);
		} catch (NumberFormatException e) {
			// Malformed timeout value, skip refactoring for this method
			return;
		}
		
		// Determine the best time unit (optimize for readability)
		// Use SECONDS if the value is >= 1000ms and evenly divisible by 1000
		// This makes the timeout more readable (e.g., "5 seconds" vs "5000 milliseconds")
		long timeoutValue;
		String timeUnit;
		if (timeoutMillis % 1000 == 0 && timeoutMillis >= 1000) {
			// Use SECONDS for better readability (e.g., 1 second instead of 1000 milliseconds)
			timeoutValue = timeoutMillis / 1000;
			timeUnit = "SECONDS";
		} else {
			// Use MILLISECONDS for values < 1000ms or not evenly divisible by 1000
			timeoutValue = timeoutMillis;
			timeUnit = "MILLISECONDS";
		}
		
		// Add @Timeout annotation
		NormalAnnotation timeoutAnnotation = ast.newNormalAnnotation();
		timeoutAnnotation.setTypeName(ast.newSimpleName(ANNOTATION_TIMEOUT));
		
		// Add value parameter
		MemberValuePair valuePair = ast.newMemberValuePair();
		valuePair.setName(ast.newSimpleName("value"));
		valuePair.setValue(ast.newNumberLiteral(String.valueOf(timeoutValue)));
		timeoutAnnotation.values().add(valuePair);
		
		// Add unit parameter
		MemberValuePair unitPair = ast.newMemberValuePair();
		unitPair.setName(ast.newSimpleName("unit"));
		QualifiedName timeUnitName = ast.newQualifiedName(
			ast.newSimpleName("TimeUnit"),
			ast.newSimpleName(timeUnit)
		);
		unitPair.setValue(timeUnitName);
		timeoutAnnotation.values().add(unitPair);
		
		// Add the @Timeout annotation to the method (after @Test)
		MethodDeclaration method = ASTNodes.getParent(testAnnotation, MethodDeclaration.class);
		if (method != null) {
			ListRewrite listRewrite = rewriter.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
			listRewrite.insertAfter(timeoutAnnotation, testAnnotation, group);
		}
		
		// Remove the timeout parameter from @Test annotation
		// If the timeout is the only parameter, replace the NormalAnnotation with a MarkerAnnotation
		// to avoid leaving an empty @Test() annotation.
		@SuppressWarnings("unchecked")
		List<MemberValuePair> testValues = testAnnotation.values();
		if (testValues.size() == 1 && testValues.get(0) == timeoutPair) {
			MarkerAnnotation markerTestAnnotation = ast.newMarkerAnnotation();
			markerTestAnnotation.setTypeName(ast.newSimpleName(ANNOTATION_TEST));
			ASTNodes.replaceButKeepComment(rewriter, testAnnotation, markerTestAnnotation, group);
		} else {
			rewriter.remove(timeoutPair, group);
		}
		
		// Add imports - order matters: remove old import first, then add new imports
		importRewriter.removeImport(ORG_JUNIT_TEST);
		importRewriter.addImport(ORG_JUNIT_JUPITER_TEST);
		importRewriter.addImport(ORG_JUNIT_JUPITER_API_TIMEOUT);
		importRewriter.addImport("java.util.concurrent.TimeUnit");
	}

	@Override
	public String getPreview(boolean afterRefactoring) {
		if (afterRefactoring) {
			return """
					import org.junit.jupiter.api.Test;
					import org.junit.jupiter.api.Timeout;
					import java.util.concurrent.TimeUnit;
					
					@Test
					@Timeout(value = 1, unit = TimeUnit.SECONDS)
					public void testWithTimeout() {
						// Test code
					}
					"""; //$NON-NLS-1$
		}
		return """
				import org.junit.Test;
				
				@Test(timeout = 1000)
				public void testWithTimeout() {
					// Test code
				}
				"""; //$NON-NLS-1$
	}

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