javagradleannotations

Using annotation processors to check for other annotations


I have an abstract class A with a function funcA, implemented. I want to enforce that the classes that extend class A override funcA to add an annotation @SampleAnnotation.

To achieve this, I tried using annotation processors. But I am unable to get the desired result. When my child classes override funcA without the @SampleAnnotation, I do not see an error during compilation.

I am not able to figure out what I am missing.

Definition of the Annotation Processor: https://github.com/BBloggsbott/annotation-enforcer/

EnforceAnnotation.java

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface EnforceAnnotation {

    Class value();

}

EnforceAnnotationProcessor.java

@SupportedAnnotationTypes("org.bbloggsbott.annotationenforcer.EnforceAnnotation")
@SupportedSourceVersion(SourceVersion.RELEASE_21)
public class EnforceAnnotationProcessor extends AbstractProcessor {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Starting EnforceAnnotationProcessor");
        StringBuilder errorMessageBuilder = new StringBuilder();
        for (final Element element: roundEnv.getElementsAnnotatedWith(EnforceAnnotation.class)){
            if (element instanceof VariableElement variableElement){
                EnforceAnnotation enforce = variableElement.getAnnotation(EnforceAnnotation.class);
                Class enforcedAnnotationClass = enforce.value();
                Annotation enforcedAnnotation = variableElement.getAnnotation(enforcedAnnotationClass);
                if (enforcedAnnotation == null){
                    processingEnv.getMessager().printMessage(
                            Diagnostic.Kind.ERROR,
                            String.format(
                                    "%s.%s does not have the necessary annotation %s",
                                    variableElement.getEnclosingElement().getClass().getName(),
                                    variableElement.getSimpleName(),
                                    enforcedAnnotationClass.getSimpleName()
                            )
                    );
                }
            }
        }
        return true;
    }
}

Sample app that uses the Annotation: https://github.com/BBloggsbott/sample-annotation-processor-example build.gradle

plugins {
    id 'java'
}

group = 'org.bbloggsbott'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
    flatDir {
        dirs '<path to the annotation-enforcer jar>'
    }
}

dependencies {
    compileOnly 'com.bbloggsbott:annotation-enforcer-1.0-SNAPSHOT'

    testImplementation platform('org.junit:junit-bom:5.10.0')
    testImplementation 'org.junit.jupiter:junit-jupiter'
}


test {
    useJUnitPlatform()
}

SampleClass.java

public abstract class SampleClass {

    @EnforceAnnotation(SampleAnnotation.class)
    public boolean isSample(){
        return true;
    }

}

SampleClassExtended.java

public class SampleClassExtended extends SampleClass{

    @Override
    // Compilation should fail since the annotation is not present
    public boolean isSample() {
        return super.isSample();
    }
}

In sample-annotation-processor-example, I have added the annotation processor Jar as a compileOnly dependency in build.gradle


Solution

  • No Output

    The reason you aren't seeing any output from your annotation processor is that it's not actually running. You have to tell Gradle which dependencies are annotation processors by adding them to the annotationProcessor configuration. So, your sample project's build file should have the following:

    dependencies {
        // To be able to use the @EnforceAnnotation annotation in your project
        compileOnly 'com.bbloggsbott:annotation-enforcer-1.0-SNAPSHOT'
        // To have the processor run when compiling your project
        annotationProcessor 'com.bbloggsbott:annotation-enforcer-1.0-SNAPSHOT'
        
        // ... other dependencies ...
    }
    

    Note every project that should run this annotation processor has to do this. I'm not aware of any way to make annotation processors transitive.


    Implementation Tips

    Implementing what you want is not as straightforward as it may seem at first. And while this answer doesn't provide a working annotation processor, there are a few problems with your current implementation that can be addressed.

    Bound the EnforceAnnotation.value element type

    Your @EnforceAnnotation currently looks like this:

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.SOURCE)
    public @interface EnforceAnnotation {
    
        Class value();
    }
    

    The type of value is the raw type of Class. You should never use raw types when writing code with Java 5 or newer. But on top of that, this allows non-annotation types to be specified for value. You want to limit the types to annotation types. To do that, define the element like so:

    // import java.lang.annotation.Annotation
    Class<? extends Annotation> value();
    

    You may also want to consider making the element's type an array (e.g., Class<? extends Annotation>[]).

    Check for the correct element type

    Your implementation checks if elements are an instance of VariableElement. However, you've meta-annotated your @EnforceAnnotation with @Target(ElementType.METHOD), which means it can only be placed on methods. A method is represented by ExecutableElement. So, you should be checking if the elements are an instance of ExecutableElement instead.

    Of course, since the @EnforceAnnotation is only applicable to methods, you don't have to perform any instanceof checks at all. It's guaranteed that elements returned by:

    roundEnv.getElementsAnnotatedWith(EnforceAnnotation.class)
    

    Will be an instance of ExecutableElement with a kind of ElementKind.METHOD.

    Use the AnnotationMirror API

    Annotations that have Class elements are a little harder to work with during annotation processing. From the documentation of AnnotatedConstruct#getAnnotation(Class):

    The annotation returned by this method could contain an element whose value is of type Class. This value cannot be returned directly: information necessary to locate and load a class (such as the class loader to use) is not available, and the class might not be loadable at all. Attempting to read a Class object by invoking the relevant method on the returned annotation will result in a MirroredTypeException, from which the corresponding TypeMirror may be extracted. Similarly, attempting to read a Class[]-valued element will result in a MirroredTypesException.

    This means your enforce.value() call with throw an exception at run-time. As noted, you can then get the TypeMirror from the exception, but it would probably be better to use the AnnotationMirror API from the start.

    Validate the correct methods

    Your current code does this:

    Annotation enforcedAnnotation = variableElement.getAnnotation(enforcedAnnotationClass);
    if (enforcedAnnotation == null) {
      // print error  
    }
    

    The problem with this is that variableElement is the element annotated with @EnforceAnnotation. Your goal is to validate that overrides of such a method have the required annotations. Unfortunately, annotations on methods are never inherited, even when they're meta-annotated with @Inherited (only annotations on classes—not interfaces—may be inherited). Which means any overrides will not have the @EnforceAnnotation annotation present and thus will not be returned by the RoundEnvironment::getElementsAnnotatedWith method.

    You'll have to scan for the overrides (see Elements::overrides) in any subtypes you can find from the types being compiled. One way to do this is to get the root elements from RoundEnvironment::getRootElements and then use an ElementVisitor to scan through them. Specifically, you'll probably want to extend from the "scanner family" (e.g., ElementScanner14).

    Perform some validation on EnforceAnnotation

    Your current annotation processor is obviously just a prototype, but in your full implementation you will probably want to validate @EnforceAnnotation is in places that makes sense. For instance, it doesn't make sense to allow that annotation on static, final, or private methods since they cannot be overridden. For the same reason, it doesn't make sense for that annotation to be allowed on methods declared in an annotation type, enum constant, record, or final class.

    Cross-project validation

    The way your annotation processor is implemented, it will only work if the method annotated with @EnforceAnnotation is currently being compiled. If project A has:

    interface Foo {
      
      @EnforceAnnotation(SomeAnnotationType.class)
      void bar();
    }
    

    And project B, which depends on project A, has:

    class FooImpl implements Foo {
      
      @Override
      public void bar() {}
    }
    

    Then your annotation processor will not catch the error in FooImpl for two reasons:

    1. Your @EnforceAnnotation annotation is annotated with Retention(RetentionPolicy.SOURCE), which means it will exist on Foo::bar when compiling project A, but will not exist when compiling project B.

    2. The Foo::bar method is compiled with project A, so that's when your annotation processor will process that method's @EnforceAnnotation annotation. But project A is compiled separately before project B, and so it will not be processed when compiling project B.

    If you want to validate overrides of an @EnforceAnnotation-annotated method have the required annotations even across projects, then from some light testing I found you need to at least: