io.codemodder.CLI Maven / Gradle / Ivy
package io.codemodder;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.LoggerContext;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.javaparser.JavaParser;
import com.google.common.base.Stopwatch;
import io.codemodder.codetf.CodeTFReport;
import io.codemodder.codetf.CodeTFReportGenerator;
import io.codemodder.codetf.CodeTFResult;
import io.codemodder.javaparser.CachingJavaParser;
import io.codemodder.javaparser.JavaParserFactory;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;
import org.jetbrains.annotations.VisibleForTesting;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;
/** The mixinStandardHelpOptions provides version and help options. */
@CommandLine.Command(
name = "codemodder",
mixinStandardHelpOptions = true,
description = "Run a codemodder codemod")
final class CLI implements Callable {
private final List> codemodTypes;
private final Clock clock;
private final FileFinder fileFinder;
private final EncodingDetector encodingDetector;
private final JavaParserFactory javaParserFactory;
private final SourceDirectoryLister sourceDirectoryLister;
private final CodeTFReportGenerator reportGenerator;
private final String[] args;
@CommandLine.Option(
names = {"--output"},
description = "the output file to produce")
private File output;
@CommandLine.Option(
names = {"--dry-run"},
description = "do everything except make changes to files",
defaultValue = "false")
private boolean dryRun;
@CommandLine.Option(
names = {"--dont-exit"},
description = "dont exit the process after running the codemods",
hidden = true,
defaultValue = "false")
private boolean dontExit;
@CommandLine.Option(
names = {"--verbose"},
description = "print more to stdout",
defaultValue = "false")
private boolean verbose;
@CommandLine.Option(
names = {"--output-format"},
description = "the format for the data output file (\"codetf\" or \"diff\")",
defaultValue = "codetf")
private OutputFormat outputFormat;
@CommandLine.Option(
names = {"--list"},
description = "print codemod(s) metadata, then exit",
defaultValue = "false")
private boolean listCodemods;
@CommandLine.Option(
names = {"--path-include"},
description = "comma-separated set of UNIX glob patterns to include",
split = ",")
private List pathIncludes;
@CommandLine.Option(
names = {"--path-exclude"},
description = "comma-separated set of UNIX glob patterns to exclude",
split = ",")
private List pathExcludes;
@CommandLine.Option(
names = {"--codemod-include"},
description = "comma-separated set of codemod IDs to include",
split = ",")
private List codemodIncludes;
@CommandLine.Option(
names = {"--parameter"},
description = "a codemod parameter")
private List codemodParameters;
@CommandLine.Option(
names = {"--codemod-exclude"},
description = "comma-separated set of codemod IDs to exclude",
split = ",")
private List codemodExcludes;
@CommandLine.Parameters(
arity = "0..1",
paramLabel = "DIRECTORY",
description = "the directory to run the codemod on")
private File projectDirectory;
@CommandLine.Option(
names = {"--sarif"},
description = "comma-separated set of path(s) to SARIF file(s) to feed to the codemods",
split = ",")
private List sarifs;
private final DryRunTempDirCreationStrategy dryRunTempDirCreationStrategy;
/** The format for the output file. */
enum OutputFormat {
CODETF,
DIFF
}
CLI(final String[] args, final List> codemodTypes) {
this(
args,
codemodTypes,
Clock.systemUTC(),
new DefaultFileFinder(),
new DefaultEncodingDetector(),
JavaParserFactory.newFactory(),
SourceDirectoryLister.createDefault(),
CodeTFReportGenerator.createDefault(),
new DefaultDryRunTempDirCreationStrategy());
}
CLI(
final String[] args,
final List> codemodTypes,
final Clock clock,
final FileFinder fileFinder,
final EncodingDetector encodingDetector,
final JavaParserFactory javaParserFactory,
final SourceDirectoryLister sourceDirectoryLister,
final CodeTFReportGenerator reportGenerator,
final DryRunTempDirCreationStrategy dryRunTempDirCreationStrategy) {
Objects.requireNonNull(codemodTypes);
this.codemodTypes = Collections.unmodifiableList(codemodTypes);
this.clock = Objects.requireNonNull(clock);
this.fileFinder = Objects.requireNonNull(fileFinder);
this.encodingDetector = Objects.requireNonNull(encodingDetector);
this.javaParserFactory = Objects.requireNonNull(javaParserFactory);
this.sourceDirectoryLister = Objects.requireNonNull(sourceDirectoryLister);
this.reportGenerator = Objects.requireNonNull(reportGenerator);
this.args = Objects.requireNonNull(args);
this.dryRunTempDirCreationStrategy = Objects.requireNonNull(dryRunTempDirCreationStrategy);
}
@VisibleForTesting
static class DefaultFileFinder implements FileFinder {
@Override
public List findFiles(final Path projectDir, final IncludesExcludes includesExcludes) {
final List allFiles;
try (Stream paths = Files.walk(projectDir)) {
allFiles =
paths
.filter(Files::isRegularFile)
.filter(
p -> !Files.isSymbolicLink(p)) // could cause infinite loop if we follow links
.filter(p -> includesExcludes.shouldInspect(p.toFile()))
.sorted()
.toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
return allFiles;
}
}
/** A seam responsible for creating a temp directory for the dry-run feature. */
@VisibleForTesting
interface DryRunTempDirCreationStrategy {
Path createTempDir() throws IOException;
}
private static class DefaultDryRunTempDirCreationStrategy
implements DryRunTempDirCreationStrategy {
@Override
public Path createTempDir() throws IOException {
return Files.createTempDirectory("codemodder-project");
}
}
@Override
public Integer call() throws IOException {
if (verbose) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.getLogger(LoggingConfigurator.OUR_ROOT_LOGGER_NAME).setLevel(Level.DEBUG);
}
if (listCodemods) {
for (Class extends CodeChanger> codemodType : codemodTypes) {
Codemod annotation = codemodType.getAnnotation(Codemod.class);
log.info(annotation.id());
}
return SUCCESS;
}
if (output == null) {
log.error("The output file is required");
return ERROR_CANT_WRITE_OUTPUT_FILE;
}
if (projectDirectory == null) {
log.error("No project directory specified");
return ERROR_CANT_READ_PROJECT_DIRECTORY;
}
Path outputPath = output.toPath();
if (!Files.isWritable(outputPath) && !Files.isWritable(outputPath.getParent())) {
log.error("The output file (or its parent directory) is not writable");
return ERROR_CANT_WRITE_OUTPUT_FILE;
}
Path projectPath = projectDirectory.toPath();
if (!Files.isDirectory(projectPath) || !Files.isReadable(projectPath)) {
log.error("The project directory is not a readable directory");
return ERROR_CANT_READ_PROJECT_DIRECTORY;
}
if (dryRun) {
// create a temp dir and copy all the contents into it -- this may be slow for big repos on
// cloud i/o
Path copiedProjectDirectory = dryRunTempDirCreationStrategy.createTempDir();
Stopwatch watch = Stopwatch.createStarted();
log.info("Copying project directory for dry run..: {}", copiedProjectDirectory);
FileUtils.copyDirectory(projectDirectory, copiedProjectDirectory.toFile());
watch.stop();
Duration elapsed = watch.elapsed();
log.info("Copy took: {}", elapsed);
// now that we've copied it, reassign the project directory to that place
projectDirectory = copiedProjectDirectory.toFile();
projectPath = copiedProjectDirectory;
}
try {
Instant start = clock.instant();
// get path includes/excludes
List pathIncludes = this.pathIncludes;
if (pathIncludes == null) {
pathIncludes = defaultPathIncludes;
}
List pathExcludes = this.pathExcludes;
if (pathExcludes == null) {
pathExcludes = defaultPathExcludes;
}
IncludesExcludes includesExcludes =
IncludesExcludes.withSettings(projectDirectory, pathIncludes, pathExcludes);
// get all files that match
List sourceDirectories =
sourceDirectoryLister.listJavaSourceDirectories(List.of(projectDirectory));
List filePaths = fileFinder.findFiles(projectPath, includesExcludes);
// get codemod includes/excludes
final CodemodRegulator regulator;
if (codemodIncludes != null && codemodExcludes != null) {
log.error("Codemod includes and excludes cannot both be specified");
return ERROR_INVALID_ARGUMENT;
} else if (codemodIncludes == null && codemodExcludes == null) {
// the user didn't pass any includes, which means all are enabled
regulator = CodemodRegulator.of(DefaultRuleSetting.ENABLED, List.of());
} else if (codemodIncludes != null) {
regulator = CodemodRegulator.of(DefaultRuleSetting.DISABLED, codemodIncludes);
} else {
// the user only specified excludes
regulator = CodemodRegulator.of(DefaultRuleSetting.ENABLED, codemodExcludes);
}
// create the loader
List sarifFiles =
sarifs != null ? sarifs.stream().map(Path::of).collect(Collectors.toList()) : List.of();
Map> pathSarifMap =
SarifParser.create().parseIntoMap(sarifFiles, projectPath);
List codemodParameters =
createFromParameterStrings(this.codemodParameters);
CodemodLoader loader =
new CodemodLoader(codemodTypes, regulator, projectPath, pathSarifMap, codemodParameters);
List codemods = loader.getCodemods();
// create the project providers
List projectProviders = loadProjectProviders();
List codeTFProviders = loadCodeTFProviders();
List results = new ArrayList<>();
/*
* Run the codemods on the files. Note that we cache the compilation units so that we're always retaining
* the original concrete syntax information (e.g., line numbers) in JavaParser from the first access. This
* is what allows our codemods to act on SARIF-providing tools data accurately over multiple codemods.
*/
JavaParser javaParser = javaParserFactory.create(sourceDirectories);
CachingJavaParser cachingJavaParser = CachingJavaParser.from(javaParser);
for (CodemodIdPair codemod : codemods) {
CodemodExecutor codemodExecutor =
new DefaultCodemodExecutor(
projectPath,
includesExcludes,
codemod,
projectProviders,
codeTFProviders,
cachingJavaParser,
encodingDetector);
CodeTFResult result = codemodExecutor.execute(filePaths);
if (!result.getChangeset().isEmpty() || !result.getFailedFiles().isEmpty()) {
results.add(result);
}
}
Instant end = clock.instant();
long elapsed = end.toEpochMilli() - start.toEpochMilli();
// write out the output
if (OutputFormat.CODETF.equals(outputFormat)) {
CodeTFReport report =
reportGenerator.createReport(
projectDirectory.toPath(),
String.join(" ", args),
sarifs == null
? List.of()
: sarifs.stream().map(Path::of).collect(Collectors.toList()),
results,
elapsed);
ObjectMapper mapper = new ObjectMapper();
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
Files.writeString(outputPath, mapper.writeValueAsString(report));
} else if (OutputFormat.DIFF.equals(outputFormat)) {
throw new UnsupportedOperationException("not supported yet");
}
// this is a special exit code that tells the caller to not exit
if (dontExit) {
return -1;
}
return SUCCESS;
} finally {
if (dryRun) {
// delete the temp directory
log.debug("Cleaning temp directory: {}", projectDirectory);
FileUtils.deleteDirectory(projectDirectory);
}
}
}
private List loadCodeTFProviders() {
List codeTFProviders = new ArrayList<>();
ServiceLoader loader = ServiceLoader.load(CodeTFProvider.class);
for (CodeTFProvider provider : loader) {
codeTFProviders.add(provider);
}
return codeTFProviders;
}
private List loadProjectProviders() {
List projectProviders = new ArrayList<>();
ServiceLoader loader = ServiceLoader.load(ProjectProvider.class);
for (ProjectProvider projectProvider : loader) {
projectProviders.add(projectProvider);
}
return projectProviders;
}
/**
* Translate the codemod parameters delivered as CLI arguments in the form of LDAP-style
* name=value pairs into their POJO form.
*/
private List createFromParameterStrings(final List parameterStrings) {
if (parameterStrings == null || parameterStrings.isEmpty()) {
return List.of();
}
return parameterStrings.stream().map(ParameterArgument::fromNameValuePairs).toList();
}
private static final int SUCCESS = 0;
private static final int ERROR_CANT_READ_PROJECT_DIRECTORY = 1;
private static final int ERROR_CANT_WRITE_OUTPUT_FILE = 2;
private static final int ERROR_INVALID_ARGUMENT = 3;
private static final List defaultPathIncludes =
List.of(
"**.java",
"**/*.java",
"pom.xml",
"**/pom.xml",
"**.jsp",
"**/*.jsp",
"web.xml",
"**/web.xml");
private static final List defaultPathExcludes =
List.of("**/test/**", "**/tests/**", "**/target/**", "**/build/**", "**/.mvn/**", ".mvn/**");
private static final Logger log = LoggerFactory.getLogger(CLI.class);
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy