XMLCleanupService.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
 *
 * Contributors:
 *     Carsten Hammer
 *******************************************************************************/
package org.sandbox.jdt.internal.corext.fix.helper;

import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Set;

import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IFolder;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.resources.IResourceVisitor;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.ILog;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.Status;

/**
 * Service for XML cleanup that can be used independently of JDT cleanup framework.
 * This allows XML cleanup to work without requiring Java compilation unit context.
 */
public class XMLCleanupService {
	
	private static final ILog LOG = Platform.getLog(XMLCleanupService.class);
	private static final String PLUGIN_ID = "sandbox_xml_cleanup";
	
	// PDE-relevant file names
	private static final Set<String> PDE_FILE_NAMES = Set.of(
		"plugin.xml",
		"feature.xml", 
		"fragment.xml"
	);
	
	// PDE-relevant file extensions
	private static final Set<String> PDE_EXTENSIONS = Set.of("exsd", "xsd");
	
	// PDE-typical directories
	private static final Set<String> PDE_DIRECTORIES = Set.of("OSGI-INF", "META-INF");
	
	// Indentation preference (default: false for size reduction)
	private boolean enableIndent = false;

	/**
	 * Set whether to enable indentation in XML output.
	 * 
	 * @param enable true to enable indentation, false for compact output (default)
	 */
	public void setEnableIndent(boolean enable) {
		this.enableIndent = enable;
	}

	/**
	 * Process a single XML file.
	 * 
	 * @param file the file to process
	 * @param monitor progress monitor (can be null)
	 * @return true if file was processed and changed, false otherwise
	 * @throws CoreException if file access fails
	 */
	public boolean processFile(IFile file, IProgressMonitor monitor) throws CoreException {
		if (monitor != null && monitor.isCanceled()) {
			return false;
		}
		
		if (!isPDERelevantFile(file)) {
			return false;
		}
		
		// Read original content
		String originalContent;
		try (InputStream is = file.getContents()) {
			originalContent = new String(is.readAllBytes(), StandardCharsets.UTF_8);
		} catch (Exception e) {
			throw new CoreException(new Status(IStatus.ERROR, PLUGIN_ID,
				"Failed to read file: " + file.getName(), e));
		}
		
		// Transform the file - check for null location
		org.eclipse.core.runtime.IPath location = file.getLocation();
		if (location == null) {
			LOG.log(new Status(IStatus.WARNING, PLUGIN_ID,
				"File does not have a physical location: " + file.getName()));
			return false;
		}
		
		Path filePath = location.toFile().toPath();
		String transformedContent;
		try {
			transformedContent = SchemaTransformationUtils.transform(filePath, enableIndent);
		} catch (Exception e) {
			throw new CoreException(new Status(IStatus.ERROR, PLUGIN_ID,
				"Failed to transform file: " + file.getName(), e));
		}
		
		// Only write if content actually changed
		if (!originalContent.equals(transformedContent)) {
			byte[] newContent = transformedContent.getBytes(StandardCharsets.UTF_8);
			ByteArrayInputStream inputStream = new ByteArrayInputStream(newContent);
			
			// Update file (don't force, keep history)
			file.setContents(inputStream, IResource.KEEP_HISTORY, monitor);
			
			// Refresh the resource to sync with filesystem
			file.refreshLocal(IResource.DEPTH_ZERO, monitor);
			
			LOG.log(new Status(IStatus.INFO, PLUGIN_ID,
				"Applied transformation to: " + file.getName()));
			
			return true;
		}
		
		return false;
	}
	
	/**
	 * Process all PDE XML files in a project.
	 * 
	 * @param project the project to process
	 * @param monitor progress monitor (can be null)
	 * @return number of files processed and changed
	 * @throws CoreException if resource traversal fails
	 */
	public int processProject(IProject project, IProgressMonitor monitor) throws CoreException {
		final int[] filesProcessed = {0};
		
		project.accept(new IResourceVisitor() {
			@Override
			public boolean visit(IResource resource) throws CoreException {
				if (monitor != null && monitor.isCanceled()) {
					return false;
				}
				
				if (resource instanceof IFile file) {
					
					if (isPDERelevantFile(file)) {
						try {
							if (monitor != null) {
								monitor.subTask("Processing: " + file.getName());
							}
							
							boolean changed = processFile(file, monitor);
							if (changed) {
								filesProcessed[0]++;
							}
							
							if (monitor != null) {
								monitor.worked(1);
							}
						} catch (CoreException e) {
							LOG.log(new Status(IStatus.ERROR, PLUGIN_ID, 
								"Error processing file: " + file.getName(), e));
						}
					}
				}
				return true; // Continue iteration
			}
		});
		
		return filesProcessed[0];
	}
	
	/**
	 * Check if file is PDE-relevant.
	 * 
	 * @param file the file to check
	 * @return true if the file should be processed
	 */
	public boolean isPDERelevantFile(IFile file) {
		String fileName = file.getName();
		String extension = file.getFileExtension();
		
		// Check if it's a known PDE file name
		if (PDE_FILE_NAMES.contains(fileName)) {
			// Must be in project root, OSGI-INF, or META-INF
			return isInPDELocation(file);
		}
		
		// Check if it's a PDE extension (exsd, xsd)
		if (extension != null && PDE_EXTENSIONS.contains(extension)) {
			return isInPDELocation(file);
		}
		
		return false;
	}
	
	/**
	 * Check if file is in a PDE-typical location.
	 * 
	 * @param file the file to check
	 * @return true if in root, OSGI-INF, or META-INF
	 */
	private boolean isInPDELocation(IFile file) {
		IResource parent = file.getParent();
		
		// Check if in project root
		if (parent instanceof IProject) {
			return true;
		}
		
		// Check if in OSGI-INF or META-INF
		if (parent instanceof IFolder) {
			String folderName = parent.getName();
			if (PDE_DIRECTORIES.contains(folderName)) {
				return true;
			}
			
			// Also check parent's parent (for nested structures)
			IResource grandParent = parent.getParent();
			if (grandParent instanceof IFolder) {
				if (PDE_DIRECTORIES.contains(grandParent.getName())) {
					return true;
				}
			}
		}
		
		return false;
	}
}