ProjectWideCodeCleanupApplication.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.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IProjectDescription;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.NullProgressMonitor;
import org.eclipse.equinox.app.IApplication;
import org.eclipse.equinox.app.IApplicationContext;

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.internal.corext.fix.CleanUpRefactoring;
import org.eclipse.jdt.internal.ui.JavaPlugin;

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

import org.eclipse.ltk.core.refactoring.Change;
import org.eclipse.ltk.core.refactoring.RefactoringStatus;
import org.eclipse.ltk.core.refactoring.RefactoringStatusEntry;

import org.sandbox.jdt.cleanup.multifile.api.IMultiFileCleanUpDiagnosticsProvider;
import org.sandbox.jdt.cleanup.multifile.api.LineDelimiterPreserver;
import org.sandbox.jdt.triggerpattern.git.UnifiedDiffFormatter;

/**
 * Executes one cleanup refactoring over every source compilation unit of one
 * already imported Eclipse Java project.
 *
 * <p>This application exists for semantic migration QA and documentation. In
 * contrast to the legacy file-oriented application it preserves the JDT cleanup
 * lifecycle: one cleanup instance sees the complete project scope in
 * {@code checkPreConditions}, one {@link Change} is previewed/applied, check
 * runs restore every tracked file byte-for-byte, and incomplete apply runs roll
 * back the source and project-resource snapshots.</p>
 */
public final class ProjectWideCodeCleanupApplication implements IApplication {

	private static final int EXIT_OK= 0;
	private static final int EXIT_ERROR= 1;
	private static final int EXIT_CHANGES= 2;

	private static final String PDE_MANIFEST= "META-INF/MANIFEST.MF"; //$NON-NLS-1$
	private static final String JUNIT_ISOLATED_TYPE=
			"org.junit.jupiter.api.parallel.Isolated"; //$NON-NLS-1$
	private static final String JUNIT_PARALLEL_PACKAGE=
			"org.junit.jupiter.api.parallel"; //$NON-NLS-1$

	private enum Mode {
		CHECK,
		APPLY
	}

	private record Arguments(String projectName, Path projectLocation, Path configuration,
			Path report, Path patch, Mode mode) {
	}

	private record SourceSnapshot(ICompilationUnit unit, IFile file, Path path, String relativePath,
			byte[] before) {
	}

	private record ResourceSnapshot(IFile file, Path path, String relativePath, byte[] before) {
	}

	private record ChangedFile(String relativePath, byte[] before, byte[] after) {
	}

	@Override
	public Object start(IApplicationContext context) {
		Instant started= Instant.now();
		List<String> errors= new ArrayList<>();
		List<String> statusMessages= new ArrayList<>();
		List<String> planningDiagnostics= new ArrayList<>();
		List<ChangedFile> changed= new ArrayList<>();
		Arguments arguments;
		try {
			arguments= parseArguments((String[]) context.getArguments().get(IApplicationContext.APPLICATION_ARGS));
		} catch (IllegalArgumentException e) {
			System.err.println(e.getMessage());
			printUsage();
			return Integer.valueOf(EXIT_ERROR);
		}
		if (arguments == null) {
			printUsage();
			return Integer.valueOf(EXIT_OK);
		}

		List<SourceSnapshot> sources= List.of();
		List<ResourceSnapshot> resources= List.of();
		Map<String, String> options= Map.of();
		try {
			options= readOptions(arguments.configuration());
			IProject project= requireProject(arguments.projectName(), arguments.projectLocation());
			IJavaProject javaProject= JavaCore.create(project);
			if (!javaProject.exists()) {
				throw new IllegalArgumentException("Workspace project is not a Java project: " //$NON-NLS-1$
						+ arguments.projectName());
			}
			sources= snapshots(javaProject);
			resources= resourceSnapshots(project);
			if (sources.isEmpty()) {
				throw new IllegalArgumentException("Java project contains no source compilation units: " //$NON-NLS-1$
						+ arguments.projectName());
			}

			IProgressMonitor monitor= new NullProgressMonitor();
			CleanUpRefactoring refactoring= new CleanUpRefactoring();
			for (SourceSnapshot source : sources) {
				refactoring.addCompilationUnit(source.unit());
			}

			ICleanUp[] cleanUps= JavaPlugin.getDefault().getCleanUpRegistry().createCleanUps();
			CleanUpOptions cleanUpOptions= new CleanUpOptions();
			options.forEach(cleanUpOptions::setOption);
			for (ICleanUp cleanUp : cleanUps) {
				cleanUp.setOptions(cleanUpOptions);
				refactoring.addCleanUp(cleanUp);
			}

			RefactoringStatus status= refactoring.checkAllConditions(monitor);
			for (RefactoringStatusEntry entry : status.getEntries()) {
				statusMessages.add(entry.getMessage());
			}
			for (ICleanUp cleanUp : cleanUps) {
				if (cleanUp instanceof IMultiFileCleanUpDiagnosticsProvider provider) {
					String json= provider.getLastPlanningDiagnosticsJson(javaProject);
					if (json != null && !json.isBlank()) {
						planningDiagnostics.add(json);
					}
				}
			}
			if (status.hasFatalError()) {
				throw new IllegalStateException("Cleanup preconditions failed: " //$NON-NLS-1$
						+ status.getMessageMatchingSeverity(RefactoringStatus.FATAL));
			}

			Change change= refactoring.createChange(monitor);
			if (change != null) {
				boolean restoreAfterExecution= arguments.mode() == Mode.CHECK;
				boolean executionCompleted= false;
				try {
					change.perform(monitor);
					preserveOriginalLineDelimiters(sources);
					updateProjectMetadata(sources, resources);
					refresh(sources, resources, monitor);
					changed= changedFiles(sources, resources);
					executionCompleted= true;
				} finally {
					if (restoreAfterExecution || !executionCompleted) {
						restore(sources, resources, monitor, errors);
					}
				}
			}
		} catch (CoreException | IOException | RuntimeException e) {
			errors.add(describeException(e));
			e.printStackTrace(System.err);
		}

		if (arguments.patch() != null) {
			try {
				writePatch(arguments.patch(), changed);
			} catch (IOException e) {
				errors.add("Cannot write cleanup patch: " + e.getMessage()); //$NON-NLS-1$
			}
		}
		Instant ended= Instant.now();
		try {
			writeReport(arguments.report(), arguments, started, ended, options, sources, changed,
					statusMessages, planningDiagnostics, errors);
		} catch (IOException e) {
			errors.add("Cannot write cleanup report: " + e.getMessage()); //$NON-NLS-1$
		}

		if (!errors.isEmpty()) {
			return Integer.valueOf(EXIT_ERROR);
		}
		if (arguments.mode() == Mode.CHECK && !changed.isEmpty()) {
			return Integer.valueOf(EXIT_CHANGES);
		}
		return Integer.valueOf(EXIT_OK);
	}

	private static Arguments parseArguments(String[] values) {
		if (values == null) {
			throw new IllegalArgumentException("Application arguments are unavailable."); //$NON-NLS-1$
		}
		String project= null;
		Path projectLocation= null;
		Path configuration= null;
		Path report= null;
		Path patch= null;
		Mode mode= Mode.CHECK;
		for (int index= 0; index < values.length; index++) {
			String value= values[index];
			switch (value) {
			case "--help", "-help" -> { //$NON-NLS-1$ //$NON-NLS-2$
				return null;
			}
			case "--project" -> project= requiredValue(values, ++index, value); //$NON-NLS-1$
			case "--project-location" -> projectLocation= Path.of(requiredValue(values, ++index, value)); //$NON-NLS-1$
			case "--config", "-config" -> configuration= Path.of(requiredValue(values, ++index, value)); //$NON-NLS-1$ //$NON-NLS-2$
			case "--report" -> report= Path.of(requiredValue(values, ++index, value)); //$NON-NLS-1$
			case "--patch" -> patch= Path.of(requiredValue(values, ++index, value)); //$NON-NLS-1$
			case "--mode" -> { //$NON-NLS-1$
				String requested= requiredValue(values, ++index, value);
				try {
					mode= Mode.valueOf(requested.toUpperCase(java.util.Locale.ROOT));
				} catch (IllegalArgumentException e) {
					throw new IllegalArgumentException("Unsupported project cleanup mode: " + requested, e); //$NON-NLS-1$
				}
			}
			default -> {
				if (!value.isBlank() && !"-pdelaunch".equals(value)) { //$NON-NLS-1$
					throw new IllegalArgumentException("Unknown option: " + value); //$NON-NLS-1$
				}
			}
			}
		}
		if (project == null || project.isBlank()) {
			throw new IllegalArgumentException("--project is required."); //$NON-NLS-1$
		}
		if (configuration == null) {
			throw new IllegalArgumentException("--config is required."); //$NON-NLS-1$
		}
		if (report == null) {
			throw new IllegalArgumentException("--report is required."); //$NON-NLS-1$
		}
		return new Arguments(project,
				projectLocation == null ? null : projectLocation.toAbsolutePath().normalize(),
				configuration.toAbsolutePath().normalize(), report.toAbsolutePath().normalize(),
				patch == null ? null : patch.toAbsolutePath().normalize(), mode);
	}

	private static String requiredValue(String[] values, int index, String option) {
		if (index >= values.length || values[index].isBlank()) {
			throw new IllegalArgumentException(option + " requires a value."); //$NON-NLS-1$
		}
		return values[index];
	}

	private static Map<String, String> readOptions(Path path) throws IOException {
		if (!Files.isRegularFile(path)) {
			throw new IOException("Cleanup configuration does not exist: " + path); //$NON-NLS-1$
		}
		Properties properties= new Properties();
		try (var reader= Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
			properties.load(reader);
		}
		Map<String, String> result= new LinkedHashMap<>();
		properties.stringPropertyNames().stream().sorted()
				.forEach(name -> result.put(name, properties.getProperty(name)));
		return result;
	}

	private static IProject requireProject(String name, Path requestedLocation) throws CoreException, IOException {
		IWorkspaceRoot root= ResourcesPlugin.getWorkspace().getRoot();
		IProject project= root.getProject(name);
		IProgressMonitor monitor= new NullProgressMonitor();
		if (!project.exists()) {
			if (requestedLocation == null) {
				throw new IllegalArgumentException("Workspace project does not exist and --project-location was not supplied: " //$NON-NLS-1$
						+ name);
			}
			Path canonicalLocation= requestedLocation.toRealPath();
			Path descriptionFile= canonicalLocation.resolve(".project"); //$NON-NLS-1$
			if (!Files.isRegularFile(descriptionFile)) {
				throw new IOException("Imported project location has no .project file: " + canonicalLocation); //$NON-NLS-1$
			}
			IProjectDescription description= ResourcesPlugin.getWorkspace().loadProjectDescription(
					org.eclipse.core.runtime.Path.fromOSString(descriptionFile.toString()));
			if (!name.equals(description.getName())) {
				throw new IOException("Imported .project declares " + description.getName() //$NON-NLS-1$
						+ " but --project requested " + name); //$NON-NLS-1$
			}
			org.eclipse.core.runtime.IPath location=
					org.eclipse.core.runtime.Path.fromOSString(canonicalLocation.toString());
			org.eclipse.core.runtime.IPath defaultLocation=
					root.getLocation() == null ? null : root.getLocation().append(name);
			description.setLocation(location.equals(defaultLocation) ? null : location);
			project.create(description, monitor);
		} else if (requestedLocation != null) {
			Path canonicalLocation= requestedLocation.toRealPath();
			org.eclipse.core.runtime.IPath actualLocation= project.getLocation();
			if (actualLocation == null || !canonicalLocation.equals(actualLocation.toFile().toPath().toRealPath())) {
				throw new IOException("Workspace project exists at another location: " + name); //$NON-NLS-1$
			}
		}
		if (!project.isOpen()) {
			project.open(monitor);
		}
		project.refreshLocal(IResource.DEPTH_INFINITE, monitor);
		return project;
	}

	private static List<SourceSnapshot> snapshots(IJavaProject project) throws CoreException, IOException {
		List<ICompilationUnit> units= new ArrayList<>();
		for (IPackageFragmentRoot root : project.getPackageFragmentRoots()) {
			if (root.getKind() != IPackageFragmentRoot.K_SOURCE) {
				continue;
			}
			for (IJavaElement child : root.getChildren()) {
				if (child instanceof IPackageFragment fragment) {
					units.addAll(Arrays.asList(fragment.getCompilationUnits()));
				}
			}
		}
		units.sort(Comparator.comparing(unit -> unit.getPath().toPortableString()));
		List<SourceSnapshot> result= new ArrayList<>();
		for (ICompilationUnit unit : units) {
			if (!(unit.getResource() instanceof IFile file) || file.getLocation() == null) {
				continue;
			}
			Path path= file.getLocation().toFile().toPath().toAbsolutePath().normalize();
			result.add(new SourceSnapshot(unit, file, path,
					file.getProjectRelativePath().toPortableString(), Files.readAllBytes(path)));
		}
		return List.copyOf(result);
	}


	private static List<ResourceSnapshot> resourceSnapshots(IProject project) throws IOException {
		IFile manifest= project.getFile(PDE_MANIFEST);
		if (!manifest.exists() || manifest.getLocation() == null) {
			return List.of();
		}
		Path path= manifest.getLocation().toFile().toPath().toAbsolutePath().normalize();
		return List.of(new ResourceSnapshot(manifest, path,
				manifest.getProjectRelativePath().toPortableString(), Files.readAllBytes(path)));
	}

	private static void updateProjectMetadata(List<SourceSnapshot> sources,
			List<ResourceSnapshot> resources) throws IOException {
		boolean isolatedIntroduced= false;
		for (SourceSnapshot source : sources) {
			byte[] after= Files.readAllBytes(source.path());
			if (!contains(source.before(), JUNIT_ISOLATED_TYPE) && contains(after, JUNIT_ISOLATED_TYPE)) {
				isolatedIntroduced= true;
				break;
			}
		}
		if (!isolatedIntroduced) {
			return;
		}
		for (ResourceSnapshot resource : resources) {
			if (!PDE_MANIFEST.equals(resource.relativePath())) {
				continue;
			}
			byte[] current= Files.readAllBytes(resource.path());
			byte[] updated= PdeManifestImportUpdater.addImport(current, JUNIT_PARALLEL_PACKAGE);
			if (!Arrays.equals(current, updated)) {
				Files.write(resource.path(), updated);
			}
			return;
		}
	}

	private static boolean contains(byte[] content, String token) {
		return new String(content, StandardCharsets.UTF_8).contains(token);
	}

	private static void refresh(List<SourceSnapshot> sources, List<ResourceSnapshot> resources,
			IProgressMonitor monitor) throws CoreException {
		for (SourceSnapshot source : sources) {
			source.file().refreshLocal(IResource.DEPTH_ZERO, monitor);
		}
		for (ResourceSnapshot resource : resources) {
			resource.file().refreshLocal(IResource.DEPTH_ZERO, monitor);
		}
	}

	private static List<ChangedFile> changedFiles(List<SourceSnapshot> sources,
			List<ResourceSnapshot> resources) throws IOException {
		List<ChangedFile> result= new ArrayList<>();
		for (SourceSnapshot source : sources) {
			byte[] after= Files.readAllBytes(source.path());
			if (!Arrays.equals(source.before(), after)) {
				result.add(new ChangedFile(source.relativePath(), source.before(), after));
			}
		}
		for (ResourceSnapshot resource : resources) {
			byte[] after= Files.readAllBytes(resource.path());
			if (!Arrays.equals(resource.before(), after)) {
				result.add(new ChangedFile(resource.relativePath(), resource.before(), after));
			}
		}
		result.sort(Comparator.comparing(ChangedFile::relativePath));
		return List.copyOf(result);
	}

	private static void restore(List<SourceSnapshot> sources, List<ResourceSnapshot> resources,
			IProgressMonitor monitor, List<String> errors) {
		restore(sources, monitor, errors);
		for (ResourceSnapshot resource : resources) {
			restore(resource.file(), resource.path(), resource.relativePath(), resource.before(), monitor, errors);
		}
	}

	private static void restore(List<SourceSnapshot> sources, IProgressMonitor monitor,
			List<String> errors) {
		for (SourceSnapshot source : sources) {
			restore(source.file(), source.path(), source.relativePath(), source.before(), monitor, errors);
		}
	}

	private static void restore(IFile file, Path path, String relativePath, byte[] before,
			IProgressMonitor monitor, List<String> errors) {
		try {
			Files.write(path, before);
			file.refreshLocal(IResource.DEPTH_ZERO, monitor);
		} catch (IOException | CoreException e) {
			errors.add("Cannot restore " + relativePath + ": " + e.getMessage()); //$NON-NLS-1$ //$NON-NLS-2$
		}
	}

	private static void writePatch(Path path, List<ChangedFile> changed) throws IOException {
		createParent(path);
		ByteArrayOutputStream patch= new ByteArrayOutputStream();
		for (ChangedFile source : changed) {
			patch.writeBytes(UnifiedDiffFormatter.format(source.relativePath(), source.before(), source.after()));
		}
		Files.write(path, patch.toByteArray());
	}

	private static void writeReport(Path path, Arguments arguments, Instant started, Instant ended,
			Map<String, String> options, List<SourceSnapshot> sources, List<ChangedFile> changed,
			List<String> statusMessages, List<String> planningDiagnostics, List<String> errors) throws IOException {
		createParent(path);
		StringBuilder json= new StringBuilder(2048);
		json.append("{\n"); //$NON-NLS-1$
		property(json, "schemaVersion", "1", 1).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
		property(json, "tool", "sandbox-project-cleanup", 1).append(",\n"); //$NON-NLS-1$ //$NON-NLS-2$
		property(json, "mode", arguments.mode().name().toLowerCase(java.util.Locale.ROOT), 1).append(",\n"); //$NON-NLS-1$
		property(json, "project", arguments.projectName(), 1).append(",\n"); //$NON-NLS-1$
		property(json, "projectLocation", arguments.projectLocation() == null ? "" //$NON-NLS-1$ //$NON-NLS-2$
				: arguments.projectLocation().toString(), 1).append(",\n"); //$NON-NLS-1$
		property(json, "startTime", started.toString(), 1).append(",\n"); //$NON-NLS-1$
		property(json, "endTime", ended.toString(), 1).append(",\n"); //$NON-NLS-1$
		number(json, "durationMs", ended.toEpochMilli() - started.toEpochMilli(), 1).append(",\n"); //$NON-NLS-1$
		number(json, "filesProcessed", sources.size(), 1).append(",\n"); //$NON-NLS-1$
		number(json, "filesChanged", changed.size(), 1).append(",\n"); //$NON-NLS-1$
		array(json, "changedFiles", changed.stream().map(ChangedFile::relativePath).toList(), 1) //$NON-NLS-1$
				.append(",\n");
		json.append("  \"cleanupOptions\": {"); //$NON-NLS-1$
		int optionIndex= 0;
		for (Map.Entry<String, String> entry : options.entrySet()) {
			if (optionIndex++ > 0) {
				json.append(',');
			}
			json.append("\n    \"").append(escape(entry.getKey())).append("\": \"") //$NON-NLS-1$ //$NON-NLS-2$
					.append(escape(entry.getValue())).append('"');
		}
		if (!options.isEmpty()) {
			json.append('\n');
		}
		json.append("  },\n"); //$NON-NLS-1$
		array(json, "refactoringStatus", statusMessages, 1).append(",\n"); //$NON-NLS-1$
		json.append("  \"planningDiagnostics\": ["); //$NON-NLS-1$
		for (int index= 0; index < planningDiagnostics.size(); index++) {
			if (index > 0) {
				json.append(',');
			}
			json.append('\n').append("    ").append(planningDiagnostics.get(index)); //$NON-NLS-1$
		}
		if (!planningDiagnostics.isEmpty()) {
			json.append('\n').append("  "); //$NON-NLS-1$
		}
		json.append("],\n"); //$NON-NLS-1$
		number(json, "errorCount", errors.size(), 1).append(",\n"); //$NON-NLS-1$
		array(json, "errors", errors, 1).append('\n'); //$NON-NLS-1$
		json.append("}\n"); //$NON-NLS-1$
		Files.writeString(path, json.toString(), StandardCharsets.UTF_8);
	}

	private static StringBuilder property(StringBuilder json, String name, String value, int indent) {
		return json.append("  ".repeat(indent)).append('"').append(escape(name)).append("\": \"") //$NON-NLS-1$ //$NON-NLS-2$
				.append(escape(value)).append('"');
	}

	private static StringBuilder number(StringBuilder json, String name, long value, int indent) {
		return json.append("  ".repeat(indent)).append('"').append(escape(name)).append("\": ").append(value); //$NON-NLS-1$ //$NON-NLS-2$
	}

	private static StringBuilder array(StringBuilder json, String name, List<String> values, int indent) {
		json.append("  ".repeat(indent)).append('"').append(escape(name)).append("\": ["); //$NON-NLS-1$ //$NON-NLS-2$
		for (int index= 0; index < values.size(); index++) {
			if (index > 0) {
				json.append(',');
			}
			json.append("\n").append("  ".repeat(indent + 1)).append('"') //$NON-NLS-1$
					.append(escape(values.get(index))).append('"');
		}
		if (!values.isEmpty()) {
			json.append('\n').append("  ".repeat(indent)); //$NON-NLS-1$
		}
		return json.append(']');
	}

	private static String escape(String value) {
		StringBuilder escaped= new StringBuilder(value.length() + 16);
		for (int index= 0; index < value.length(); index++) {
			char character= value.charAt(index);
			switch (character) {
			case '"' -> escaped.append("\\\""); //$NON-NLS-1$
			case '\\' -> escaped.append("\\\\"); //$NON-NLS-1$
			case '\b' -> escaped.append("\\b"); //$NON-NLS-1$
			case '\f' -> escaped.append("\\f"); //$NON-NLS-1$
			case '\n' -> escaped.append("\\n"); //$NON-NLS-1$
			case '\r' -> escaped.append("\\r"); //$NON-NLS-1$
			case '\t' -> escaped.append("\\t"); //$NON-NLS-1$
			default -> {
				if (character < 0x20) {
					escaped.append(String.format("\\u%04x", Integer.valueOf(character))); //$NON-NLS-1$
				} else {
					escaped.append(character);
				}
			}
			}
		}
		return escaped.toString();
	}

	private static String describeException(Throwable throwable) {
		StringBuilder description= new StringBuilder();
		Set<Throwable> visited= java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>());
		Throwable current= throwable;
		while (current != null && visited.add(current)) {
			if (!description.isEmpty()) {
				description.append(" <- caused by: "); //$NON-NLS-1$
			}
			description.append(current.getClass().getName());
			String message= current.getMessage();
			if (message != null && !message.isBlank()) {
				description.append(": ").append(message); //$NON-NLS-1$
			}
			current= current.getCause();
		}
		return description.toString();
	}


	private static void preserveOriginalLineDelimiters(List<SourceSnapshot> sources)
			throws IOException, CoreException {
		for (SourceSnapshot source : sources) {
			byte[] current= Files.readAllBytes(source.path());
			byte[] normalized= LineDelimiterPreserver.preserve(
					source.before(), current, source.file().getCharset());
			if (!Arrays.equals(current, normalized)) {
				Files.write(source.path(), normalized);
			}
		}
	}

	private static void createParent(Path path) throws IOException {
		Path parent= path.getParent();
		if (parent != null) {
			Files.createDirectories(parent);
		}
	}

	private static void printUsage() {
		System.out.println("""
				Usage: eclipse -nosplash -data <workspace>
				  -application org.sandbox.jdt.core.ProjectWideJavaCleanup
				  --project <workspace-project>
				  [--project-location <external-project-directory>]
				  --config <cleanup.properties>
				  --report <result.json>
				  [--patch <changes.patch>]
				  [--mode check|apply]
				"""); //$NON-NLS-1$
	}

	@Override
	public void stop() {
		// Nothing to stop.
	}
}