AbstractEclipseJava.java

/*******************************************************************************
 * Copyright (c) 2024 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
 *
 * Contributors:
 *
 *******************************************************************************/
package org.sandbox.jdt.ui.tests.quickfix.rules;

import static org.eclipse.jdt.internal.corext.fix.CleanUpConstants.DEFAULT_CLEAN_UP_OPTIONS;

/**
 * JUnit 5 extension that provides the test infrastructure for Eclipse JDT cleanup and refactoring tests.
 * <p>
 * This class acts as a test fixture that:
 * <ul>
 * <li>Creates and configures a temporary Eclipse Java project for each test</li>
 * <li>Sets up the Java compiler compliance level (e.g., Java 8, 17, 21)</li>
 * <li>Manages cleanup profiles and options for testing JDT cleanup implementations</li>
 * <li>Provides helper methods for executing refactorings and asserting results</li>
 * <li>Cleans up resources after test execution</li>
 * </ul>
 * </p>
 * <p>
 * Concrete subclasses like {@code EclipseJava17} specify the runtime stubs and compiler version.
 * Test classes use this as a {@code @RegisterExtension} to obtain a configured test environment.
 * </p>
 * 
 * @see org.junit.jupiter.api.extension.BeforeEachCallback
 * @see org.junit.jupiter.api.extension.AfterEachCallback
 */
import static org.junit.jupiter.api.Assertions.assertEquals;
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.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.junit.jupiter.api.extension.AfterEachCallback;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleReference;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.FileLocator;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;
import org.eclipse.core.runtime.preferences.InstanceScope;

import org.eclipse.core.resources.IContainer;
import org.eclipse.core.resources.IFolder;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IProjectDescription;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IWorkspace;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.IWorkspaceRunnable;
import org.eclipse.core.resources.ResourcesPlugin;

import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.CheckConditionsOperation;
import org.eclipse.ltk.core.refactoring.CompositeChange;
import org.eclipse.ltk.core.refactoring.CreateChangeOperation;
import org.eclipse.ltk.core.refactoring.GroupCategory;
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.ltk.core.refactoring.TextEditBasedChange;
import org.eclipse.ltk.core.refactoring.TextEditBasedChangeGroup;

import org.eclipse.jdt.core.IClasspathAttribute;
import org.eclipse.jdt.core.IClasspathEntry;
import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;
import org.eclipse.jdt.core.compiler.IProblem;
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.CleanUpConstants;
import org.eclipse.jdt.internal.corext.fix.CleanUpPreferenceUtil;
import org.eclipse.jdt.internal.corext.fix.CleanUpRefactoring;

import org.eclipse.jdt.ui.JavaUI;
import org.eclipse.jdt.ui.cleanup.CleanUpOptions;
import org.eclipse.jdt.ui.cleanup.ICleanUp;

import org.eclipse.jdt.internal.ui.JavaPlugin;
import org.eclipse.jdt.internal.ui.dialogs.StatusInfo;
import org.eclipse.jdt.internal.ui.preferences.cleanup.CleanUpProfileVersioner;
import org.eclipse.jdt.internal.ui.preferences.formatter.ProfileManager;
import org.eclipse.jdt.internal.ui.preferences.formatter.ProfileManager.CustomProfile;
import org.eclipse.jdt.internal.ui.preferences.formatter.ProfileManager.Profile;
import org.eclipse.jdt.internal.ui.preferences.formatter.ProfileStore;
import org.eclipse.jdt.internal.ui.util.CoreUtility;

public class AbstractEclipseJava implements AfterEachCallback, BeforeEachCallback {

	/** Name of the temporary test project created for each test */
	private static final String TEST_SETUP_PROJECT = "TestSetupProject"; //$NON-NLS-1$
	
	/** Default source folder name */
	private static final String DEFAULT_SRC_FOLDER = "src"; //$NON-NLS-1$
	
	/** Default binary output folder name */
	private static final String DEFAULT_BIN_FOLDER = "bin"; //$NON-NLS-1$
	
	/** Maximum number of retry attempts when deleting resources */
	private static final int MAX_RETRY = 5;
	
	/** Delay in milliseconds between retry attempts */
	private static final int RETRY_DELAY = 1000;

	/** Path to the runtime stubs JAR (e.g., rtstubs_17.jar) for the configured Java version */
	private final String testResourcesStubs;
	
	/** Java compiler compliance level (e.g., JavaCore.VERSION_17) */
	private final String complianceLevel;
	
	/** The source folder root for test compilation units */
	private IPackageFragmentRoot sourceFolder;
	
	/** The cleanup profile used for configuring cleanup options in tests */
	private CustomProfile cleanUpProfile;
	
	/** The temporary Java project created for the test */
	private IJavaProject javaProject;

	/**
	 * Constructs a new test environment with the specified runtime stubs and compiler version.
	 * 
	 * @param stubs path to the runtime stubs JAR file (e.g., "testresources/rtstubs_17.jar")
	 * @param compilerVersion the Java compiler compliance level (e.g., JavaCore.VERSION_17)
	 */
	public AbstractEclipseJava(final String stubs, final String compilerVersion) {
		this.testResourcesStubs = stubs;
		this.complianceLevel = compilerVersion;
	}

	/**
	 * Sets up the test environment before each test execution.
	 * <p>
	 * This method:
	 * <ul>
	 * <li>Creates a new temporary Java project</li>
	 * <li>Configures the project's classpath with runtime stubs</li>
	 * <li>Sets the Java compiler compliance level</li>
	 * <li>Creates a source folder for test compilation units</li>
	 * <li>Initializes a cleanup profile with all options disabled by default</li>
	 * </ul>
	 * </p>
	 * 
	 * @param context the extension context (provided by JUnit)
	 * @throws CoreException if project setup fails
	 */
	@Override
	public void beforeEach(final ExtensionContext context) throws CoreException {
		setJavaProject(createJavaProject(TEST_SETUP_PROJECT, DEFAULT_BIN_FOLDER));
		getJavaProject().setRawClasspath(getDefaultClasspath(), null);
		final Map<String, String> options = getJavaProject().getOptions(false);
		JavaCore.setComplianceOptions(complianceLevel, options);
		getJavaProject().setOptions(options);
		setSourceFolder(addSourceContainer(getProject(TEST_SETUP_PROJECT), DEFAULT_SRC_FOLDER, new Path[0],
				new Path[0], null, new IClasspathAttribute[0]));
		final Map<String, String> settings = new HashMap<>();
		cleanUpProfile = new ProfileManager.CustomProfile("testProfile", settings, CleanUpProfileVersioner.CURRENT_VERSION, //$NON-NLS-1$
				CleanUpProfileVersioner.PROFILE_KIND);
		InstanceScope.INSTANCE.getNode(JavaUI.ID_PLUGIN).put(CleanUpConstants.CLEANUP_PROFILE, cleanUpProfile.getID());
		InstanceScope.INSTANCE.getNode(JavaUI.ID_PLUGIN).put(CleanUpConstants.SAVE_PARTICIPANT_PROFILE,
				cleanUpProfile.getID());
		disableAll();
	}
	/**
	 * Creates a classpath for JUnit testing by adding the specified JUnit container to the project.
	 * 
	 * @param junitContainerPath the path to the JUnit container (e.g., for JUnit 5)
	 * @return the source folder root for test compilation units
	 * @throws JavaModelException if classpath modification fails
	 * @throws CoreException if project configuration fails
	 */
	public IPackageFragmentRoot createClasspathForJUnit(final IPath junitContainerPath) throws JavaModelException, CoreException {
		final IJavaProject project = getJavaProject();
		project.setRawClasspath(getDefaultClasspath(), null);
		final IClasspathEntry cpe = JavaCore.newContainerEntry(junitContainerPath);
		AbstractEclipseJava.addToClasspath(project, cpe);
		sourceFolder = AbstractEclipseJava.addSourceContainer(project, DEFAULT_SRC_FOLDER);
		return sourceFolder;
	}

	/**
	 * Cleans up resources after each test execution.
	 * <p>
	 * This method deletes the source folder and related resources created during test setup.
	 * </p>
	 * 
	 * @param context the extension context (provided by JUnit)
	 * @throws CoreException if cleanup fails
	 */
	@Override
	public void afterEach(final ExtensionContext context) throws CoreException {
		delete(getSourceFolder());
	}

	/**
	 * Gets the Java project with the specified name from the workspace.
	 * 
	 * @param projectName the name of the project
	 * @return the Java project handle
	 */
	public IJavaProject getProject(final String projectName) {
		return JavaCore.create(ResourcesPlugin.getWorkspace().getRoot().getProject(projectName));
	}

	/**
	 * Returns the default classpath for the test project.
	 * <p>
	 * The classpath contains the runtime stubs JAR configured for the test Java version.
	 * </p>
	 * 
	 * @return array containing the rt.jar classpath entry
	 * @throws CoreException if the runtime stubs cannot be located
	 */
	public IClasspathEntry[] getDefaultClasspath() throws CoreException {
		final IPath[] rtJarPath = findRtJar(new Path(testResourcesStubs));
		return new IClasspathEntry[] { JavaCore.newLibraryEntry(rtJarPath[0], rtJarPath[1], rtJarPath[2], true) };
	}

	/**
	 * Disables all cleanup options in the current profile.
	 * <p>
	 * This provides a clean slate for tests to selectively enable specific cleanup options.
	 * </p>
	 * 
	 * @throws CoreException if the profile cannot be updated
	 */
	protected void disableAll() throws CoreException {
		final Map<String, String> settings = cleanUpProfile.getSettings();
		JavaPlugin.getDefault().getCleanUpRegistry().getDefaultOptions(DEFAULT_CLEAN_UP_OPTIONS).getKeys()
		.forEach(a -> settings.put(a, CleanUpOptions.FALSE));
		commitProfile();
	}

	/**
	 * Removes an IJavaElement's resource with retry logic.
	 * <p>
	 * Retries deletion if it fails initially (e.g., because the indexer still locks the file).
	 * For Java projects, the classpath is cleared before deletion.
	 * </p>
	 *
	 * @param elem the element to delete
	 * @throws CoreException if all deletion attempts fail
	 */
	public void delete(final IJavaElement elem) throws CoreException {
		final IWorkspaceRunnable runnable = monitor -> {
			if (elem instanceof IJavaProject jproject) {
				jproject.setRawClasspath(new IClasspathEntry[0], jproject.getProject().getFullPath(), null);
			}
			delete(elem.getResource());
		};
		ResourcesPlugin.getWorkspace().run(runnable, null);
	}

	/**
	 * Removes a resource with retry logic.
	 * <p>
	 * Retries deletion if it fails initially (e.g., because the indexer still locks the file).
	 * Waits {@link #RETRY_DELAY} milliseconds between attempts, up to {@link #MAX_RETRY} times.
	 * </p>
	 *
	 * @param resource the resource to delete
	 * @throws CoreException if all deletion attempts fail
	 */
	public static void delete(final IResource resource) throws CoreException {
		for (int i = 0; i < MAX_RETRY; i++) {
			try {
				resource.delete(IResource.FORCE | IResource.ALWAYS_DELETE_PROJECT_CONTENT, null);
				return; // Success, exit early
			} catch (CoreException e) {
				if (i == MAX_RETRY - 1) {
					// Last attempt failed, throw the exception
					throw e;
				}
				try {
					Thread.sleep(RETRY_DELAY); // give other threads time to close the file
				} catch (InterruptedException e1) {
					// Restore interrupt status
					Thread.currentThread().interrupt();
				}
			}
		}
	}

	/**
	 * Locates the runtime stubs JAR file for the configured Java version.
	 * 
	 * @param rtStubsPath the path to the RT stubs
	 * @return an array containing [jar path, source attachment path, source attachment root path]
	 * @throws CoreException if the stubs file doesn't exist
	 */
	public IPath[] findRtJar(final IPath rtStubsPath) throws CoreException {
		final File rtStubs = rtStubsPath.toFile().getAbsoluteFile();
		assertNotNull(rtStubs, "Runtime stubs file must not be null");
		assertTrue(rtStubs.exists(), "Runtime stubs file must exist: " + rtStubs.getAbsolutePath());
		return new IPath[] { Path.fromOSString(rtStubs.getPath()), null, null };
	}

	/**
	 * Returns the OSGi bundle associated with this class.
	 *
	 * @return the associated bundle, or null if not running in an OSGi environment
	 */
	public final Bundle getBundle() {
		final ClassLoader cl = getClass().getClassLoader();
		if (cl instanceof BundleReference) {
			return ((BundleReference) cl).getBundle();
		}
		return null;
	}

	/**
	 * Creates a new Java project in the workspace.
	 *
	 * @param projectName the name of the project
	 * @param binFolderName name of the output folder, or null/empty for project root
	 * @return the newly created Java project handle
	 * @throws CoreException if project creation fails
	 */
	public static IJavaProject createJavaProject(final String projectName, final String binFolderName) throws CoreException {
		final IWorkspaceRoot root = ResourcesPlugin.getWorkspace().getRoot();
		final IProject project = root.getProject(projectName);
		if (!project.exists()) {
			project.create(null);
		} else {
			project.refreshLocal(IResource.DEPTH_INFINITE, null);
		}
		if (!project.isOpen()) {
			project.open(null);
		}
		final IPath outputLocation;
		if (binFolderName != null && binFolderName.length() > 0) {
			final IFolder binFolder = project.getFolder(binFolderName);
			if (!binFolder.exists()) {
				CoreUtility.createFolder(binFolder, false, true, null);
			}
			outputLocation = binFolder.getFullPath();
		} else {
			outputLocation = project.getFullPath();
		}
		if (!project.hasNature(JavaCore.NATURE_ID)) {
			addNatureToProject(project, JavaCore.NATURE_ID, null);
		}
		final IJavaProject jproject = JavaCore.create(project);
		jproject.setOutputLocation(outputLocation, null);
		jproject.setRawClasspath(new IClasspathEntry[0], null);
		return jproject;
	}

	/**
	 * Adds a nature to a project.
	 * 
	 * @param proj the project
	 * @param natureId the nature ID to add (e.g., JavaCore.NATURE_ID)
	 * @param monitor progress monitor, or null
	 * @throws CoreException if the operation fails
	 */
	private static void addNatureToProject(final IProject proj, final String natureId, final IProgressMonitor monitor)
			throws CoreException {
		final IProjectDescription description = proj.getDescription();
		final String[] prevNatures = description.getNatureIds();
		final String[] newNatures = new String[prevNatures.length + 1];
		System.arraycopy(prevNatures, 0, newNatures, 0, prevNatures.length);
		newNatures[prevNatures.length] = natureId;
		description.setNatureIds(newNatures);
		proj.setDescription(description, monitor);
	}

	/**
	 * Adds a source container to a Java project with full configuration options.
	 *
	 * @param jproject the parent project
	 * @param containerName the name of the new source container, or null/empty for project root
	 * @param inclusionFilters inclusion filters to set (paths to include)
	 * @param exclusionFilters exclusion filters to set (paths to exclude)
	 * @param outputLocation the location where class files are written to, or null for project output folder
	 * @param attributes the classpath attributes to set
	 * @return the handle to the new source container
	 * @throws CoreException if creation fails
	 */
	public static IPackageFragmentRoot addSourceContainer(final IJavaProject jproject, final String containerName,
			final IPath[] inclusionFilters, final IPath[] exclusionFilters, final String outputLocation, final IClasspathAttribute[] attributes)
					throws CoreException {
		final IProject project = jproject.getProject();
		final IContainer container;
		if (containerName == null || containerName.length() == 0) {
			container = project;
		} else {
			final IFolder folder = project.getFolder(containerName);
			if (!folder.exists()) {
				CoreUtility.createFolder(folder, false, true, null);
			}
			container = folder;
		}
		final IPackageFragmentRoot root = jproject.getPackageFragmentRoot(container);

		final IPath outputPath;
		if (outputLocation != null) {
			final IFolder folder = project.getFolder(outputLocation);
			if (!folder.exists()) {
				CoreUtility.createFolder(folder, false, true, null);
			}
			outputPath = folder.getFullPath();
		} else {
			outputPath = null;
		}
		final IClasspathEntry cpe = JavaCore.newSourceEntry(root.getPath(), inclusionFilters, exclusionFilters, outputPath,
				attributes);
		addToClasspath(jproject, cpe);
		return root;
	}

	/**
	 * Adds a source container to a Java project with simple configuration.
	 * 
	 * @param jproject the parent project
	 * @param containerName the name of the new source container
	 * @return the handle to the new source container
	 * @throws CoreException if creation fails
	 */
	public static IPackageFragmentRoot addSourceContainer(final IJavaProject jproject, final String containerName) throws CoreException {
		return addSourceContainer(jproject, containerName, new Path[0]);
	}
	
	/**
	 * Adds a source container to a Java project with exclusion filters.
	 * 
	 * @param jproject the parent project
	 * @param containerName the name of the new source container
	 * @param exclusionFilters exclusion filters to set (paths to exclude)
	 * @return the handle to the new source container
	 * @throws CoreException if creation fails
	 */
	public static IPackageFragmentRoot addSourceContainer(final IJavaProject jproject, final String containerName, final IPath[] exclusionFilters) throws CoreException {
		return addSourceContainer(jproject, containerName, new Path[0], exclusionFilters);
	}
	
	/**
	 * Adds a source container to a Java project with inclusion and exclusion filters.
	 * 
	 * @param jproject the parent project
	 * @param containerName the name of the new source container
	 * @param inclusionFilters inclusion filters to set (paths to include)
	 * @param exclusionFilters exclusion filters to set (paths to exclude)
	 * @return the handle to the new source container
	 * @throws CoreException if creation fails
	 */
	public static IPackageFragmentRoot addSourceContainer(final IJavaProject jproject, final String containerName, final IPath[] inclusionFilters, final IPath[] exclusionFilters) throws CoreException {
		return addSourceContainer(jproject, containerName, inclusionFilters, exclusionFilters, null);
	}
	
	/**
	 * Adds a source container to a Java project with custom output location.
	 * 
	 * @param jproject the parent project
	 * @param containerName the name of the new source container
	 * @param inclusionFilters inclusion filters to set (paths to include)
	 * @param exclusionFilters exclusion filters to set (paths to exclude)
	 * @param outputLocation the location where class files are written to, or null for project output folder
	 * @return the handle to the new source container
	 * @throws CoreException if creation fails
	 */
	public static IPackageFragmentRoot addSourceContainer(final IJavaProject jproject, final String containerName, final IPath[] inclusionFilters, final IPath[] exclusionFilters, final String outputLocation) throws CoreException {
		return addSourceContainer(jproject, containerName, inclusionFilters, exclusionFilters, outputLocation,
				new IClasspathAttribute[0]);
	}
	
	/**
	 * Adds a classpath entry to a Java project if it doesn't already exist.
	 * 
	 * @param jproject the project to modify
	 * @param cpe the classpath entry to add
	 * @throws JavaModelException if the operation fails
	 */
	public static void addToClasspath(final IJavaProject jproject, final IClasspathEntry cpe) throws JavaModelException {
		final IClasspathEntry[] oldEntries = jproject.getRawClasspath();
		for (final IClasspathEntry oldEntry : oldEntries) {
			if (oldEntry.equals(cpe)) {
				return; // Entry already exists
			}
		}
		final int nEntries = oldEntries.length;
		final IClasspathEntry[] newEntries = new IClasspathEntry[nEntries + 1];
		System.arraycopy(oldEntries, 0, newEntries, 0, nEntries);
		newEntries[nEntries] = cpe;
		jproject.setRawClasspath(newEntries, null);
	}

	/**
	 * Adds a local JAR file from the test resources to the test project's classpath.
	 * <p>
	 * This is useful for providing stub libraries (e.g., Mockito, Spring) that are
	 * needed for type binding resolution in test source code, without requiring
	 * the full library on the target platform.
	 * </p>
	 *
	 * @param relativeJarPath the path to the JAR file relative to the working directory
	 *                        (e.g., "testresources/mockito-stubs.jar")
	 * @throws CoreException if the JAR file doesn't exist or classpath modification fails
	 */
	public void addLocalJarToClasspath(String relativeJarPath) throws CoreException {
		File jarFile = new File(relativeJarPath).getAbsoluteFile();
		if (!jarFile.exists()) {
			throw new CoreException(Status.error("Stub JAR file not found: " + jarFile.getAbsolutePath())); //$NON-NLS-1$
		}
		IClasspathEntry cpe = JavaCore.newLibraryEntry(Path.fromOSString(jarFile.getPath()), null, null);
		addToClasspath(getJavaProject(), cpe);
	}

	/**
	 * Adds an Eclipse platform bundle to the test project's classpath.
	 * <p>
	 * This is needed when test input source code references classes from
	 * platform bundles (e.g., org.eclipse.swt, org.eclipse.jface).
	 * The method handles both JAR bundles and directory bundles, as well as
	 * platform-specific fragments (e.g., org.eclipse.swt.gtk.linux.x86_64).
	 * </p>
	 * 
	 * @param bundleSymbolicName the symbolic name of the bundle (e.g., "org.eclipse.swt")
	 * @throws CoreException if the bundle cannot be found or added
	 */
	public void addBundleToClasspath(String bundleSymbolicName) throws CoreException {
		Bundle bundle = Platform.getBundle(bundleSymbolicName);
		if (bundle == null) {
			throw new CoreException(Status.error("Bundle not found: " + bundleSymbolicName)); //$NON-NLS-1$
		}

		// Get the bundle's file location
		File bundleFile;
		try {
			bundleFile = FileLocator.getBundleFile(bundle);
		} catch (IOException e) {
			throw new CoreException(Status.error("Cannot locate bundle file: " + bundleSymbolicName, e)); //$NON-NLS-1$
		}

		if (bundleFile == null || !bundleFile.exists()) {
			throw new CoreException(Status.error("Bundle file does not exist: " + bundleSymbolicName)); //$NON-NLS-1$
		}

		IPath bundlePath = new Path(bundleFile.getAbsolutePath());
		IClasspathEntry cpe;

		if (bundleFile.isDirectory()) {
			File binDir = new File(bundleFile, "bin"); //$NON-NLS-1$
			if (binDir.exists() && binDir.isDirectory()) {
				cpe = JavaCore.newLibraryEntry(new Path(binDir.getAbsolutePath()), null, null);
			} else {
				cpe = JavaCore.newLibraryEntry(bundlePath, null, null);
			}
		} else {
			cpe = JavaCore.newLibraryEntry(bundlePath, null, null);
		}

		addToClasspath(getJavaProject(), cpe);

		// Also add any host or fragment bundles that may contain the actual classes
		// This handles cases like org.eclipse.swt where the API bundle re-exports
		// a platform-specific implementation fragment
		Bundle[] fragments = Platform.getFragments(bundle);
		if (fragments != null) {
			for (Bundle fragment : fragments) {
				try {
					File fragmentFile = FileLocator.getBundleFile(fragment);
					if (fragmentFile != null && fragmentFile.exists()) {
						IPath fragmentPath = new Path(fragmentFile.getAbsolutePath());
						IClasspathEntry fragmentCpe;
						if (fragmentFile.isDirectory()) {
							File fragmentBinDir = new File(fragmentFile, "bin"); //$NON-NLS-1$
							if (fragmentBinDir.exists() && fragmentBinDir.isDirectory()) {
								fragmentCpe = JavaCore.newLibraryEntry(new Path(fragmentBinDir.getAbsolutePath()), null, null);
							} else {
								fragmentCpe = JavaCore.newLibraryEntry(fragmentPath, null, null);
							}
						} else {
							fragmentCpe = JavaCore.newLibraryEntry(fragmentPath, null, null);
						}
						addToClasspath(getJavaProject(), fragmentCpe);
					}
				} catch (IOException e) {
					// Skip fragments that can't be resolved
				}
			}
		}
	}

	/**
	 * Executes the configured refactoring and asserts the result matches expectations.
	 * <p>
	 * This method does NOT validate compilation errors in the input code.
	 * Use {@link #assertRefactoringResultAsExpectedWithCompileCheck} when test fixtures
	 * should be validated for compilation errors before refactoring.
	 * </p>
	 * 
	 * @param cus the compilation units to refactor
	 * @param expected the expected source code after refactoring (one per CU)
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	public RefactoringStatus assertRefactoringResultAsExpected(final ICompilationUnit[] cus, final String[] expected,
			final Set<String> setOfExpectedGroupCategories) throws CoreException {
		final RefactoringStatus status = performRefactoring(cus, setOfExpectedGroupCategories);
		final String[] previews = new String[cus.length];
		for (int i = 0; i < cus.length; i++) {
			final ICompilationUnit cu = cus[i];
			previews[i] = normalizeLineEndings(cu.getBuffer().getContents());
		}
		assertEqualStringsIgnoreOrder(previews, expected);
		return status;
	}

	/**
	 * Executes the configured refactoring and asserts the result matches expectations,
	 * after validating that the input compilation units have no compilation errors.
	 * <p>
	 * Use this variant when test fixtures should compile cleanly before refactoring.
	 * Test fixtures with known compilation issues (e.g., multi-class patterns)
	 * should use {@link #assertRefactoringResultAsExpected} instead, or set
	 * the skip flag appropriately.
	 * </p>
	 * 
	 * @param cus the compilation units to refactor
	 * @param expected the expected source code after refactoring (one per CU)
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	public RefactoringStatus assertRefactoringResultAsExpectedWithCompileCheck(final ICompilationUnit[] cus,
			final String[] expected, final Set<String> setOfExpectedGroupCategories) throws CoreException {
		for (final ICompilationUnit cu : cus) {
			assertNoCompilationError(cu);
		}
		return assertRefactoringResultAsExpected(cus, expected, setOfExpectedGroupCategories);
	}

	/**
	 * Executes the configured refactoring and asserts the result matches expectations,
	 * after validating that both the input and output compilation units have no compilation errors.
	 * <p>
	 * Use this variant when both test input and expected output should compile cleanly.
	 * This is the strictest validation mode and should be preferred for all new tests.
	 * </p>
	 *
	 * @param cus the compilation units to refactor
	 * @param expected the expected source code after refactoring (one per CU)
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	public RefactoringStatus assertRefactoringResultAsExpectedWithFullCompileCheck(final ICompilationUnit[] cus,
			final String[] expected, final Set<String> setOfExpectedGroupCategories) throws CoreException {
		for (final ICompilationUnit cu : cus) {
			assertNoCompilationError(cu);
		}
		final RefactoringStatus status = assertRefactoringResultAsExpected(cus, expected, setOfExpectedGroupCategories);
		for (final ICompilationUnit cu : cus) {
			assertNoCompilationError(cu);
		}
		return status;
	}

	/**
	 * Asserts that the refactoring produces no changes.
	 * <p>
	 * Also validates that the compilation units have no compilation errors.
	 * </p>
	 * 
	 * @param cus the compilation units to check
	 * @return the refactoring status
	 * @throws CoreException if the validation fails
	 */
	public RefactoringStatus assertRefactoringHasNoChange(final ICompilationUnit[] cus) throws CoreException {
		for (final ICompilationUnit cu : cus) {
			assertNoCompilationError(cu);
		}
		return assertRefactoringHasNoChangeEventWithError(cus);
	}

	/**
	 * Asserts that the refactoring produces no changes (even when compilation errors are present).
	 * 
	 * @param cus the compilation units to check
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	protected RefactoringStatus assertRefactoringHasNoChangeEventWithError(final ICompilationUnit[] cus)
			throws CoreException {
		final String[] expected = new String[cus.length];
		for (int i = 0; i < cus.length; i++) {
			expected[i] = cus[i].getBuffer().getContents();
		}
		return assertRefactoringResultAsExpected(cus, expected, null);
	}

	/**
	 * Parses and validates a compilation unit for compilation errors.
	 * 
	 * <p>Uses the JDT {@link IProblem} API to provide detailed error diagnostics
	 * including problem severity, source line number, and problem ID for
	 * easier debugging of test input code.</p>
	 * 
	 * @param cu the compilation unit to check
	 * @return the AST compilation unit root
	 * @throws AssertionError if compilation errors (non-warnings) are found
	 */
	protected CompilationUnit assertNoCompilationError(final ICompilationUnit cu) {
		final ASTParser parser = ASTParser.newParser(IASTSharedValues.SHARED_AST_LEVEL);
		parser.setSource(cu);
		parser.setResolveBindings(true);
		final CompilationUnit root = (CompilationUnit) parser.createAST(null);
		final IProblem[] problems = root.getProblems();
		boolean hasProblems = false;
		for (final IProblem prob : problems) {
			if (!prob.isWarning() && !prob.isInfo()) {
				hasProblems = true;
				break;
			}
		}
		if (hasProblems) {
			final StringBuilder builder = new StringBuilder();
			builder.append(cu.getElementName()).append(" has compilation problems: \n"); //$NON-NLS-1$
			for (final IProblem prob : problems) {
				if (!prob.isWarning() && !prob.isInfo()) {
					builder.append("ERROR line "); //$NON-NLS-1$
					builder.append(prob.getSourceLineNumber());
					builder.append(": "); //$NON-NLS-1$
					builder.append(prob.getMessage());
					builder.append(" [id="); //$NON-NLS-1$
					builder.append(prob.getID());
					builder.append("]\n"); //$NON-NLS-1$
				}
			}
			fail(builder.toString());
		}
		return root;
	}

	/**
	 * Asserts that two string arrays contain the same elements, ignoring order.
	 * <p>
	 * If the arrays differ, produces a detailed comparison showing the differences.
	 * </p>
	 * 
	 * @param actuals the actual strings
	 * @param expecteds the expected strings
	 */
	public static void assertEqualStringsIgnoreOrder(final String[] actuals, final String[] expecteds) {
		final ArrayList<String> actualList = new ArrayList<>(
				Arrays.stream(actuals).map(AbstractEclipseJava::normalizeLineEndings).toList());
		final ArrayList<String> expectedList = new ArrayList<>(
				Arrays.stream(expecteds).map(AbstractEclipseJava::normalizeLineEndings).toList());
		
		// Remove matching elements from both lists
		for (int i = actualList.size() - 1; i >= 0; i--) {
			if (expectedList.remove(actualList.get(i))) {
				actualList.remove(i);
			}
		}
		
		final int numUnmatchedActuals = actualList.size();
		final int numUnmatchedExpected = expectedList.size();
		
		if (numUnmatchedActuals + numUnmatchedExpected > 0) {
			// Special case: if there's exactly one difference, show a direct comparison
			if (numUnmatchedActuals == 1 && numUnmatchedExpected == 1) {
				assertEquals(expectedList.get(0), actualList.get(0));
			}
			
			// Build detailed error message showing all differences
			final String actual = buildStringFromList(actualList);
			final String expected = buildStringFromList(expectedList);
			assertEquals(expected, actual);
		}
	}

	/**
	 * Helper method to build a single string from a list of strings.
	 * 
	 * @param strings the list of strings
	 * @return a single concatenated string with newline separators
	 */
	private static String buildStringFromList(final ArrayList<String> strings) {
		final StringBuilder buf = new StringBuilder();
		for (final String s : strings) {
			if (s != null) {
				buf.append(s);
				buf.append("\n"); //$NON-NLS-1$
			}
		}
		return buf.toString();
	}

	/**
	 * Normalizes line endings to Unix-style {@code \n}.
	 * <p>
	 * This prevents false test failures caused by platform-dependent line endings
	 * (e.g., {@code \r\n} on Windows or from Copilot-generated code) when comparing
	 * actual refactoring output against expected strings that use {@code \n}.
	 * </p>
	 *
	 * @param s the string to normalize, may be {@code null}
	 * @return the normalized string, or {@code null} if input was {@code null}
	 */
	private static String normalizeLineEndings(final String s) {
		if (s == null) {
			return null;
		}
		return s.replace("\r\n", "\n").replace("\r", "\n"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$
	}

	/**
	 * Normalizes whitespace for robust comparison that is tolerant of unrelated
	 * formatting changes in refactoring output.
	 * <p>
	 * This method:
	 * </p>
	 * <ul>
	 * <li>Normalizes line endings to {@code \n}</li>
	 * <li>Converts leading tabs to 4 spaces (handles mixed tab/space indentation)</li>
	 * <li>Trims trailing whitespace from each line</li>
	 * <li>Collapses multiple consecutive blank lines into a single blank line</li>
	 * <li>Strips leading and trailing whitespace from the whole string</li>
	 * </ul>
	 * <p>
	 * <b>Limitation:</b> This method normalizes all leading whitespace on every line,
	 * which may alter significant whitespace inside Java text blocks. Use the standard
	 * {@link #assertRefactoringResultAsExpected} for tests involving text block content.
	 * </p>
	 *
	 * @param s the string to normalize, may be {@code null}
	 * @return the normalized string, or {@code null} if input was {@code null}
	 */
	private static String normalizeWhitespace(final String s) {
		if (s == null) {
			return null;
		}
		final String normalized = normalizeLineEndings(s);
		return Arrays.stream(normalized.split("\n", -1)) //$NON-NLS-1$
				.map(line -> normalizeIndentation(line).stripTrailing())
				.collect(Collectors.joining("\n")) //$NON-NLS-1$
				.replaceAll("\n{3,}", "\n\n") //$NON-NLS-1$ //$NON-NLS-2$
				.strip();
	}

	/**
	 * Normalizes indentation by replacing leading tabs with 4 spaces each.
	 *
	 * @param line the line to normalize
	 * @return the line with tabs replaced by spaces in leading whitespace
	 */
	private static String normalizeIndentation(final String line) {
		int i = 0;
		while (i < line.length() && (line.charAt(i) == ' ' || line.charAt(i) == '\t')) {
			i++;
		}
		if (i == 0) {
			return line;
		}
		final String leading = line.substring(0, i).replace("\t", "    "); //$NON-NLS-1$ //$NON-NLS-2$
		return leading + line.substring(i);
	}

	/**
	 * Asserts that two string arrays contain the same elements, ignoring order
	 * and normalizing whitespace differences.
	 * <p>
	 * Unlike {@link #assertEqualStringsIgnoreOrder}, this method normalizes
	 * whitespace (leading tabs to spaces, trailing spaces, multiple blank lines,
	 * leading/trailing blanks) before comparison, making tests robust against
	 * unrelated formatting changes from the refactoring engine.
	 * </p>
	 *
	 * @param actuals the actual strings
	 * @param expecteds the expected strings
	 */
	public static void assertEqualStringsIgnoreOrderAndWhitespace(final String[] actuals, final String[] expecteds) {
		final ArrayList<String> actualList = new ArrayList<>(
				Arrays.stream(actuals).map(AbstractEclipseJava::normalizeWhitespace).toList());
		final ArrayList<String> expectedList = new ArrayList<>(
				Arrays.stream(expecteds).map(AbstractEclipseJava::normalizeWhitespace).toList());

		// Remove matching elements from both lists
		for (int i = actualList.size() - 1; i >= 0; i--) {
			if (expectedList.remove(actualList.get(i))) {
				actualList.remove(i);
			}
		}

		final int numUnmatchedActuals = actualList.size();
		final int numUnmatchedExpected = expectedList.size();

		if (numUnmatchedActuals + numUnmatchedExpected > 0) {
			if (numUnmatchedActuals == 1 && numUnmatchedExpected == 1) {
				assertEquals(expectedList.get(0), actualList.get(0));
			}

			final String actual = buildStringFromList(actualList);
			final String expected = buildStringFromList(expectedList);
			assertEquals(expected, actual);
		}
	}

	/**
	 * Executes the configured refactoring and asserts the result matches expectations,
	 * normalizing whitespace differences before comparison.
	 * <p>
	 * Use this variant when tests should be robust against unrelated whitespace
	 * changes in refactoring output (e.g., extra blank lines, trailing spaces).
	 * </p>
	 *
	 * @param cus the compilation units to refactor
	 * @param expected the expected source code after refactoring (one per CU)
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	public RefactoringStatus assertRefactoringResultAsExpectedNormalizingWhitespace(final ICompilationUnit[] cus,
			final String[] expected, final Set<String> setOfExpectedGroupCategories) throws CoreException {
		final RefactoringStatus status = performRefactoring(cus, setOfExpectedGroupCategories);
		final String[] previews = new String[cus.length];
		for (int i = 0; i < cus.length; i++) {
			final ICompilationUnit cu = cus[i];
			previews[i] = cu.getBuffer().getContents();
		}
		assertEqualStringsIgnoreOrderAndWhitespace(previews, expected);
		return status;
	}

	/**
	 * Performs a cleanup refactoring on the given compilation units.
	 * <p>
	 * This is the main method that executes cleanup operations configured via the current profile.
	 * </p>
	 * 
	 * @param cus the compilation units to refactor
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	protected final RefactoringStatus performRefactoring(final ICompilationUnit[] cus,
			final Set<String> setOfExpectedGroupCategories) throws CoreException {
		final CleanUpRefactoring ref = new CleanUpRefactoring();
		ref.setUseOptionsFromProfile(true);
		return performRefactoring(ref, cus, JavaPlugin.getDefault().getCleanUpRegistry().createCleanUps(),
				setOfExpectedGroupCategories);
	}

	/**
	 * Performs a cleanup refactoring with specified cleanup instances.
	 * 
	 * @param ref the cleanup refactoring instance
	 * @param cus the compilation units to refactor
	 * @param cleanUps the cleanup instances to apply
	 * @param setOfExpectedGroupCategories expected group category names, or null to skip validation
	 * @return the refactoring status
	 * @throws CoreException if the refactoring fails
	 */
	protected RefactoringStatus performRefactoring(final CleanUpRefactoring ref, final ICompilationUnit[] cus,
			final ICleanUp[] cleanUps, final Set<String> setOfExpectedGroupCategories) throws CoreException {
		for (final ICompilationUnit cu : cus) {
			ref.addCompilationUnit(cu);
		}
		for (final ICleanUp cleanUp : cleanUps) {
			ref.addCleanUp(cleanUp);
		}
		final IUndoManager undoManager = RefactoringCore.getUndoManager();
		undoManager.flush();
		final CreateChangeOperation create = new CreateChangeOperation(
				new CheckConditionsOperation(ref, CheckConditionsOperation.ALL_CONDITIONS), RefactoringStatus.FATAL);
		final PerformChangeOperation perform = new PerformChangeOperation(create);
		perform.setUndoManager(undoManager, ref.getName());
		final IWorkspace workspace = ResourcesPlugin.getWorkspace();
		workspace.run(perform, new NullProgressMonitor());
		final RefactoringStatus status = create.getConditionCheckingStatus();
		if (status.hasFatalError()) {
			throw new CoreException(
					new StatusInfo(status.getSeverity(), status.getMessageMatchingSeverity(status.getSeverity())));
		}
		assertTrue(perform.changeExecuted(), "Change wasn't executed"); //$NON-NLS-1$
		final Change undo = perform.getUndoChange();
		assertNotNull(undo, "Undo doesn't exist"); //$NON-NLS-1$
		assertTrue(undoManager.anythingToUndo(), "Undo manager is empty"); //$NON-NLS-1$
		if (setOfExpectedGroupCategories != null) {
			final Change change = create.getChange();
			final Set<GroupCategory> actualCategories = new HashSet<>();
			collectGroupCategories(actualCategories, change);
			actualCategories.forEach(actualCategory -> {
				assertTrue(setOfExpectedGroupCategories.contains(actualCategory.getName()),
						() -> "Unexpected group category: " + actualCategory.getName() + ", should find: " //$NON-NLS-1$ //$NON-NLS-2$
								+ String.join(", ", setOfExpectedGroupCategories)); //$NON-NLS-1$
			});
		}
		return status;
	}

	/**
	 * Recursively collects group categories from a change tree.
	 * 
	 * @param result the set to collect categories into
	 * @param change the change to examine
	 */
	private void collectGroupCategories(final Set<GroupCategory> result, final Change change) {
		if (change instanceof TextEditBasedChange) {
			for (final TextEditBasedChangeGroup group : ((TextEditBasedChange) change).getChangeGroups()) {
				result.addAll(group.getGroupCategorySet().asList());
			}
		} else if (change instanceof CompositeChange) {
			for (final Change child : ((CompositeChange) change).getChildren()) {
				collectGroupCategories(result, child);
			}
		}
	}

	/**
	 * Enables a cleanup option in the current profile.
	 * 
	 * @param key the cleanup option key (from MYCleanUpConstants or CleanUpConstants)
	 * @throws CoreException if the profile cannot be updated
	 */
	public void enable(final String key) throws CoreException {
		cleanUpProfile.getSettings().put(key, CleanUpOptions.TRUE);
		commitProfile();
	}

	/**
	 * Sets a cleanup option to a specific value in the current profile.
	 * <p>
	 * This is useful for non-boolean options like target format selections.
	 * </p>
	 * 
	 * @param key the cleanup option key (from MYCleanUpConstants or CleanUpConstants)
	 * @param value the value to set
	 * @throws CoreException if the profile cannot be updated
	 */
	public void set(final String key, final String value) throws CoreException {
		cleanUpProfile.getSettings().put(key, value);
		commitProfile();
	}

	/**
	 * Disables a cleanup option in the current profile.
	 * 
	 * @param key the cleanup option key (from MYCleanUpConstants or CleanUpConstants)
	 * @throws CoreException if the profile cannot be updated
	 */
	public void disable(final String key) throws CoreException {
		cleanUpProfile.getSettings().put(key, CleanUpOptions.FALSE);
		commitProfile();
	}

	/**
	 * Commits the current cleanup profile changes to persistent storage.
	 * 
	 * @throws CoreException if the profile cannot be saved
	 */
	private void commitProfile() throws CoreException {
		final List<Profile> profiles = CleanUpPreferenceUtil.getBuiltInProfiles();
		profiles.add(cleanUpProfile);
		final CleanUpProfileVersioner versioner = new CleanUpProfileVersioner();
		final ProfileStore profileStore = new ProfileStore(CleanUpConstants.CLEANUP_PROFILES, versioner);
		profileStore.writeProfiles(profiles, InstanceScope.INSTANCE);
		CleanUpPreferenceUtil.saveSaveParticipantOptions(InstanceScope.INSTANCE, cleanUpProfile.getSettings());
	}

	/**
	 * Gets the source folder for test compilation units.
	 * 
	 * @return the source folder root
	 */
	public IPackageFragmentRoot getSourceFolder() {
		return sourceFolder;
	}

	/**
	 * Sets the source folder for test compilation units.
	 * 
	 * @param sourceFolder the source folder root
	 */
	public void setSourceFolder(final IPackageFragmentRoot sourceFolder) {
		this.sourceFolder = sourceFolder;
	}

	/**
	 * Gets the temporary Java project created for the test.
	 * 
	 * @return the Java project
	 */
	public IJavaProject getJavaProject() {
		return javaProject;
	}

	/**
	 * Sets the temporary Java project for the test.
	 * 
	 * @param javaProject the Java project
	 */
	public void setJavaProject(final IJavaProject javaProject) {
		this.javaProject = javaProject;
	}
}