RuleFingerprintIndex.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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;
import org.sandbox.jdt.triggerpattern.internal.HintFileSerializer;

/**
 * Builds canonical fingerprints for individual parsed transformation rules and
 * indexes the curated bundled rule library. File metadata, comments, rule IDs,
 * descriptions, severity, and rule order do not affect a transformation
 * fingerprint.
 */
public final class RuleFingerprintIndex {

	private RuleFingerprintIndex() {
	}

	/** Returns the canonical fingerprint of the single rule in candidate DSL. */
	public static String fingerprintDsl(String dslRule) throws IOException {
		try {
			HintFile parsed = new HintFileParser().parse(dslRule);
			if (parsed.getRules().size() != 1) {
				throw new IOException("Candidate DSL must contain exactly one rule"); //$NON-NLS-1$
			}
			return fingerprintRule(parsed.getRules().get(0));
		} catch (IOException e) {
			throw e;
		} catch (Exception e) {
			throw new IOException("Could not fingerprint candidate DSL: " + e.getMessage(), e); //$NON-NLS-1$
		}
	}

	/** Returns a stable fingerprint for one parsed transformation rule. */
	public static String fingerprintRule(TransformationRule rule) {
		TransformationRule semanticRule = new TransformationRule(
				null,
				null,
				rule.sourcePattern(),
				rule.sourceGuard(),
				rule.alternatives(),
				rule.getImportDirective(),
				null);
		HintFile singleRuleFile = new HintFile();
		singleRuleFile.addRule(semanticRule);
		String canonical = new HintFileSerializer().serialize(singleRuleFile)
				.replace("\r\n", "\n") //$NON-NLS-1$ //$NON-NLS-2$
				.replace('\r', '\n')
				.strip();
		return "sha256:" + sha256(canonical); //$NON-NLS-1$
	}

	/**
	 * Loads all curated {@code .sandbox-hint} files below a directory.
	 *
	 * @return fingerprint to human-readable file/rule reference
	 */
	public static Map<String, String> loadCurated(Path hintDirectory) throws IOException {
		if (hintDirectory == null || !Files.isDirectory(hintDirectory)) {
			throw new IOException("Curated hint directory does not exist: " + hintDirectory); //$NON-NLS-1$
		}
		List<Path> files;
		try (Stream<Path> paths = Files.walk(hintDirectory)) {
			files = paths.filter(RuleFingerprintIndex::isHintFile)
					.sorted()
					.toList();
		}

		Map<String, String> index = new LinkedHashMap<>();
		HintFileParser parser = new HintFileParser();
		for (Path file : files) {
			HintFile hintFile;
			try {
				hintFile = parser.parse(Files.readString(file, StandardCharsets.UTF_8));
			} catch (Exception e) {
				throw new IOException("Could not parse curated hint file " + file + ": " //$NON-NLS-1$ //$NON-NLS-2$
						+ e.getMessage(), e);
			}
			for (int i = 0; i < hintFile.getRules().size(); i++) {
				TransformationRule rule = hintFile.getRules().get(i);
				String ruleName = rule.getRuleId() == null || rule.getRuleId().isBlank()
						? "rule " + (i + 1) : rule.getRuleId(); //$NON-NLS-1$
				String relativeFile = hintDirectory.relativize(file).toString()
						.replace(File.separatorChar, '/');
				String reference = relativeFile + "#" + ruleName; //$NON-NLS-1$
				index.putIfAbsent(fingerprintRule(rule), reference);
			}
		}
		return Map.copyOf(index);
	}

	private static boolean isHintFile(Path path) {
		Path fileName = path.getFileName();
		return Files.isRegularFile(path)
				&& fileName != null
				&& fileName.toString().endsWith(".sandbox-hint"); //$NON-NLS-1$
	}

	private static String sha256(String value) {
		try {
			MessageDigest digest = MessageDigest.getInstance("SHA-256"); //$NON-NLS-1$
			byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
			StringBuilder hex = new StringBuilder(hash.length * 2);
			for (byte b : hash) {
				hex.append(Character.forDigit((b >>> 4) & 0x0f, 16));
				hex.append(Character.forDigit(b & 0x0f, 16));
			}
			return hex.toString();
		} catch (NoSuchAlgorithmException e) {
			throw new IllegalStateException("SHA-256 algorithm not available", e); //$NON-NLS-1$
		}
	}
}