Suppose you are working on an older Java project. Your team has decided that it wants to introduce Lombok to get rid of all the getter and setter methods in the code base to make the code more readable. Instead of manually introducing the Lombok annotations and removing the getter/setter methods, you want to leverage OpenRewrite to automate this task. Since there are no officially supported Lombok recipes available, you’ll have to write your own recipe.

Planning the structure of the recipe

Before writing the recipe, you should first think carefully about all the constraints that your recipe should adhere to. Otherwise you run the risk of changing too much code or forgetting certain use cases. For the sake of simplicity, let’s focus on replacing getter methods with the Lombok @Getter annotation, since replacing setter methods should work almost identically. For the getter methods, these constraints should be as follows:

To avoid creating recipes that perform too much complex logic, it is helpful to split the recipe into multiple building blocks. Each building block should be a recipe in itself and can be linked together to form a larger recipe. The advantage of this approach is that you have many small recipes that are easy to test, and that you can often use existing recipes to perform small tasks. In our case, it would be helpful to divide the recipe into the following three parts:

For the first two steps you can use pre-existing recipes from Openrewrite. The AddDependency recipe can add the Lombok dependency to the project if it does already exist. In addition, the MultipleVariableDeclarations recipe can simplify the multiple variable declarations. The final recipe has to be implemented by yourself.

As described in the previous blog post, yu can use the declarative approach to combine these recipes into a single recipe. The rewrite.yml file for the complete recipe should then look something like this:

type: specs.openrewrite.org/v1beta/recipe

name: com.yourorg.openrewrite.lombok.IntroduceGetter

recipeList:

  - org.openrewrite.maven.AddDependency:

      groupId: org.projectlombok

      artifactId: lombok

      version: 1.18.24

  - org.openrewrite.java.cleanup.MultipleVariableDeclarations

  - com.yourorg.openrewrite.lombok.AddGetterAnnotations
rewrite.yml

Creating the project

To create this OpenRewrite recipe, you must first create a separate Java project. Recipes should be implemented in their own projects, so that they can be referenced as a dependency in the OpenRewrite Maven/Gradle plugin.

Since we want to modify the Java code and also update the Maven pom.xml of our original codebase, the dependencies section in our build file (in this case build.gradle.kts) of the recipe-project should look like this:

dependencies {
    // import Rewrite's bill of materials.

    implementation(platform("org.openrewrite.recipe:rewrite-recipe-bom:1.14.0"))

    // rewrite-java dependencies for Java Recipe development

    // To be able to support different java versions, we have to include

    // the rewrite-java-8, rewrite-java-11 and rewrite-java-17 dependencies

    implementation("org.openrewrite:rewrite-java")
    runtimeOnly("org.openrewrite:rewrite-java-8") 
    runtimeOnly("org.openrewrite:rewrite-java-11")
    runtimeOnly("org.openrewrite:rewrite-java-17")
    testImplementation("org.openrewrite:rewrite-test")

    // recipes for modifying maven dependencies

    implementation("org.openrewrite:rewrite-maven")

    // Use JUnit Jupiter for testing.

    testImplementation("org.junit.jupiter:junit-jupiter:5.9.1")
}
build.gradle.kts

Defining an acceptance test

Before writing the recipes, it is very helpful to define one or more acceptance tests to make sure that they do what they are supposed to be doing. Fortunately, OpenRewrite provides a testing framework that allows you to easily test your recipes. All you have to do is pass it a snippet of source code and the expected code snippet and the library does the rest!

Now think of a class definition that covers all the constraints that need to be considered by the recipe. A good example of such a class would be:

package com.yourorg;
                            
public class Customer {
    
    private boolean deleted;
    private int a, b=2;
    
    private String noGetter;
    
    public boolean isDeleted() {
        return this.name;
    }
    
    public int getA() {
        return this.a;
    }
    
    public int getB() {
        return this.b;
    }
    
    public String getSomeData() {
        return "some data";
    }
}
Test class for the acceptance test

After running the recipe, the class should be transformed like this:

package com.yourorg;

import lombok.Getter;
                        
public class Customer {
    
    @Getter
    private boolean deleted;
    @Getter
    private int a;
    @Getter
    private int b = 2;
    
    private String noGetter;
    
    public String getSomeData() {
        return "some data";
    }
}
Expected outcome for acceptance test

Recipe-tests must implement the org.openrewrite.test.RewriteTest interface. The specific recipe to be tested can be specified by overriding the defaults(RecipeSpec spec) method. Using the spec parameter, you can either specify a rewrite.yml file or instantiate the java class for the recipe. Within the test method, you can run openrewrite by executing the rewriteRun method, which accepts different implementations of SourceSpec depending on the type of recipe you want to test. In this case we want to rewrite Java code, so we will use the java(..) method. The acceptance test for our recipe will therefore look like this:

class LombokIntroduceGetterRewriteTest implements RewriteTest {

    @Override
    public void defaults(RecipeSpec spec) {
        spec.recipe("rewrite.yml");
    }

    @Test
    void testRecipe() {
        String before = "..."; // The source code for the class definition

        String after = "..."; // The source code for the expected result

        rewriteRun(java(before, after));
    }
}
Acceptance Test for the recipe

Writing the recipe for adding the Lombok @Getter annotation

OpenRewrite Recipes are defined as Java classes that extend the abstract base class org.openrewrite.Recipe. Let us first start with writing the recipe for adding the @Getter annotation to fields having a getter method:

import org.openrewrite.Recipe;

public class AddGetterAnnotations extends Recipe {

    @Override
    public String getDisplayName() {
        return "Lombok - Add getter annotation to fields";
    }

    @Override
    public String getDescription() {
        return "Adds the Lombok @Getter annotation to fields, " + 
          "if they have a corresponding getter method.";
    }

    protected TreeVisitor<?, ExecutionContext> getVisitor() {
        return new JavaIsoVisitor<>() {
            // Implementation of the visitor for the recipe.

        }
    }
}
Structure of the OpenRewrite recipe

The core of the recipe logic lies in the implementation of the abstract base class TreeVisitor, which is in most cases is an implementation of the abstract JavaIsoVisitor class (see the documentation for more details on this topic). The TreeVisitor has methods for all possible visitors that can be defined for the LST of the source code file. When implementing the class it is therefore important to know on which LST elements the visitor should act upon. In our case, we want to add the Lombok @Getter annotation to a field of a class. Therefore it makes sense to implement the visitor method for variable declarations. The structure of this implementation can be written as follows:

@Override
public J.VariableDeclarations visitVariableDeclarations(
        J.VariableDeclarations multiVariable, ExecutionContext executionContext) {
            
    J.VariableDeclarations v = super.visitVariableDeclarations(multiVariable, 
        executionContext);

    // Do nothing if the variable declaration is not a field 

    // or if it already has a Getter annotation

    if (!isField(getCursor()) || hasGetterAnnotation(v)) {
        return v;
    }

    // Check if getter methods exist for all variables in the declaration.

    if (hasGetterMethods(v)) {
        v = addGetterAnnotation(v);

        // Add the import of lombok.Getter to the class, if it not yet present.

        maybeAddImport("lombok.Getter"); 
    }
    return v;
}
Implementing the visitVariableDeclarations visitor

Now let us see how we can implement the individual methods. First, we need to see how we can tell if a variable declaration is actually a field declaration inside a class. A key aspect of a field is that it should be declared in a class and not inside a method. Therefore, we can recognise a field in the LST if the parent node of a variable declaration is a class declaration. This can be done by using the getCursor() method. The cursor points to the currently visited element in the LST and provides methods to navigate to other elements in the LST.

private boolean isField(Cursor cursor) {
    return cursor
            .dropParentUntil(parent -> parent instanceof J.ClassDeclaration
                    || parent instanceof J.MethodDeclaration)
            .getValue() instanceof J.ClassDeclaration;
}

To find out if the variable declaration already has the @Getter annotation, we can directly use one of the available methods of J.VariableDeclarations:

private boolean hasGetterAnnotation(J.VariableDeclarations v) {
    return v.getAllAnnotations().stream()
            .anyMatch(it -> TypeUtils.isOfClassType(it.getType(), "lombok.Getter"));
}

In order to find the getter methods for all the variables in the variable declaration, it would be very difficult to use our current visitor - we would have to manually move the cursor through the LST and can not take advantage of the predefined visitor methods in TreeVisitor. Therefore it is best to use a second visitor to find the methods we want. Fortunately, OpenRewrite provides many “search” visitors for finding specific elements in an LST. In our case, we can take advantage of the FindMethods visitor. This visitor takes a method pattern as a search parameter and returns a set of all matching method declarations in the provided LST (which in our case should be the LST of the enclosing class). The method pattern is defined as an AspectJ-like expression (see the documentation for more details). For example, if the name of the variable is x, the method expression should be "* isX()" if it is a boolean and "* getX()" in all other cases (for simplicity, we ignore the return type here).

private boolean hasGetterMethod(J.VariableDeclarations v) {
    J.ClassDeclaration enclosingClass = getCursor()
            .firstEnclosingOrThrow(J.ClassDeclaration.class);
    
    return v.getVariables()
            .stream()
            .noneMatch(variable -> 
                findGetterMethods(enclosingClass, variable).isEmpty());
}

private Set<J.MethodDeclaration> findGetterMethods(
    J.ClassDeclaration enclosingClass, 
    J.VariableDeclaration.NamedVariable variable) {

    return FindMethods.findDeclaration(enclosingClass, 
        getterMethodPattern(variable));
}

private String getterMethodPattern(J.VariableDeclarations.NamedVariable v) {
    String prefix = TypeUtils.isOfClassType(v.getType(),"boolean") ? "is" : "get";
    return "* " + prefix + StringUtils.capitalize(it.getSimpleName()) + "()";
}

Finally, we are at the point where we can write the code to add the @Getter annotation to the variable declaration! To generate new code for the current LST, it is not advisable to write the LST-elements by hand and add them to the respective objects. Instead, you should use the JavaTemplate class, which can generate the LST-elements by parsing a snippet of code. To parse the snippet correctly, JavaTemplate should know the correct implementations of all the classes used in its definition.

By default, JavaTemplate only knows the classes provided by the Java runtime. To add new classes to its scope, you can use its javaParser(...) method to add the dependencies, for example by specifying the classpath location or by providing a stub implementation. Stubs are needed when the classes are not available on the runtime classpath. This is often the case when writing recipes for framework migrations, where only the older framework version is on the classpath. When defining stubs, they only need to have the bare minimum amount of declarations for the JavaParser to work (just enough to determine the necessary LST elements).

In our case, the JavaTemplate should use a stub for the Lombok annotation, as we do not have Lombok on our classpath. The method addGetterAnnotation, which adds to annotation to the variable declaration, then looks like this:

private final JavaTemplate addAnnotation = 
        JavaTemplate.builder(this::getCursor, "@Getter")
            .imports("lombok.Getter")
            .javaParser(() -> JavaParser.fromJavaVersion()
                    .dependsOn("package lombok;"
                            + "import java.lang.annotation.ElementType;\n" +
                            "import java.lang.annotation.Retention;\n" +
                            "import java.lang.annotation.RetentionPolicy;\n" +
                            "import java.lang.annotation.Target;" +
                            "@Target({ElementType.FIELD, ElementType.TYPE})\n" +
                            "@Retention(RetentionPolicy.SOURCE)\n" +
                            "public @interface Getter {" +
                            "}")
                    .build())
            .build();

private J.VariableDeclaration addGetterAnnotation(J.VariableDeclaration v) {
    return v.withTemplate(addAnnotation, 
        v.getCoordinates()
         .addAnnotation(Comparator.comparing(J.Annotation::getSimpleName)));
}
JavaTemplate for the Lombok annotation

Removing the relevant getter methods from the class

As the final step in our Lombok refactoring recipe, we should extend our visitor to remove all the getter methods for fields that have the @Getter annotation. The perfect candidate for this callback method is the visitClassDeclaration(...) method. The ClassDeclaration knows all the statements declared in the class, such as variable and method declarations.

The steps that we should take to to remove the relevant getter methods are as follows:

The code for the visitClassDeclaration(...) will then look like this:

@Override
public J.ClassDeclaration visitClassDeclaration(
        J.ClassDeclaration classDecl, ExecutionContext executionContext) {

    J.ClassDeclaration c = super.visitClassDeclaration(classDecl, 
        executionContext);

    // Find all getter methods for fields that have the @Getter annotation

    var methodsToRemove =  c.getBody().getStatements().stream()
            .filter(it -> it instanceof J.VariableDeclarations)
            .map(it -> (J.VariableDeclarations) it)
            .filter(this::hasGetterAnnotation)
            .flatMap(it -> it.getVariables().stream())
            .flatMap(it -> findGetterMethods(classDecl, it).stream())
            .collect(Collectors.toSet());

    // Remove all these getter methods from the body of the class declaration

    var statements = c.getBody()
            .getStatements()
            .stream()
            .filter(statement -> {
                if (statement instanceof J.MethodDeclaration) {
                    J.MethodDeclaration method = (J.MethodDeclaration) statement;
                    return !methodsToRemove.contains(method);
                }
                return true;
            }).collect(Collectors.toList());

    c = c.withBody(c.getBody().withStatements(statements));

    // Return the modified class declaration

    return c;
}
Visitor method for removing the getter methods

So why does this work at all? At first glance, it is not obvious why the code in visitVariableDeclaration(...) that adds the @Getter annotation, is executed before the code in visitClassDeclaration(...). So you might think that not much should be happening here, asthe Lombok annotation could be added after the class declaration is visited!

The key point here, is the call to super.visitClassDeclaration(...) at the beginning of the method. This basically causes the visitor to traverse the subtree contained in the class declaration and return the potentially modified subtree. Since variable declarations are contained in the subtree of the class declaration, the result of this method call is a modified LST with Lombok annotations on its fields. This in turn allows us to operate on the class declaration in the way we need to.

Final remarks

By writing refactoring recipes yourself, you can really exploit the full potential of OpenRewrite to maintain your software projects. However, as you have seen above, it can be quite a daunting task to implement them by yourself. It is therefore best to rely as much as possible on using recipes provided by OpenWrite itself, or on collections of recipes written by framework authors. Fortunately, more and more frameworks are providing recipes for their dependency upgrades, so finding the right ones should be less of a problem in the future!

In this blog post, we have limited ourselves to the basic mechanics of writing recipes. There are many more (advanced) concepts in OpenRewrite that you can explore, such as

More details on these topics can be found in the documentation or in the source code of OpenRewrite on Github.

The code for the refactoring recipe created in this post can be found here on my Github account