How to Duplicate Classes with OpenRewrite

How to Duplicate Classes with OpenRewrite

Using the Scanning Recipe Generation Phase

·

4 min read

Lets create a new DuplicateClass scanning recipe to show how we can use OpenRewrite to create new files.

Why a Scanning Recipe?

As we want to create a new file we cannot use the standard imperative recipe. Standard Recipes are only able to work on existing files, they cannot create new ones. Scanning Recipes are more complex but get around this limitation.

Scanning Recipe Phases

Scanning recipes have three phases, the scanning, generation, and editing phase. See What is a ScanningRecipe?

  1. Scanning Phase

The Accumulator

During the scanning phase the recipe has the ability to read (but not edit) all other files in your project. Any relevant data read can be stored in the accumulator object. The accumulator can be any mutable object. For our DuplicateClass recipe we'll define a static nested class for our accumulator.

@Getter
@Setter
static class Accumulator {
    // The contents of the file we want to duplicate
    SourceFile sourceFileToDuplicate;
    // Flag to see if we should generate a new file
    boolean shouldCreate = true;
}

When declaring a scanning recipe you define the type of the accumulator as a generic for the class like so:

class DuplicateClass extends ScanningRecipe<DuplicateClass.Accumulator>

The Scanner

The scanner is any class that extends TreeVisitor. Here we have access to the accumulator we created earlier.

public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
    return new TreeVisitor<>() {
        public Tree visit(Tree tree, ExecutionContext executionContext, Cursor parent) {
            SourceFile sourceFile = (SourceFile) tree;
            String path = sourceFile.getSourcePath().toString();

            if (path.contains(getNewClassPath())) {
                // Don't generate file if it already exists
                acc.setShouldCreate(false);
            } else if (acc.getSourceFileToDuplicate() == null 
                    && path.contains(getClassPath())) {
                // Save reference to file to duplicate
                acc.setSourceFileToDuplicate(sourceFile);
            }

            return sourceFile;
        }
    };
}

There are only two things we need to do during the scanning phase for our DuplicateClass recipe, see the if-else block.

First if we find a file that matches the one we want to duplicate we need to save a reference to it. We will use this during the next generation phase to duplicate the class.

Second if we encounter a Java file with the same path as the one we are expecting to create we should set the shouldCreate flag to false. This keeps our recipe idempotent.

  1. Generation Phase

In the previous scanning phase we saved a reference to the file we want to duplicate in the accumulator. We will use this to generate our duplicated file.

public Collection<? extends SourceFile> generate(Accumulator acc, ExecutionContext ctx) {
    var sourceFile = acc.getSourceFileToDuplicate();
    // Don't generate if duplicate file already exists 
    // or we didn't find the target file
    if (!acc.shouldCreate || sourceFile == null) {
        return Set.of();
    }

    var oldPath = sourceFile.getSourcePath();
    var newPath = Path.of(oldPath.toString().replace(getClassPath(), getNewClassPath()));
    SourceFile newSourceFile = sourceFile.withSourcePath(newPath)
            // Give new UUID to prevent OpenRewrite confusion
            .withId(UUID.randomUUID());

    return Set.of(newSourceFile);
}

We should only generate a duplicate of the existing file if it does not already exist (we checked for this during the scanning phase) and we actually found the file we are trying to copy.

Before we can return the SourceFile we need to update two things. We need to update its path to reflect the new name (in our case we are just prefixing the old name with CopyOf). We also need to give the source file a new UUID. The new UUID prevents OpenRewrite from thinking both files are the same.

  1. Editing Phase

The editing phase is similar to standard imperative recipes where the actual modifications to files are made.

So far the file we have generated in the previous phase is exactly the same as the original file (except it has a new file name). This is exactly what we want except the class declared in the file still uses the old name. By convention Java classes have the same name as the file they are declared in.

To fix this we can register the ChangeType recipe to run after our DuplicateClass recipe has finished visiting the file. This will rename all references of the original class with its new name.

public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext executionContext) {
    var changeTypeRecipe = new ChangeType(getFullyQualifiedClassName(),
            getNewFullyQualifiedClassName(),
            false);
    doAfterVisit(changeTypeRecipe.getVisitor());

    return super.visitClassDeclaration(classDecl, executionContext);
}

Finished Recipe

And that is the recipe is done 🥳 See the final code here DuplicateClass.java and here TestDuplicateClass.java.

Summary

There are three phases to scanning recipes.

Scanning - Save a reference to the file we want to duplicate and test if it already exists.

Generation - Create a copy of the original file with a new name.

Editing - Rename the Java class in the generated file to match the new file name.

References

Support our work