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:
- It should add the
@Getter
annotation to a variable if and only if this variable is a field and has a corresponding getter method in the same class. - If a field
f
has a boolean type, the name of the getter method should be equal toisF()
, otherwise it should be equal togetF()
. - The return type of the getter method should match the type of the field.
- In the case of multiple field declarations on a single line, the annotation should only be added if
the class contains a getter method for each field in this declaration.
For example if the class has the declaration
int a, b;
it should also have the getter methodsgetA()
andgetB()
.
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:
- Add the Maven dependency for Lombok to the project’s
pom.xml
. - Place each variable declaration in its own statement and on its own line. This prevents the extra complications with multiple variable declarations on a single line.
- Add the Lombok
@Getter
annotation to all fields that have a corresponding getter method. After adding the annotation, it should additionaly remove these getter methods from the class.
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:
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:
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:
After running the recipe, the class should be transformed like this:
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:
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:
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:
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.
To find out if the variable declaration already has the @Getter
annotation, we can directly use one of the available methods of
J.VariableDeclarations
:
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).
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:
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:
- First, we should find all the fields that have the
@Getter
annotation. - For each of these fields, we should find the corresponding getter methods
- Finally, we need to modify the ClassDeclaration, so that these getter methods are no longer included in its list of statements.
The code for the visitClassDeclaration(...)
will then look like this:
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
- Writing recipes over multiple source file types, e.g. to evaluate conditions in a property file before modifying a piece of Java code.
- Writing non-isomorphic Java visitors, for example, when the visitor method for a class declaration should return a different type of LST element.
- Sharing data between visitor methods by using markers or cursor messages.
- Defining a style for your source code so that the OpenRewrite recipes will format their results according to the rules in the style definition.
- And much more …
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