Java Compiler Plugin Development in Java

Overview

Java Compiler Plugin API (also known as JSR 269: Pluggable Annotation Processing API) allows you to hook into the Java compilation process to analyze and modify code during compilation.

Key Components

  • Annotation Processors: Process annotations during compilation
  • AbstractProcessor: Base class for implementing processors
  • ProcessingEnvironment: Provides compiler environment and utilities
  • RoundEnvironment: Information about the current compilation round
  • Elements: Utility for working with program elements
  • Types: Utility for type operations

Basic Setup

1. Project Structure

compiler-plugin/
├── src/main/java/
│   └── com/example/processor/
│       ├── SimpleProcessor.java
│       └── BuilderProcessor.java
├── src/main/resources/
│   └── META-INF/services/
│       └── javax.annotation.processing.Processor
└── pom.xml

2. Maven Dependencies

<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>compiler-plugin</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- Compiler API -->
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
<optional>true</optional>
</dependency>
<!-- For code generation -->
<dependency>
<groupId>com.squareup</groupId>
<artifactId>javapoet</artifactId>
<version>1.13.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>11</source>
<target>11</target>
<annotationProcessorPaths>
<path>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.0.1</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</project>

Basic Annotation Processor

1. Simple Processor Example

package com.example.processor;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.Element;
import javax.tools.Diagnostic;
import java.util.Set;
// AutoService automatically registers the processor
@SupportedAnnotationTypes("com.example.annotations.SimpleAnnotation")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class SimpleProcessor extends AbstractProcessor {
private Messager messager;
private Filer filer;
private Elements elementUtils;
private Types typeUtils;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.messager = processingEnv.getMessager();
this.filer = processingEnv.getFiler();
this.elementUtils = processingEnv.getElementUtils();
this.typeUtils = processingEnv.getTypeUtils();
messager.printMessage(Diagnostic.Kind.NOTE, "SimpleProcessor initialized");
}
@Override
public boolean process(Set<? extends TypeElement> annotations, 
RoundEnvironment roundEnv) {
if (roundEnv.processingOver() || annotations.isEmpty()) {
return false;
}
for (TypeElement annotation : annotations) {
Set<? extends Element> annotatedElements = 
roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : annotatedElements) {
processAnnotatedElement(element, annotation);
}
}
return true; // Claim these annotations
}
private void processAnnotatedElement(Element element, TypeElement annotation) {
String message = String.format("Processing element: %s with annotation: %s",
element.getSimpleName(), annotation.getSimpleName());
messager.printMessage(Diagnostic.Kind.NOTE, message, element);
// Example: Validate that annotation is only on classes
if (element.getKind() != ElementKind.CLASS) {
messager.printMessage(Diagnostic.Kind.ERROR,
"SimpleAnnotation can only be applied to classes", element);
return;
}
// Additional processing logic here
analyzeClass((TypeElement) element);
}
private void analyzeClass(TypeElement classElement) {
// Analyze the class structure
String className = classElement.getQualifiedName().toString();
messager.printMessage(Diagnostic.Kind.NOTE, 
"Analyzing class: " + className);
// You can access class members, methods, fields, etc.
classElement.getEnclosedElements().forEach(member -> {
messager.printMessage(Diagnostic.Kind.NOTE,
"Class member: " + member.getSimpleName() + " (" + member.getKind() + ")");
});
}
}

2. Service Registration

META-INF/services/javax.annotation.processing.Processor:

com.example.processor.SimpleProcessor
com.example.processor.BuilderProcessor

Code Generation with JavaPoet

1. Builder Pattern Generator

package com.example.processor;
import com.example.annotations.Builder;
import com.squareup.javapoet.*;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.TypeMirror;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.Writer;
import java.util.*;
@SupportedAnnotationTypes("com.example.annotations.Builder")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class BuilderProcessor extends AbstractProcessor {
private Messager messager;
private Filer filer;
private Elements elementUtils;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.messager = processingEnv.getMessager();
this.filer = processingEnv.getFiler();
this.elementUtils = processingEnv.getElementUtils();
}
@Override
public boolean process(Set<? extends TypeElement> annotations, 
RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
Set<? extends Element> annotatedElements = 
roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : annotatedElements) {
if (element.getKind() == ElementKind.CLASS) {
generateBuilderClass((TypeElement) element);
}
}
}
return true;
}
private void generateBuilderClass(TypeElement classElement) {
try {
String className = classElement.getSimpleName().toString();
String builderClassName = className + "Builder";
String packageName = elementUtils.getPackageOf(classElement).getQualifiedName().toString();
// Collect all non-static fields
List<FieldSpec> builderFields = new ArrayList<>();
List<MethodSpec> setterMethods = new ArrayList<>();
List<ParameterSpec> constructorParams = new ArrayList<>();
List<CodeBlock> constructorAssignments = new ArrayList<>();
for (Element enclosed : classElement.getEnclosedElements()) {
if (enclosed.getKind() == ElementKind.FIELD && 
!enclosed.getModifiers().contains(Modifier.STATIC)) {
VariableElement field = (VariableElement) enclosed;
String fieldName = field.getSimpleName().toString();
TypeName fieldType = TypeName.get(field.asType());
// Builder field
builderFields.add(FieldSpec.builder(fieldType, fieldName)
.addModifiers(Modifier.PRIVATE)
.build());
// Setter method
setterMethods.add(MethodSpec.methodBuilder("with" + capitalize(fieldName))
.addModifier(Modifier.PUBLIC)
.returns(ClassName.get(packageName, builderClassName))
.addParameter(fieldType, fieldName)
.addStatement("this.$L = $L", fieldName, fieldName)
.addStatement("return this")
.build());
// Constructor parameter and assignment
constructorParams.add(ParameterSpec.builder(fieldType, fieldName).build());
constructorAssignments.add(CodeBlock.of("this.$L = $L", fieldName, fieldName));
}
}
// Build method
MethodSpec buildMethod = MethodSpec.methodBuilder("build")
.addModifiers(Modifier.PUBLIC)
.returns(ClassName.get(packageName, className))
.addStatement("return new $T($L)", 
ClassName.get(packageName, className), 
getFieldNames(builderFields))
.build();
// Builder class
TypeSpec builderClass = TypeSpec.classBuilder(builderClassName)
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addFields(builderFields)
.addMethods(setterMethods)
.addMethod(buildMethod)
.build();
// Write the Java file
JavaFile javaFile = JavaFile.builder(packageName, builderClass)
.addFileComment("Generated code - do not modify!")
.build();
javaFile.writeTo(filer);
messager.printMessage(Diagnostic.Kind.NOTE, 
"Generated builder: " + packageName + "." + builderClassName);
} catch (IOException e) {
messager.printMessage(Diagnostic.Kind.ERROR, 
"Failed to generate builder: " + e.getMessage(), classElement);
}
}
private String capitalize(String str) {
return str.substring(0, 1).toUpperCase() + str.substring(1);
}
private String getFieldNames(List<FieldSpec> fields) {
List<String> names = new ArrayList<>();
for (FieldSpec field : fields) {
names.add(field.name);
}
return String.join(", ", names);
}
}

2. Custom Annotation

package com.example.annotations;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE) // Only needed during compilation
public @interface Builder {
String prefix() default "with";
}

Advanced Processor Examples

1. Validation Processor

package com.example.processor;
import com.example.annotations.Validate;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.lang.model.type.TypeKind;
import javax.tools.Diagnostic;
import java.util.Set;
@SupportedAnnotationTypes("com.example.annotations.Validate")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class ValidationProcessor extends AbstractProcessor {
private Messager messager;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.messager = processingEnv.getMessager();
}
@Override
public boolean process(Set<? extends TypeElement> annotations, 
RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
Set<? extends Element> annotatedElements = 
roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : annotatedElements) {
validateElement(element);
}
}
return true;
}
private void validateElement(Element element) {
switch (element.getKind()) {
case FIELD:
validateField((VariableElement) element);
break;
case METHOD:
validateMethod((ExecutableElement) element);
break;
case CLASS:
validateClass((TypeElement) element);
break;
default:
messager.printMessage(Diagnostic.Kind.WARNING,
"@Validate not supported on " + element.getKind(), element);
}
}
private void validateField(VariableElement field) {
Validate validate = field.getAnnotation(Validate.class);
// Check if field is private
if (!field.getModifiers().contains(Modifier.PRIVATE)) {
messager.printMessage(Diagnostic.Kind.WARNING,
"Validated fields should be private", field);
}
// Check nullability for reference types
if (field.asType().getKind() == TypeKind.DECLARED && !isNullable(validate)) {
messager.printMessage(Diagnostic.Kind.ERROR,
"Non-nullable field must be initialized", field);
}
}
private void validateMethod(ExecutableElement method) {
Validate validate = method.getAnnotation(Validate.class);
// Validate return type
if (method.getReturnType().getKind() == TypeKind.VOID && validate.notNull()) {
messager.printMessage(Diagnostic.Kind.ERROR,
"void method cannot be annotated with notNull", method);
}
// Validate parameters
for (VariableElement param : method.getParameters()) {
if (param.asType().getKind() == TypeKind.DECLARED && validate.notNull()) {
messager.printMessage(Diagnostic.Kind.WARNING,
"Consider adding null checks for parameters", method);
}
}
}
private void validateClass(TypeElement clazz) {
// Check if class has a no-arg constructor
boolean hasNoArgConstructor = clazz.getEnclosedElements().stream()
.filter(element -> element.getKind() == ElementKind.CONSTRUCTOR)
.map(element -> (ExecutableElement) element)
.anyMatch(constructor -> constructor.getParameters().isEmpty());
if (!hasNoArgConstructor) {
messager.printMessage(Diagnostic.Kind.WARNING,
"Validated class should have a no-argument constructor", clazz);
}
}
private boolean isNullable(Validate validate) {
return validate != null && !validate.notNull();
}
}

2. Singleton Pattern Generator

package com.example.processor;
import com.example.annotations.Singleton;
import com.squareup.javapoet.*;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.*;
import javax.tools.Diagnostic;
import java.io.IOException;
@SupportedAnnotationTypes("com.example.annotations.Singleton")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class SingletonProcessor extends AbstractProcessor {
private Filer filer;
private Elements elementUtils;
@Override
public synchronized void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
this.filer = processingEnv.getFiler();
this.elementUtils = processingEnv.getElementUtils();
}
@Override
public boolean process(Set<? extends TypeElement> annotations, 
RoundEnvironment roundEnv) {
for (TypeElement annotation : annotations) {
Set<? extends Element> annotatedElements = 
roundEnv.getElementsAnnotatedWith(annotation);
for (Element element : annotatedElements) {
if (element.getKind() == ElementKind.CLASS) {
generateSingleton((TypeElement) element);
}
}
}
return true;
}
private void generateSingleton(TypeElement classElement) {
try {
String className = classElement.getSimpleName().toString();
String singletonClassName = className + "Singleton";
String packageName = elementUtils.getPackageOf(classElement).getQualifiedName().toString();
// Singleton instance field
FieldSpec instanceField = FieldSpec.builder(
ClassName.get(packageName, singletonClassName), 
"INSTANCE")
.addModifiers(Modifier.PRIVATE, Modifier.STATIC, Modifier.FINAL)
.initializer("new $T()", ClassName.get(packageName, singletonClassName))
.build();
// Private constructor
MethodSpec constructor = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PRIVATE)
.build();
// Get instance method
MethodSpec getInstanceMethod = MethodSpec.methodBuilder("getInstance")
.addModifiers(Modifier.PUBLIC, Modifier.STATIC)
.returns(ClassName.get(packageName, singletonClassName))
.addStatement("return INSTANCE")
.build();
// Delegate methods to original class
TypeSpec singletonClass = TypeSpec.classBuilder(singletonClassName)
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addField(instanceField)
.addMethod(constructor)
.addMethod(getInstanceMethod)
.addMethod(generateGetValueMethod(classElement))
.build();
JavaFile javaFile = JavaFile.builder(packageName, singletonClass)
.addFileComment("Generated Singleton - do not modify!")
.build();
javaFile.writeTo(filer);
} catch (IOException e) {
processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR,
"Failed to generate singleton: " + e.getMessage(), classElement);
}
}
private MethodSpec generateGetValueMethod(TypeElement classElement) {
return MethodSpec.methodBuilder("getValue")
.addModifiers(Modifier.PUBLIC)
.returns(ClassName.get(classElement.asType()))
.addStatement("return new $T()", ClassName.get(classElement.asType()))
.build();
}
}

Using Java Compiler Tree API (Advanced)

1. Custom Compilation Task

package com.example.processor;
import com.sun.source.util.JavacTask;
import com.sun.source.util.TaskEvent;
import com.sun.source.util.TaskListener;
import com.sun.tools.javac.api.BasicJavacTask;
import com.sun.tools.javac.tree.JCTree;
import com.sun.tools.javac.tree.TreeMaker;
import com.sun.tools.javac.util.Context;
import com.sun.tools.javac.util.Names;
import javax.annotation.processing.*;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
import java.util.Set;
@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class ASTProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, 
RoundEnvironment roundEnv) {
return false; // Not claiming any annotations
}
@Override
public void init(ProcessingEnvironment processingEnv) {
super.init(processingEnv);
// Get the JavacTask from the processing environment
JavacTask javacTask = (JavacTask) processingEnv.getProcessingTask();
// Add a task listener to hook into compilation phases
javacTask.addTaskListener(new ASTTaskListener(javacTask));
}
private static class ASTTaskListener implements TaskListener {
private final TreeMaker treeMaker;
private final Names names;
public ASTTaskListener(JavacTask javacTask) {
Context context = ((BasicJavacTask) javacTask).getContext();
this.treeMaker = TreeMaker.instance(context);
this.names = Names.instance(context);
}
@Override
public void started(TaskEvent e) {
// Called when a compilation phase starts
}
@Override
public void finished(TaskEvent e) {
if (e.getKind() == TaskEvent.Kind.PARSE) {
// AST is available after parsing
JCTree.JCCompilationUnit unit = (JCTree.JCCompilationUnit) e.getCompilationUnit();
processCompilationUnit(unit);
}
}
private void processCompilationUnit(JCTree.JCCompilationUnit unit) {
// Traverse and modify the AST
unit.accept(new TreeScanner() {
@Override
public void visitMethodDef(JCTree.JCMethodDecl methodDecl) {
super.visitMethodDef(methodDecl);
// Example: Add logging to methods
if (shouldAddLogging(methodDecl)) {
addLoggingStatement(methodDecl);
}
}
});
}
private boolean shouldAddLogging(JCTree.JCMethodDecl method) {
return method.getModifiers().getFlags().contains(com.sun.tools.javac.code.Flags.PUBLIC)
&& !method.getName().toString().equals("main");
}
private void addLoggingStatement(JCTree.JCMethodDecl method) {
// Create a logging statement
JCTree.JCExpression println = treeMaker.Apply(
com.sun.tools.javac.util.List.nil(),
treeMaker.Select(
treeMaker.Select(
treeMaker.Ident(names.fromString("System")),
names.fromString("out")
),
names.fromString("println")
),
com.sun.tools.javac.util.List.of(
treeMaker.Literal("Entering method: " + method.getName())
)
);
// Add the logging statement at the beginning of the method
method.body.stats = method.body.stats.prepend(
treeMaker.Exec(println)
);
}
}
}

Testing Compiler Plugins

1. Testing Framework Setup

package com.example.processor.test;
import com.google.testing.compile.Compilation;
import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import org.junit.jupiter.api.Test;
import javax.tools.JavaFileObject;
import java.util.Arrays;
import static com.google.testing.compile.CompilationSubject.assertThat;
import static com.google.testing.compile.Compiler.javac;
class BuilderProcessorTest {
@Test
void testBuilderGeneration() {
JavaFileObject source = JavaFileObjects.forSourceString("com.example.TestClass",
"package com.example;\n" +
"import com.example.annotations.Builder;\n" +
"@Builder\n" +
"public class TestClass {\n" +
"    private String name;\n" +
"    private int age;\n" +
"}");
Compilation compilation = javac()
.withProcessors(new com.example.processor.BuilderProcessor())
.compile(source);
assertThat(compilation).succeeded();
assertThat(compilation)
.generatedSourceFile("com.example.TestClassBuilder")
.hasSourceEquivalentTo(JavaFileObjects.forSourceString(
"com.example.TestClassBuilder",
"// Expected generated builder code here"));
}
@Test
void testValidationErrors() {
JavaFileObject source = JavaFileObjects.forSourceString("com.example.InvalidClass",
"package com.example;\n" +
"import com.example.annotations.Validate;\n" +
"public class InvalidClass {\n" +
"    @Validate(notNull = true)\n" +
"    public String publicField; // Should warn about private\n" +
"}"));
Compilation compilation = javac()
.withProcessors(new com.example.processor.ValidationProcessor())
.compile(source);
assertThat(compilation).hadWarningContaining("should be private");
}
}

2. Maven Testing Configuration

<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.testing.compile</groupId>
<artifactId>compile-testing</artifactId>
<version>0.21.0</version>
<scope>test</scope>
</dependency>
</dependencies>

Deployment and Usage

1. Maven Plugin Configuration

<!-- In consuming project's pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>11</source>
<target>11</target>
<annotationProcessorPaths>
<path>
<groupId>com.example</groupId>
<artifactId>compiler-plugin</artifactId>
<version>1.0.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

2. Command Line Usage

# Compile with annotation processor
javac -cp processor.jar -processor com.example.processor.SimpleProcessor MyClass.java
# Process annotations only
javac -proc:only -processor com.example.processor.SimpleProcessor MyClass.java
# Disable annotation processing
javac -proc:none MyClass.java

Best Practices

  1. Incremental Processing: Design processors to handle incremental compilation
  2. Error Reporting: Use Messager for clear error messages with proper element locations
  3. Performance: Avoid expensive operations in processors
  4. Resource Management: Properly manage generated files and resources
  5. Compatibility: Support multiple Java versions when possible
  6. Testing: Thoroughly test processors with various input scenarios

Common Pitfalls

  1. Forgetting Service Registration: Ensure processors are properly registered in META-INF/services
  2. Infinite Loops: Avoid generating code that triggers the same processor again
  3. Memory Leaks: Don't store references to compilation elements between rounds
  4. Platform Dependencies: Be cautious with compiler-specific APIs (like Javac Tree API)

Java compiler plugins are powerful tools for code generation, validation, and analysis. They enable compile-time metaprogramming and can significantly improve code quality and developer productivity.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper