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.LinkedHashMap;
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$
};
/**
* Bundled library resource names that are shipped for maintenance and validation,
* but are not loaded by default or exposed as active cleanup bundles.
*/
private static final String[] DISABLED_BUNDLED_LIBRARIES = {
"anonymous-to-lambda.sandbox-hint", //$NON-NLS-1$
"array-initialization.sandbox-hint", //$NON-NLS-1$
"code-style.sandbox-hint", //$NON-NLS-1$
"collections-immutable.sandbox-hint", //$NON-NLS-1$
"comparable-compareto-cleanup.sandbox-hint", //$NON-NLS-1$
"concurrency.sandbox-hint", //$NON-NLS-1$
"deprecated-api.sandbox-hint", //$NON-NLS-1$
"eclipse-api-configuration.sandbox-hint", //$NON-NLS-1$
"eclipse-api-deprecations.sandbox-hint", //$NON-NLS-1$
"eclipse-api-modernization.sandbox-hint", //$NON-NLS-1$
"eclipse-platform-ui-mined.sandbox-hint", //$NON-NLS-1$
"icu-migration.sandbox-hint", //$NON-NLS-1$
"java19-deprecations.sandbox-hint", //$NON-NLS-1$
"jdt-api-modernization.sandbox-hint", //$NON-NLS-1$
"jdt-formatter-modernization.sandbox-hint", //$NON-NLS-1$
"jdt-internal-refactoring.sandbox-hint", //$NON-NLS-1$
"jdt-internal-ui-browser-fixes.sandbox-hint", //$NON-NLS-1$
"jdt-internal-ui-javadoc-fixes.sandbox-hint", //$NON-NLS-1$
"jface-deprecations.sandbox-hint", //$NON-NLS-1$
"lambda-simplification.sandbox-hint", //$NON-NLS-1$
"logical-simplification.sandbox-hint", //$NON-NLS-1$
"modernize-java14.sandbox-hint", //$NON-NLS-1$
"null-safety.sandbox-hint", //$NON-NLS-1$
"string-object-compare.sandbox-hint", //$NON-NLS-1$
"type-safety.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);
/**
* Describes a validation problem found in a bundled hint file resource.
*
* @param resourceName the {@code .sandbox-hint} resource name, or {@code <registry>} for list-level errors
* @param message human-readable problem description
*/
public record HintFileValidationProblem(String resourceName, String message) {
// value object
}
/**
* 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();
}
/**
* Returns bundled pattern libraries that are shipped but disabled by default.
*
* @return array of disabled bundled library resource names
*/
public static String[] getDisabledBundledLibraryNames() {
return DISABLED_BUNDLED_LIBRARIES.clone();
}
/**
* Returns all bundled pattern library resource names that should be validated.
*
* <p>This includes the active libraries returned by {@link #getBundledLibraryNames()}
* and additional disabled libraries that are intentionally not loaded by
* {@link #loadBundledLibraries(ClassLoader)}.</p>
*
* @return array of all bundled library resource names to validate
*/
public static String[] getAllBundledLibraryNames() {
String[] result = new String[BUNDLED_LIBRARIES.length + DISABLED_BUNDLED_LIBRARIES.length];
System.arraycopy(BUNDLED_LIBRARIES, 0, result, 0, BUNDLED_LIBRARIES.length);
System.arraycopy(DISABLED_BUNDLED_LIBRARIES, 0, result, BUNDLED_LIBRARIES.length,
DISABLED_BUNDLED_LIBRARIES.length);
return result;
}
/**
* Attempts to load all active bundled pattern libraries from the classpath.
* Disabled bundled libraries are intentionally not registered by this method;
* they are covered by {@link #validateBundledLibraries(ClassLoader)} instead.
*
* <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 = toLibraryId(libraryName);
String resourcePath = BUNDLED_RESOURCE_PREFIX + libraryName;
try {
if (loadFromClasspath(id, resourcePath, classLoader)) {
loaded.add(id);
}
} catch (HintParseException | IOException e) {
LOGGER.log(Level.WARNING,
"Failed to load bundled hint library: " + libraryName, e); //$NON-NLS-1$
}
}
return loaded;
}
/**
* Validates all active and disabled bundled hint file resources.
*
* <p>The validation intentionally does not register disabled libraries in this store.
* It only parses them and checks invariants that should fail PR builds when broken.</p>
*
* @param classLoader the class loader to use for loading resources
* @return validation problems; empty when all bundled resources are valid
*/
public List<HintFileValidationProblem> validateBundledLibraries(ClassLoader classLoader) {
List<HintFileValidationProblem> problems = new ArrayList<>();
Map<String, HintFile> parsedByResource = new LinkedHashMap<>();
Map<String, String> resourceByDeclaredId = new LinkedHashMap<>();
Set<String> resourceNames = new HashSet<>();
Set<String> registryIds = new HashSet<>();
for (String libraryName : getAllBundledLibraryNames()) {
String registryId = toLibraryId(libraryName);
if (!resourceNames.add(libraryName)) {
problems.add(new HintFileValidationProblem("<registry>", //$NON-NLS-1$
"Duplicate bundled hint resource: " + libraryName)); //$NON-NLS-1$
}
if (!registryIds.add(registryId)) {
problems.add(new HintFileValidationProblem(libraryName,
"Duplicate bundled hint registry ID: " + registryId)); //$NON-NLS-1$
}
String resourcePath = BUNDLED_RESOURCE_PREFIX + libraryName;
try (InputStream is = classLoader.getResourceAsStream(resourcePath)) {
if (is == null) {
problems.add(new HintFileValidationProblem(libraryName,
"Bundled hint resource not found: " + resourcePath)); //$NON-NLS-1$
continue;
}
try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
HintFile hintFile = parser.parse(reader);
parsedByResource.put(libraryName, hintFile);
validateHintFileId(libraryName, registryId, hintFile, resourceByDeclaredId, problems);
validateRuleIds(libraryName, hintFile, problems);
}
} catch (HintParseException e) {
problems.add(new HintFileValidationProblem(libraryName,
"Parse error: " + e.getMessage())); //$NON-NLS-1$
} catch (IOException e) {
problems.add(new HintFileValidationProblem(libraryName,
"I/O error: " + e.getMessage())); //$NON-NLS-1$
}
}
validateIncludes(parsedByResource, resourceByDeclaredId.keySet(), problems);
return Collections.unmodifiableList(problems);
}
private static String toLibraryId(String libraryName) {
return libraryName.replace(SANDBOX_HINT_EXTENSION, ""); //$NON-NLS-1$
}
private static void validateHintFileId(String libraryName, String registryId, HintFile hintFile,
Map<String, String> resourceByDeclaredId, List<HintFileValidationProblem> problems) {
String declaredId = hintFile.getId();
String effectiveId = declaredId == null || declaredId.isBlank() ? registryId : declaredId;
String previousResource = resourceByDeclaredId.putIfAbsent(effectiveId, libraryName);
if (previousResource != null) {
problems.add(new HintFileValidationProblem(libraryName,
"Duplicate hint file ID '" + effectiveId //$NON-NLS-1$
+ "' also used by " + previousResource)); //$NON-NLS-1$
}
}
private static void validateRuleIds(String libraryName, HintFile hintFile,
List<HintFileValidationProblem> problems) {
Set<String> ruleIds = new HashSet<>();
for (TransformationRule rule : hintFile.getRules()) {
String ruleId = rule.getRuleId();
if (ruleId != null && !ruleId.isBlank() && !ruleIds.add(ruleId)) {
problems.add(new HintFileValidationProblem(libraryName,
"Duplicate rule ID '" + ruleId + "'")); //$NON-NLS-1$ //$NON-NLS-2$
}
}
}
private static void validateIncludes(Map<String, HintFile> parsedByResource, Set<String> declaredIds,
List<HintFileValidationProblem> problems) {
Set<String> availableIds = new HashSet<>(declaredIds);
for (String libraryName : parsedByResource.keySet()) {
availableIds.add(toLibraryId(libraryName));
}
for (Map.Entry<String, HintFile> entry : parsedByResource.entrySet()) {
for (String includeId : entry.getValue().getIncludes()) {
if (!availableIds.contains(includeId)) {
problems.add(new HintFileValidationProblem(entry.getKey(),
"Unresolved include: " + includeId)); //$NON-NLS-1$
}
}
}
}
// ------------------------------------------------------------
// 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);
}
}