PlanAwareHintFileFixCore.java

/*******************************************************************************
 * Copyright (c) 2026 Carsten Hammer and others.
 *
 * 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.triggerpattern.cleanup;

import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;

import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MarkerAnnotation;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
import org.eclipse.jdt.core.dom.rewrite.ListRewrite;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperation;
import org.eclipse.jdt.internal.corext.fix.CompilationUnitRewriteOperationsFixCore.CompilationUnitRewriteOperationWithSourceRange;
import org.eclipse.jdt.internal.corext.fix.LinkedProposalModelCore;
import org.eclipse.jdt.internal.corext.refactoring.structure.CompilationUnitRewrite;

import org.eclipse.text.edits.TextEditGroup;

import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.HintPlanRequirement;
import org.sandbox.jdt.triggerpattern.api.RewriteAlternative;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlan.NodeKey;
import org.sandbox.jdt.triggerpattern.api.SemanticRewritePlanContext;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
import org.sandbox.jdt.triggerpattern.cleanup.actions.StructuredRewriteActionOperation;
import org.sandbox.jdt.triggerpattern.internal.GuardRegistry;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;
import org.sandbox.jdt.triggerpattern.internal.HintFileSerializer;
import org.sandbox.jdt.triggerpattern.internal.HintProgramParser;
import org.sandbox.jdt.triggerpattern.internal.HintProgramParser.ParsedProgram;

/**
 * Adds semantic-plan authorization and exact coverage checks around the existing
 * {@link HintFileFixCore} rewrite backend.
 */
public final class PlanAwareHintFileFixCore {

	private static final Pattern ANNOTATION_NAME= Pattern.compile(
			"@([A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*)"); //$NON-NLS-1$

	private PlanAwareHintFileFixCore() {
	}

	/**
	 * Adds authorized operations and returns the exact semantic targets covered by
	 * at least one rewriting rule.
	 */
	public static Set<NodeKey> findOperationsFromContent(CompilationUnit compilationUnit,
			String hintFileContent, SemanticRewritePlan plan, Map<String, String> compilerOptions,
			Set<CompilationUnitRewriteOperationWithSourceRange> operations, Set<ASTNode> nodesProcessed)
			throws CoreException {
		GuardRegistry.getInstance();
		String requiredPlan= requiredPlan(hintFileContent);
		if (plan == null || plan.isEmpty()) {
			throw failure("Hint program requires semantic plan " + requiredPlan //$NON-NLS-1$
					+ " but no non-empty authorization plan was supplied", null); //$NON-NLS-1$
		}
		if (!plan.satisfiesContract(requiredPlan)) {
			throw failure("Hint program requires semantic plan " + requiredPlan //$NON-NLS-1$
					+ " but received contract " + plan.contractId(), null); //$NON-NLS-1$
		}
		if (hintFileContent.contains("$widestType")) { //$NON-NLS-1$
			throw failure("Plan-aware hint programs may not use analysis-dependent $widestType replacements", null); //$NON-NLS-1$
		}
		ParsedProgram program= parse(hintFileContent);
		HintFile hintFile= program.hintFile();
		validateAlternativeKinds(hintFile);
		BatchTransformationProcessor processor= new BatchTransformationProcessor(hintFile);
		List<TransformationResult> authorized= processor.process(compilationUnit, compilerOptions, plan).stream()
				.filter(TransformationResult::hasRewrite)
				.toList();

		Set<NodeKey> covered= new LinkedHashSet<>();
		for (TransformationResult result : authorized) {
			ASTNode matched= result.match().getMatchedNode();
			NodeKey key= NodeKey.from(matched);
			if (matched == null || key == null) {
				throw failure("A plan-aware hint result has no stable semantic AST target", null); //$NON-NLS-1$
			}
			covered.add(key);
			nodesProcessed.add(matched);
		}

		List<TransformationResult> textResults= authorized.stream()
				.filter(TransformationResult::hasReplacement).toList();
		Set<CompilationUnitRewriteOperation> delegated= new LinkedHashSet<>();
		if (!textResults.isEmpty()) {
			HintFile textHints= textOnlyHintFile(hintFile);
			String textProgram= new HintFileSerializer().serialize(textHints);
			try (SemanticRewritePlanContext.Scope ignored=
					SemanticRewritePlanContext.install(plan, compilerOptions)) {
				HintFileFixCore.findOperationsFromContent(compilationUnit, textProgram, delegated);
			}
		}
		if (delegated.size() != textResults.size()) {
			throw failure("The existing hint backend produced " + delegated.size() //$NON-NLS-1$
					+ " operations for " + textResults.size() + " authorized text rewrites", null); //$NON-NLS-1$ //$NON-NLS-2$
		}

		Iterator<CompilationUnitRewriteOperation> textIterator= delegated.iterator();
		for (TransformationResult result : authorized) {
			if (result.hasStructuredActions()) {
				operations.add(new StructuredRewriteActionOperation(result, plan));
				continue;
			}
			if (!textIterator.hasNext()) {
				throw failure("The existing hint backend text operation order is incomplete", null); //$NON-NLS-1$
			}
			CompilationUnitRewriteOperation delegate= textIterator.next();
			if (result.match().getMatchedNode() instanceof MethodDeclaration) {
				operations.add(new PlannedMethodAnnotationOperation(result));
			} else {
				operations.add(new DelegatingHintOperation(delegate));
			}
		}
		return Set.copyOf(covered);
	}

	private static void validateAlternativeKinds(HintFile hintFile) throws CoreException {
		for (TransformationRule rule : hintFile.getRules()) {
			boolean hasText= rule.alternatives().stream().anyMatch(RewriteAlternative::hasTextReplacement);
			boolean hasActions= rule.alternatives().stream().anyMatch(RewriteAlternative::hasStructuredActions);
			if (hasText && hasActions) {
				throw failure("Plan-aware rule " + rule.getRuleId() //$NON-NLS-1$
						+ " mixes text and structured alternatives; split it into separate rules", null); //$NON-NLS-1$
			}
		}
	}

	private static HintFile textOnlyHintFile(HintFile source) {
		HintFile copy= new HintFile();
		copy.setId(source.getId());
		copy.setDescription(source.getDescription());
		copy.setSeverity(source.getSeverity());
		copy.setMinJavaVersion(source.getMinJavaVersion());
		copy.setBindingPolicy(source.getBindingPolicy());
		copy.setTags(source.getTags());
		copy.setCaseInsensitive(source.isCaseInsensitive());
		copy.setSuppressWarnings(source.getSuppressWarnings());
		copy.setTreeKindNodeTypes(source.getTreeKindNodeTypes());
		source.getIncludes().forEach(copy::addInclude);
		source.getEmbeddedJavaBlocks().forEach(copy::addEmbeddedJavaBlock);
		for (TransformationRule rule : source.getRules()) {
			if (rule.alternatives().stream().noneMatch(RewriteAlternative::hasStructuredActions)) {
				copy.addRule(rule);
			}
		}
		return copy;
	}

	private static String requiredPlan(String content) throws CoreException {
		try {
			return HintPlanRequirement.fromContent(content)
					.orElseThrow(() -> new IllegalArgumentException(
							"A plan-aware hint program must declare <!requires-plan: ...>")); //$NON-NLS-1$
		} catch (IllegalArgumentException e) {
			throw failure("Invalid plan-aware hint contract: " + e.getMessage(), e); //$NON-NLS-1$
		}
	}

	private static ParsedProgram parse(String content) throws CoreException {
		try {
			return new HintProgramParser().parse(content);
		} catch (HintFileParser.HintParseException e) {
			throw failure("Cannot parse trusted plan-aware hint program", e); //$NON-NLS-1$
		}
	}

	private static CoreException failure(String message, Throwable cause) {
		return new CoreException(new Status(IStatus.ERROR, "sandbox_common", message, cause)); //$NON-NLS-1$
	}

	private static Set<String> annotationNames(String source) {
		Set<String> names= new LinkedHashSet<>();
		if (source == null) {
			return names;
		}
		Matcher matcher= ANNOTATION_NAME.matcher(source);
		while (matcher.find()) {
			names.add(matcher.group(1));
		}
		return names;
	}

	private static String simpleName(String qualifiedName) {
		int separator= qualifiedName.lastIndexOf('.');
		return separator < 0 ? qualifiedName : qualifiedName.substring(separator + 1);
	}

	/**
	 * Applies marker annotations declared by a legacy authorized method rule using
	 * real AST nodes. New declarative programs should use {@code addAnnotation}.
	 */
	private static final class PlannedMethodAnnotationOperation
			extends CompilationUnitRewriteOperationWithSourceRange {
		private final TransformationResult result;

		PlannedMethodAnnotationOperation(TransformationResult result) {
			this.result= result;
		}

		@Override
		public void rewriteASTInternal(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel)
				throws CoreException {
			if (!(result.match().getMatchedNode() instanceof MethodDeclaration method)) {
				throw failure("The planned method annotation target is missing", null); //$NON-NLS-1$
			}
			Set<String> annotationsToAdd= annotationNames(result.replacement());
			if (result.rule() != null) {
				annotationsToAdd.removeAll(annotationNames(result.rule().sourcePattern().getValue()));
			}
			if (annotationsToAdd.isEmpty()) {
				throw failure("The planned method hint does not add an annotation", null); //$NON-NLS-1$
			}

			Set<String> existing= new LinkedHashSet<>();
			for (Object modifier : method.modifiers()) {
				if (modifier instanceof Annotation annotation) {
					ITypeBinding binding= annotation.resolveTypeBinding();
					String name= binding == null ? annotation.getTypeName().getFullyQualifiedName()
							: binding.getQualifiedName();
					existing.add(simpleName(name));
				}
			}

			ASTRewrite rewrite= cuRewrite.getASTRewrite();
			AST ast= cuRewrite.getRoot().getAST();
			ImportRewrite imports= cuRewrite.getImportRewrite();
			ListRewrite modifiers= rewrite.getListRewrite(method, MethodDeclaration.MODIFIERS2_PROPERTY);
			TextEditGroup group= createTextEditGroup(
					result.description() == null ? "Apply planned method annotation" : result.description(), //$NON-NLS-1$
					cuRewrite);
			for (String annotationName : annotationsToAdd) {
				String simple= simpleName(annotationName);
				if (existing.contains(simple)) {
					continue;
				}
				String importedName= annotationName.indexOf('.') >= 0
						? imports.addImport(annotationName) : annotationName;
				MarkerAnnotation annotation= ast.newMarkerAnnotation();
				annotation.setTypeName(ast.newName(importedName));
				modifiers.insertFirst(annotation, group);
				existing.add(simple);
			}
		}
	}

	/** Preserves the source-range behavior expected by coordinated cleanups. */
	private static final class DelegatingHintOperation extends CompilationUnitRewriteOperationWithSourceRange {
		private final CompilationUnitRewriteOperation delegate;

		DelegatingHintOperation(CompilationUnitRewriteOperation delegate) {
			this.delegate= delegate;
		}

		@Override
		public void rewriteASTInternal(CompilationUnitRewrite cuRewrite, LinkedProposalModelCore linkedModel)
				throws CoreException {
			delegate.rewriteAST(cuRewrite, linkedModel);
		}

		@Override
		public String getAdditionalInfo() {
			return delegate.getAdditionalInfo();
		}
	}
}