RuleMatchDiagnostics.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, available at
 * https://www.eclipse.org/legal/epl-2.0/
 * SPDX-License-Identifier: EPL-2.0
 *******************************************************************************/
package org.sandbox.jdt.triggerpattern.api;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.AbstractTypeDeclaration;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.Type;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;
import org.sandbox.jdt.triggerpattern.api.GuardContext.UnknownSemanticRequirement;
import org.sandbox.jdt.triggerpattern.api.GuardExpression.Evaluation;
import org.sandbox.jdt.triggerpattern.api.GuardExpression.TruthValue;

/**
 * Read-only explanations of structurally matching candidates on a current AST.
 *
 * <p>Only structurally similar candidates are considered, and selection and count
 * limits bound the returned details. Replacements come from the ordinary batch
 * processor; this service never authorizes a change that processor rejected.
 * Guard success is not a proof of behavioral equivalence, compilation, or a
 * completed project migration.</p>
 *
 * <p>The caller owns the current AST and hint configuration and must keep both
 * unchanged during {@link #explain}. Candidates retain the original match and
 * AST references so node identity and resolved bindings remain available. The
 * returned lists and excerpts are bounded snapshots; they do not isolate or
 * freeze the underlying JDT tree. The service reads the current configuration
 * for each call and does not rewrite that tree.</p>
 */
public final class RuleMatchDiagnostics {
	private static final int MAX_DESCRIPTION_CHARS= 2048;
	private static final int MAX_SOURCE_CHARS= 1200;
	private static final int MAX_BINDING_CHARS= 240;
	private static final int MAX_ENTRIES= 64;
	private static final String TRUNCATED= "… [truncated]"; //$NON-NLS-1$

	/** One evaluated guard, or an alternative not reached by ordered selection. */
	public record GuardCheck(String condition, TruthValue truthValue, boolean evaluated) {
		public GuardCheck {
			condition= clip(condition, MAX_BINDING_CHARS);
		}
	}

	/** A placeholder's original node/range and bounded source/semantic excerpts. */
	public record BindingDetail(String placeholder, ASTNode originalNode, String source,
			int offset, int length, String resolvedType, String resolvedMethod) {
		public BindingDetail {
			placeholder= clip(placeholder, MAX_BINDING_CHARS);
			source= clip(source, MAX_BINDING_CHARS);
			resolvedType= clip(resolvedType, MAX_BINDING_CHARS);
			resolvedMethod= clip(resolvedMethod, MAX_BINDING_CHARS);
		}
	}

	/**
	 * One structural candidate, including rejected and unknown cases. Source and
	 * diagnostic fields are bounded excerpts; the match retains original ranges
	 * and {@code plannedReplacement} remains the exact batch processor result.
	 */
	public record Candidate(TransformationRule rule, Match match, String originalSource,
			String plannedReplacement, GuardCheck sourceGuard, List<GuardCheck> alternatives,
			List<BindingDetail> bindings, String matchedMethod,
			List<UnknownSemanticRequirement> unknownSemanticRequirements, boolean batchAccepted) {
		public Candidate {
			originalSource= clip(originalSource, MAX_SOURCE_CHARS);
			matchedMethod= clip(matchedMethod, MAX_BINDING_CHARS);
			alternatives= List.copyOf(alternatives.subList(0, Math.min(MAX_ENTRIES, alternatives.size())));
			bindings= List.copyOf(bindings.subList(0, Math.min(MAX_ENTRIES, bindings.size())));
			unknownSemanticRequirements= unknownSemanticRequirements.stream().limit(MAX_ENTRIES)
					.map(unknown -> new UnknownSemanticRequirement(clip(unknown.guardName(), MAX_BINDING_CHARS),
							clip(unknown.detail(), MAX_BINDING_CHARS))).toList();
		}

		/** Explanation limited to 2048 characters, including explicit truncation markers. */
		public String describe() {
			BoundedText text= new BoundedText(MAX_DESCRIPTION_CHARS);
			text.append("Source requirement: ").append(sourceGuard.truthValue()).append('\n'); //$NON-NLS-1$
			text.append(sourceGuard.condition()).append('\n');
			if (matchedMethod != null) {
				text.append("Resolved method: ").append(matchedMethod).append('\n'); //$NON-NLS-1$
			}
			text.append("Before:\n").append(clip(originalSource, 480)).append('\n'); //$NON-NLS-1$
			if (plannedReplacement != null) {
				text.append("Planned replacement:\n").append(clip(plannedReplacement, 480)).append('\n'); //$NON-NLS-1$
			} else {
				text.append(batchAccepted ? "No text replacement selected.\n" : "No change authorized by the batch processor.\n"); //$NON-NLS-1$ //$NON-NLS-2$
			}
			for (UnknownSemanticRequirement unknown : unknownSemanticRequirements) {
				text.append("UNKNOWN: ").append(unknown.guardName()).append(": ").append(unknown.detail()).append('\n'); //$NON-NLS-1$ //$NON-NLS-2$
				if (text.isFull()) {
					break;
				}
			}
			for (BindingDetail binding : bindings) {
				text.append(binding.placeholder()).append(" = ").append(binding.source()); //$NON-NLS-1$
				text.append("; type: ").append(binding.resolvedType() == null ? "unresolved" : binding.resolvedType()); //$NON-NLS-1$ //$NON-NLS-2$
				if (binding.resolvedMethod() != null) {
					text.append("; method: ").append(binding.resolvedMethod()); //$NON-NLS-1$
				}
				text.append('\n');
				if (text.isFull()) {
					break;
				}
			}
			for (int i= 0; i < alternatives.size(); i++) {
				GuardCheck check= alternatives.get(i);
				text.append("Alternative ").append(i + 1).append(": ") //$NON-NLS-1$ //$NON-NLS-2$
						.append(check.evaluated() ? check.truthValue() : "not reached") //$NON-NLS-1$
						.append(" — ").append(check.condition()).append('\n'); //$NON-NLS-1$
				if (text.isFull()) {
					break;
				}
			}
			if (batchAccepted && !unknownSemanticRequirements.isEmpty()) {
				text.append("Unresolved observations are listed above; each guard's truth state determines selection.\n"); //$NON-NLS-1$
			}
			return text.toString();
		}
	}

	private final HintFile hintFile;

	public RuleMatchDiagnostics(HintFile hintFile) {
		this.hintFile= Objects.requireNonNull(hintFile);
	}

	/**
	 * Explains at most {@code maxCandidates} structural candidates, in source order.
	 * A negative selection offset selects the whole compilation unit; zero length
	 * selects candidates containing the cursor. Source must be the snapshot used
	 * to parse {@code cu}. Compiler options and semantic plan must match execution.
	 */
	public List<Candidate> explain(CompilationUnit cu, String source, int selectionOffset,
			int selectionLength, int maxCandidates, Map<String, String> compilerOptions,
			SemanticRewritePlan semanticPlan) {
		if (cu == null || maxCandidates <= 0) {
			return List.of();
		}
		Objects.requireNonNull(source, "Original source snapshot is required"); //$NON-NLS-1$
		if (selectionLength < 0) {
			throw new IllegalArgumentException("Selection length cannot be negative"); //$NON-NLS-1$
		}
		SemanticRewritePlan plan= semanticPlan == null ? SemanticRewritePlan.empty() : semanticPlan;
		BatchTransformationProcessor processor= new BatchTransformationProcessor(hintFile);
		var structural= processor.getPatternIndex().findAllMatches(cu);
		List<Map.Entry<TransformationRule, Match>> candidates= new ArrayList<>();
		structural.forEach((rule, matches) -> matches.stream()
				.filter(match -> selected(match, selectionOffset, selectionLength))
				.forEach(match -> candidates.add(Map.entry(rule, match))));
		candidates.sort(Comparator.comparingInt(entry -> entry.getValue().getOffset()));
		if (candidates.isEmpty()) {
			return List.of();
		}
		List<TransformationResult> transformations= processor.process(cu, compilerOptions, plan);
		boolean requiredBindings= hintFile.getBindingPolicy() == HintBindingPolicy.REQUIRED || !plan.isEmpty();
		List<Candidate> result= new ArrayList<>();
		for (var entry : candidates.subList(0, Math.min(maxCandidates, candidates.size()))) {
			TransformationRule rule= entry.getKey();
			boolean strict= requiredBindings || rule.alternatives().stream()
					.anyMatch(alternative -> alternative.hasRewrite() || alternative.isEmbeddedFix());
			Match match= entry.getValue();
			GuardContext context= GuardContext.fromMatch(match, cu, compilerOptions, plan);
			boolean unknownSource= !match.getUnknownSemanticRequirements().isEmpty();
			if (strict) {
				match.getUnknownSemanticRequirements().forEach(unknown -> context.markUnknown(unknown.guardName(), unknown.detail()));
			}
			Evaluation sourceEvaluation= rule.sourceGuard() == null ? new Evaluation(TruthValue.MATCH, true)
							: rule.sourceGuard().evaluateDetailed(context);
			if (unknownSource && sourceEvaluation.truthValue() == TruthValue.MATCH) {
				sourceEvaluation= new Evaluation(TruthValue.UNKNOWN, sourceEvaluation.compatibilityValue());
			}
			String sourceCondition= rule.sourceGuard() == null ? "No source guard" : guardText(rule.sourceGuard()); //$NON-NLS-1$
			if (unknownSource) {
				sourceCondition= "Concrete source API identity unresolved; " + sourceCondition; //$NON-NLS-1$
			}
			GuardCheck sourceCheck= new GuardCheck(sourceCondition, sourceEvaluation.truthValue(), true);
			boolean reached= sourceEvaluation.compatibilityValue()
					&& !(strict && sourceEvaluation.truthValue() == TruthValue.UNKNOWN);
			List<GuardCheck> alternatives= new ArrayList<>();
			for (RewriteAlternative alternative : rule.alternatives().subList(0, Math.min(MAX_ENTRIES, rule.alternatives().size()))) {
				String condition= alternative.isOtherwise() ? "otherwise" : guardText(alternative.condition()); //$NON-NLS-1$
				if (!reached) {
					alternatives.add(new GuardCheck(condition, TruthValue.UNKNOWN, false));
					continue;
				}
				Evaluation evaluation= alternative.isOtherwise() ? new Evaluation(TruthValue.MATCH, true)
						: alternative.condition().evaluateDetailed(context);
				alternatives.add(new GuardCheck(condition, evaluation.truthValue(), true));
				if (evaluation.compatibilityValue() || strict && evaluation.truthValue() == TruthValue.UNKNOWN) {
					reached= false;
				}
			}
			TransformationResult actual= transformations.stream().filter(transformation -> transformation.rule() == rule
					&& transformation.match().getOffset() == match.getOffset()
					&& transformation.match().getLength() == match.getLength()).findFirst().orElse(null);
			List<UnknownSemanticRequirement> unknowns= new ArrayList<>(match.getUnknownSemanticRequirements().stream().limit(MAX_ENTRIES).toList());
			context.getUnknownSemanticRequirements().stream().filter(unknown -> !unknowns.contains(unknown))
					.limit(MAX_ENTRIES - unknowns.size()).forEach(unknowns::add);
			if (actual != null) {
				for (UnknownSemanticRequirement unknown : actual.unknownSemanticRequirements()) {
					if (unknowns.size() == MAX_ENTRIES) {
						break;
					}
					if (!unknowns.contains(unknown)) {
						unknowns.add(unknown);
					}
				}
			}
			result.add(new Candidate(rule, match, snippet(source, match.getOffset(), match.getLength(), MAX_SOURCE_CHARS),
					actual == null ? null : actual.replacement(), sourceCheck, alternatives,
					bindingDetails(match, source), methodSignature(resolveMethod(match.getMatchedNode())),
					unknowns, actual != null && !actual.isSemanticUnknown()));
		}
		return List.copyOf(result);
	}

	private static String guardText(GuardExpression guard) {
		BoundedText text= new BoundedText(MAX_BINDING_CHARS);
		appendGuard(text, guard);
		return text.toString();
	}

	private static void appendGuard(BoundedText text, GuardExpression guard) {
		if (text.isFull()) {
			return;
		}
		switch (guard) {
		case GuardExpression.FunctionCall call -> {
			if ("instanceof".equals(call.name()) && call.args().size() == 2) { //$NON-NLS-1$
				text.append(call.args().get(0)).append(" instanceof ").append(call.args().get(1)); //$NON-NLS-1$
			} else {
				text.append(call.name()).append('(');
				for (int i= 0; i < call.args().size() && !text.isFull(); i++) {
					if (i > 0) {
						text.append(", "); //$NON-NLS-1$
					}
					text.append(call.args().get(i));
				}
				text.append(')');
			}
		}
		case GuardExpression.And and -> {
			text.append('(');
			appendGuard(text, and.left());
			text.append(" && "); //$NON-NLS-1$
			appendGuard(text, and.right());
			text.append(')');
		}
		case GuardExpression.Or or -> {
			text.append('(');
			appendGuard(text, or.left());
			text.append(" || "); //$NON-NLS-1$
			appendGuard(text, or.right());
			text.append(')');
		}
		case GuardExpression.Not not -> {
			text.append("!("); //$NON-NLS-1$
			appendGuard(text, not.operand());
			text.append(')');
		}
		}
	}

	private static boolean selected(Match match, int offset, int length) {
		if (offset < 0) {
			return true;
		}
		long end= (long) match.getOffset() + match.getLength();
		return length == 0 ? match.getOffset() <= offset && offset < end
				: match.getOffset() < (long) offset + length && offset < end;
	}

	private static List<BindingDetail> bindingDetails(Match match, String source) {
		List<BindingDetail> details= new ArrayList<>();
		for (var binding : match.getBindings().entrySet().stream().sorted(Map.Entry.comparingByKey()).limit(MAX_ENTRIES).toList()) {
			if (details.size() == MAX_ENTRIES) {
				break;
			}
			if (binding.getValue() instanceof ASTNode node) {
				details.add(bindingDetail(binding.getKey(), node, source));
			} else if (binding.getValue() instanceof List<?> nodes) {
				for (int i= 0; i < nodes.size() && details.size() < MAX_ENTRIES; i++) {
					if (nodes.get(i) instanceof ASTNode node) {
						details.add(bindingDetail(binding.getKey() + "[" + i + "]", node, source)); //$NON-NLS-1$ //$NON-NLS-2$
					}
				}
			}
		}
		return details;
	}

	private static BindingDetail bindingDetail(String name, ASTNode node, String source) {
		ITypeBinding type= switch (node) {
		case AbstractTypeDeclaration value -> value.resolveBinding();
		case Name value -> value.resolveTypeBinding();
		case Expression value -> value.resolveTypeBinding();
		case Type value -> value.resolveBinding();
		default -> null;
		};
		String excerpt= "$this".equals(name) && node instanceof AbstractTypeDeclaration enclosing //$NON-NLS-1$
				? "enclosing type " + clip(type == null || type.isRecovered() ? enclosing.getName().getIdentifier() //$NON-NLS-1$
						: type.getErasure().getQualifiedName(), MAX_BINDING_CHARS - 15)
				: snippet(source, node.getStartPosition(), node.getLength(), MAX_BINDING_CHARS);
		return new BindingDetail(name, node, excerpt,
				node.getStartPosition(), node.getLength(), type == null || type.isRecovered() ? null : type.getQualifiedName(),
				methodSignature(resolveMethod(node)));
	}

	private static IMethodBinding resolveMethod(ASTNode node) {
		return switch (node) {
		case MethodInvocation invocation -> invocation.resolveMethodBinding();
		case ClassInstanceCreation creation -> creation.resolveConstructorBinding();
		case MethodDeclaration declaration -> declaration.resolveBinding();
		default -> null;
		};
	}

	private static String methodSignature(IMethodBinding binding) {
		if (binding == null || binding.isRecovered()) {
			return null;
		}
		IMethodBinding declaration= binding.getMethodDeclaration();
		ITypeBinding declaringClass= declaration.getDeclaringClass();
		BoundedText text= new BoundedText(MAX_BINDING_CHARS);
		text.append(declaringClass == null ? "?" : declaringClass.getErasure().getQualifiedName()) //$NON-NLS-1$
				.append('#').append(declaration.getName()).append('(');
		ITypeBinding[] parameters= declaration.getParameterTypes();
		for (int i= 0; i < parameters.length && !text.isFull(); i++) {
			if (i > 0) {
				text.append(", "); //$NON-NLS-1$
			}
			text.append(parameters[i].getQualifiedName());
		}
		return text.append(')').toString();
	}

	private static String snippet(String source, int offset, int length, int limit) {
		if (offset < 0 || length < 0 || (long) offset + length > source.length()) {
			return "[source unavailable for this AST node]"; //$NON-NLS-1$
		}
		return new BoundedText(limit).append(source, offset, offset + length).toString();
	}

	private static String clip(String value, int limit) {
		return value == null || value.length() <= limit ? value : new BoundedText(limit).append(value).toString();
	}

	/** Appends only a bounded range, without allocating the full source substring. */
	private static final class BoundedText {
		private final int limit;
		private final StringBuilder value;
		private boolean truncated;

		BoundedText(int limit) {
			this.limit= limit;
			value= new StringBuilder(limit);
		}

		BoundedText append(Object text) {
			String string= String.valueOf(text);
			return append(string, 0, string.length());
		}

		BoundedText append(CharSequence source, int start, int end) {
			if (truncated) {
				return this;
			}
			int remaining= limit - value.length();
			if (end - start <= remaining) {
				value.append(source, start, end);
			} else {
				int prefix= Math.max(0, remaining - TRUNCATED.length());
				value.append(source, start, start + prefix);
				value.setLength(Math.min(value.length(), limit - TRUNCATED.length()));
				value.append(TRUNCATED);
				truncated= true;
			}
			return this;
		}

		boolean isFull() {
			return truncated;
		}

		@Override
		public String toString() {
			return value.toString();
		}
	}
}