RelatedCompilationUnitSearch.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
 *******************************************************************************/
package org.sandbox.jdt.cleanup.multifile;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
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.core.runtime.OperationCanceledException;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaElement;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.search.IJavaSearchConstants;
import org.eclipse.jdt.core.search.SearchEngine;
import org.eclipse.jdt.core.search.SearchMatch;
import org.eclipse.jdt.core.search.SearchParticipant;
import org.eclipse.jdt.core.search.SearchPattern;
import org.eclipse.jdt.core.search.SearchRequestor;

/**
 * Resolves the source compilation units related to binding-derived Java elements.
 *
 * <p>The search is workspace-wide so references outside the selected project or
 * in binary inputs are visible as safety failures. Only accurate matches in the
 * explicitly allowed source units of the owning project are admitted to the
 * returned closure. Any unresolved, external, generated, derived, output, or
 * otherwise excluded match makes the result incomplete.</p>
 */
public final class RelatedCompilationUnitSearch {

	/** Stable result of one related-source search. */
	public record Result(List<ICompilationUnit> compilationUnits, boolean complete,
			List<String> rejectionReasons) {
		public Result {
			compilationUnits= List.copyOf(compilationUnits);
			rejectionReasons= List.copyOf(rejectionReasons);
		}
	}

	/** Package-visible deterministic match classifier used by common-layer tests. */
	static final class MatchAccumulator {
		private final Set<String> projectHandles;
		private final Map<String, ICompilationUnit> allowedByHandle;
		private final Map<String, ICompilationUnit> resultByHandle= new LinkedHashMap<>();
		private final Set<String> reasons= new LinkedHashSet<>();

		MatchAccumulator(IJavaProject project, Collection<ICompilationUnit> initialUnits,
				Collection<ICompilationUnit> allowedUnits) {
			this(List.of(project), initialUnits, allowedUnits);
		}

		MatchAccumulator(Collection<IJavaProject> projects, Collection<ICompilationUnit> initialUnits,
				Collection<ICompilationUnit> allowedUnits) {
			this.projectHandles= projectHandles(projects);
			allowedByHandle= byHandle(allowedUnits);
			addUnits(projectHandles, initialUnits, allowedByHandle, resultByHandle, reasons,
					"The initial cleanup scope contains a source unit outside the permitted root policy."); //$NON-NLS-1$
		}

		void addTarget(IJavaElement target) {
			ICompilationUnit declarationUnit= compilationUnit(target);
			if (declarationUnit == null) {
				reasons.add("A search target is declared outside an editable source compilation unit."); //$NON-NLS-1$
				return;
			}
			addUnit(projectHandles, declarationUnit, allowedByHandle, resultByHandle, reasons,
					"A search-target declaration is outside the permitted source-root policy."); //$NON-NLS-1$
		}

		void accept(Object element, int accuracy) {
			if (accuracy != SearchMatch.A_ACCURATE) {
				reasons.add("JDT reported an inaccurate reference match."); //$NON-NLS-1$
				return;
			}
			if (!(element instanceof IJavaElement javaElement)) {
				reasons.add("A reference match has no Java-model element."); //$NON-NLS-1$
				return;
			}
			ICompilationUnit unit= compilationUnit(javaElement);
			if (unit == null) {
				reasons.add("A reference exists in a binary or otherwise non-source element."); //$NON-NLS-1$
				return;
			}
			addUnit(projectHandles, unit, allowedByHandle, resultByHandle, reasons,
					"A reference exists outside the permitted project source-root policy."); //$NON-NLS-1$
		}

		void reject(String reason) {
			reasons.add(reason);
		}

		Result finish() {
			return result(resultByHandle, reasons);
		}
	}

	private RelatedCompilationUnitSearch() {
	}

	/**
	 * Finds source units containing references to the supplied Java elements.
	 *
	 * @param project project that owns the coordinated cleanup
	 * @param targets binding-derived fields, methods, or types to search for
	 * @param initialUnits units explicitly selected or already admitted
	 * @param allowedUnits complete source-root-policy allow-list
	 * @param monitor progress monitor, may be {@code null}
	 * @return deterministic related-unit closure and completeness status
	 * @throws CoreException if the JDT search engine fails
	 */
	public static Result findReferences(IJavaProject project,
			Collection<? extends IJavaElement> targets,
			Collection<ICompilationUnit> initialUnits,
			Collection<ICompilationUnit> allowedUnits,
			IProgressMonitor monitor) throws CoreException {
		return findReferences(project == null ? List.<IJavaProject>of() : List.of(project), targets, initialUnits,
				allowedUnits, monitor);
	}

	/**
	 * Finds source units containing references to the supplied Java elements across
	 * several coordinated projects, for example a fixture bundle and the test bundles
	 * that consume it. References found outside those projects make the result
	 * incomplete so that a partial cross-bundle migration is never planned.
	 *
	 * @param projects coordinated Java projects that may be modified
	 * @param targets binding-derived fields, methods, or types to search for
	 * @param initialUnits units explicitly selected or already admitted
	 * @param allowedUnits complete source-root-policy allow-list across all projects
	 * @param monitor progress monitor, may be {@code null}
	 * @return deterministic related-unit closure and completeness status
	 * @throws CoreException if the JDT search engine fails
	 */
	public static Result findReferences(Collection<IJavaProject> projects,
			Collection<? extends IJavaElement> targets,
			Collection<ICompilationUnit> initialUnits,
			Collection<ICompilationUnit> allowedUnits,
			IProgressMonitor monitor) throws CoreException {
		checkCanceled(monitor);
		if (projects == null || projects.isEmpty() || targets == null || targets.isEmpty()) {
			return new Result(normalize(initialUnits), false,
					List.of("No binding-derived search target is available.")); //$NON-NLS-1$
		}

		MatchAccumulator accumulator= new MatchAccumulator(projects, initialUnits, allowedUnits);
		SearchPattern combinedPattern= null;
		for (IJavaElement target : targets) {
			checkCanceled(monitor);
			if (target == null || !target.exists()) {
				accumulator.reject("A binding-derived search target is missing or no longer exists."); //$NON-NLS-1$
				continue;
			}
			accumulator.addTarget(target);
			SearchPattern pattern= SearchPattern.createPattern(target, IJavaSearchConstants.REFERENCES);
			if (pattern == null) {
				accumulator.reject("JDT could not create an exact reference-search pattern."); //$NON-NLS-1$
				continue;
			}
			combinedPattern= combinedPattern == null
					? pattern
					: SearchPattern.createOrPattern(combinedPattern, pattern);
		}
		if (combinedPattern == null) {
			return accumulator.finish();
		}

		SearchRequestor requestor= new SearchRequestor() {
			@Override
			public void acceptSearchMatch(SearchMatch match) {
				accumulator.accept(match.getElement(), match.getAccuracy());
			}
		};
		new SearchEngine().search(combinedPattern,
				new SearchParticipant[] { SearchEngine.getDefaultSearchParticipant() },
				SearchEngine.createWorkspaceScope(), requestor, monitor);
		checkCanceled(monitor);
		return accumulator.finish();
	}

	private static Result result(Map<String, ICompilationUnit> resultByHandle, Set<String> reasons) {
		List<ICompilationUnit> units= new ArrayList<>(resultByHandle.values());
		units.sort(Comparator.comparing(IJavaElement::getHandleIdentifier));
		return new Result(units, reasons.isEmpty(), List.copyOf(reasons));
	}

	private static Map<String, ICompilationUnit> byHandle(Collection<ICompilationUnit> units) {
		Map<String, ICompilationUnit> result= new LinkedHashMap<>();
		if (units == null) {
			return result;
		}
		for (ICompilationUnit unit : units) {
			if (unit != null && unit.exists()) {
				ICompilationUnit primary= unit.getPrimary();
				result.put(primary.getHandleIdentifier(), primary);
			}
		}
		return result;
	}

	private static List<ICompilationUnit> normalize(Collection<ICompilationUnit> units) {
		List<ICompilationUnit> result= new ArrayList<>(byHandle(units).values());
		result.sort(Comparator.comparing(IJavaElement::getHandleIdentifier));
		return List.copyOf(result);
	}

	private static void addUnits(Set<String> projectHandles, Collection<ICompilationUnit> units,
			Map<String, ICompilationUnit> allowedByHandle,
			Map<String, ICompilationUnit> resultByHandle, Set<String> reasons, String rejectionReason) {
		if (units == null) {
			return;
		}
		for (ICompilationUnit unit : units) {
			addUnit(projectHandles, unit, allowedByHandle, resultByHandle, reasons, rejectionReason);
		}
	}

	private static void addUnit(Set<String> projectHandles, ICompilationUnit unit,
			Map<String, ICompilationUnit> allowedByHandle,
			Map<String, ICompilationUnit> resultByHandle, Set<String> reasons, String rejectionReason) {
		if (unit == null || !unit.exists()) {
			reasons.add(rejectionReason);
			return;
		}
		ICompilationUnit primary= unit.getPrimary();
		String handle= primary.getHandleIdentifier();
		ICompilationUnit allowed= allowedByHandle.get(handle);
		IJavaProject owner= primary.getJavaProject();
		if (owner == null || !projectHandles.contains(owner.getHandleIdentifier()) || allowed == null) {
			reasons.add(rejectionReason);
			return;
		}
		resultByHandle.put(handle, allowed);
	}

	private static Set<String> projectHandles(Collection<IJavaProject> projects) {
		Set<String> handles= new LinkedHashSet<>();
		if (projects == null) {
			return handles;
		}
		for (IJavaProject project : projects) {
			if (project != null && project.exists()) {
				handles.add(project.getHandleIdentifier());
			}
		}
		return handles;
	}

	private static ICompilationUnit compilationUnit(IJavaElement element) {
		if (element == null) {
			return null;
		}
		IJavaElement ancestor= element.getAncestor(IJavaElement.COMPILATION_UNIT);
		return ancestor instanceof ICompilationUnit unit ? unit.getPrimary() : null;
	}

	private static void checkCanceled(IProgressMonitor monitor) {
		if (monitor != null && monitor.isCanceled()) {
			throw new OperationCanceledException();
		}
	}
}