HintFileStore.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 - initial API and implementation
*******************************************************************************/
package org.sandbox.jdt.triggerpattern.internal;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.sandbox.jdt.triggerpattern.api.HintFile;
import org.sandbox.jdt.triggerpattern.api.Severity;
import org.sandbox.jdt.triggerpattern.api.TransformationRule;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;
import org.sandbox.jdt.triggerpattern.llm.CommitEvaluation;
/**
* Eclipse-independent store for loading, indexing and managing
* {@code .sandbox-hint} files.
*
* <p>This class contains pure-Java storage and loading logic that does
* <b>not</b> depend on the Eclipse runtime, OSGi, or workspace APIs.
* It can be used stand-alone in tests, CLI tools, or any JVM application
* that needs to work with hint files.</p>
*
* <p>This class is <b>not</b> a singleton—callers may create as many
* instances as required. Thread safety is provided by
* {@link ConcurrentHashMap} and {@link AtomicBoolean}.</p>
*
* @since 1.2.6
*/
public final class HintFileStore {
private static final Logger LOGGER = Logger.getLogger(HintFileStore.class.getName());
/** ID prefix for AI-inferred hint files. */
static final String INFERRED_PREFIX = "inferred:"; //$NON-NLS-1$
/** File name prefix for persisted AI-inferred hint files. */
private static final String AI_INFERRED_FILE_PREFIX = "ai-inferred-"; //$NON-NLS-1$
/** File extension for sandbox hint files. */
private static final String SANDBOX_HINT_EXTENSION = ".sandbox-hint"; //$NON-NLS-1$
/** Directory name within a project for persisted hint files. */
static final String HINTS_DIRECTORY = ".hints"; //$NON-NLS-1$
/**
* Bundled library resource names.
*/
private static final String[] BUNDLED_LIBRARIES = {
"collections.sandbox-hint", //$NON-NLS-1$
"modernize-java9.sandbox-hint", //$NON-NLS-1$
"modernize-java11.sandbox-hint", //$NON-NLS-1$
"performance.sandbox-hint", //$NON-NLS-1$
"stream-performance.sandbox-hint", //$NON-NLS-1$
"io-performance.sandbox-hint", //$NON-NLS-1$
"collection-performance.sandbox-hint", //$NON-NLS-1$
"number-compare.sandbox-hint", //$NON-NLS-1$
"string-equals.sandbox-hint", //$NON-NLS-1$
"string-isblank.sandbox-hint", //$NON-NLS-1$
"arrays.sandbox-hint", //$NON-NLS-1$
"collection-toarray.sandbox-hint", //$NON-NLS-1$
"probable-bugs.sandbox-hint", //$NON-NLS-1$
"misc.sandbox-hint", //$NON-NLS-1$
"deprecations.sandbox-hint", //$NON-NLS-1$
"classfile-api.sandbox-hint", //$NON-NLS-1$
"serialization.sandbox-hint", //$NON-NLS-1$
"stringbuffer-to-stringbuilder.sandbox-hint", //$NON-NLS-1$
"platform-logging.sandbox-hint", //$NON-NLS-1$
"type-inference.sandbox-hint", //$NON-NLS-1$
"try-with-resources.sandbox-hint", //$NON-NLS-1$
"string-modernization.sandbox-hint", //$NON-NLS-1$
"optional-modernization.sandbox-hint" //$NON-NLS-1$
};
/**
* Classpath resource prefix for bundled library files.
*/
private static final String BUNDLED_RESOURCE_PREFIX =
"org/sandbox/jdt/triggerpattern/internal/"; //$NON-NLS-1$
private final Map<String, HintFile> hintFiles = new ConcurrentHashMap<>();
/** Secondary index: declared {@code <!id: ...>} → HintFile, for efficient include resolution. */
private final Map<String, HintFile> hintFilesByDeclaredId = new ConcurrentHashMap<>();
private final HintFileParser parser = new HintFileParser();
private final AtomicBoolean bundledLoaded = new AtomicBoolean(false);
/**
* Creates a new, empty hint-file store.
*/
public HintFileStore() {
// public, non-singleton constructor
}
// ------------------------------------------------------------
// Loading
// ------------------------------------------------------------
/**
* Loads and registers a hint file from a string.
*
* @param id the unique ID for this hint file
* @param content the hint file content
* @throws HintParseException if parsing fails
*/
public void loadFromString(String id, String content) throws HintParseException {
HintFile hintFile = parser.parse(content);
if (hintFile.getId() == null) {
hintFile.setId(id);
}
hintFiles.put(id, hintFile);
indexByDeclaredId(hintFile);
}
/**
* Loads and registers a hint file from a reader.
*
* @param id the unique ID for this hint file
* @param reader the reader to read from
* @throws HintParseException if parsing fails
* @throws IOException if an I/O error occurs
*/
public void loadFromReader(String id, Reader reader) throws HintParseException, IOException {
HintFile hintFile = parser.parse(reader);
if (hintFile.getId() == null) {
hintFile.setId(id);
}
hintFiles.put(id, hintFile);
indexByDeclaredId(hintFile);
}
/**
* Loads and registers a hint file from a classpath resource.
*
* @param id the unique ID for this hint file
* @param resourcePath the classpath resource path
* @param classLoader the class loader to use for loading
* @return {@code true} if the resource was found and loaded
* @throws HintParseException if parsing fails
* @throws IOException if an I/O error occurs
*/
public boolean loadFromClasspath(String id, String resourcePath, ClassLoader classLoader)
throws HintParseException, IOException {
InputStream is = classLoader.getResourceAsStream(resourcePath);
if (is == null) {
return false;
}
try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
loadFromReader(id, reader);
return true;
}
}
// ------------------------------------------------------------
// Queries
// ------------------------------------------------------------
/**
* Returns a registered hint file by ID.
*
* @param id the hint file ID
* @return the hint file, or {@code null} if not found
*/
public HintFile getHintFile(String id) {
return hintFiles.get(id);
}
/**
* Returns all registered hint files.
*
* @return unmodifiable map of ID to hint file
*/
public Map<String, HintFile> getAllHintFiles() {
return Collections.unmodifiableMap(hintFiles);
}
/**
* Returns the IDs of all registered hint files.
*
* @return unmodifiable list of hint file IDs
*/
public List<String> getRegisteredIds() {
return Collections.unmodifiableList(new ArrayList<>(hintFiles.keySet()));
}
// ------------------------------------------------------------
// Mutation
// ------------------------------------------------------------
/**
* Removes a registered hint file.
*
* @param id the hint file ID to remove
* @return the removed hint file, or {@code null} if not found
*/
public HintFile unregister(String id) {
HintFile removed = hintFiles.remove(id);
if (removed != null && removed.getId() != null) {
hintFilesByDeclaredId.remove(removed.getId());
}
return removed;
}
/**
* Removes all registered hint files and resets the bundled-loaded flag.
*/
public void clear() {
hintFiles.clear();
hintFilesByDeclaredId.clear();
bundledLoaded.set(false);
}
// ------------------------------------------------------------
// Include resolution
// ------------------------------------------------------------
/**
* Resolves include directives for a hint file by collecting all rules
* from referenced hint files.
*
* <p>When a hint file has {@code <!include: other-id>} directives, this method
* looks up the referenced hint files by ID and returns a combined list of all
* rules (the file's own rules plus all included rules).</p>
*
* <p>Circular includes are detected and silently broken to prevent infinite loops.</p>
*
* @param hintFile the hint file whose includes should be resolved
* @return list of all rules including those from included files
*/
public List<TransformationRule> resolveIncludes(HintFile hintFile) {
List<TransformationRule> allRules = new ArrayList<>(hintFile.getRules());
Set<String> visited = new HashSet<>();
if (hintFile.getId() != null) {
visited.add(hintFile.getId());
}
resolveIncludesRecursive(hintFile, allRules, visited);
return Collections.unmodifiableList(allRules);
}
/**
* Recursively resolves includes, tracking visited IDs to prevent cycles.
*
* <p>Looks up included files first by registry key, then by declared
* {@code <!id: ...>} so that {@code <!include:>} directives work
* consistently regardless of how the hint file was registered.</p>
*/
private void resolveIncludesRecursive(HintFile hintFile,
List<TransformationRule> allRules, Set<String> visited) {
for (String includeId : hintFile.getIncludes()) {
if (visited.contains(includeId)) {
continue; // Break circular reference
}
visited.add(includeId);
HintFile included = findByKeyOrDeclaredId(includeId);
if (included != null) {
allRules.addAll(included.getRules());
resolveIncludesRecursive(included, allRules, visited);
}
}
}
// ------------------------------------------------------------
// Bundled libraries
// ------------------------------------------------------------
/**
* Returns the names of bundled pattern libraries.
*
* @return array of bundled library resource names
*/
public static String[] getBundledLibraryNames() {
return BUNDLED_LIBRARIES.clone();
}
/**
* Attempts to load all bundled pattern libraries from the classpath.
* Libraries that are not found are silently skipped.
*
* <p>This method is idempotent—subsequent calls after a successful
* first invocation return the currently registered IDs without reloading.</p>
*
* @param classLoader the class loader to use for loading resources
* @return list of successfully loaded library IDs
*/
public List<String> loadBundledLibraries(ClassLoader classLoader) {
if (!bundledLoaded.compareAndSet(false, true)) {
return getRegisteredIds();
}
List<String> loaded = new ArrayList<>();
for (String libraryName : BUNDLED_LIBRARIES) {
String id = libraryName.replace(".sandbox-hint", ""); //$NON-NLS-1$ //$NON-NLS-2$
String resourcePath = BUNDLED_RESOURCE_PREFIX + libraryName;
try {
if (loadFromClasspath(id, resourcePath, classLoader)) {
loaded.add(id);
}
} catch (HintParseException | IOException e) {
// Silently skip libraries that fail to load
}
}
return loaded;
}
// ------------------------------------------------------------
// Inferred rules
// ------------------------------------------------------------
/**
* Registers a set of inferred rules as a new hint file in the store.
*
* <p>The rules are wrapped in a {@link HintFile} with the tag
* {@code "inferred"} and the given source commit ID for traceability.
* The hint file is immediately available for look-up.</p>
*
* @param hintFile the hint file containing inferred rules
* @param sourceCommit the commit ID from which the rules were derived
* @since 1.2.6
*/
public void registerInferredRules(HintFile hintFile, String sourceCommit) {
String id = INFERRED_PREFIX + sourceCommit;
hintFile.setId(id);
if (hintFile.getTags() == null || hintFile.getTags().isEmpty()) {
hintFile.setTags(List.of("inferred", "mining", sourceCommit)); //$NON-NLS-1$ //$NON-NLS-2$
}
hintFiles.put(id, hintFile);
hintFilesByDeclaredId.put(id, hintFile);
}
/**
* Returns all hint files that were inferred (have the {@code "inferred:"} key prefix).
*
* @return list of inferred hint files
* @since 1.2.6
*/
public List<HintFile> getInferredHintFiles() {
List<HintFile> inferred = new ArrayList<>();
for (Map.Entry<String, HintFile> entry : hintFiles.entrySet()) {
if (entry.getKey().startsWith(INFERRED_PREFIX)) {
inferred.add(entry.getValue());
}
}
return inferred;
}
/**
* Promotes an inferred hint file to a manual (user-authored) one by
* removing the {@code "inferred:"} prefix from its ID and re-registering it.
*
* @param hintFileId the original inferred hint file ID
* @since 1.2.6
*/
public void promoteToManual(String hintFileId) {
HintFile hintFile = hintFiles.remove(hintFileId);
if (hintFile != null) {
hintFilesByDeclaredId.remove(hintFileId);
String newId = hintFileId.replace(INFERRED_PREFIX, "manual:"); //$NON-NLS-1$
hintFile.setId(newId);
hintFiles.put(newId, hintFile);
hintFilesByDeclaredId.put(newId, hintFile);
}
}
/**
* Registers inferred rules from a list of {@link CommitEvaluation} results.
*
* <p>Each evaluation with a non-null, non-blank {@code dslRule} and a valid
* DSL parse result is converted into a {@link HintFile} and registered with
* the tag {@code "ai-inferred"}. Evaluations without valid rules are
* silently skipped.</p>
*
* @param evaluations the list of commit evaluations from AI inference
* @param source a label identifying the source of these evaluations
* (e.g., repository URL or branch name)
* @return list of IDs of successfully registered hint files
* @since 1.3.2
*/
public List<String> registerInferredRules(List<CommitEvaluation> evaluations, String source) {
List<String> registered = new ArrayList<>();
if (evaluations == null) {
return registered;
}
for (CommitEvaluation eval : evaluations) {
if (eval == null || !eval.relevant()) {
continue;
}
String dslRule = eval.dslRule();
if (dslRule == null || dslRule.isBlank()) {
continue;
}
try {
HintFile hintFile = parser.parse(dslRule);
String normalizedSource = (source != null && !source.isBlank()) ? source : "unknown"; //$NON-NLS-1$
String commitId = eval.commitHash();
if (commitId == null || commitId.isBlank()) {
commitId = normalizedSource;
}
String id = INFERRED_PREFIX + commitId;
hintFile.setId(id);
hintFile.setTags(List.of("ai-inferred", normalizedSource, commitId)); //$NON-NLS-1$
hintFile.setSeverity(Severity.INFO);
if (hintFile.getDescription() == null) {
hintFile.setDescription(eval.summary());
}
hintFiles.put(id, hintFile);
hintFilesByDeclaredId.put(id, hintFile);
registered.add(id);
} catch (HintParseException e) {
LOGGER.log(Level.WARNING,
"Failed to parse DSL rule from evaluation: " + eval.commitHash(), e); //$NON-NLS-1$
} catch (RuntimeException e) {
LOGGER.log(Level.WARNING,
"Unexpected error while parsing DSL rule from evaluation: " + eval.commitHash(), e); //$NON-NLS-1$
}
}
return registered;
}
// ------------------------------------------------------------
// Persistence
// ------------------------------------------------------------
/**
* Saves all AI-inferred hint files to a {@code .hints/} directory.
*
* <p>Each inferred hint file is serialized to the {@code .sandbox-hint}
* DSL format and written to
* {@code <directory>/.hints/ai-inferred-<sanitized-id>.sandbox-hint}.</p>
*
* <p>The {@code .hints/} subdirectory is created if it does not exist.
* Existing files with the same name are overwritten.</p>
*
* @param baseDirectory the project root directory
* @return list of paths that were written
* @throws IOException if a write error occurs
* @since 1.3.2
*/
public List<Path> saveInferredHintFiles(Path baseDirectory) throws IOException {
List<Path> written = new ArrayList<>();
Path hintsDir = baseDirectory.resolve(HINTS_DIRECTORY);
List<HintFile> inferred = getInferredHintFiles();
if (inferred.isEmpty()) {
return written;
}
Files.createDirectories(hintsDir);
HintFileSerializer serializer = new HintFileSerializer();
for (HintFile hf : inferred) {
String hfId = hf.getId();
// Strip the "inferred:" prefix to avoid doubled prefix on reload
if (hfId != null && hfId.startsWith(INFERRED_PREFIX)) {
hfId = hfId.substring(INFERRED_PREFIX.length());
}
String safeName = sanitizeFileName(hfId);
Path target = hintsDir.resolve(AI_INFERRED_FILE_PREFIX + safeName + SANDBOX_HINT_EXTENSION);
String content = serializer.serialize(hf);
Files.writeString(target, content, StandardCharsets.UTF_8);
written.add(target);
}
return written;
}
/**
* Loads all {@code ai-inferred-*.sandbox-hint} files from the
* {@code .hints/} directory under the given base directory.
*
* <p>Each loaded file is registered with the {@code "inferred:"} prefix
* so it appears in {@link #getInferredHintFiles()}.</p>
*
* @param baseDirectory the project root directory
* @return list of IDs of successfully loaded hint files
* @since 1.3.2
*/
public List<String> loadInferredHintFiles(Path baseDirectory) {
List<String> loaded = new ArrayList<>();
Path hintsDir = baseDirectory.resolve(HINTS_DIRECTORY);
if (!Files.isDirectory(hintsDir)) {
return loaded;
}
try (DirectoryStream<Path> stream = Files.newDirectoryStream(hintsDir,
AI_INFERRED_FILE_PREFIX + "*" + SANDBOX_HINT_EXTENSION)) { //$NON-NLS-1$
for (Path file : stream) {
try {
String content = Files.readString(file, StandardCharsets.UTF_8);
HintFile hintFile = parser.parse(content);
String declaredId = hintFile.getId();
if (declaredId == null) {
Path fileNamePath = file.getFileName();
if (fileNamePath == null) {
continue;
}
String fileName = fileNamePath.toString();
String baseName = fileName
.replace(AI_INFERRED_FILE_PREFIX, "") //$NON-NLS-1$
.replace(SANDBOX_HINT_EXTENSION, ""); //$NON-NLS-1$
declaredId = INFERRED_PREFIX + baseName;
hintFile.setId(declaredId);
}
hintFiles.put(declaredId, hintFile);
indexByDeclaredId(hintFile);
loaded.add(declaredId);
} catch (HintParseException | IOException | RuntimeException e) {
LOGGER.log(Level.WARNING,
"Failed to load inferred hint file: " + file, e); //$NON-NLS-1$
}
}
} catch (IOException e) {
LOGGER.log(Level.WARNING,
"Failed to scan .hints directory: " + hintsDir, e); //$NON-NLS-1$
}
return loaded;
}
// ------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------
/**
* Sanitizes a hint file ID for use as a file name.
* Replaces characters that are unsafe in file names.
*/
private static String sanitizeFileName(String id) {
if (id == null) {
return "unknown"; //$NON-NLS-1$
}
return id.replaceAll("[^a-zA-Z0-9._-]", "_"); //$NON-NLS-1$ //$NON-NLS-2$
}
/**
* Updates the secondary index for declared IDs.
*/
private void indexByDeclaredId(HintFile hintFile) {
if (hintFile.getId() != null) {
hintFilesByDeclaredId.put(hintFile.getId(), hintFile);
}
}
/**
* Finds a hint file by registry key first, then falls back to the
* secondary index of declared {@link HintFile#getId()} values.
*
* @param id the ID to look up
* @return the matching hint file, or {@code null} if not found
*/
private HintFile findByKeyOrDeclaredId(String id) {
HintFile result = hintFiles.get(id);
if (result != null) {
return result;
}
// Fall back: lookup by declared <!id: ...> via secondary index
return hintFilesByDeclaredId.get(id);
}
}