What is an annotation processor? It's code that writes code. In fact, you may have already used an annotation processor,
such as Immutables or Dagger.
For example, you can annotate an interface with @Value.Immutable, and Immutables will generate an implementation.
But what if you want to write (and test) an annotation processor? This project demystifies that process and provides a reference example.
ImmutableProcessor generates a simplified implementation of an immutable interface.
For example, you can create an interface annotated with @Immutable, like this:
package org.example.immutable.example;
import org.example.immutable.Immutable;
@Immutable
public interface Rectangle {
static Rectangle of(double width, double height) {
return new ImmutableRectangle(width, height);
}
double width();
double height();
default double area() {
return width() * height();
}
}...and ImmutableProcessor will generate this implementation:
package org.example.immutable.example;
import javax.annotation.processing.Generated;
@Generated("org.example.immutable.processor.ImmutableProcessor")
class ImmutableRectangle implements Rectangle {
private final double width;
private final double height;
ImmutableRectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double width() {
return width;
}
@Override
public double height() {
return height;
}
}(A real-world annotation processor would also implement equals(), hashCode(), and toString(), among other things.)
- Demystify the process of writing an annotation processor.
- Include enough features to demonstrate the complexity of writing an annotation processor.
- Provide guidance for how to...
- ...debug an annotation processor.
- ...design an annotation processor.
- ...unit-test an annotation processor.
- Build an annotation processor that should be used in the real world.
- I.e., you should use Immutables in the real world.
./gradlew -Dorg.gradle.debug=true --no-daemon :immutable-example:clean :immutable-example:compileJava- From there, you can attach a debugger to Gradle.
- If you use the Gradle Error Prone plugin with JDK 16+, you will also need to add some JVM args.
- You could also debug a test written with Compile Testing.
immutable-example/build/generated/sources/annotationProcessor/java/main
We will start with ImmutableLiteProcessor and work downstream from there:
ImmutableLiteProcessorimplements a single method:void process(TypeElement annotatedElement) throws Exception- The
TypeElementcorresponds to a type that is annotated with@Immutable.
ImmutableLiteProcessor processes an interface annotated with @Immutable in two stages:
- The
modelerstage converts theTypeElementto anImmutableImpl.- The entry point for this stage is
ImmutableImpls. - The code lives in the
org.example.immutable.processor.modelerpackage.
- The entry point for this stage is
- The
generatorstage converts theImmutableImplto source code.- The entry point for this stage is
ImmutableGenerator. - The code lives in the
org.example.immutable.processor.generatorpackage.
- The entry point for this stage is
ImmutableImpl lives in the org.example.immutable.processor.model package:
- Types in this package are
@Value.Immutableinterfaces that can easily be created directly. - Types in this package are designed to be serializable (via Jackson).
- Types in this package have corresponding types in the
modelerandgeneratorpackages.
The implementation of the annotation processor is split into two projects:
processorcontains generic, reusable logic for annotation processing.immutable-processorcontains logic specific to the@Immutableannotation.
Annotation processors will use tools that are provided via Java's ProcessingEnvironment:
- The
Messagerreports compilation errors (and warnings). - The
Filercreates source files. ElementsandTypesprovide utilities for working withElement's andTypeMirror's.
ProcessorModule can be used to @Inject these objects (via Dagger).
This annotation processor generates a single output for each input. Thus, it can be configured to support incremental annotation processing.
The following steps are needed to enable incremental annotation processing:
- Use
CLASSorRUNTIMEretention for the annotation. (The default isCLASS.) - Use
gradle-incap-helperto enable incremental annotation processing. - Include the originating element when creating a file via
Filer.createSourceFile().
Diagnostics is used to report any diagnostics, including compilation errors.
It serves as a wrapper around Messager.
The main reason that Diagnostics wraps Messager is to enable error tracking.
The error tracker converts the result to Optional.empty() if Diagnostics.add(Diagnostic.Kind.ERROR, ...) is called.
This allows processing to continue for non-fatal errors; compilers don't stop on the first error.
See this condensed snippet from ImmutableImpls:
try (Diagnostics.ErrorTracker errorTracker = diagnostics.trackErrors()) {
// [snip]
ImmutableImpl impl = ImmutableImpl.of(type, members);
return errorTracker.checkNoErrors(impl);
}So how do we work upstream from ImmutableLiteProcessor to ImmutableProcessor?
These classes rely on generic infrastructure in the org.example.processor.base package:
interface LiteProcessor- Lightweight, simple version of Java's
Processorinterface - Contains a single method:
void process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) throws Exception
- Lightweight, simple version of Java's
abstract class IsolatingLiteProcessor<E extends Element> implements LiteProcessor- Designed for isolating annotation processors where each output is generated from a single input
- Contains a single abstract method:
void process(E annotatedElement) throws Exception
abstract class AdapterProcessor implements Processor- Adapts a
LiteProcessorto aProcessor - The
LiteProcessoris provided via an abstract method:LiteProcessor createLiteProcessor(ProcessingEnvironment processingEnv)
- Adapts a
Here is how the annotation processor for @Immutable consumes this infrastructure:
final class ImmutableLiteProcessor extends IsolatingLiteProcessor<TypeElement>final class ImmutableProcessor extends AdapterProcessor
For end-to-end-testing, Google's Compile Testing framework is used.
To use Compile Testing with JDK 16+, add these lines to build.gradle.kts:
tasks.named<Test>("test") {
// See: https://github.com/google/compile-testing/issues/222
jvmArgs("--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED")
jvmArgs("--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED")
jvmArgs("--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED")
}TestCompiler serves as a useful wrapper around the Compile Testing framework.
See this snippet to compile a single source with ImmutableProcessor:
Compilation compilation = TestCompiler.create().compile(sourcePath);Without TestCompiler, you could also directly compile this source with this snippet:
Compilation compilation = Compiler.javac()
.withProcessors(new ImmutableProcessor())
// Suppress this warning: "Implicitly compiled files were not subject to annotation processing."
.withOptions("-implicit:none")
.compile(JavaFileObjects.forResource(sourcePath));TestCompiler also verifies that the compilation succeeded or failed.
By default, it expects that the compilation will succeed.
See this snippet from ImmutableProcessorTest, where a compilation failure is expected:
TestCompiler.create().expectingCompilationFailure().compile(sourcePath);Compile Testing also provides fluent assertions. Here is the static import to use those assertions:
import static com.google.testing.compile.CompilationSubject.assertThat;Source files are stored in resource folders:
- Example source files live in the
testfolder:Rectangle.javaColoredRectangle.javaEmpty.java
- The expected generated source files live in the
generated/testfolder:ImmutableRectangle.javaImmutableColoredRectangle.javaImmutableEmpty.java
Many design decisions were made with testability in mind. Case in point, most classes have a corresponding test class.
The two-stage design of the annotation processor facilitates testability as well.
More specifically, ImmutableImpl is a pure type that can easily be created directly:
- For testing the
modelerstage, anImmutableImpl(or othermodeltypes) can be used as the expected value. - For testing the
generatorstage, anImmutableImplcan be used as the starting point.
TestImmutableImpls provides pre-built ImmutableImpl's that correspond to the examples sources:
TestImmutableImpls.rectangle()TestImmutableImpls.coloredRectangle()TestImmutableImpls.empty()
Here are the core testability challenges:
- In the
modelerstage, it is costly and/or difficult to directly create or mock out the variousElement's. - In the
generatorstage, it is (somewhat less) costly and/or difficult to directly create or mock out aFiler.- The unit tests verify the conversion of a
modeltype (e.g.,ImmutableImpl) to source code. - The end-to-end tests for this stage do mock out a
Filer(via Mockito). SeeImmutableGeneratorTest.
- The unit tests verify the conversion of a
The unit testing strategy for the modeler stage is built around custom annotation processors:
- The custom annotation processor creates a Java object.
- The annotation processor serializes that Java object to JSON. (Recall that
ImmutableImplis serializable.) - The annotation processor writes that JSON to a generated resource file (instead of a generated source file).
- The test reads and deserializes that resource file to obtain the Java object.
- The test verifies the contents of the deserialized Java object.
TestCompiler.create() has another overload: TestCompiler.create(Class<? extends LiteProcessor> liteProcessorClass)
- It uses
TestImmutableProcessor, which uses a custom implementation ofLiteProcessor. - A unit test can create a test implementation of
LiteProcessor.- See
ImmutableImplsTest.TestLiteProcessorfor an example. - Each test implementation of
LiteProcessoralso needs to be added toTestProcessorModule.
- See
TestResources saves Java objects to generated resource files and then loads those objects.
See this snippet from ImmutableImplsTest.TestLiteProcessor, which saves the Java object:
@Override
protected void process(TypeElement typeElement) {
implFactory.create(typeElement).ifPresent(impl -> TestResources.saveObject(filer, typeElement, impl));
}...and this snippet from ImmutableImplsTest, which loads the Java object and verifies it:
private void create(String sourcePath, ImmutableImpl expectedImpl) throws Exception {
Compilation compilation = TestCompiler.create(TestLiteProcessor.class).compile(sourcePath);
ImmutableImpl impl = TestResources.loadObjectForSource(compilation, sourcePath, new TypeReference<>() {});
assertThat(impl).isEqualTo(expectedImpl);
}