Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions astra-cli/example-refactors.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# ──────────────────────────────────────────────────────────────────────────────
# Astra YAML refactor configuration
#
# Run with:
# astra yaml --config example-refactors.yaml \
# --dir /path/to/source/checkout \
# --cp /path/to/dep.jar,/path/to/other-dep.jar
#
# Supported types: typeReference, methodInvocation
# ──────────────────────────────────────────────────────────────────────────────

refactors:

# ── typeReference ────────────────────────────────────────────────────────────
# Changes every reference to the old fully-qualified type to the new one,
# including imports, variable declarations, Javadoc, and qualified names.

- type: typeReference
from: com.example.legacy.OldService
to: com.example.services.NewService

# Inner class references: use dot notation (not dollar sign)
- type: typeReference
from: com.example.Outer.OldInner
to: com.example.NewInner

# ── methodInvocation ─────────────────────────────────────────────────────────
# Matches method invocations and applies rename / type-change transforms.
#
# Required in 'from': qualifiedClass, method
# Optional in 'from': parameters (list of FQNs), isVarargs (boolean)
# Required in 'to': at least one of: method, qualifiedClass

# Rename a method (keep same class)
- type: methodInvocation
from:
qualifiedClass: com.example.OldClass
method: oldMethod
to:
method: newMethod

# Move a method to a different class (keep same name)
- type: methodInvocation
from:
qualifiedClass: com.example.OldService
method: processRequest
to:
qualifiedClass: com.example.NewService

# Rename and move simultaneously
- type: methodInvocation
from:
qualifiedClass: com.example.OldClass
method: doSomething
to:
qualifiedClass: com.example.NewClass
method: execute

# Match by exact parameter types to disambiguate overloads
- type: methodInvocation
from:
qualifiedClass: com.example.Processor
method: process
parameters:
- java.lang.String
- int
to:
method: handle

# Match a varargs method specifically
- type: methodInvocation
from:
qualifiedClass: com.example.Logger
method: log
parameters:
- java.lang.String
- java.lang.Object
isVarargs: true
to:
method: logFormatted
6 changes: 6 additions & 0 deletions astra-cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
<artifactId>picocli</artifactId>
<version>4.5.1</version>
</dependency>

<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.17.2</version>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
subcommands = {
AstraMethodInvocation.class,
AstraChangeType.class,
AstraYamlRefactor.class,
CommandLine.HelpCommand.class
})
public class AstraCli implements Runnable {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package org.alfasoftware.astracli.commandline;

import java.io.File;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

import org.alfasoftware.astra.core.refactoring.UseCase;
import org.alfasoftware.astra.core.utils.ASTOperation;
import org.alfasoftware.astra.core.utils.AstraCore;
import org.alfasoftware.astracli.config.AstraOperationFactory;
import org.alfasoftware.astracli.config.RefactorConfig;
import org.alfasoftware.astracli.config.YamlConfigParser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import picocli.CommandLine;
import picocli.CommandLine.Option;

/**
* CLI subcommand {@code astra yaml} — applies refactoring operations declared in a YAML file.
*
* <pre>
* astra yaml --config refactors.yaml --dir /path/to/source --cp /path/to/dep.jar
* </pre>
*
* <p>Multiple operations of different types can be combined in a single YAML file.
* See the project's {@code example-refactors.yaml} for the full schema.
*/
@CommandLine.Command(name = "yaml",
sortOptions = false,
headerHeading = "@|bold,underline Usage:|@%n%n",
synopsisHeading = "%n",
descriptionHeading = "%n@|bold,underline Description:|@%n%n",
parameterListHeading = "%n@|bold,underline Parameters:|@%n",
optionListHeading = "%n@|bold,underline Options:|@%n",
header = "Apply refactors from a YAML config file.",
description = "Reads one or more refactoring operations from a YAML configuration file and applies them to the target source directory.")
class AstraYamlRefactor implements Runnable {

private static final Logger log = LoggerFactory.getLogger(AstraYamlRefactor.class);

@Option(
names = {"-f", "--config"},
required = true,
paramLabel = "<config>",
description = "Path to the YAML refactor configuration file.")
File configFile;

@Option(
names = {"-d", "--dir"},
required = true,
description = "Set the path to the code checkout.")
File directory;

@Option(
names = "--cp",
required = true,
description = "Set the path to the additional jar files. At least the jar containing the 'before' type should be specified.",
split = "[,;]")
File[] classpath;

@Override
public void run() {
log.info("Starting [yaml] refactor from config: [" + configFile.getAbsolutePath() + "]");

RefactorConfig config;
try {
config = new YamlConfigParser().parse(configFile);
} catch (Exception e) {
throw new RuntimeException(
"Failed to parse YAML config file '" + configFile.getAbsolutePath() + "': " + e.getMessage(), e);
}

List<ASTOperation> operations;
try {
operations = new AstraOperationFactory().createOperations(config);
} catch (IllegalArgumentException e) {
throw new RuntimeException("Invalid refactor configuration: " + e.getMessage(), e);
}

log.info("Loaded " + operations.size() + " refactoring operation(s) from config");

AstraCore.run(directory.getAbsolutePath(), new UseCase() {

@Override
public Set<? extends ASTOperation> getOperations() {
return new HashSet<>(operations);
}

@Override
public Set<String> getAdditionalClassPathEntries() {
return Arrays.asList(classpath).stream()
.map(File::getAbsolutePath)
.collect(Collectors.toSet());
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package org.alfasoftware.astracli.config;

import java.util.ArrayList;
import java.util.List;

import org.alfasoftware.astra.core.matchers.MethodMatcher;
import org.alfasoftware.astra.core.refactoring.operations.methods.MethodInvocationRefactor;
import org.alfasoftware.astra.core.refactoring.operations.types.TypeReferenceRefactor;
import org.alfasoftware.astra.core.utils.ASTOperation;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* Converts a {@link RefactorConfig} (parsed from YAML) into a list of {@link ASTOperation} instances
* ready to be passed to {@code AstraCore.run()}.
*
* <p>Supported {@code type} values:
* <ul>
* <li>{@code typeReference} — produces a {@link TypeReferenceRefactor}</li>
* <li>{@code methodInvocation} — produces a {@link MethodInvocationRefactor}</li>
* </ul>
*
* <p>Throws {@link IllegalArgumentException} for missing required fields or unknown types.
*/
public class AstraOperationFactory {

static final String TYPE_METHOD_INVOCATION = "methodInvocation";
static final String TYPE_TYPE_REFERENCE = "typeReference";

private final ObjectMapper mapper = new ObjectMapper();

/**
* Creates one {@link ASTOperation} for each entry in the config.
*
* @throws IllegalArgumentException if the config is null/empty or any entry is invalid
*/
public List<ASTOperation> createOperations(RefactorConfig config) {
if (config == null || config.getRefactors() == null || config.getRefactors().isEmpty()) {
throw new IllegalArgumentException("Config must contain at least one refactor entry under 'refactors'");
}

List<ASTOperation> operations = new ArrayList<>();
List<RefactorEntry> refactors = config.getRefactors();
for (int i = 0; i < refactors.size(); i++) {
try {
operations.add(createOperation(refactors.get(i)));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Error in refactor entry " + i + ": " + e.getMessage(), e);
}
}
return operations;
}

private ASTOperation createOperation(RefactorEntry entry) {
if (entry.getType() == null || entry.getType().isBlank()) {
throw new IllegalArgumentException("'type' is required");
}

switch (entry.getType()) {
case TYPE_METHOD_INVOCATION:
return createMethodInvocationRefactor(entry);
case TYPE_TYPE_REFERENCE:
return createTypeReferenceRefactor(entry);
default:
throw new IllegalArgumentException(
"Unknown refactor type: '" + entry.getType() + "'. Must be one of: "
+ TYPE_METHOD_INVOCATION + ", " + TYPE_TYPE_REFERENCE);
}
}

private MethodInvocationRefactor createMethodInvocationRefactor(RefactorEntry entry) {
JsonNode fromNode = entry.getFrom();
if (fromNode == null || !fromNode.isObject()) {
throw new IllegalArgumentException(
"'from' must be a YAML mapping (object) for type '" + TYPE_METHOD_INVOCATION + "'");
}

MethodFromConfig fromConfig = mapper.convertValue(fromNode, MethodFromConfig.class);

if (isNullOrBlank(fromConfig.getQualifiedClass())) {
throw new IllegalArgumentException("'from.qualifiedClass' is required for type '" + TYPE_METHOD_INVOCATION + "'");
}
if (isNullOrBlank(fromConfig.getMethod())) {
throw new IllegalArgumentException("'from.method' is required for type '" + TYPE_METHOD_INVOCATION + "'");
}

MethodMatcher.Builder matcherBuilder = MethodMatcher.builder()
.withFullyQualifiedDeclaringType(fromConfig.getQualifiedClass())
.withMethodName(fromConfig.getMethod());

if (fromConfig.getParameters() != null) {
matcherBuilder = matcherBuilder.withFullyQualifiedParameters(fromConfig.getParameters());
}

if (Boolean.TRUE.equals(fromConfig.getVarargs())) {
matcherBuilder = matcherBuilder.isVarargs(true);
} else if (Boolean.FALSE.equals(fromConfig.getVarargs())) {
matcherBuilder = matcherBuilder.isVarargs(false);
}

JsonNode toNode = entry.getTo();
if (toNode == null || !toNode.isObject()) {
throw new IllegalArgumentException(
"'to' must be a YAML mapping (object) for type '" + TYPE_METHOD_INVOCATION + "'");
}

MethodToConfig toConfig = mapper.convertValue(toNode, MethodToConfig.class);

if (isNullOrBlank(toConfig.getMethod()) && isNullOrBlank(toConfig.getQualifiedClass())) {
throw new IllegalArgumentException(
"At least one of 'to.method' or 'to.qualifiedClass' must be specified for type '" + TYPE_METHOD_INVOCATION + "'");
}

MethodInvocationRefactor.Changes changes = new MethodInvocationRefactor.Changes();
if (!isNullOrBlank(toConfig.getMethod())) {
changes = changes.toNewMethodName(toConfig.getMethod());
}
if (!isNullOrBlank(toConfig.getQualifiedClass())) {
changes = changes.toNewType(toConfig.getQualifiedClass());
}

return MethodInvocationRefactor.from(matcherBuilder.build()).to(changes);
}

private TypeReferenceRefactor createTypeReferenceRefactor(RefactorEntry entry) {
JsonNode fromNode = entry.getFrom();
JsonNode toNode = entry.getTo();

if (fromNode == null || !fromNode.isTextual() || fromNode.asText().isBlank()) {
throw new IllegalArgumentException(
"'from' must be a non-empty string (fully qualified type name) for type '" + TYPE_TYPE_REFERENCE + "'");
}
if (toNode == null || !toNode.isTextual() || toNode.asText().isBlank()) {
throw new IllegalArgumentException(
"'to' must be a non-empty string (fully qualified type name) for type '" + TYPE_TYPE_REFERENCE + "'");
}

return TypeReferenceRefactor.builder()
.fromType(fromNode.asText())
.toType(toNode.asText())
.build();
}

private static boolean isNullOrBlank(String s) {
return s == null || s.isBlank();
}
}
Loading
Loading