CompilationUnitProjectGrouper.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.core.cleanupapp;

import java.io.File;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.Path;

import org.eclipse.jdt.core.ICompilationUnit;
import org.eclipse.jdt.core.IJavaProject;
import org.eclipse.jdt.core.JavaCore;

/**
 * Resolves canonical input files to primary compilation units and groups them by
 * Java project without modifying workspace resources or source contents.
 */
final class CompilationUnitProjectGrouper {

	private static final String OUTSIDE_WORKSPACE= "OUTSIDE_WORKSPACE"; //$NON-NLS-1$
	private static final String NOT_COMPILATION_UNIT= "NOT_COMPILATION_UNIT"; //$NON-NLS-1$
	private static final String NO_JAVA_PROJECT= "NO_JAVA_PROJECT"; //$NON-NLS-1$
	private static final String RESOLUTION_FAILED= "RESOLUTION_FAILED"; //$NON-NLS-1$

	/** One requested file and its normalized primary compilation unit. */
	record Input(File file, ICompilationUnit compilationUnit) {
		Input {
			Objects.requireNonNull(file, "file"); //$NON-NLS-1$
			Objects.requireNonNull(compilationUnit, "compilationUnit"); //$NON-NLS-1$
		}
	}

	/** One deterministic per-project input transaction candidate. */
	record ProjectGroup(IJavaProject project, List<Input> inputs) {
		ProjectGroup {
			Objects.requireNonNull(project, "project"); //$NON-NLS-1$
			inputs= List.copyOf(inputs);
		}

		List<ICompilationUnit> compilationUnits() {
			return inputs.stream().map(Input::compilationUnit).toList();
		}
	}

	/** Structured resolution problem for one requested file. */
	record Diagnostic(String input, String reasonCode, String message) {
		Diagnostic {
			input= input == null ? "" : input; //$NON-NLS-1$
			reasonCode= reasonCode == null ? RESOLUTION_FAILED : reasonCode;
			message= message == null ? "" : message; //$NON-NLS-1$
		}
	}

	/** Stable grouping result. */
	record Result(List<ProjectGroup> groups, List<Diagnostic> diagnostics) {
		Result {
			groups= List.copyOf(groups);
			diagnostics= List.copyOf(diagnostics);
		}
	}

	private static final class ResolutionException extends RuntimeException {
		private static final long serialVersionUID= 1L;
		private final String reasonCode;

		ResolutionException(String reasonCode, String message) {
			super(message);
			this.reasonCode= reasonCode;
		}
	}

	private final Function<File, ICompilationUnit> resolver;

	CompilationUnitProjectGrouper() {
		this(CompilationUnitProjectGrouper::resolveWorkspaceCompilationUnit);
	}

	CompilationUnitProjectGrouper(Function<File, ICompilationUnit> resolver) {
		this.resolver= Objects.requireNonNull(resolver, "resolver"); //$NON-NLS-1$
	}

	Result group(Collection<File> files) {
		if (files == null) {
			return new Result(List.of(), List.of(new Diagnostic("", RESOLUTION_FAILED, //$NON-NLS-1$
					"Input file collection is null"))); //$NON-NLS-1$
		}
		List<File> orderedFiles= files.stream().filter(Objects::nonNull)
				.sorted(Comparator.comparing(CompilationUnitProjectGrouper::stablePath)).toList();
		Map<String, ProjectAccumulator> projects= new LinkedHashMap<>();
		List<Diagnostic> diagnostics= new ArrayList<>();
		for (File file : orderedFiles) {
			try {
				ICompilationUnit resolved= resolver.apply(file);
				if (resolved == null || !resolved.exists()) {
					diagnostics.add(new Diagnostic(stablePath(file), NOT_COMPILATION_UNIT,
							"No existing compilation unit was resolved")); //$NON-NLS-1$
					continue;
				}
				ICompilationUnit primary= resolved.getPrimary();
				if (primary == null || !primary.exists()) {
					diagnostics.add(new Diagnostic(stablePath(file), NOT_COMPILATION_UNIT,
							"The resolved compilation unit has no existing primary unit")); //$NON-NLS-1$
					continue;
				}
				IJavaProject project= primary.getJavaProject();
				if (project == null || !project.exists()) {
					diagnostics.add(new Diagnostic(stablePath(file), NO_JAVA_PROJECT,
							"The resolved compilation unit is not in an existing Java project")); //$NON-NLS-1$
					continue;
				}
				String projectKey= stableProjectKey(project);
				projects.computeIfAbsent(projectKey, ignored -> new ProjectAccumulator(project))
						.add(file, primary);
			} catch (ResolutionException exception) {
				diagnostics.add(new Diagnostic(stablePath(file), exception.reasonCode, exception.getMessage()));
			} catch (RuntimeException exception) {
				diagnostics.add(new Diagnostic(stablePath(file), RESOLUTION_FAILED,
						exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage()));
			}
		}

		List<ProjectGroup> groups= projects.entrySet().stream().sorted(Map.Entry.comparingByKey())
				.map(entry -> entry.getValue().freeze()).toList();
		diagnostics.sort(Comparator.comparing(Diagnostic::input)
				.thenComparing(Diagnostic::reasonCode).thenComparing(Diagnostic::message));
		return new Result(groups, diagnostics);
	}

	private static final class ProjectAccumulator {
		private final IJavaProject project;
		private final Map<String, Input> inputsByUnitHandle= new LinkedHashMap<>();

		ProjectAccumulator(IJavaProject project) {
			this.project= project;
		}

		void add(File file, ICompilationUnit unit) {
			String handle= unit.getHandleIdentifier();
			if (handle == null || handle.isBlank()) {
				throw new ResolutionException(NOT_COMPILATION_UNIT,
						"The resolved compilation unit has no stable Java-model handle"); //$NON-NLS-1$
			}
			inputsByUnitHandle.putIfAbsent(handle, new Input(file, unit));
		}

		ProjectGroup freeze() {
			List<Input> inputs= inputsByUnitHandle.entrySet().stream().sorted(Map.Entry.comparingByKey())
					.map(Map.Entry::getValue).toList();
			return new ProjectGroup(project, inputs);
		}
	}

	private static ICompilationUnit resolveWorkspaceCompilationUnit(File file) {
		IPath path= Path.fromOSString(file.getAbsolutePath());
		IFile workspaceFile= ResourcesPlugin.getWorkspace().getRoot().getFileForLocation(path);
		if (workspaceFile == null || !workspaceFile.exists()) {
			throw new ResolutionException(OUTSIDE_WORKSPACE,
					"File is outside the current Eclipse workspace"); //$NON-NLS-1$
		}
		ICompilationUnit unit= JavaCore.createCompilationUnitFrom(workspaceFile);
		if (unit == null) {
			throw new ResolutionException(NOT_COMPILATION_UNIT,
					"Workspace file is not a Java compilation unit"); //$NON-NLS-1$
		}
		return unit;
	}

	private static String stableProjectKey(IJavaProject project) {
		String handle= project.getHandleIdentifier();
		return handle == null || handle.isBlank() ? project.getElementName() : handle;
	}

	private static String stablePath(File file) {
		return file.toPath().toAbsolutePath().normalize().toString().replace(File.separatorChar, '/');
	}
}