JavaProjectCompilationUnits.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.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Deque;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;

import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.ResourceAttributes;

import org.eclipse.core.runtime.IPath;

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.IPackageFragment;
import org.eclipse.jdt.core.IPackageFragmentRoot;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.JavaModelException;

/** Utility for obtaining stable, policy-aware source compilation-unit scopes. */
public final class JavaProjectCompilationUnits {

	private static final Set<String> GENERATED_SEGMENTS= Set.of(
			"generated", "generated-sources", "generated-test-sources", "generated-src", "src-gen"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$
	private static final Set<String> TEST_SEGMENTS= Set.of(
			"test", "tests", "testfixtures", "integrationtest", "integration-test"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$ //$NON-NLS-4$ //$NON-NLS-5$

	private JavaProjectCompilationUnits() {
	}

	/**
	 * Collects all editable production and test compilation units. Generated,
	 * derived, output, missing, and non-source roots are excluded by default.
	 *
	 * @param project Java project
	 * @return stable list of editable source compilation units
	 * @throws JavaModelException if the Java model cannot be read
	 */
	public static List<ICompilationUnit> collect(IJavaProject project) throws JavaModelException {
		return collect(project, List.of(), SourceRootPolicy.COMPLETE_PROJECT);
	}

	/**
	 * Collects compilation units according to an explicit coordinated-cleanup
	 * source-root policy. The result is sorted by Java element handle for
	 * deterministic planning.
	 *
	 * @param project Java project
	 * @param currentScope explicitly selected cleanup scope
	 * @param policy source-root expansion policy
	 * @return stable list of compilation units allowed by the policy
	 * @throws JavaModelException if the Java model cannot be read
	 */
	public static List<ICompilationUnit> collect(IJavaProject project,
			Collection<ICompilationUnit> currentScope, SourceRootPolicy policy) throws JavaModelException {
		if (project == null || policy == null) {
			return List.of();
		}
		Set<String> selectedRootHandles= selectedRootHandles(currentScope);
		boolean productionSelected= hasSelectedRootOfKind(project, selectedRootHandles, SourceRootKind.PRODUCTION);
		List<ICompilationUnit> result= new ArrayList<>();
		for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
			SourceRootKind kind= classify(root);
			if (!kind.isEditableByDefault()
					|| !includes(policy, root.getHandleIdentifier(), kind, selectedRootHandles, productionSelected)) {
				continue;
			}
			collectRootUnits(root, result);
		}
		result.sort(Comparator.comparing(IJavaElement::getHandleIdentifier));
		return List.copyOf(result);
	}

	/**
	 * Collects compilation units across several Java projects, for example a fixture
	 * bundle together with the test bundles that consume it. Projects that cannot be
	 * edited are reported through {@link #readOnlyProjects(Collection)} and must be
	 * checked before a coordinated cleanup is planned.
	 *
	 * @param projects Java projects contributing to the coordinated scope
	 * @param currentScope explicitly selected cleanup scope
	 * @param policy source-root expansion policy
	 * @return stable list of compilation units allowed by the policy across all projects
	 * @throws JavaModelException if the Java model cannot be read
	 */
	public static List<ICompilationUnit> collect(Collection<IJavaProject> projects,
			Collection<ICompilationUnit> currentScope, SourceRootPolicy policy) throws JavaModelException {
		if (projects == null || policy == null) {
			return List.of();
		}
		Set<String> seen= new HashSet<>();
		List<ICompilationUnit> result= new ArrayList<>();
		for (IJavaProject project : projects) {
			for (ICompilationUnit unit : collect(project, currentScope, policy)) {
				if (seen.add(unit.getHandleIdentifier())) {
					result.add(unit);
				}
			}
		}
		result.sort(Comparator.comparing(IJavaElement::getHandleIdentifier));
		return List.copyOf(result);
	}

	/**
	 * Returns the Java projects that own the given compilation units, in stable order.
	 *
	 * @param units compilation units, may be {@code null}
	 * @return owning Java projects
	 */
	public static List<IJavaProject> owningProjects(Collection<ICompilationUnit> units) {
		if (units == null) {
			return List.of();
		}
		Map<String, IJavaProject> projects= new TreeMap<>();
		for (ICompilationUnit unit : units) {
			IJavaProject project= unit == null ? null : unit.getJavaProject();
			if (project != null && project.exists()) {
				projects.putIfAbsent(project.getHandleIdentifier(), project);
			}
		}
		return List.copyOf(projects.values());
	}

	/**
	 * Returns the transitive closure of Java projects that reference the given
	 * projects, including the given projects themselves. A coordinated cleanup of a
	 * shared test fixture must include every project that consumes it, otherwise the
	 * fixture would be migrated while its users keep the old API.
	 *
	 * @param projects seed Java projects, may be {@code null}
	 * @return seed projects and all transitively referencing Java projects
	 */
	public static List<IJavaProject> withReferencingProjects(Collection<IJavaProject> projects) {
		Map<String, IJavaProject> result= new TreeMap<>();
		if (projects == null) {
			return List.of();
		}
		Deque<IProject> pending= new ArrayDeque<>();
		for (IJavaProject project : projects) {
			if (project != null && project.exists()
					&& result.putIfAbsent(project.getHandleIdentifier(), project) == null) {
				pending.add(project.getProject());
			}
		}
		while (!pending.isEmpty()) {
			IProject current= pending.removeFirst();
			if (current == null || !current.isAccessible()) {
				continue;
			}
			for (IProject referencing : current.getReferencingProjects()) {
				if (referencing == null || !referencing.isAccessible()) {
					continue;
				}
				IJavaProject javaProject= JavaCore.create(referencing);
				if (javaProject == null || !javaProject.exists()
						|| result.putIfAbsent(javaProject.getHandleIdentifier(), javaProject) != null) {
					continue;
				}
				pending.add(referencing);
			}
		}
		return List.copyOf(result.values());
	}

	/**
	 * Returns whether a coordinated cleanup may write to the project. Target-platform
	 * bundles and other read-only or closed projects must abort the migration instead
	 * of being migrated partially.
	 *
	 * @param project Java project, may be {@code null}
	 * @return {@code true} if the project exists, is open and is writable
	 */
	public static boolean isEditable(IJavaProject project) {
		IProject resource= project == null ? null : project.getProject();
		if (resource == null || !resource.exists() || !resource.isAccessible()) {
			return false;
		}
		ResourceAttributes attributes= resource.getResourceAttributes();
		return attributes == null || !attributes.isReadOnly();
	}

	/**
	 * Returns the projects that must not be modified by a coordinated cleanup.
	 *
	 * @param projects Java projects to inspect, may be {@code null}
	 * @return read-only or inaccessible projects, in input order
	 */
	public static List<IJavaProject> readOnlyProjects(Collection<IJavaProject> projects) {
		if (projects == null) {
			return List.of();
		}
		List<IJavaProject> result= new ArrayList<>();
		for (IJavaProject project : projects) {
			if (!isEditable(project)) {
				result.add(project);
			}
		}
		return List.copyOf(result);
	}

	/**
	 * Classifies a package-fragment root for cleanup scope decisions.
	 *
	 * @param root package-fragment root
	 * @return conservative source-root classification
	 * @throws JavaModelException if classpath metadata cannot be read
	 */
	public static SourceRootKind classify(IPackageFragmentRoot root) throws JavaModelException {
		if (root == null || !root.exists() || root.getKind() != IPackageFragmentRoot.K_SOURCE) {
			return SourceRootKind.EXCLUDED;
		}
		IResource resource= root.getResource();
		if (resource != null && resource.isDerived()) {
			return SourceRootKind.DERIVED;
		}
		IPath rootPath= root.getPath();
		if (isOutputRoot(root, rootPath)) {
			return SourceRootKind.OUTPUT;
		}
		if (containsSegment(rootPath, GENERATED_SEGMENTS)) {
			return SourceRootKind.GENERATED;
		}
		IClasspathEntry entry= root.getResolvedClasspathEntry();
		if (entry == null) {
			entry= root.getRawClasspathEntry();
		}
		if (entry == null) {
			return SourceRootKind.EXCLUDED;
		}
		if (entry.isTest()) {
			return SourceRootKind.TEST;
		}
		return containsSegment(rootPath, TEST_SEGMENTS) ? SourceRootKind.TEST : SourceRootKind.PRODUCTION;
	}

	private static boolean includes(SourceRootPolicy policy, String rootHandle, SourceRootKind kind,
			Set<String> selectedRootHandles, boolean productionSelected) {
		return switch (policy) {
			case EXPLICIT_SELECTED_ROOTS -> selectedRootHandles.contains(rootHandle);
			case TEST_ROOTS_AND_SELECTED_SUPPORT -> kind == SourceRootKind.TEST
					|| selectedRootHandles.contains(rootHandle);
			case PRODUCTION_WITH_DEPENDENT_TESTS -> productionSelected
					? kind == SourceRootKind.PRODUCTION || kind == SourceRootKind.TEST
					: selectedRootHandles.contains(rootHandle);
			case COMPLETE_PROJECT -> true;
		};
	}

	private static Set<String> selectedRootHandles(Collection<ICompilationUnit> currentScope) {
		if (currentScope == null || currentScope.isEmpty()) {
			return Set.of();
		}
		Set<String> handles= new HashSet<>();
		for (ICompilationUnit unit : currentScope) {
			if (unit == null) {
				continue;
			}
			IJavaElement ancestor= unit.getAncestor(IJavaElement.PACKAGE_FRAGMENT_ROOT);
			if (ancestor instanceof IPackageFragmentRoot root) {
				handles.add(root.getHandleIdentifier());
			}
		}
		return Set.copyOf(handles);
	}

	private static boolean hasSelectedRootOfKind(IJavaProject project, Set<String> selectedRootHandles,
			SourceRootKind expected) throws JavaModelException {
		if (selectedRootHandles.isEmpty()) {
			return false;
		}
		for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
			if (selectedRootHandles.contains(root.getHandleIdentifier()) && classify(root) == expected) {
				return true;
			}
		}
		return false;
	}

	private static void collectRootUnits(IPackageFragmentRoot root, List<ICompilationUnit> result)
			throws JavaModelException {
		for (IJavaElement child : root.getChildren()) {
			if (child instanceof IPackageFragment fragment && fragment.exists()) {
				for (ICompilationUnit unit : fragment.getCompilationUnits()) {
					if (unit.exists()) {
						result.add(unit);
					}
				}
			}
		}
	}

	private static boolean isOutputRoot(IPackageFragmentRoot root, IPath rootPath) throws JavaModelException {
		if (rootPath == null) {
			return false;
		}
		IJavaProject project= root.getJavaProject();
		IPath projectOutput= project == null ? null : project.getOutputLocation();
		if (projectOutput != null && projectOutput.isPrefixOf(rootPath)) {
			return true;
		}
		IClasspathEntry rawEntry= root.getRawClasspathEntry();
		IPath entryOutput= rawEntry == null ? null : rawEntry.getOutputLocation();
		return entryOutput != null && entryOutput.isPrefixOf(rootPath);
	}

	private static boolean containsSegment(IPath path, Set<String> candidates) {
		if (path == null) {
			return false;
		}
		for (String segment : path.segments()) {
			if (candidates.contains(segment.toLowerCase(Locale.ROOT))) {
				return true;
			}
		}
		return false;
	}
}