MiningCli.java
/*******************************************************************************
* Copyright (c) 2025 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;
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.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jgit.api.errors.GitAPIException;
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.BuiltInGuards;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser;
import org.sandbox.jdt.triggerpattern.internal.HintFileParser.HintParseException;
import org.sandbox.mining.config.MiningConfig;
import org.sandbox.mining.config.RepoEntry;
import org.sandbox.mining.git.RepoCloner;
import org.sandbox.mining.report.JsonReporter;
import org.sandbox.mining.report.MarkdownReporter;
import org.sandbox.mining.report.MiningReport;
import org.sandbox.mining.scanner.SourceScanner;
import org.sandbox.mining.scanner.StandaloneAstParser;
/**
* CLI entry point for the refactoring mining tool.
*
* <p>Usage:
* <pre>
* java -jar sandbox-mining-cli.jar [options]
* --config <path> Path to repos.yml configuration file
* --hints <dir> Directory with .sandbox-hint files (overrides config)
* --repo <url> Single repo to scan (ad-hoc mode, overrides config)
* --output <dir> Output directory for reports (default: mining-results)
* --format <fmt> Report format: markdown|json|both (default: both)
* --dry-run Only count matches, don't generate candidate files
* </pre>
*/
public class MiningCli {
private static final String DEFAULT_OUTPUT = "mining-results"; //$NON-NLS-1$
private static final String FORMAT_MARKDOWN = "markdown"; //$NON-NLS-1$
private static final String FORMAT_JSON = "json"; //$NON-NLS-1$
private static final String FORMAT_BOTH = "both"; //$NON-NLS-1$
/** Map of bundled hint names to their classpath resource paths. */
private static final Map<String, String> BUNDLED_HINTS = Map.ofEntries(
Map.entry("arrays", "/org/sandbox/jdt/triggerpattern/internal/arrays.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("collection-performance", "/org/sandbox/jdt/triggerpattern/internal/collection-performance.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("collections", "/org/sandbox/jdt/triggerpattern/internal/collections.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("deprecations", "/org/sandbox/jdt/triggerpattern/internal/deprecations.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("deprecated-api", "/org/sandbox/jdt/triggerpattern/internal/deprecations.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("io-performance", "/org/sandbox/jdt/triggerpattern/internal/io-performance.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("modernize-java11", "/org/sandbox/jdt/triggerpattern/internal/modernize-java11.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("modernize-java9", "/org/sandbox/jdt/triggerpattern/internal/modernize-java9.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("performance", "/org/sandbox/jdt/triggerpattern/internal/performance.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("probable-bugs", "/org/sandbox/jdt/triggerpattern/internal/probable-bugs.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("stream-performance", "/org/sandbox/jdt/triggerpattern/internal/stream-performance.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("string-equals", "/org/sandbox/jdt/triggerpattern/internal/string-equals.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("string-isblank", "/org/sandbox/jdt/triggerpattern/internal/string-isblank.sandbox-hint"), //$NON-NLS-1$ //$NON-NLS-2$
Map.entry("try-with-resources", "/org/sandbox/jdt/triggerpattern/internal/try-with-resources.sandbox-hint")); //$NON-NLS-1$ //$NON-NLS-2$
public static void main(String[] args) {
try {
int exitCode = run(args);
System.exit(exitCode);
} catch (Exception e) {
System.err.println("Error: " + e.getMessage()); //$NON-NLS-1$
e.printStackTrace();
System.exit(1);
}
}
static int run(String[] args) throws IOException, GitAPIException, HintParseException {
// Parse arguments
String configPath = null;
String hintsDir = null;
String repoUrl = null;
String outputDir = DEFAULT_OUTPUT;
String format = FORMAT_BOTH;
boolean dryRun = false;
for (int i = 0; i < args.length; i++) {
switch (args[i]) {
case "--config": //$NON-NLS-1$
configPath = args[++i];
break;
case "--hints": //$NON-NLS-1$
hintsDir = args[++i];
break;
case "--repo": //$NON-NLS-1$
repoUrl = args[++i];
break;
case "--output": //$NON-NLS-1$
outputDir = args[++i];
break;
case "--format": //$NON-NLS-1$
format = args[++i];
break;
case "--dry-run": //$NON-NLS-1$
dryRun = true;
break;
case "--help": //$NON-NLS-1$
printUsage();
return 0;
default:
System.err.println("Unknown option: " + args[i]); //$NON-NLS-1$
printUsage();
return 1;
}
}
// Initialize guard functions
initializeGuards();
// Load configuration
MiningConfig config;
if (configPath != null) {
config = MiningConfig.parse(Path.of(configPath));
} else {
config = new MiningConfig();
}
Map<String, String> compilerOptions = Map.of(JavaCore.COMPILER_SOURCE, config.getSourceVersion());
// Override with ad-hoc repo if specified
List<RepoEntry> repos;
if (repoUrl != null) {
RepoEntry entry = new RepoEntry(repoUrl, "main", List.of()); //$NON-NLS-1$
repos = List.of(entry);
} else {
repos = config.getRepositories();
}
if (repos.isEmpty()) {
System.err.println("No repositories to scan. Use --config or --repo."); //$NON-NLS-1$
return 1;
}
// Load hint files
List<HintFile> hintFiles = loadHintFiles(config, hintsDir);
if (hintFiles.isEmpty()) {
System.err.println("No hint files found. Check your configuration."); //$NON-NLS-1$
return 1;
}
System.out.println("Mining with " + hintFiles.size() + " hint file(s) against " + repos.size() //$NON-NLS-1$ //$NON-NLS-2$
+ " repository(ies), source level " + config.getSourceVersion() + '.'); //$NON-NLS-1$
// Set up scanner
StandaloneAstParser astParser = new StandaloneAstParser();
SourceScanner scanner = new SourceScanner(astParser, config.getMaxFilesPerRepo());
RepoCloner cloner = new RepoCloner();
// Process each repository
MiningReport totalReport = new MiningReport();
for (RepoEntry repo : repos) {
String repoName = extractRepoName(repo.getUrl());
System.out.println("Scanning: " + repoName + " ..."); //$NON-NLS-1$ //$NON-NLS-2$
Path tempDir = Files.createTempDirectory("mining-" + repoName); //$NON-NLS-1$
try {
cloner.shallowClone(repo.getUrl(), repo.getBranch(), tempDir);
MiningReport repoReport = scanner.scan(repoName, tempDir, repo.getPaths(), hintFiles, compilerOptions);
totalReport.merge(repoReport);
int matchCount = repoReport.getMatches().size();
System.out.println(" Found " + matchCount + " match(es) in " //$NON-NLS-1$ //$NON-NLS-2$
+ repoReport.getFileCounts().getOrDefault(repoName, 0) + " file(s)."); //$NON-NLS-1$
} catch (Exception e) {
System.err.println(" Error scanning " + repoName + ": " + e.getMessage()); //$NON-NLS-1$ //$NON-NLS-2$
totalReport.addError(repoName, e.getMessage());
totalReport.addFileCount(repoName, 0);
} finally {
deleteDirectory(tempDir);
}
}
// Generate reports
if (!dryRun) {
Path output = Path.of(outputDir);
writeReports(totalReport, output, format);
}
System.out.println("\nTotal: " + totalReport.getMatches().size() + " match(es) found."); //$NON-NLS-1$ //$NON-NLS-2$
return 0;
}
private static void initializeGuards() {
Map<String, GuardFunction> guards = new HashMap<>();
BuiltInGuards.registerAll(guards);
GuardFunctionResolverHolder.setResolver(guards::get);
}
private static List<HintFile> loadHintFiles(MiningConfig config, String hintsDirOverride)
throws IOException, HintParseException {
List<HintFile> hintFiles = new ArrayList<>();
HintFileParser parser = new HintFileParser();
if (hintsDirOverride != null) {
// Load all .sandbox-hint files from the given directory
Path hintsPath = Path.of(hintsDirOverride);
if (Files.isDirectory(hintsPath)) {
try (var stream = Files.walk(hintsPath)) {
List<Path> hintPaths = stream.filter(p -> p.toString().endsWith(".sandbox-hint")).toList(); //$NON-NLS-1$
for (Path p : hintPaths) {
String content = Files.readString(p, StandardCharsets.UTF_8);
hintFiles.add(parser.parse(content));
}
}
}
return hintFiles;
}
// Load hints from configuration
for (String hint : config.getHints()) {
if (hint.startsWith("bundled:")) { //$NON-NLS-1$
String name = hint.substring("bundled:".length()); //$NON-NLS-1$
HintFile hf = loadBundledHint(parser, name);
if (hf != null) {
hintFiles.add(hf);
}
} else if (hint.startsWith("path:")) { //$NON-NLS-1$
String path = hint.substring("path:".length()); //$NON-NLS-1$
Path p = Path.of(path);
if (Files.isRegularFile(p)) {
String content = Files.readString(p, StandardCharsets.UTF_8);
hintFiles.add(parser.parse(content));
} else {
System.err.println("Warning: Hint file not found: " + path); //$NON-NLS-1$
}
} else {
// Try as bundled first, then as path
HintFile hf = loadBundledHint(parser, hint);
if (hf == null) {
Path p = Path.of(hint);
if (Files.isRegularFile(p)) {
String content = Files.readString(p, StandardCharsets.UTF_8);
hf = parser.parse(content);
}
}
if (hf != null) {
hintFiles.add(hf);
}
}
}
// If no hints configured, load all bundled hints known to the mining CLI.
if (hintFiles.isEmpty()) {
for (String name : BUNDLED_HINTS.keySet()) {
HintFile hf = loadBundledHint(parser, name);
if (hf != null) {
hintFiles.add(hf);
}
}
}
return hintFiles;
}
private static HintFile loadBundledHint(HintFileParser parser, String name) {
String resourcePath = BUNDLED_HINTS.get(name);
if (resourcePath == null) {
System.err.println("Warning: Unknown bundled hint: " + name); //$NON-NLS-1$
return null;
}
try (InputStream is = MiningCli.class.getResourceAsStream(resourcePath)) {
if (is == null) {
System.err.println("Warning: Bundled hint resource not found: " + resourcePath); //$NON-NLS-1$
return null;
}
try (Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
return parser.parse(reader);
}
} catch (IOException e) {
System.err.println("Warning: Error loading bundled hint " + name + ": " + e.getMessage()); //$NON-NLS-1$ //$NON-NLS-2$
return null;
} catch (HintParseException e) {
System.err.println("Warning: Error parsing bundled hint " + name + ": " + e.getMessage()); //$NON-NLS-1$ //$NON-NLS-2$
return null;
}
}
static String extractRepoName(String url) {
if (url == null) {
return "unknown"; //$NON-NLS-1$
}
String name = url;
if (name.endsWith(".git")) { //$NON-NLS-1$
name = name.substring(0, name.length() - 4);
}
int lastSlash = name.lastIndexOf('/');
if (lastSlash >= 0) {
name = name.substring(lastSlash + 1);
}
return name;
}
private static void writeReports(MiningReport report, Path outputDir, String format) throws IOException {
switch (format) {
case FORMAT_MARKDOWN:
new MarkdownReporter().write(report, outputDir);
System.out.println("Report written to " + outputDir.resolve("report.md")); //$NON-NLS-1$ //$NON-NLS-2$
break;
case FORMAT_JSON:
new JsonReporter().write(report, outputDir);
System.out.println("Report written to " + outputDir.resolve("report.json")); //$NON-NLS-1$ //$NON-NLS-2$
break;
case FORMAT_BOTH:
default:
new MarkdownReporter().write(report, outputDir);
new JsonReporter().write(report, outputDir);
System.out.println("Reports written to " + outputDir); //$NON-NLS-1$
break;
}
}
private static void deleteDirectory(Path dir) {
if (dir == null || !Files.exists(dir)) {
return;
}
try {
try (var stream = Files.walk(dir)) {
stream
.sorted((a, b) -> b.compareTo(a)) // delete children first
.forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException e) {
// ignore cleanup errors
}
});
}
} catch (IOException e) {
// ignore cleanup errors
}
}
private static void printUsage() {
System.out.println("Usage: java -jar sandbox-mining-cli.jar [options]"); //$NON-NLS-1$
System.out.println(" --config <path> Path to repos.yml configuration file"); //$NON-NLS-1$
System.out.println(" --hints <dir> Directory with .sandbox-hint files"); //$NON-NLS-1$
System.out.println(" --repo <url> Single repo to scan"); //$NON-NLS-1$
System.out.println(" --output <dir> Output directory (default: mining-results)"); //$NON-NLS-1$
System.out.println(" --format <fmt> markdown|json|both (default: both)"); //$NON-NLS-1$
System.out.println(" --dry-run Only count matches"); //$NON-NLS-1$
System.out.println(" --help Show this help"); //$NON-NLS-1$
}
}