MultiFileCleanUpLifecycleAssertions.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.ui.tests.quickfix.rules;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.eclipse.core.resources.IWorkspace;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.NullProgressMonitor;

import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.CheckConditionsOperation;
import org.eclipse.ltk.core.refactoring.CreateChangeOperation;
import org.eclipse.ltk.core.refactoring.IUndoManager;
import org.eclipse.ltk.core.refactoring.PerformChangeOperation;
import org.eclipse.ltk.core.refactoring.RefactoringCore;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.internal.corext.dom.IASTSharedValues;
import org.eclipse.jdt.internal.corext.fix.CleanUpRefactoring;
import org.eclipse.jdt.internal.ui.JavaPlugin;
import org.eclipse.jdt.ui.cleanup.ICleanUp;

/**
 * Assertions for the complete lifecycle of one coordinated multi-file cleanup.
 *
 * <p>The helper executes one {@link CleanUpRefactoring}, verifies every affected
 * compilation unit and its Java error set after apply, performs the returned undo
 * change, and finally proves that source and semantic state returned to the
 * original per-file baseline.</p>
 */
public final class MultiFileCleanUpLifecycleAssertions {

	private record ProblemSignature(int id, String message) {
	}

	private MultiFileCleanUpLifecycleAssertions() {
		// assertions only
	}

	/**
	 * Applies the configured cleanup profile atomically and verifies compilation and
	 * undo for the complete source set.
	 *
	 * @param compilationUnits all units participating in the coordinated cleanup
	 * @param expectedAfterApply expected source contents after the common change, in
	 *            the same order as {@code compilationUnits}
	 * @return the final-condition status of the cleanup refactoring
	 * @throws CoreException if Eclipse cannot create or execute either change
	 */
	public static RefactoringStatus assertApplyCompileAndUndo(ICompilationUnit[] compilationUnits,
			String[] expectedAfterApply) throws CoreException {
		return assertApplyCompileAndUndo(compilationUnits, compilationUnits, expectedAfterApply);
	}

	/**
	 * Applies a cleanup to an initial selection and verifies the complete set of
	 * units expected to be affected after coordinated scope expansion.
	 *
	 * @param selectedUnits compilation units explicitly added to the refactoring
	 * @param verifiedUnits units whose source and Java errors must be verified
	 * @param expectedAfterApply expected source contents for {@code verifiedUnits}
	 * @return the final-condition status of the cleanup refactoring
	 * @throws CoreException if Eclipse cannot create or execute either change
	 */
	public static RefactoringStatus assertApplyCompileAndUndo(ICompilationUnit[] selectedUnits,
			ICompilationUnit[] verifiedUnits, String[] expectedAfterApply) throws CoreException {
		assertEquals(verifiedUnits.length, expectedAfterApply.length,
				"Each verified compilation unit needs one expected post-apply source"); //$NON-NLS-1$
		Set<String> verifiedHandles= Arrays.stream(verifiedUnits)
				.map(ICompilationUnit::getPrimary)
				.map(ICompilationUnit::getHandleIdentifier)
				.collect(Collectors.toSet());
		assertTrue(Arrays.stream(selectedUnits)
				.map(ICompilationUnit::getPrimary)
				.map(ICompilationUnit::getHandleIdentifier)
				.allMatch(verifiedHandles::contains),
				"Each selected compilation unit must be included in verifiedUnits"); //$NON-NLS-1$
		String[] originals= contents(verifiedUnits);
		Map<String, Map<ProblemSignature, Long>> baselineProblems= errorCounts(verifiedUnits);

		CleanUpRefactoring refactoring= new CleanUpRefactoring();
		refactoring.setUseOptionsFromProfile(true);
		for (ICompilationUnit unit : selectedUnits) {
			refactoring.addCompilationUnit(unit);
		}
		for (ICleanUp cleanup : JavaPlugin.getDefault().getCleanUpRegistry().createCleanUps()) {
			refactoring.addCleanUp(cleanup);
		}

		IUndoManager undoManager= RefactoringCore.getUndoManager();
		undoManager.flush();
		CreateChangeOperation create= new CreateChangeOperation(
				new CheckConditionsOperation(refactoring, CheckConditionsOperation.ALL_CONDITIONS),
				RefactoringStatus.FATAL);
		PerformChangeOperation apply= new PerformChangeOperation(create);
		apply.setUndoManager(undoManager, refactoring.getName());
		IWorkspace workspace= ResourcesPlugin.getWorkspace();
		workspace.run(apply, new NullProgressMonitor());

		RefactoringStatus status= create.getConditionCheckingStatus();
		assertFalse(status.hasFatalError(), () -> "Cleanup final conditions failed: " + status); //$NON-NLS-1$
		assertTrue(apply.changeExecuted(), "The coordinated cleanup change was not executed"); //$NON-NLS-1$
		Change undo= apply.getUndoChange();
		assertNotNull(undo, "The coordinated cleanup did not create an undo change"); //$NON-NLS-1$

		assertPostApplyContents(verifiedUnits, expectedAfterApply);
		assertNoNewErrors(verifiedUnits, baselineProblems, "after apply"); //$NON-NLS-1$

		PerformChangeOperation performUndo= new PerformChangeOperation(undo);
		workspace.run(performUndo, new NullProgressMonitor());
		assertTrue(performUndo.changeExecuted(), "The coordinated cleanup undo change was not executed"); //$NON-NLS-1$
		assertRestoredContents(verifiedUnits, originals);
		assertEquals(baselineProblems, errorCounts(verifiedUnits),
				"Undo must restore the original Java error set"); //$NON-NLS-1$
		return status;
	}

	private static void assertPostApplyContents(ICompilationUnit[] units, String[] expected) throws CoreException {
		String[] actual= contents(units);
		for (int i= 0; i < units.length; i++) {
			String unitName= units[i].getElementName();
			assertEquals(normalizeWhitespace(expected[i]), normalizeWhitespace(actual[i]),
					() -> unitName + " has unexpected content after the coordinated cleanup"); //$NON-NLS-1$
		}
	}

	private static void assertRestoredContents(ICompilationUnit[] units, String[] originals) throws CoreException {
		String[] actual= contents(units);
		for (int i= 0; i < units.length; i++) {
			String unitName= units[i].getElementName();
			assertEquals(normalizeLineEndings(originals[i]), normalizeLineEndings(actual[i]),
					() -> unitName + " was not restored exactly by undo"); //$NON-NLS-1$
		}
	}

	private static String[] contents(ICompilationUnit[] units) throws CoreException {
		String[] result= new String[units.length];
		for (int i= 0; i < units.length; i++) {
			result[i]= units[i].getBuffer().getContents();
		}
		return result;
	}

	private static void assertNoNewErrors(ICompilationUnit[] units,
			Map<String, Map<ProblemSignature, Long>> baseline, String phase) {
		Map<String, Map<ProblemSignature, Long>> current= errorCounts(units);
		StringBuilder newErrors= new StringBuilder();
		for (Map.Entry<String, Map<ProblemSignature, Long>> unitEntry : current.entrySet()) {
			Map<ProblemSignature, Long> previous= baseline.getOrDefault(unitEntry.getKey(), Map.of());
			for (Map.Entry<ProblemSignature, Long> problemEntry : unitEntry.getValue().entrySet()) {
				long added= problemEntry.getValue() - previous.getOrDefault(problemEntry.getKey(), 0L);
				if (added > 0) {
					newErrors.append(unitEntry.getKey()).append(": ") //$NON-NLS-1$
							.append(problemEntry.getKey().message()).append(" [id=") //$NON-NLS-1$
							.append(problemEntry.getKey().id()).append("] x").append(added).append('\n'); //$NON-NLS-1$
				}
			}
		}
		if (!newErrors.isEmpty()) {
			fail("New Java errors " + phase + ":\n" + newErrors); //$NON-NLS-1$ //$NON-NLS-2$
		}
	}

	private static Map<String, Map<ProblemSignature, Long>> errorCounts(ICompilationUnit[] units) {
		Map<String, Map<ProblemSignature, Long>> result= new LinkedHashMap<>();
		for (ICompilationUnit unit : units) {
			ASTParser parser= ASTParser.newParser(IASTSharedValues.SHARED_AST_LEVEL);
			parser.setSource(unit);
			parser.setProject(unit.getJavaProject());
			parser.setResolveBindings(true);
			parser.setBindingsRecovery(IASTSharedValues.SHARED_BINDING_RECOVERY);
			parser.setStatementsRecovery(IASTSharedValues.SHARED_AST_STATEMENT_RECOVERY);
			CompilationUnit root= (CompilationUnit) parser.createAST(null);
			Map<ProblemSignature, Long> problems= Arrays.stream(root.getProblems())
					.filter(problem -> !problem.isWarning() && !problem.isInfo())
					.map(problem -> new ProblemSignature(problem.getID(), problem.getMessage()))
					.collect(Collectors.groupingBy(Function.identity(), LinkedHashMap::new, Collectors.counting()));
			result.put(unit.getPrimary().getHandleIdentifier(), problems);
		}
		return result;
	}

	private static String normalizeWhitespace(String source) {
		return Arrays.stream(normalizeLineEndings(source).split("\n", -1)) //$NON-NLS-1$
				.map(MultiFileCleanUpLifecycleAssertions::normalizeIndentation)
				.map(String::stripTrailing)
				.collect(Collectors.joining("\n")) //$NON-NLS-1$
				.replaceAll("\n{3,}", "\n\n") //$NON-NLS-1$ //$NON-NLS-2$
				.strip();
	}

	private static String normalizeIndentation(String line) {
		int endOfIndent= 0;
		while (endOfIndent < line.length()
				&& (line.charAt(endOfIndent) == ' ' || line.charAt(endOfIndent) == '\t')) {
			endOfIndent++;
		}
		if (endOfIndent == 0) {
			return line;
		}
		String leading= line.substring(0, endOfIndent).replace("\t", "    "); //$NON-NLS-1$ //$NON-NLS-2$
		return leading + line.substring(endOfIndent);
	}

	private static String normalizeLineEndings(String source) {
		return source.replace("\r\n", "\n").replace("\r", "\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
	}
}