AbstractPlannedMultiFileCleanUp.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.Collection;
import java.util.Collections;
import java.util.HashMap;
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.ltk.core.refactoring.RefactoringStatus;

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

import org.eclipse.jdt.ui.cleanup.CleanUpContext;
import org.eclipse.jdt.ui.cleanup.ICleanUpFix;

import org.eclipse.jdt.internal.ui.fix.AbstractCleanUp;

import org.sandbox.jdt.cleanup.multifile.api.IMultiFileCleanUpScopeProvider;

/**
 * Base class for cleanups that first build one immutable semantic plan for all
 * selected compilation units and then emit the local part of that plan for each
 * {@link CleanUpContext}.
 *
 * <p>This uses the existing {@code ICleanUp} lifecycle. Eclipse calls
 * {@code checkPreConditions} once with all selected compilation units in a Java
 * project and subsequently calls {@code createFix} on the same cleanup instance
 * for every target. The ordinary cleanup refactoring already combines all local
 * changes into one preview, apply operation, and undo.</p>
 *
 * <p>The JDT cleanup orchestrator invokes one cleanup instance sequentially.
 * Alternative callers must not assume that project plans can be built or
 * resolved concurrently on the same instance. All lifecycle entry points that
 * can access retained plan state are therefore serialized on a private lock.
 * The lock is deliberately not the publicly reachable cleanup instance or class
 * monitor. An instance may be reused after {@link #checkPostConditions(IProgressMonitor)}
 * has completed and cleared all retained state.</p>
 *
 * <p>Overridable planning, scope-discovery, fix-resolution, and postcondition
 * hooks execute while that private lifecycle lock is held. Implementations must
 * therefore remain synchronous and must not wait for another thread to call a
 * lifecycle method on the same cleanup instance.</p>
 *
 * <p>Plans must not retain AST nodes from the planning parser. Previous cleanups
 * may change working copies before this cleanup receives its current AST. Store
 * Java model handles, binding keys, signatures, and semantic edit descriptions,
 * then resolve them again in {@link #createFixForPlan(Object, CleanUpContext)}.</p>
 *
 * @param <P> immutable plan type
 */
public abstract class AbstractPlannedMultiFileCleanUp<P> extends AbstractCleanUp
		implements IMultiFileCleanUpScopeProvider {

	private static final String UNKNOWN_CLEANUP_ID= "unknown"; //$NON-NLS-1$

	private static final class ScopeAccumulator {
		private final Set<String> selectedHandles= new LinkedHashSet<>();
		private final Set<String> addedHandles= new LinkedHashSet<>();

		void record(Collection<ICompilationUnit> currentScope, Collection<ICompilationUnit> additions) {
			if (selectedHandles.isEmpty()) {
				addHandles(selectedHandles, currentScope);
			}
			Set<String> discovered= new LinkedHashSet<>();
			addHandles(discovered, additions);
			discovered.removeAll(selectedHandles);
			addedHandles.addAll(discovered);
		}

		MultiFileScopeDiagnostic merge(MultiFileScopeDiagnostic existing) {
			if (addedHandles.isEmpty()) {
				return new MultiFileScopeDiagnostic(List.copyOf(selectedHandles),
						existing.addedCompilationUnitHandles(), existing.reasonCode(), existing.explanation(),
						existing.complete());
			}
			Set<String> allAdded= new LinkedHashSet<>(existing.addedCompilationUnitHandles());
			allAdded.addAll(addedHandles);
			return new MultiFileScopeDiagnostic(List.copyOf(selectedHandles), List.copyOf(allAdded),
					"RELATED_SOURCE_CLOSURE", //$NON-NLS-1$
					"Related source compilation units were added to close coordinated cleanup references.", //$NON-NLS-1$
					existing.complete());
		}

		private static void addHandles(Set<String> target, Collection<ICompilationUnit> units) {
			if (units == null) {
				return;
			}
			for (ICompilationUnit unit : units) {
				if (unit == null) {
					continue;
				}
				ICompilationUnit primary= unit.getPrimary();
				String handle= (primary == null ? unit : primary).getHandleIdentifier();
				if (handle != null) {
					target.add(handle);
				}
			}
		}
	}

	private final Object lifecycleLock= new Object();

	private final Map<IJavaProject, P> plansByProject= new HashMap<>();
	private final Map<IJavaProject, MultiFilePlanningMetrics> metricsByProject= new HashMap<>();
	private final Map<IJavaProject, MultiFileCleanUpDiagnostics> diagnosticsByProject= new HashMap<>();
	private final Map<IJavaProject, ScopeAccumulator> scopesByProject= new HashMap<>();

	/** Creates a base class without options. */
	protected AbstractPlannedMultiFileCleanUp() {
	}

	/** Creates a base class with cleanup options. */
	protected AbstractPlannedMultiFileCleanUp(Map<String, String> settings) {
		super(settings);
	}

	/**
	 * Analyses all selected units and creates the immutable run plan.
	 *
	 * @param project Java project being cleaned
	 * @param compilationUnits complete selected scope for the project
	 * @param monitor progress monitor
	 * @return plan and diagnostics
	 * @throws CoreException if analysis cannot be completed
	 */
	protected abstract MultiFileCleanUpPlanResult<P> createPlan(IJavaProject project,
			ICompilationUnit[] compilationUnits, IProgressMonitor monitor) throws CoreException;

	/**
	 * Creates the local fix for the current compilation unit from the common plan.
	 *
	 * @param plan immutable project-wide plan
	 * @param context current context, potentially containing a fresh AST after
	 *                earlier cleanups
	 * @return local fix or {@code null}
	 * @throws CoreException if a planned edit can no longer be resolved safely
	 */
	protected abstract ICleanUpFix createFixForPlan(P plan, CleanUpContext context) throws CoreException;

	@Override
	public final RefactoringStatus checkPreConditions(IJavaProject project, ICompilationUnit[] compilationUnits,
			IProgressMonitor monitor) throws CoreException {
		synchronized (lifecycleLock) {
			clearPlanningState(project);
			MultiFileCleanUpPlanResult<P> result;
			try {
				result= createPlan(project, compilationUnits.clone(), monitor);
			} catch (CoreException | RuntimeException e) {
				clearProjectState(project);
				throw e;
			}
			MultiFileCleanUpDiagnostics diagnostics= result.diagnostics();
			ScopeAccumulator scope= scopesByProject.remove(project);
			if (scope != null) {
				MultiFileScopeDiagnostic mergedScope= scope.merge(diagnostics.scope());
				String cleanupId= UNKNOWN_CLEANUP_ID.equals(diagnostics.cleanupId())
						? getClass().getSimpleName()
						: diagnostics.cleanupId();
				diagnostics= new MultiFileCleanUpDiagnostics(cleanupId, mergedScope, diagnostics.candidates());
			}
			metricsByProject.put(project, result.metrics());
			diagnosticsByProject.put(project, diagnostics);
			diagnostics.appendSummary(result.status());
			if (!result.status().hasFatalError() && result.plan() != null) {
				plansByProject.put(project, result.plan());
			}
			return result.status();
		}
	}

	@Override
	public final ICleanUpFix createFix(CleanUpContext context) throws CoreException {
		synchronized (lifecycleLock) {
			ICompilationUnit unit= context.getCompilationUnit();
			if (unit == null) {
				return null;
			}
			IJavaProject project= unit.getJavaProject();
			P plan= plansByProject.get(project);
			if (plan == null) {
				return null;
			}
			try {
				return createFixForPlan(plan, context);
			} catch (CoreException | RuntimeException e) {
				clearProjectState(project);
				throw e;
			}
		}
	}

	@Override
	public final RefactoringStatus checkPostConditions(IProgressMonitor monitor) throws CoreException {
		synchronized (lifecycleLock) {
			try {
				return checkPlanPostConditions(monitor);
			} finally {
				plansByProject.clear();
				metricsByProject.clear();
				diagnosticsByProject.clear();
				scopesByProject.clear();
			}
		}
	}

	/**
	 * Hook for consumer-specific postcondition checks.
	 *
	 * @param monitor progress monitor
	 * @return postcondition status
	 * @throws CoreException if validation fails unexpectedly
	 */
	protected RefactoringStatus checkPlanPostConditions(IProgressMonitor monitor) throws CoreException {
		return new RefactoringStatus();
	}

	@Override
	public final Collection<ICompilationUnit> expandCleanUpScope(IJavaProject project,
			Collection<ICompilationUnit> currentScope, IProgressMonitor monitor) throws CoreException {
		synchronized (lifecycleLock) {
			Collection<ICompilationUnit> result= discoverAdditionalCompilationUnits(project,
					Collections.unmodifiableCollection(currentScope), monitor);
			Collection<ICompilationUnit> normalized= result == null ? Collections.emptyList() : result;
			scopesByProject.computeIfAbsent(project, ignored -> new ScopeAccumulator()).record(currentScope, normalized);
			return normalized;
		}
	}

	/**
	 * Optional target-scope discovery used by a patched cleanup orchestrator.
	 * The unpatched Eclipse cleanup framework simply never calls this method.
	 *
	 * @param project current Java project
	 * @param currentScope immutable current target scope
	 * @param monitor progress monitor
	 * @return related compilation units, or an empty collection
	 * @throws CoreException if discovery cannot be completed safely
	 */
	protected Collection<ICompilationUnit> discoverAdditionalCompilationUnits(IJavaProject project,
			Collection<ICompilationUnit> currentScope, IProgressMonitor monitor) throws CoreException {
		return Collections.emptyList();
	}

	/**
	 * Returns the currently retained plan for tests and specialised subclasses.
	 *
	 * @param project Java project
	 * @return plan or {@code null}
	 */
	protected final P getPlan(IJavaProject project) {
		synchronized (lifecycleLock) {
			return plansByProject.get(project);
		}
	}

	/**
	 * Returns metrics from the current project's most recent planning attempt.
	 * Metrics remain available through fix creation and postcondition checks and are
	 * cleared with the rest of the lifecycle state.
	 *
	 * @param project Java project
	 * @return retained metrics or empty metrics when no planning run exists
	 */
	protected final MultiFilePlanningMetrics getPlanningMetrics(IJavaProject project) {
		synchronized (lifecycleLock) {
			return metricsByProject.getOrDefault(project, MultiFilePlanningMetrics.empty());
		}
	}

	/**
	 * Returns structured diagnostics from the current project's most recent planning
	 * attempt. Diagnostics remain available through fix creation and postcondition
	 * checks and are cleared with the rest of the lifecycle state.
	 *
	 * @param project Java project
	 * @return retained diagnostics or empty diagnostics when no planning run exists
	 */
	protected final MultiFileCleanUpDiagnostics getPlanningDiagnostics(IJavaProject project) {
		synchronized (lifecycleLock) {
			return diagnosticsByProject.getOrDefault(project, MultiFileCleanUpDiagnostics.empty());
		}
	}

	private void clearPlanningState(IJavaProject project) {
		plansByProject.remove(project);
		metricsByProject.remove(project);
		diagnosticsByProject.remove(project);
	}

	private void clearProjectState(IJavaProject project) {
		clearPlanningState(project);
		scopesByProject.remove(project);
	}
}