Avaje Prisms
Discord | Source | API Docs | Issues | Releases |
---|---|---|---|---|
Discord | Github | Javadoc | Github |
This library is an upgraded fork of the unmaintained hickory library that helps make coding annotation processors
more straighforward by generating annotation "Prisms" and other various utilities.
What's A Prism?
When writing annotation processors, the two conventional mechanisms to access the annotation attributes are both
awkward. Element.getAnnotation()
can throw Exceptions if the annotation or its members are not
semantically correct, and it can also fail to work on some modular projects.
(This is one the reasons why annotationProcessorPaths
is required for modular projects. This solution
is seriously limited and has it's own issues (See
MCOMPILER-412).
Moreover, when calling a member with a Class
return type, you need to catch an exception to extract the
TypeMirror
.
On the other hand, AnnotationMirror
and AnnotationValue
do a good job of modeling both
correct and incorrect annotations. Unfortunately, they provide no simple mechanism to determine whether correctness,
and provide no convenient functionality to access the member values in a simple type-specific way.
A Prism provides a solution to this problem by combining the advantages of the pure reflective model of
AnnotationMirror
and the runtime (real) model provided by Element#getAnnotation()
, hence
the term Prism to capture this idea of partial reflection.
A prism has the same member methods as the annotation except that the return types are translated from runtime types
to compile time types as follows...
- Primitive members return their equivalent boxed class in the prism.
- Class members return a
TypeMirror
from the mirror API. - Enum members return a String bearing the name of the enum constant (because the constant value in the mirror API might not match those available in the runtime it cannot consistently return the appropriate enum).
- Annotation members return a Prism of the annotation. If a prism for that annotation is generated from
the same
@GeneratePrisms
annotation as the prism that uses it, then an instance of that prism will be returned. Otherwise, a Prism for that annotation is supplied as an inner class of the dependant Prism. - Array members return a
List
where X is the appropriate prism mapping of the array component as above.
Quick Start
1. Add avaje-prisms dependency.
<dependency>
<groupId>io.avaje</groupId>
<artifactId>avaje-prisms</artifactId>
<version>{prism.versions}</version>
<optional>true</optional>
<scope>provided</scope>
</dependency>
2. Add @GeneratePrism containing the desired Annotation Type to any class/package.info
The annotation processor will generate a prism class that will help extract an annotation's values.
Example:
public @interface ExampleAnnotation {
String someAttribute();
}
@GeneratePrism(ExampleAnnotation.class)
@SupportedAnnotationTypes(ExampleAnnotationPrism.PRISM_TYPE)
public final class MyProcessor extends AbstractProcessor {
//processing logic...
void someFunction(Element element) {
ExampleAnnotationPrism exampleAnnotation = ExampleAnnotationPrism.getInstanceOn(element);
//can get the fully qualified annotation type as a string
String annotationQualifiedType = MyExampleAnnotationPrism.PRISM_TYPE;
//can easily retrieve the annotation values as if the annotation was present on the classpath.
String value = exampleAnnotation.someAttribute();
...
}
}
@GeneratedPrism
We use @GeneratedPrism
to let the generator create a prism for an annotation. Prisms contain useful
static methods to extract annotation values from Elements.
Common Prism Members
PRISM_TYPE
All generated prisms will have a public static final String PRISM_TYPE field containing the target annotation's fully qualified type.
isPresent
Returns a true if the target annotation is present on the element.
Element e = ...;
boolean value = ExampleAnnotationPrism.isPresent(e);
getInstanceOn/getOptionalOn
Returns a prism representing the target annotation present on the given element.
Element e = ...;
ExampleAnnotationPrism value = ExampleAnnotationPrism.getInstanceOn(e);
Optional<ExampleAnnotationPrism> value = ExampleAnnotationPrism.getOptionalOn(e);
getInstance/getOptional
Converts an AnnotationMirror
into the target prism if applicable.
AnnotationMirror m = ...;
ExampleAnnotationPrism value = ExampleAnnotationPrism.getInstance(m);
Optional<ExampleAnnotationPrism> value = ExampleAnnotationPrism.getOptional(m);
getAllInstancesOn (@Repeatable Annotations only)
Returns a list of prisms representing the target annotation present on the given element.
Element e = ...;
List<ExampleAnnotationPrism> value = ExampleAnnotationPrism.getAllInstancesOn(e);
getAllOnMetaAnnotations (ANNOTATION_TYPE Annotations only)
Return a list of prisms representing the target annotation on all the annotations of the given element. Will recursively search all the annotations on the element.
Element e = ...;
List<ExampleAnnotationPrism> value = ExampleAnnotationPrism.getAllOnMetaAnnotations(e);
Prism Naming and Inheritance
If we have similar annotations (Think javax and jakarta) we can create a common interface the generated prisms will extend.
@GeneratePrism(
value = javax.validation.NotNull.class,
name = "JavaxNotNullPrism",
superInterfaces = NotNullPrism.class)
@GeneratePrism(
value = jakarta.validation.NotNull.class,
name = "JakartaNotNullPrism",
superInterfaces = NotNullPrism.class)
public interface NotNullPrism {
//we create methods for the common annotation members
String message();
//we also need to create static methods to get the prisms
static Optional<NotNullPrism> getInstanceOn(Element e) {
return Optional.<NotNullPrism>empty()
.or(() -> JakartaNotNullPrism.getOptionalOn(e))
.or(() -> JavaxNotNullPrism.getOptionalOn(e))
.orElse(null);
}
static Optional<NotNullPrism> getOptionalOn(Element e) {
return Optional.<NotNullPrism>empty()
.or(() -> JakartaNotNullPrism.getOptionalOn(e))
.or(() -> JavaxNotNullPrism.getOptionalOn(e));
}
}
@GenerateAPContext
As your annotation processor grows in size and complexity, you may find it difficult to properly access the
ProcessingEnvironment
and its utilities. @GenerateAPContext
generates a helper class that
stores the processing env and its utilities in a ThreadLocal for easy access anywhere in the processor.
To initialize/cleanup the generated APContext, we must initialize it during the init phase and clear it when processing is over.
@GenerateAPContext
public final class MyProcessor extends AbstractProcessor {
@Override
public synchronized void init(ProcessingEnvironment env) {
super.init(env);
APContext.init(env);
}
@Override
public boolean process(Set<? extends TypeElement> tes, RoundEnvironment renv) {
if (renv.processingOver()) {
APContext.clear();
return true;
}
//can call these from anywhere
ProcessingEnvironment env = APContext.processingEnv();
Types types = APContext.types();
Elements elements = APContext.elements();
}
}
Generated Code
The generated APContext looks like the below.
Generated Code: (click to expand)
/**
* Utiliy Class that stores the {@link ProcessingEnvironment} and provides various helper methods
*/
Generated("avaje-prism-generator")
ublic final class APContext {
private static int jdkVersion;
private static boolean previewEnabled;
private static final ThreadLocal<Ctx> CTX = new ThreadLocal<>();
private APContext() {}
public static final class Ctx {
private final ProcessingEnvironment processingEnv;
private final Messager messager;
private final Filer filer;
private final Elements elementUtils;
private final Types typeUtils;
private ModuleElement module;
public Ctx(ProcessingEnvironment processingEnv) {
this.processingEnv = processingEnv;
messager = processingEnv.getMessager();
filer = processingEnv.getFiler();
elementUtils = processingEnv.getElementUtils();
typeUtils = processingEnv.getTypeUtils();
}
public Ctx(Messager messager, Filer filer, Elements elementUtils, Types typeUtils) {
this.processingEnv = null;
this.messager = messager;
this.filer = filer;
this.elementUtils = elementUtils;
this.typeUtils = typeUtils;
}
}
/**
* Initialize the ThreadLocal containing the Processing Enviroment. this typically should be
* called during the init phase of processing. Be sure to run the clear method at the last round
* of processing
*
* @param processingEnv the current annotation processing enviroment
*/
public static void init(ProcessingEnvironment processingEnv) {
CTX.set(new Ctx(processingEnv));
jdkVersion = processingEnv.getSourceVersion().ordinal();
previewEnabled = processingEnv.isPreviewEnabled();
}
/**
* Initialize the ThreadLocal containing the {@link ProcessingEnvironment}. Be sure to run the
* clear method at the last round of processing
*
* @param context the current annotation processing enviroment
* @param jdkVersion the JDK version number
* @param preview whether preview features are enabled
*/
public static void init(Ctx context, int jdkVersion, boolean preview) {
CTX.set(context);
jdkVersion = jdkVersion;
previewEnabled = preview;
}
/** Clears the ThreadLocal containing the {@link ProcessingEnvironment}. */
public static void clear() {
CTX.remove();
}
/**
* Returns the source version that any generated source and class files should conform to
*
* @return the source version as an int
*/
public static int jdkVersion() {
return jdkVersion;
}
/**
* Returns whether {@code --preview-enabled} has been added to compiler flags.
*
* @return true if preview features are enabled
*/
public static boolean previewEnabled() {
return previewEnabled;
}
/**
* Prints an error at the location of the element.
*
* @param e the element to use as a position hint
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logError(Element e, String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.ERROR, String.format(msg, args), e);
}
/**
* Prints an error.
*
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logError(String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.ERROR, String.format(msg, args));
}
/**
* Prints an warning at the location of the element.
*
* @param e the element to use as a position hint
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logWarn(Element e, String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.WARNING, String.format(msg, args), e);
}
/**
* Prints a warning.
*
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logWarn(String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.WARNING, String.format(msg, args));
}
/**
* Prints a note.
*
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logNote(Element e, String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.NOTE, String.format(msg, args), e);
}
/**
* Prints a note at the location of the element.
*
* @param e the element to use as a position hint
* @param msg the message, or an empty string if none
* @param args {@code String#format} arguments
*/
public static void logNote(String msg, Object... args) {
messager().printMessage(Diagnostic.Kind.NOTE, String.format(msg, args));
}
/**
* Returns the elements annotated with the given annotation interface.
*
* @param round RoundEnviroment to extract the elements
* @param annotationFQN the fqn of the annotation
* @return the elements annotated with the given annotation interface,or an empty set if there are
* none
*/
public static Set<? extends Element> elementsAnnotatedWith(
RoundEnvironment round, String annotationFQN) {
return Optional.ofNullable(typeElement(annotationFQN))
.map(round::getElementsAnnotatedWith)
.orElse(Set.of());
}
/**
* Create a file writer for the given class name.
*
* @param name canonical (fully qualified) name of the principal class or interface being declared
* in this file or a package name followed by {@code ".package-info"} for a package
* information file
* @param originatingElements class, interface, package, or module elements causally associated
* with the creation of this file, may be elided or {@code null}
* @return a JavaFileObject to write the new source file
*/
public static JavaFileObject createSourceFile(CharSequence name, Element... originatingElements)
throws IOException {
return filer().createSourceFile(name, originatingElements);
}
/**
* Returns a type element given its canonical name.
*
* @param name the canonical name
* @return the named type element, or null if no type element can be uniquely determined
*/
public static TypeElement typeElement(String name) {
return elements().getTypeElement(name);
}
/**
* Returns the element corresponding to a type.The type may be a DeclaredType or
* TypeVariable.Returns null if the type is not one with a corresponding element.
*
* @param t the type to map to an element
* @return the element corresponding to the given type
*/
public static TypeElement asTypeElement(TypeMirror t) {
return (TypeElement) types().asElement(t);
}
/**
* Get current {@link ProcessingEnvironment}
*
* @return the enviroment
*/
public static ProcessingEnvironment processingEnv() {
return CTX.get().processingEnv;
}
/**
* Get current {@link Filer} from the {@link ProcessingEnvironment}
*
* @return the filer
*/
public static Filer filer() {
return CTX.get().filer;
}
/**
* Get current {@link Elements} from the {@link ProcessingEnvironment}
*
* @return the filer
*/
public static Elements elements() {
return CTX.get().elementUtils;
}
/**
* Get current {@link Messager} from the {@link ProcessingEnvironment}
*
* @return the messager
*/
public static Messager messager() {
return CTX.get().messager;
}
/**
* Get current {@link Types} from the {@link ProcessingEnvironment}
*
* @return the types
*/
public static Types types() {
return CTX.get().typeUtils;
}
/**
* Determine whether the first type can be assigned to the second
*
* @param type string type to check
* @param superType the type that should be assignable to.
* @return true if type can be assinged to supertype
*/
public static boolean isAssignable(String type, String superType) {
return type.equals(superType) || isAssignable(typeElement(type), superType);
}
/**
* Determine whether the first type can be assigned to the second
*
* @param type type to check
* @param superType the type that should be assignable to.
* @return true if type can be assinged to supertype
*/
public static boolean isAssignable(TypeElement type, String superType) {
return Optional.ofNullable(type).stream()
.flatMap(APContext::superTypes)
.anyMatch(superType::equals);
}
private static Stream<String> superTypes(TypeElement element) {
final var types = types();
return types.directSupertypes(element.asType()).stream()
.filter(type -> !type.toString().contains("java.lang.Object"))
.map(superType -> (TypeElement) types.asElement(superType))
.flatMap(e -> Stream.concat(superTypes(e), Stream.of(e)))
.map(Object::toString);
}
/**
* Discover the {@link ModuleElement} for the project being processed and set in the context.
*
* @param annotations the annotation interfaces requested to be processed
* @param roundEnv environment for information about the current and prior round
*/
public static void setProjectModuleElement(
Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (CTX.get().module == null) {
CTX.get().module =
annotations.stream()
.map(roundEnv::getElementsAnnotatedWith)
.filter(not(Collection::isEmpty))
.findAny()
.map(s -> s.iterator().next())
.map(elements()::getModuleOf)
.orElse(null);
}
}
/**
* Retrieve the project's {@link ModuleElement}. {@code setProjectModuleElement} must be called
* before this.
*
* @return the {@link ModuleElement} associated with the current project
*/
public static ModuleElement getProjectModuleElement() {
return CTX.get().module;
}
/**
* Gets a {@link BufferedReader} for the project's {@code module-info.java} source file.
*
* <p>Calling {@link ModuleElement}'s {@code getDirectives()} method has a chance of making
* compilation fail in certain situations. Therefore, manually parsing {@code module-info.java}
* seems to be the safest way to get module information.
*
* @return
* @throws IOException if unable to read the module-info
*/
public static BufferedReader getModuleInfoReader() throws IOException {
var inputStream =
filer()
.getResource(StandardLocation.SOURCE_PATH, "", "module-info.java")
.toUri()
.toURL()
.openStream();
return new BufferedReader(new InputStreamReader(inputStream));
}
}
@GenerateModuleInfoReader
There is currently a bug in javac where in certain situations calling ModuleElement#getDirectives on the application's root module crashes compilation. Using @GenerateModuleInfoReader in combination with APContext generates classes that allow you to read a module's directives by parsing the module-info.java source file.
@GenerateAPContext
@GenerateModuleInfoReader
public final class MyProcessor extends AbstractProcessor {
@Override
public synchronized void init(ProcessingEnvironment env) {
super.init(env);
APContext.init(env);
}
@Override
public boolean process(Set<? extends TypeElement> tes, RoundEnvironment renv) {
if (renv.processingOver()) {
APContext.clear();
return true;
}
APContext.setProjectModuleElement(tes, renv);
try (var reader = APContext.getModuleInfoReader()) {
var moduleInfo = new ModuleInfoReader(APContext.getProjectModuleElement(), reader);
boolean contains = moduleInfo.containsOnModulePath("some.module");
var provides = moduleInfo.provides();
var requires = moduleInfo.requires();
//and so on...
}
}
}
@GenerateUtils
Using this annotation will generate classes with useful methods for processing TypeMirrors and their component parts.
UType
This generated utility interface has methods for handling type mirrors and extracting their component types and annotations (including TYPE_USE). In addition it allows us to get a readable source-code safe string of the type names as well as the required imports.
public class TypeUseExample {
@NotEmpty
Map<@NotBlank String, @NotEmpty Map<NestedKey, @NotNull(groups=Default.class) DataRecord>>
map;
record NestedKey(){}
record DataRecord(){}
}
@GenerateUtils
@GeneratePrism(NotNull.class)
public final class MyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> tes, RoundEnvironment renv) {
TypeMirror type = //... some processing logic that gets the map field type mirror;
var utilityType = UType.parse(type);
var map = typeUseFields.get(0);
assertThat(map.importTypes())
.contains(
"jakarta.validation.constraints.NotBlank",
"java.util.Map",
"jakarta.validation.constraints.NotEmpty",
"jakarta.validation.groups.Default"
"io.avaje.prisms.example.TypeUseExample");
assertThat(map.mainType()).isEqualTo("java.util.Map");
assertThat(map.full())
.isEqualTo(
"@jakarta.validation.constraints.NotEmpty java.util.Map<@jakarta.validation.constraints.NotBlank java.lang.String, @jakarta.validation.constraints.NotEmpty java.util.Map<io.avaje.prisms.example.TypeUseExample.NestedKey, io.avaje.prisms.example.TypeUseExample.@jakarta.validation.constraints.NotNull(groups={jakarta.validation.groups.Default.class}) DataRecord>>");
assertThat(map.shortType())
.isEqualTo(
"@NotEmpty Map<@NotBlank String, @NotEmpty Map<TypeUseExample.NestedKey, io.avaje.prisms.example.TypeUseExample.@NotNull(groups={Default.class}) DataRecord>>");
assertThat(map.fullWithoutAnnotations())
.isEqualTo(
"java.util.Map<java.lang.String, java.util.Map<io.avaje.prisms.example.TypeUseExample.NestedKey, io.avaje.prisms.example.TypeUseExample.DataRecord>>");
assertThat(map.shortWithoutAnnotations())
.isEqualTo("Map<String, Map<TypeUseExample.NestedKey, TypeUseExample.DataRecord>>");
//We can retrieve the mirror component types as a UType
AnnotationMirror notNullMirror = map.param1().param1().annotations().get(0);
NotNullPrism notNull = NotNullPrism.getInstance(notNullMirror);
}
}
Generated Code
The generated UType interface looks like the below.
Generated Code: (click to expand)
@Generated("avaje-prism-generator")
public interface UType {
/**
* Create a UType from the given TypeMirror.
*/
static UType parse(TypeMirror mirror) {
//interface implementation is generated too
return TypeMirrorVisitor.create(mirror);
}
/**
* Return all the import types needed to write this mirror in source code (annotations included).
*
* @return Return the import types required.
*/
Set<String> importTypes();
/**
* Return the full type as a code safe string. (with annotations if present)
*
* @return the full typeName
*/
String full();
/**
* Return the main type (outermost type). e.g for mirror {@ java.util.List<Something> you'll get java.util.List
*
* @return the outermost type
*/
String mainType();
/**
* Return the full (but unqualified) type as a code safe string. Use in tandem with {@link
* #importTypes()} to generate readable code
*
* @return the short name with unqualified type
*/
String shortType();
/**
* Return the first generic parameter.
*
* @see UType#componentTypes
*/
default UType param0() {
return null;
}
/**
* Return the second componentType.
*
* @see UType#componentTypes
*/
default UType param1() {
return null;
}
/**
* Retrieve the component types associated with this mirror.
*
* <ul>
* <li>{@link TypeKind#ARRAY}: will contain the array componentType
* <li>{@link TypeKind#DECLARED}: will contain the generic parameters
* <li>{@link TypeKind#TYPEVAR}: will contain the upper bound for the type variable
* <li>{@link TypeKind#WILDCARD}: will contain the extends bound or super bound
* <li>{@link TypeKind#INTERSECTION}: will contain the bounds of the intersection
* <li>{@link TypeKind#UNION}: will contain the alternative types
* </ul>
*
* @return the component types
*/
default List<UType> componentTypes() {
return List.of();
}
/** The {@link TypeKind} of the type mirror used to create this Utype. */
TypeKind kind();
/**
* Returns whether the type mirror is generic
*
* @return whether the type is generic
*/
default boolean isGeneric() {
return false;
}
/**
* Return the annotation mirrors directly on the type.
*
* <p>For a {@code UType} representing {@code @NotEmpty Map<@Notblank String, Object>} you will
* get mirrors for {@code @NotEmpty} only
*
* @return the annotations directly present
*/
default List<AnnotationMirror> annotations() {
return List.of();
}
/**
* Return the annotation mirrors directly on the type and in within generic type use.
*
* <p>For a {@code UType} representing {@code @NotEmpty Map<@Notblank String, Object>} you will
* get mirrors for {@code @NotEmpty} and {@code @Notblank}
*
* @return all annotations present on this type
*/
default List<AnnotationMirror> allAnnotationsInType() {
return List.of();
}
/**
* Return the full type as a string, stripped of annotations.
*
* @return full type, but without annotations
*/
default String fullWithoutAnnotations() {
return ProcessorUtils.trimAnnotations(full()).replace(",", ", ");
}
/**
* Return the short type as a string, stripped of annotations.
*
* @return short type, but without annotations
*/
default String shortWithoutAnnotations() {
return ProcessorUtils.trimAnnotations(shortType()).replace(",", ", ");
}
/** Compare whether the current full() type is identical to the given UType's full() type */
@Override
boolean equals(Object other);
}