CandidateVerifier.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.mining.core.candidate;

import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.regex.Pattern;

import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.compiler.IProblem;
import org.eclipse.jdt.core.dom.AST;
import org.eclipse.jdt.core.dom.ASTMatcher;
import org.eclipse.jdt.core.dom.ASTParser;
import org.eclipse.jdt.core.dom.CompilationUnit;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor;
import org.sandbox.jdt.triggerpattern.api.BatchTransformationProcessor.TransformationResult;
import org.sandbox.jdt.triggerpattern.api.GuardExpression;
import org.sandbox.jdt.triggerpattern.api.GuardFunction;
import org.sandbox.jdt.triggerpattern.api.GuardFunctionResolverHolder;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.internal.BuiltInGuardRegistration;
import org.sandbox.jdt.triggerpattern.internal.DslValidator;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;

/**
 * Deterministically verifies a staged candidate without generating Java source.
 *
 * <p>The verifier parses one self-contained DSL rule and compiling Java examples
 * with binding resolution, verifies every referenced guard, applies exactly one
 * replacement to the positive example by AST match offset/length, compares the
 * complete transformed syntax tree with the expected after example, and proves
 * that the negative example does not match.</p>
 */
public final class CandidateVerifier {

	/** Persisted verifier version for reproducibility. */
	public static final String VERSION = "7"; //$NON-NLS-1$

	private final DslValidator dslValidator;
	private final HintFileParser hintFileParser;

	public CandidateVerifier() {
		this(new DslValidator(), new HintFileParser());
	}

	CandidateVerifier(DslValidator dslValidator, HintFileParser hintFileParser) {
		this.dslValidator = dslValidator;
		this.hintFileParser = hintFileParser;
	}

	/** Verifies one candidate and returns structured diagnostics. */
	public CandidateVerification verify(MiningCandidate candidate) {
		String schemaProblem = validateSchema(candidate);
		if (schemaProblem != null) {
			return failure(CandidateVerification.Stage.SCHEMA, schemaProblem, 0, 0);
		}

		var dslValidation = dslValidator.validate(candidate.getDslRule());
		if (!dslValidation.valid()) {
			return failure(CandidateVerification.Stage.DSL_VALIDATION,
					dslValidation.message(), 0, 0);
		}

		HintFile hintFile;
		try {
			hintFile = hintFileParser.parse(candidate.getDslRule());
		} catch (Exception e) {
			return failure(CandidateVerification.Stage.DSL_PARSE,
					"Could not parse DSL: " + safeMessage(e), 0, 0); //$NON-NLS-1$
		}
		String containerProblem = validateCandidateContainer(hintFile);
		if (containerProblem != null) {
			return failure(CandidateVerification.Stage.DSL_PARSE, containerProblem, 0, 0);
		}
		if (hintFile.getRules().size() != 1) {
			return failure(CandidateVerification.Stage.DSL_PARSE,
					"Candidate DSL must contain exactly one transformation rule, but contains " //$NON-NLS-1$
							+ hintFile.getRules().size(), 0, 0);
		}

		Function<String, GuardFunction> previousResolver = GuardFunctionResolverHolder.getResolver();
		installBuiltInGuards(previousResolver);
		try {
			String unresolvedGuard = findUnresolvedGuard(hintFile);
			if (unresolvedGuard != null) {
				return failure(CandidateVerification.Stage.GUARD_RESOLUTION,
						"Unknown guard function: " + unresolvedGuard, 0, 0); //$NON-NLS-1$
			}
			return verifyExamples(candidate, hintFile);
		} finally {
			GuardFunctionResolverHolder.setResolver(previousResolver);
		}
	}

	private CandidateVerification verifyExamples(MiningCandidate candidate, HintFile hintFile) {
		ParseResult beforeParse = parse(candidate.getBeforeExample(), candidate.getSourceVersion());
		if (!beforeParse.valid()) {
			return failure(CandidateVerification.Stage.BEFORE_PARSE,
					beforeParse.message(), 0, 0);
		}
		ParseResult afterParse = parse(candidate.getAfterExample(), candidate.getSourceVersion());
		if (!afterParse.valid()) {
			return failure(CandidateVerification.Stage.AFTER_PARSE,
					afterParse.message(), 0, 0);
		}

		Map<String, String> compilerOptions = compilerOptions(candidate.getSourceVersion());
		List<TransformationResult> positiveResults;
		try {
			positiveResults = new BatchTransformationProcessor(hintFile)
					.process(beforeParse.compilationUnit(), compilerOptions);
		} catch (RuntimeException e) {
			return failure(CandidateVerification.Stage.BEFORE_MATCH,
					"Positive example evaluation failed: " + safeMessage(e), 0, 0); //$NON-NLS-1$
		}

		List<TransformationResult> replacements = positiveResults.stream()
				.filter(TransformationResult::hasReplacement)
				.toList();
		if (replacements.isEmpty()) {
			return failure(CandidateVerification.Stage.BEFORE_MATCH,
					"Positive example produced no replacement", positiveResults.size(), 0); //$NON-NLS-1$
		}
		if (replacements.size() != 1) {
			return failure(CandidateVerification.Stage.BEFORE_MATCH,
					"Positive example is ambiguous: expected one replacement but got " //$NON-NLS-1$
							+ replacements.size(), positiveResults.size(), replacements.size());
		}

		TransformationResult replacement = replacements.get(0);
		String transformed;
		try {
			transformed = applyReplacement(candidate.getBeforeExample(), replacement);
		} catch (IllegalArgumentException e) {
			return failure(CandidateVerification.Stage.AFTER_REWRITE,
					e.getMessage(), positiveResults.size(), replacements.size());
		}
		ParseResult transformedParse = parse(transformed, candidate.getSourceVersion());
		if (!transformedParse.valid()) {
			return failure(CandidateVerification.Stage.AFTER_REWRITE,
					"Transformed source is invalid: " + transformedParse.message(), //$NON-NLS-1$
					positiveResults.size(), replacements.size());
		}
		if (!transformedParse.compilationUnit().subtreeMatch(
				new ASTMatcher(true), afterParse.compilationUnit())) {
			return failure(CandidateVerification.Stage.AFTER_REWRITE,
					"Transformed syntax tree does not equal afterExample", //$NON-NLS-1$
					positiveResults.size(), replacements.size());
		}

		ParseResult negativeParse = parse(candidate.getNegativeExample(), candidate.getSourceVersion());
		if (!negativeParse.valid()) {
			return failure(CandidateVerification.Stage.NEGATIVE_PARSE,
					negativeParse.message(), positiveResults.size(), replacements.size());
		}

		List<TransformationResult> negativeResults;
		try {
			negativeResults = new BatchTransformationProcessor(hintFile)
					.process(negativeParse.compilationUnit(), compilerOptions);
		} catch (RuntimeException e) {
			return failure(CandidateVerification.Stage.NEGATIVE_MATCH,
					"Negative example evaluation failed: " + safeMessage(e), //$NON-NLS-1$
					positiveResults.size(), replacements.size());
		}
		if (!negativeResults.isEmpty()) {
			return failure(CandidateVerification.Stage.NEGATIVE_MATCH,
					"Negative example unexpectedly matched " + negativeResults.size() + " time(s)", //$NON-NLS-1$ //$NON-NLS-2$
					positiveResults.size(), replacements.size());
		}

		return CandidateVerification.success(VERSION, positiveResults.size(), replacements.size());
	}

	private static String validateCandidateContainer(HintFile hintFile) {
		if (hintFile.getId() != null || hintFile.getDescription() != null
				|| hintFile.getBindingPolicy() != null
				|| hintFile.getMinJavaVersion() > 0 || !hintFile.getTags().isEmpty()
				|| !hintFile.getIncludes().isEmpty() || !hintFile.getEmbeddedJavaBlocks().isEmpty()
				|| hintFile.isCaseInsensitive() || !hintFile.getSuppressWarnings().isEmpty()
				|| !hintFile.getTreeKindNodeTypes().isEmpty()
				|| !"info".equals(hintFile.getSeverityAsString())) { //$NON-NLS-1$
			return "Candidate DSL must contain only one rule and optional per-rule metadata; " //$NON-NLS-1$
					+ "file metadata, includes, and embedded Java are not promotable"; //$NON-NLS-1$
		}
		return null;
	}

	private static String findUnresolvedGuard(HintFile hintFile) {
		Function<String, GuardFunction> resolver = GuardFunctionResolverHolder.getResolver();
		for (var rule : hintFile.getRules()) {
			String unresolved = findUnresolvedGuard(rule.sourceGuard(), resolver);
			if (unresolved != null) {
				return unresolved;
			}
			for (var alternative : rule.alternatives()) {
				unresolved = findUnresolvedGuard(alternative.condition(), resolver);
				if (unresolved != null) {
					return unresolved;
				}
			}
		}
		return null;
	}

	private static String findUnresolvedGuard(GuardExpression expression,
			Function<String, GuardFunction> resolver) {
		if (expression == null) {
			return null;
		}
		return switch (expression) {
		case GuardExpression.FunctionCall call -> isResolvable(call.name(), resolver)
				? null : call.name();
		case GuardExpression.And and -> firstNonNull(
				findUnresolvedGuard(and.left(), resolver),
				findUnresolvedGuard(and.right(), resolver));
		case GuardExpression.Or or -> firstNonNull(
				findUnresolvedGuard(or.left(), resolver),
				findUnresolvedGuard(or.right(), resolver));
		case GuardExpression.Not not -> findUnresolvedGuard(not.operand(), resolver);
		};
	}

	private static boolean isResolvable(String name, Function<String, GuardFunction> resolver) {
		if (resolver == null) {
			return false;
		}
		try {
			return resolver.apply(name) != null;
		} catch (RuntimeException e) {
			return false;
		}
	}

	private static String firstNonNull(String first, String second) {
		return first != null ? first : second;
	}

	private static String validateSchema(MiningCandidate candidate) {
		if (candidate == null) {
			return "Candidate is null"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getSourceRepo())) {
			return "sourceRepo is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getSourceCommit())) {
			return "sourceCommit is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getDslRule())) {
			return "dslRule is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getBeforeExample())) {
			return "beforeExample is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getAfterExample())) {
			return "afterExample is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getNegativeExample())) {
			return "negativeExample is required"; //$NON-NLS-1$
		}
		if (isBlank(candidate.getSourceVersion())) {
			return "sourceVersion is required"; //$NON-NLS-1$
		}
		return null;
	}

	private static ParseResult parse(String source, String sourceVersion) {
		try {
			ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
			parser.setSource(source.toCharArray());
			parser.setKind(ASTParser.K_COMPILATION_UNIT);
			parser.setCompilerOptions(compilerOptions(sourceVersion));
			parser.setResolveBindings(true);
			parser.setBindingsRecovery(false);
			parser.setStatementsRecovery(false);
			parser.setUnitName("Candidate.java"); //$NON-NLS-1$
			parser.setEnvironment(runtimeClasspath(), null, null, true);
			CompilationUnit compilationUnit = (CompilationUnit) parser.createAST(null);
			for (IProblem problem : compilationUnit.getProblems()) {
				if (problem.isError()) {
					return new ParseResult(null, false,
							"Java compile error at line " + problem.getSourceLineNumber() //$NON-NLS-1$
									+ ": " + problem.getMessage()); //$NON-NLS-1$
				}
			}
			return new ParseResult(compilationUnit, true, "valid"); //$NON-NLS-1$
		} catch (RuntimeException e) {
			return new ParseResult(null, false, "Java parse failed: " + safeMessage(e)); //$NON-NLS-1$
		}
	}

	private static String[] runtimeClasspath() {
		String classpath = System.getProperty("java.class.path", ""); //$NON-NLS-1$ //$NON-NLS-2$
		if (classpath.isBlank()) {
			return new String[0];
		}
		return Arrays.stream(classpath.split(Pattern.quote(File.pathSeparator)))
				.filter(entry -> !entry.isBlank())
				.toArray(String[]::new);
	}

	private static Map<String, String> compilerOptions(String sourceVersion) {
		Map<String, String> options = new HashMap<>(JavaCore.getOptions());
		JavaCore.setComplianceOptions(sourceVersion, options);
		options.put(JavaCore.COMPILER_SOURCE, sourceVersion);
		return options;
	}

	private static void installBuiltInGuards(Function<String, GuardFunction> previousResolver) {
		Map<String, GuardFunction> guards = new HashMap<>();
		BuiltInGuardRegistration.registerAll(guards);
		GuardFunctionResolverHolder.setResolver(name -> {
			GuardFunction builtIn = guards.get(name);
			return builtIn != null || previousResolver == null
					? builtIn : previousResolver.apply(name);
		});
	}

	private static String applyReplacement(String source, TransformationResult result) {
		int offset = result.match().getOffset();
		int length = result.match().getLength();
		if (offset < 0 || length < 0 || offset + length > source.length()) {
			throw new IllegalArgumentException(
					"Invalid match range: offset=" + offset + ", length=" + length); //$NON-NLS-1$ //$NON-NLS-2$
		}
		return source.substring(0, offset) + result.replacement()
				+ source.substring(offset + length);
	}

	private static boolean isBlank(String value) {
		return value == null || value.isBlank();
	}

	private static String safeMessage(Throwable throwable) {
		String message = throwable.getMessage();
		return message == null || message.isBlank()
				? throwable.getClass().getSimpleName() : message;
	}

	private static CandidateVerification failure(CandidateVerification.Stage stage,
			String message, int matches, int replacements) {
		return CandidateVerification.failure(stage, message, VERSION, matches, replacements);
	}

	private record ParseResult(CompilationUnit compilationUnit, boolean valid, String message) {
	}
}