Writing a Java Refactoring Recipe
Adding a method to a class that returns a String
In this tutorial, we'll create a basic refactoring recipe that adds a method returning aString to a user-specified class. This SayHelloRecipe will take a class like this:
1
package com.yourorg;
2
3
class A {
4
5
}
Copied!
And refactor it into a class like this:
1
package com.yourorg;
2
3
class A {
4
public String hello() {
5
return "Hello from com.yourorg.A!";
6
}
7
8
}
Copied!
This guide assumes you've already set up your Recipe Development Environment.

Defining SayHelloRecipe

Begin by creating a class that extends org.openrewrite.Recipe. This recipe should accept as a configuration parameter the fully qualified name of the class to add a hello() method to and it should validate that parameter is configured with a valid value.
1
package org.openrewrite.samples;
2
3
import com.fasterxml.jackson.annotation.JsonCreator;
4
import com.fasterxml.jackson.annotation.JsonProperty;
5
import org.openrewrite.ExecutionContext;
6
import org.openrewrite.Option;
7
import org.openrewrite.Recipe;
8
import org.openrewrite.internal.lang.NonNull;
9
import org.openrewrite.java.JavaIsoVisitor;
10
import org.openrewrite.java.JavaTemplate;
11
import org.openrewrite.java.tree.J;
12
13
public class SayHelloRecipe extends Recipe {
14
// Making your recipe immutable helps make them idempotent and eliminates categories of possible bugs
15
// Configuring your recipe in this way also guarantees that basic validation of parameters will be done for you by rewrite
16
@Option(displayName = "Fully Qualified Class Name",
17
description = "A fully-qualified class name indicating which class to add a hello() method.",
18
example = "com.yourorg.FooBar")
19
@NonNull
20
private final String fullyQualifiedClassName;
21
22
// Recipes must be serializable. This is verified by RecipeTest.assertChanged() and RecipeTest.assertUnchanged()
23
@JsonCreator
24
public SayHelloRecipe(@NonNull @JsonProperty("fullyQualifiedClassName") String fullyQualifiedClassName) {
25
this.fullyQualifiedClassName = fullyQualifiedClassName;
26
}
27
28
@Override
29
public String getDisplayName() {
30
return "Say Hello";
31
}
32
33
@Override
34
public String getDescription() {
35
return "Adds a \"hello\" method to the specified class";
36
}
37
38
// TODO: Override getVisitor() to return a JavaIsoVisitor to perform the refactoring
39
}
Copied!
The "discover" feature of the Gradle and Maven plugins will display information from the @Option annotation to users of your Recipe.
So now we have a Recipe implementation that validates that a single parameter is filled in with a non-blank value. It doesn't have any actual refactoring behavior yet, so that's what we'll add next.

Implementing the Visitor

To actually refactor the code in question we override Recipe.getVisitor() to return a new JavaIsoVisitor<ExecutionContext> instance that will actually perform the refactoring.
1
package org.openrewrite.samples;
2
3
import com.fasterxml.jackson.annotation.JsonCreator;
4
import com.fasterxml.jackson.annotation.JsonProperty;
5
import org.openrewrite.ExecutionContext;
6
import org.openrewrite.Option;
7
import org.openrewrite.Recipe;
8
import org.openrewrite.internal.lang.NonNull;
9
import org.openrewrite.java.JavaIsoVisitor;
10
import org.openrewrite.java.JavaTemplate;
11
import org.openrewrite.java.tree.J;
12
13
public class SayHelloRecipe extends Recipe {
14
// Making your recipe immutable helps make them idempotent and eliminates categories of possible bugs
15
// Configuring your recipe in this way also guarantees that basic validation of parameters will be done for you by rewrite
16
@Option(displayName = "Fully Qualified Class Name",
17
description = "A fully-qualified class name indicating which class to add a hello() method.",
18
example = "com.yourorg.FooBar")
19
@NonNull
20
private final String fullyQualifiedClassName;
21
22
// Recipes must be serializable. This is verified by RecipeTest.assertChanged() and RecipeTest.assertUnchanged()
23
@JsonCreator
24
public SayHelloRecipe(@NonNull @JsonProperty("fullyQualifiedClassName") String fullyQualifiedClassName) {
25
this.fullyQualifiedClassName = fullyQualifiedClassName;
26
}
27
28
@Override
29
public String getDisplayName() {
30
return "Say Hello";
31
}
32
33
@Override
34
public String getDescription() {
35
return "Adds a \"hello\" method to the specified class";
36
}
37
38
@Override
39
protected JavaIsoVisitor<ExecutionContext> getVisitor() {
40
// getVisitor() should always return a new instance of the visitor to avoid any state leaking between cycles
41
return new SayHelloVisitor();
42
}
43
44
public class SayHelloVisitor extends JavaIsoVisitor<ExecutionContext> {
45
private final JavaTemplate helloTemplate =
46
JavaTemplate.builder(this::getCursor, "public String hello() { return \"Hello from #{}!\"; }")
47
.build();
48
49
@Override
50
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
51
// TODO: Filter out classes that don't need refactoring, refactor those that do
52
return classDecl;
53
}
54
}
55
}
Copied!
Here we override JavaIsoVisitor.visitClassDeclaration in preparation for returning a modified class declaration that includes our new hello() method. The first step in any refactoring visit method is to avoid refactoring any class which the visitor should not change. In this case, that means any class that isn't the one specified in the recipe, or any class that already has a hello() method. Adding this filtering to SayHelloRecipe.SayHelloVisitor.visitClassDeclaration() looks like this:
1
@Override
2
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
3
// In any visit() method the call to super() is what causes sub-elements of to be visited
4
J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, executionContext);
5
6
if (classDecl.getType() == null || !classDecl.getType().getFullyQualifiedName().equals(fullyQualifiedClassName)) {
7
// We aren't looking at the specified class so return without making any modifications
8
return cd;
9
}
10
11
// Check if the class already has a method named "hello" so we don't incorrectly add a second "hello" method
12
boolean helloMethodExists = classDecl.getBody().getStatements().stream()
13
.filter(statement -> statement instanceof J.MethodDeclaration)
14
.map(J.MethodDeclaration.class::cast)
15
.anyMatch(methodDeclaration -> methodDeclaration.getName().getSimpleName().equals("hello"));
16
if (helloMethodExists) {
17
return cd;
18
}
19
20
// TODO: Use JavaTemplate to say hello()
21
return cd;
22
}
Copied!

Using JavaTemplate to say hello()

Templates are created using the JavaTemplate.builder() method. Within a template #{} is the signifier for positional parameter substitution. So to produce the hello() method add this to the visitor:
1
public class SayHelloVisitor extends JavaIsoVisitor<ExecutionContext> {
2
private final JavaTemplate helloTemplate =
3
JavaTemplate.builder(this::getCursor, "public String hello() { return \"Hello from #{}!\"; }")
4
.build();
5
...
6
}
Copied!
And use that template within SayHelloVisitor.visitClassDeclaration like so:
1
@Override
2
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
3
// In any visit() method the call to super() is what causes sub-elements of to be visited
4
J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, executionContext);
5
6
if (classDecl.getType() == null || !classDecl.getType().getFullyQualifiedName().equals(fullyQualifiedClassName)) {
7
// We aren't looking at the specified class so return without making any modifications
8
return cd;
9
}
10
11
// Check if the class already has a method named "hello" so we don't incorrectly add a second "hello" method
12
boolean helloMethodExists = classDecl.getBody().getStatements().stream()
13
.filter(statement -> statement instanceof J.MethodDeclaration)
14
.map(J.MethodDeclaration.class::cast)
15
.anyMatch(methodDeclaration -> methodDeclaration.getName().getSimpleName().equals("hello"));
16
if (helloMethodExists) {
17
return cd;
18
}
19
20
// Interpolate the fullyQualifiedClassName into the template and use the resulting AST to update the class body
21
cd = cd.withBody(
22
cd.getBody().withTemplate(
23
helloTemplate,
24
cd.getBody().getCoordinates().lastStatement(),
25
fullyQualifiedClassName
26
));
27
28
return cd;
29
}
Copied!
Running this visitor on a class like A { } will now produce the desired result:
1
package com.yourorg;
2
3
class A {
4
public String hello() {
5
return "Hello from com.yourorg.A!";
6
}
7
8
}
Copied!
So the complete SayHelloRecipe looks like this:
1
package org.openrewrite.samples;
2
3
import com.fasterxml.jackson.annotation.JsonCreator;
4
import com.fasterxml.jackson.annotation.JsonProperty;
5
import org.openrewrite.ExecutionContext;
6
import org.openrewrite.Option;
7
import org.openrewrite.Recipe;
8
import org.openrewrite.internal.lang.NonNull;
9
import org.openrewrite.java.JavaIsoVisitor;
10
import org.openrewrite.java.JavaTemplate;
11
import org.openrewrite.java.tree.J;
12
13
public class SayHelloRecipe extends Recipe {
14
// Making your recipe immutable helps make them idempotent and eliminates categories of possible bugs
15
// Configuring your recipe in this way also guarantees that basic validation of parameters will be done for you by rewrite
16
@Option(displayName = "Fully Qualified Class Name",
17
description = "A fully-qualified class name indicating which class to add a hello() method.",
18
example = "com.yourorg.FooBar")
19
@NonNull
20
private final String fullyQualifiedClassName;
21
22
// Recipes must be serializable. This is verified by RecipeTest.assertChanged() and RecipeTest.assertUnchanged()
23
@JsonCreator
24
public SayHelloRecipe(@NonNull @JsonProperty("fullyQualifiedClassName") String fullyQualifiedClassName) {
25
this.fullyQualifiedClassName = fullyQualifiedClassName;
26
}
27
28
@Override
29
public String getDisplayName() {
30
return "Say Hello";
31
}
32
33
@Override
34
public String getDescription() {
35
return "Adds a \"hello\" method to the specified class";
36
}
37
38
@Override
39
protected JavaIsoVisitor<ExecutionContext> getVisitor() {
40
// getVisitor() should always return a new instance of the visitor to avoid any state leaking between cycles
41
return new SayHelloVisitor();
42
}
43
44
public class SayHelloVisitor extends JavaIsoVisitor<ExecutionContext> {
45
private final JavaTemplate helloTemplate =
46
JavaTemplate.builder(this::getCursor, "public String hello() { return \"Hello from #{}!\"; }")
47
.build();
48
49
@Override
50
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
51
// In any visit() method the call to super() is what causes sub-elements of to be visited
52
J.ClassDeclaration cd = super.visitClassDeclaration(classDecl, executionContext);
53
54
if (classDecl.getType() == null || !classDecl.getType().getFullyQualifiedName().equals(fullyQualifiedClassName)) {
55
// We aren't looking at the specified class so return without making any modifications
56
return cd;
57
}
58
59
// Check if the class already has a method named "hello" so we don't incorrectly add a second "hello" method
60
boolean helloMethodExists = classDecl.getBody().getStatements().stream()
61
.filter(statement -> statement instanceof J.MethodDeclaration)
62
.map(J.MethodDeclaration.class::cast)
63
.anyMatch(methodDeclaration -> methodDeclaration.getName().getSimpleName().equals("hello"));
64
if (helloMethodExists) {
65
return cd;
66
}
67
68
// Interpolate the fullyQualifiedClassName into the template and use the resulting AST to update the class body
69
cd = cd.withBody(
70
cd.getBody().withTemplate(
71
helloTemplate,
72
cd.getBody().getCoordinates().lastStatement(),
73
fullyQualifiedClassName
74
));
75
76
return cd;
77
}
78
}
79
}
Copied!

Testing

To create automated tests of this visitor we use the kotlin language, mostly for convenient access to multi-line Strings, with JUnit 5 and JavaRecipeTest class provided by rewrite-test. For SayHelloRecipe it is sensible to test:
    That a class matching the configured fullyQualifiedClassName with no hello() method will have a hello() method added
    That a class that already has a different hello() implementation will be left untouched
    That a class not matching the configured fullyQualifiedClassName with no hello() method will be left untouched
To assert that a Recipe does make a change, use RecipeTest.assertChanged. To assert that a Recipe does not make a change it shouldn't, use RecipeTest.assertUnchanged. These methods will default to using the parser and recipe properties on the class.
1
package org.openrewrite.samples
2
3
import org.junit.jupiter.api.Test
4
import org.openrewrite.java.JavaParser
5
import org.openrewrite.java.JavaRecipeTest
6
7
class SayHelloRecipeTest: JavaRecipeTest {
8
override val parser = JavaParser.fromJavaVersion().build()
9
override val recipe = SayHelloRecipe("com.yourorg.A")
10
11
@Test
12
fun addsHelloToA() = assertChanged(
13
before = """
14
package com.yourorg;
15
16
class A {
17
}
18
""",
19
after = """
20
package com.yourorg;
21
22
class A {
23
public String hello() {
24
return "Hello from com.yourorg.A!";
25
}
26
}
27
"""
28
)
29
30
@Test
31
fun doesNotChangeExistingHello() = assertUnchanged(
32
before = """
33
package com.yourorg;
34
35
class A {
36
public String hello() { return ""; }
37
}
38
"""
39
)
40
41
@Test
42
fun doesNotChangeOtherClass() = assertUnchanged(
43
before = """
44
package com.yourorg;
45
46
class B {
47
}
48
"""
49
)
50
}
Copied!
Users of IntelliJ Idea benefit from Java syntax highlighting when authoring these tests.

Declarative YAML Usage

SayHelloRecipe is now ready to be used in code or declaratively from YAML.
YAML
1
---
2
type: specs.openrewrite.org/v1beta/recipe
3
name: com.yourorg.sayHelloA
4
recipeList:
5
- org.openrewrite.samples.SayHelloRecipe:
6
fullyQualifiedClassName: com.yourorg.A
Copied!
Last modified 3mo ago