New posts
This commit is contained in:
@@ -25,6 +25,7 @@ article; each module's own README has that article's version table, quickstart,
|
||||
| [`immutable`](immutable/) | Immutable Collections in Java: List.of vs unmodifiableList vs copyOf |
|
||||
| [`cow`](cow/) | CopyOnWriteArrayList in Java: When It Wins (JMH) and When It Hurts |
|
||||
| [`sorting`](sorting/) | Java Comparator & Comparable: The Complete Guide |
|
||||
| [`annotation-processor`](annotation-processor/) | Custom Annotations and Annotation Processors in Java: Generate Code at Compile Time |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# annotation-processor
|
||||
|
||||
Companion code for the ankurm.com post *"Custom Annotations and Annotation Processors in Java:
|
||||
Generate Code at Compile Time."* A module of `java-core-examples`; itself a small two-module Maven build.
|
||||
|
||||
## Versions this was built and tested against
|
||||
|
||||
| Component | Version |
|
||||
|---|---|
|
||||
| JDK | 25.0.5+8 (Temurin, LTS) |
|
||||
| Maven | 3.9.11 |
|
||||
| maven-compiler-plugin | 3.16.0 |
|
||||
| maven-surefire-plugin | 3.6.1 |
|
||||
| JUnit Jupiter | 6.1.3 |
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
mvn -B clean test # from this directory: builds the processor, then the demo that uses it
|
||||
scripts/run-all.sh # also regenerates every file in output/
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `processor/.../GenerateBuilder.java` | The annotation (SOURCE retention, TYPE target, two members with defaults) |
|
||||
| `processor/.../BuilderProcessor.java` | The `AbstractProcessor` that writes `<Type>Builder` through `Filer`, reports problems through `Messager` |
|
||||
| `processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor` | Registration |
|
||||
| `processor/src/test/.../Compilation.java`, `ProcessorCompileTest.java` | In-memory `javax.tools.JavaCompiler` harness and its tests (writes `output/09`) |
|
||||
| `demo/.../Person.java`, `Pizza.java` | Consumers of `@GenerateBuilder` (a record and a class) |
|
||||
| `demo/.../annotations/*` | SOURCE / CLASS / RUNTIME annotations, `@Inherited`, `@Repeatable`, members and defaults |
|
||||
| `demo/.../RetentionDemo.java`, `RuntimeValidatorDemo.java` | What reflection sees; a reflection-based validator |
|
||||
| `demo/src/test/...` | Runtime tests of the generated builders and of the reflection behaviour |
|
||||
| `bad/Shape.java` | Deliberately invalid input for the compile-error transcript |
|
||||
| `output/01..10` | Captured runs; the post quotes these verbatim |
|
||||
|
||||
`demo/pom.xml` has two throwaway profiles (`classpath-only`, `classpath-proc-full`) used only to capture
|
||||
`output/08`.
|
||||
@@ -0,0 +1,7 @@
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
|
||||
// Deliberately wrong: @GenerateBuilder is only meaningful on a class or a record.
|
||||
@GenerateBuilder
|
||||
public interface Shape {
|
||||
double area();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import com.ankurm.demo.annotations.NotBlank;
|
||||
|
||||
// Deliberately wrong: @NotBlank is declared @Target(ElementType.FIELD), so it cannot sit on a class.
|
||||
@NotBlank
|
||||
public class WrongTarget {
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>annotation-processor</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>builder-demo</artifactId>
|
||||
<name>builder-demo</name>
|
||||
<description>A consumer of @GenerateBuilder, plus the retention / reflection demos.</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- Needed on the compile classpath so javac can resolve the @GenerateBuilder type.
|
||||
It is SOURCE-retention, so nothing needs it at run time: scope "provided". -->
|
||||
<dependency>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>builder-processor</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- The processor is named here, on the PROCESSOR path - not just on the classpath.
|
||||
On JDK 23+ a processor found only on the classpath is not run by default. -->
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>builder-processor</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- For the article only: drops <annotationProcessorPaths> so the processor is on the classpath
|
||||
(it is a "provided" dependency) but NOT on the processor path. Used by scripts/run-all.sh. -->
|
||||
<profile>
|
||||
<id>classpath-only</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths combine.self="override"/>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
<!-- Same, plus an explicit request to run the processors found on the classpath. -->
|
||||
<profile>
|
||||
<id>classpath-proc-full</id>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths combine.self="override"/>
|
||||
<proc>full</proc>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
/** Uses the generated PersonBuilder and PizzaMaker. Neither class exists in src/main/java. */
|
||||
public final class BuilderUsageDemo {
|
||||
public static void main(String[] args) {
|
||||
Person p = new PersonBuilder()
|
||||
.name("Asha")
|
||||
.age(34)
|
||||
.addToNicknames("Ash")
|
||||
.addToNicknames("A")
|
||||
.build();
|
||||
System.out.println(p);
|
||||
|
||||
Pizza pizza = new PizzaMaker()
|
||||
.withSize("large")
|
||||
.withExtraCheese(true)
|
||||
.addToToppings("olives")
|
||||
.build();
|
||||
System.out.println(pizza);
|
||||
|
||||
// Unset properties take the Java default for their type: null for references, 0 for int.
|
||||
System.out.println(new PersonBuilder().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A record: the builder is generated from its canonical constructor. */
|
||||
@GenerateBuilder
|
||||
public record Person(String name, int age, List<String> nicknames) {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** A plain class, with options on the annotation: builder name and a "with" prefix. */
|
||||
@GenerateBuilder(name = "PizzaMaker", prefix = "with")
|
||||
public final class Pizza {
|
||||
private final String size;
|
||||
private final boolean extraCheese;
|
||||
private final List<String> toppings;
|
||||
|
||||
public Pizza(String size, boolean extraCheese, List<String> toppings) {
|
||||
this.size = size;
|
||||
this.extraCheese = extraCheese;
|
||||
this.toppings = toppings == null ? List.of() : List.copyOf(toppings);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Pizza[size=" + size + ", extraCheese=" + extraCheese + ", toppings=" + toppings + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.demo.annotations.ClassOnly;
|
||||
import com.ankurm.demo.annotations.Level;
|
||||
import com.ankurm.demo.annotations.Role;
|
||||
import com.ankurm.demo.annotations.Roles;
|
||||
import com.ankurm.demo.annotations.SourceOnly;
|
||||
import com.ankurm.demo.annotations.Tracked;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/** What reflection can and cannot see, plus members, defaults, @Inherited and @Repeatable. */
|
||||
public final class RetentionDemo {
|
||||
|
||||
@SourceOnly @ClassOnly @Tracked
|
||||
static class Base { }
|
||||
|
||||
static class Child extends Base { }
|
||||
|
||||
@Role("admin") @Role("auditor")
|
||||
static class Staff { }
|
||||
|
||||
@Role("admin")
|
||||
static class Solo { }
|
||||
|
||||
@Tracked(value = "billing", priority = 9, level = Level.HIGH, tags = {"pci", "money"})
|
||||
static class Configured { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("-- retention: annotations reflection can see on Base");
|
||||
System.out.println(Arrays.toString(Base.class.getAnnotations()));
|
||||
|
||||
System.out.println("-- members and defaults");
|
||||
Tracked t = Base.class.getAnnotation(Tracked.class);
|
||||
System.out.println("default value=" + quote(t.value()) + " priority=" + t.priority()
|
||||
+ " level=" + t.level() + " tags=" + Arrays.toString(t.tags()));
|
||||
Tracked c = Configured.class.getAnnotation(Tracked.class);
|
||||
System.out.println("explicit value=" + quote(c.value()) + " priority=" + c.priority()
|
||||
+ " level=" + c.level() + " tags=" + Arrays.toString(c.tags()));
|
||||
|
||||
System.out.println("-- @Inherited: Child extends Base, Child itself has no annotations");
|
||||
System.out.println("getAnnotation(Tracked) = " + (Child.class.getAnnotation(Tracked.class) != null));
|
||||
System.out.println("getDeclaredAnnotation(Tracked) = " + (Child.class.getDeclaredAnnotation(Tracked.class) != null));
|
||||
|
||||
System.out.println("-- @Repeatable");
|
||||
System.out.println("Staff direct getAnnotations().length = " + Staff.class.getAnnotations().length
|
||||
+ " (" + Staff.class.getAnnotations()[0].annotationType().getSimpleName() + " container)");
|
||||
System.out.println("Staff getAnnotationsByType(Role) = " + roleValues(Staff.class.getAnnotationsByType(Role.class)));
|
||||
System.out.println("Solo getAnnotation(Role) != null = " + (Solo.class.getAnnotation(Role.class) != null));
|
||||
System.out.println("Solo getAnnotation(Roles) != null = " + (Solo.class.getAnnotation(Roles.class) != null));
|
||||
System.out.println("Solo getAnnotationsByType(Role) = " + roleValues(Solo.class.getAnnotationsByType(Role.class)));
|
||||
}
|
||||
|
||||
private static String quote(String s) {
|
||||
return "\"" + s + "\"";
|
||||
}
|
||||
|
||||
private static String roleValues(Role[] roles) {
|
||||
return Arrays.stream(roles).map(Role::value).toList().toString();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.demo.annotations.NotBlank;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** The runtime-reflection style: discover annotated fields while the program runs. */
|
||||
public final class RuntimeValidatorDemo {
|
||||
|
||||
static class Signup {
|
||||
@NotBlank String email;
|
||||
@NotBlank(message = "name is required") String name;
|
||||
String note; // not annotated: ignored
|
||||
Signup(String email, String name, String note) { this.email = email; this.name = name; this.note = note; }
|
||||
}
|
||||
|
||||
static List<String> validate(Object o) throws IllegalAccessException {
|
||||
List<String> problems = new ArrayList<>();
|
||||
for (Field f : o.getClass().getDeclaredFields()) {
|
||||
NotBlank nb = f.getAnnotation(NotBlank.class);
|
||||
if (nb == null) continue;
|
||||
f.setAccessible(true);
|
||||
Object v = f.get(o);
|
||||
if (v == null || v.toString().isBlank()) {
|
||||
problems.add(f.getName() + ": " + nb.message());
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
System.out.println(validate(new Signup("[email protected]", "Asha", null)));
|
||||
System.out.println(validate(new Signup("", " ", null)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** CLASS (also the default when @Retention is omitted): in the .class file, invisible to reflection. */
|
||||
@Retention(RetentionPolicy.CLASS)
|
||||
public @interface ClassOnly {
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
public enum Level { LOW, NORMAL, HIGH }
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Marker for the reflection-based validator in {@code RuntimeValidatorDemo}. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.FIELD)
|
||||
public @interface NotBlank {
|
||||
String message() default "must not be blank";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.Repeatable;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** Repeatable: javac wraps two or more of these into one {@link Roles} container. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Repeatable(Roles.class)
|
||||
public @interface Role {
|
||||
String value();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** The container annotation that {@link Role} is repeatable into. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface Roles {
|
||||
Role[] value();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** SOURCE: discarded by javac after processing. Not in the .class file at all. */
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
public @interface SourceOnly {
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.demo.annotations;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Inherited;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* RUNTIME: readable through reflection. Shows member types and defaults, plus
|
||||
* {@code @Documented}, {@code @Inherited} and {@code @Target}.
|
||||
*/
|
||||
@Documented
|
||||
@Inherited
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
|
||||
public @interface Tracked {
|
||||
String value() default ""; // "value" lets you write @Tracked("x")
|
||||
int priority() default 5;
|
||||
Level level() default Level.NORMAL;
|
||||
String[] tags() default {};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/** Runtime tests of classes that the annotation processor wrote. They would not compile if it had not run. */
|
||||
class GeneratedBuilderTest {
|
||||
|
||||
@Test
|
||||
void recordBuilderSetsEveryComponent() {
|
||||
Person p = new PersonBuilder().name("Asha").age(34).addToNicknames("Ash").addToNicknames("A").build();
|
||||
assertEquals("Asha", p.name());
|
||||
assertEquals(34, p.age());
|
||||
assertEquals(List.of("Ash", "A"), p.nicknames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsetPropertiesTakeJavaDefaults() {
|
||||
Person p = new PersonBuilder().build();
|
||||
assertNull(p.name());
|
||||
assertEquals(0, p.age());
|
||||
assertNull(p.nicknames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixAndCustomNameFromAnnotationMembersAreHonoured() {
|
||||
Pizza pizza = new PizzaMaker().withSize("large").withExtraCheese(true).addToToppings("olives").build();
|
||||
assertEquals("Pizza[size=large, extraCheese=true, toppings=[olives]]", pizza.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void addToDoesNotMutateAListHandedToTheSetter() {
|
||||
List<String> original = List.of("x");
|
||||
Person p = new PersonBuilder().nicknames(original).addToNicknames("y").build();
|
||||
assertEquals(List.of("x"), original);
|
||||
assertEquals(List.of("x", "y"), p.nicknames());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceRetentionLeavesNoTraceInTheClassFile() throws Exception {
|
||||
assertNotNull(Class.forName("com.ankurm.demo.PersonBuilder"));
|
||||
assertTrue(Person.class.getAnnotations().length == 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.demo.annotations.Level;
|
||||
import com.ankurm.demo.annotations.Role;
|
||||
import com.ankurm.demo.annotations.Roles;
|
||||
import com.ankurm.demo.annotations.Tracked;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
class RetentionReflectionTest {
|
||||
|
||||
@Test
|
||||
void onlyRuntimeAnnotationsAreVisibleToReflection() {
|
||||
var annotations = RetentionDemo.Base.class.getAnnotations();
|
||||
assertEquals(1, annotations.length);
|
||||
assertEquals(Tracked.class, annotations[0].annotationType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void membersFallBackToDefaults() {
|
||||
Tracked t = RetentionDemo.Base.class.getAnnotation(Tracked.class);
|
||||
assertEquals("", t.value());
|
||||
assertEquals(5, t.priority());
|
||||
assertEquals(Level.NORMAL, t.level());
|
||||
assertEquals(0, t.tags().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inheritedAnnotationsAreVisibleOnSubclassesButNotDeclaredThere() {
|
||||
assertNotNull(RetentionDemo.Child.class.getAnnotation(Tracked.class));
|
||||
assertNull(RetentionDemo.Child.class.getDeclaredAnnotation(Tracked.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedAnnotationsAreWrappedInTheContainer() {
|
||||
assertEquals(Roles.class, RetentionDemo.Staff.class.getAnnotations()[0].annotationType());
|
||||
assertNull(RetentionDemo.Staff.class.getAnnotation(Role.class));
|
||||
assertEquals(List.of("admin", "auditor"),
|
||||
java.util.Arrays.stream(RetentionDemo.Staff.class.getAnnotationsByType(Role.class)).map(Role::value).toList());
|
||||
assertNotNull(RetentionDemo.Solo.class.getAnnotation(Role.class)); // a single one is not wrapped
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeValidatorFindsBlankFields() throws Exception {
|
||||
var s = new RuntimeValidatorDemo.Signup("", " ", null);
|
||||
assertEquals(List.of("email: must not be blank", "name: name is required"), RuntimeValidatorDemo.validate(s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
-- retention: annotations reflection can see on Base
|
||||
[@com.ankurm.demo.annotations.Tracked(priority=5, value="", level=NORMAL, tags={})]
|
||||
-- members and defaults
|
||||
default value="" priority=5 level=NORMAL tags=[]
|
||||
explicit value="billing" priority=9 level=HIGH tags=[pci, money]
|
||||
-- @Inherited: Child extends Base, Child itself has no annotations
|
||||
getAnnotation(Tracked) = true
|
||||
getDeclaredAnnotation(Tracked) = false
|
||||
-- @Repeatable
|
||||
Staff direct getAnnotations().length = 1 (Roles container)
|
||||
Staff getAnnotationsByType(Role) = [admin, auditor]
|
||||
Solo getAnnotation(Role) != null = true
|
||||
Solo getAnnotation(Roles) != null = false
|
||||
Solo getAnnotationsByType(Role) = [admin]
|
||||
@@ -0,0 +1,2 @@
|
||||
[]
|
||||
[email: must not be blank, name: name is required]
|
||||
@@ -0,0 +1,6 @@
|
||||
javap -v -p -cp demo/target/classes com.ankurm.demo.RetentionDemo$Base (filtered)
|
||||
RuntimeVisibleAnnotations:
|
||||
com.ankurm.demo.annotations.Tracked
|
||||
RuntimeInvisibleAnnotations:
|
||||
com.ankurm.demo.annotations.ClassOnly
|
||||
occurrences of SourceOnly in the class file: 0
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
// Generated by com.ankurm.builder.BuilderProcessor - do not edit.
|
||||
public final class PersonBuilder {
|
||||
|
||||
private java.lang.String name;
|
||||
private int age;
|
||||
private java.util.List<java.lang.String> nicknames;
|
||||
|
||||
public PersonBuilder name(java.lang.String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersonBuilder age(int age) {
|
||||
this.age = age;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersonBuilder nicknames(java.util.List<java.lang.String> nicknames) {
|
||||
this.nicknames = nicknames;
|
||||
return this;
|
||||
}
|
||||
|
||||
public PersonBuilder addToNicknames(java.lang.String element) {
|
||||
java.util.List<java.lang.String> copy = this.nicknames == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(this.nicknames);
|
||||
copy.add(element);
|
||||
this.nicknames = copy;
|
||||
return this;
|
||||
}
|
||||
|
||||
public com.ankurm.demo.Person build() {
|
||||
return new com.ankurm.demo.Person(name, age, nicknames);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Person[name=Asha, age=34, nicknames=[Ash, A]]
|
||||
Pizza[size=large, extraCheese=true, toppings=[olives]]
|
||||
Person[name=null, age=0, nicknames=null]
|
||||
@@ -0,0 +1,6 @@
|
||||
$ javac -processorpath processor/target/classes -d <tmp> bad/Shape.java
|
||||
bad/Shape.java:5: error: @GenerateBuilder can only be applied to a class or a record, not to interface
|
||||
public interface Shape {
|
||||
^
|
||||
1 error
|
||||
exit=1
|
||||
@@ -0,0 +1,30 @@
|
||||
$ javac -version
|
||||
javac 25.0.5
|
||||
|
||||
$ javac -cp processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
|
||||
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:6: error: cannot find symbol
|
||||
Person p = new PersonBuilder()
|
||||
^
|
||||
symbol: class PersonBuilder
|
||||
location: class BuilderUsageDemo
|
||||
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:14: error: cannot find symbol
|
||||
Pizza pizza = new PizzaMaker()
|
||||
^
|
||||
symbol: class PizzaMaker
|
||||
location: class BuilderUsageDemo
|
||||
demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:22: error: cannot find symbol
|
||||
System.out.println(new PersonBuilder().build());
|
||||
^
|
||||
symbol: class PersonBuilder
|
||||
location: class BuilderUsageDemo
|
||||
3 errors
|
||||
exit=1
|
||||
|
||||
$ javac -Xlint:all -cp processor/target/classes -d <tmp> Person.java
|
||||
exit=0 (no output at all: nothing warned that a processor was skipped)
|
||||
|
||||
$ javac -cp processor/target/classes -proc:full -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
|
||||
exit=0
|
||||
|
||||
$ javac -cp processor/target/classes -processorpath processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java
|
||||
exit=0
|
||||
@@ -0,0 +1,13 @@
|
||||
$ mvn -B clean compile -Pclasspath-only # no annotationProcessorPaths
|
||||
[INFO] Compiling 2 source files with javac [debug release 25] to target/classes
|
||||
[INFO] Compiling 12 source files with javac [debug release 25] to target/classes
|
||||
[ERROR] COMPILATION ERROR :
|
||||
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[6,24] cannot find symbol
|
||||
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[14,27] cannot find symbol
|
||||
[ERROR] demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java:[22,32] cannot find symbol
|
||||
[INFO] BUILD FAILURE
|
||||
|
||||
$ mvn -B clean compile -Pclasspath-proc-full # same, plus <proc>full</proc>
|
||||
[INFO] Compiling 2 source files with javac [debug release 25] to target/classes
|
||||
[INFO] Compiling 12 source files with javac [debug release 25] to target/classes
|
||||
[INFO] BUILD SUCCESS
|
||||
@@ -0,0 +1,26 @@
|
||||
== 1. in-memory compile: Person + Main, BuilderProcessor supplied directly
|
||||
success=true generated=[com.acme.PersonBuilder]
|
||||
classes=[com.acme.Main, com.acme.Person, com.acme.PersonBuilder]
|
||||
|
||||
== 2. error cases: javac diagnostics produced via Messager
|
||||
interface -> ERROR /com/acme/Shape.java:4: @GenerateBuilder can only be applied to a class or a record, not to interface
|
||||
abstract class -> ERROR /com/acme/Animal.java:4: @GenerateBuilder cannot be applied to abstract class Animal
|
||||
two constructors -> ERROR /com/acme/Point.java:4: Point must have exactly one non-private constructor, but has 2
|
||||
generic class -> ERROR /com/acme/Box.java:4: @GenerateBuilder does not support generic types such as Box
|
||||
|
||||
== 3. rounds, with -Abuilder.debug=true
|
||||
builder round 1: annotations=[com.ankurm.builder.GenerateBuilder] roots=[com.acme.Person] processingOver=false
|
||||
builder round 2: annotations=[] roots=[com.acme.PersonBuilder] processingOver=false
|
||||
builder round 3: annotations=[] roots=[] processingOver=true
|
||||
|
||||
== 4. no setProcessors(): discovered through META-INF/services via -processorpath
|
||||
success=true generated=[com.acme.PersonBuilder]
|
||||
|
||||
== 5. -proc:none: the processor never runs
|
||||
success=false generated=[]
|
||||
ERROR /com/acme/Main.java:5: cannot find symbol
|
||||
|
||||
== 6. a processor that forgot @SupportedSourceVersion
|
||||
WARNING: No SupportedSourceVersion annotation found on com.ankurm.builder.LazyProcessor, returning RELEASE_6.
|
||||
WARNING: Supported source version 'RELEASE_6' from annotation processor 'com.ankurm.builder.LazyProcessor' less than -source '25'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.ankurm.builder.ProcessorCompileTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.268 s -- in com.ankurm.builder.ProcessorCompileTest
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.ankurm.demo.GeneratedBuilderTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.131 s -- in com.ankurm.demo.GeneratedBuilderTest
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Test set: com.ankurm.demo.RetentionReflectionTest
|
||||
-------------------------------------------------------------------------------
|
||||
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.351 s -- in com.ankurm.demo.RetentionReflectionTest
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
$ javac -cp demo/target/classes -d <tmp> bad/WrongTarget.java
|
||||
bad/WrongTarget.java:4: error: annotation interface not applicable to this kind of declaration
|
||||
@NotBlank
|
||||
^
|
||||
1 error
|
||||
exit=1
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>java-core-examples</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>annotation-processor</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<name>annotation-processor</name>
|
||||
<description>Custom annotations and a @GenerateBuilder annotation processor: the processor (builder-processor) and a consumer that uses it (builder-demo).</description>
|
||||
|
||||
<modules>
|
||||
<module>processor</module>
|
||||
<module>demo</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
<junit.version>6.1.3</junit.version>
|
||||
<compiler-plugin.version>3.16.0</compiler-plugin.version>
|
||||
<surefire.version>3.6.1</surefire.version>
|
||||
</properties>
|
||||
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>${compiler-plugin.version}</version>
|
||||
<configuration>
|
||||
<release>25</release>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>${surefire.version}</version>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<output.dir>${project.basedir}/../output</output.dir>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>annotation-processor</artifactId>
|
||||
<version>1.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>builder-processor</artifactId>
|
||||
<name>builder-processor</name>
|
||||
<description>The @GenerateBuilder annotation and the annotation processor that implements it.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<version>${junit.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<!-- This module ships META-INF/services/javax.annotation.processing.Processor, which
|
||||
names a class that does not exist yet while we are compiling it. Switch annotation
|
||||
processing off for this module's own compilation. -->
|
||||
<proc>none</proc>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package com.ankurm.builder;
|
||||
|
||||
import javax.annotation.processing.AbstractProcessor;
|
||||
import javax.annotation.processing.Filer;
|
||||
import javax.annotation.processing.Messager;
|
||||
import javax.annotation.processing.ProcessingEnvironment;
|
||||
import javax.annotation.processing.RoundEnvironment;
|
||||
import javax.annotation.processing.SupportedAnnotationTypes;
|
||||
import javax.annotation.processing.SupportedOptions;
|
||||
import javax.lang.model.SourceVersion;
|
||||
import javax.lang.model.element.Element;
|
||||
import javax.lang.model.element.ElementKind;
|
||||
import javax.lang.model.element.ExecutableElement;
|
||||
import javax.lang.model.element.Modifier;
|
||||
import javax.lang.model.element.TypeElement;
|
||||
import javax.lang.model.element.VariableElement;
|
||||
import javax.lang.model.type.DeclaredType;
|
||||
import javax.lang.model.type.TypeKind;
|
||||
import javax.lang.model.type.TypeMirror;
|
||||
import javax.lang.model.util.ElementFilter;
|
||||
import javax.lang.model.util.Elements;
|
||||
import javax.lang.model.util.Types;
|
||||
import javax.tools.Diagnostic;
|
||||
import javax.tools.JavaFileObject;
|
||||
import java.io.IOException;
|
||||
import java.io.Writer;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Generates a fluent builder for every type annotated with {@link GenerateBuilder}.
|
||||
*
|
||||
* <p>Pass {@code -Abuilder.debug=true} to javac to see one NOTE per processing round.
|
||||
*/
|
||||
@SupportedAnnotationTypes("com.ankurm.builder.GenerateBuilder")
|
||||
@SupportedOptions("builder.debug")
|
||||
public final class BuilderProcessor extends AbstractProcessor {
|
||||
|
||||
private Elements elements;
|
||||
private Types types;
|
||||
private Filer filer;
|
||||
private Messager messager;
|
||||
private boolean debug;
|
||||
private int round;
|
||||
|
||||
@Override
|
||||
public synchronized void init(ProcessingEnvironment env) {
|
||||
super.init(env);
|
||||
this.elements = env.getElementUtils();
|
||||
this.types = env.getTypeUtils();
|
||||
this.filer = env.getFiler();
|
||||
this.messager = env.getMessager();
|
||||
this.debug = "true".equals(env.getOptions().get("builder.debug"));
|
||||
}
|
||||
|
||||
/** Without this, AbstractProcessor answers RELEASE_6 and javac warns. */
|
||||
@Override
|
||||
public SourceVersion getSupportedSourceVersion() {
|
||||
return SourceVersion.latestSupported();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
|
||||
round++;
|
||||
if (debug) {
|
||||
messager.printMessage(Diagnostic.Kind.NOTE,
|
||||
"builder round " + round + ": annotations=" + annotations
|
||||
+ " roots=" + roundEnv.getRootElements()
|
||||
+ " processingOver=" + roundEnv.processingOver());
|
||||
}
|
||||
for (Element element : roundEnv.getElementsAnnotatedWith(GenerateBuilder.class)) {
|
||||
generate(element);
|
||||
}
|
||||
return true; // we claim GenerateBuilder: no other processor needs to see it
|
||||
}
|
||||
|
||||
private void generate(Element element) {
|
||||
if (element.getKind() != ElementKind.CLASS && element.getKind() != ElementKind.RECORD) {
|
||||
error(element, "@GenerateBuilder can only be applied to a class or a record, not to "
|
||||
+ element.getKind().toString().toLowerCase());
|
||||
return;
|
||||
}
|
||||
TypeElement type = (TypeElement) element;
|
||||
if (type.getModifiers().contains(Modifier.ABSTRACT)) {
|
||||
error(type, "@GenerateBuilder cannot be applied to abstract class " + type.getSimpleName());
|
||||
return;
|
||||
}
|
||||
if (!type.getTypeParameters().isEmpty()) {
|
||||
error(type, "@GenerateBuilder does not support generic types such as " + type.getSimpleName());
|
||||
return;
|
||||
}
|
||||
if (type.getNestingKind().isNested()) {
|
||||
error(type, "@GenerateBuilder supports top-level types only; " + type.getSimpleName() + " is nested");
|
||||
return;
|
||||
}
|
||||
List<ExecutableElement> ctors = ElementFilter.constructorsIn(type.getEnclosedElements()).stream()
|
||||
.filter(c -> !c.getModifiers().contains(Modifier.PRIVATE))
|
||||
.toList();
|
||||
if (ctors.size() != 1) {
|
||||
error(type, type.getSimpleName() + " must have exactly one non-private constructor, but has "
|
||||
+ ctors.size());
|
||||
return;
|
||||
}
|
||||
ExecutableElement ctor = ctors.get(0);
|
||||
|
||||
GenerateBuilder config = type.getAnnotation(GenerateBuilder.class);
|
||||
String pkg = elements.getPackageOf(type).getQualifiedName().toString();
|
||||
String builderName = config.name().isEmpty() ? type.getSimpleName() + "Builder" : config.name();
|
||||
String source = render(pkg, builderName, type, ctor, config.prefix());
|
||||
|
||||
String qualified = pkg.isEmpty() ? builderName : pkg + "." + builderName;
|
||||
try {
|
||||
JavaFileObject file = filer.createSourceFile(qualified, type); // originating element
|
||||
try (Writer w = file.openWriter()) {
|
||||
w.write(source);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
error(type, "could not write " + qualified + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String render(String pkg, String builderName, TypeElement type, ExecutableElement ctor, String prefix) {
|
||||
TypeMirror listType = types.erasure(elements.getTypeElement("java.util.List").asType());
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (!pkg.isEmpty()) {
|
||||
sb.append("package ").append(pkg).append(";\n\n");
|
||||
}
|
||||
sb.append("// Generated by ").append(BuilderProcessor.class.getName()).append(" - do not edit.\n");
|
||||
sb.append("public final class ").append(builderName).append(" {\n\n");
|
||||
|
||||
for (VariableElement p : ctor.getParameters()) {
|
||||
sb.append(" private ").append(p.asType()).append(' ').append(p.getSimpleName()).append(";\n");
|
||||
}
|
||||
for (VariableElement p : ctor.getParameters()) {
|
||||
String n = p.getSimpleName().toString();
|
||||
sb.append("\n public ").append(builderName).append(' ').append(methodName(prefix, n))
|
||||
.append('(').append(p.asType()).append(' ').append(n).append(") {\n")
|
||||
.append(" this.").append(n).append(" = ").append(n).append(";\n")
|
||||
.append(" return this;\n }\n");
|
||||
|
||||
// Types in action: for a List<E> parameter, also generate addTo<Name>(E).
|
||||
if (p.asType().getKind() == TypeKind.DECLARED
|
||||
&& types.isAssignable(types.erasure(p.asType()), listType)) {
|
||||
List<? extends TypeMirror> args = ((DeclaredType) p.asType()).getTypeArguments();
|
||||
if (args.size() == 1 && args.get(0).getKind() == TypeKind.DECLARED) {
|
||||
String e = args.get(0).toString();
|
||||
sb.append("\n public ").append(builderName).append(" addTo").append(capitalize(n))
|
||||
.append('(').append(e).append(" element) {\n")
|
||||
.append(" java.util.List<").append(e).append("> copy = this.").append(n)
|
||||
.append(" == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(this.")
|
||||
.append(n).append(");\n")
|
||||
.append(" copy.add(element);\n")
|
||||
.append(" this.").append(n).append(" = copy;\n")
|
||||
.append(" return this;\n }\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\n public ").append(type.getQualifiedName()).append(" build() {\n")
|
||||
.append(" return new ").append(type.getQualifiedName()).append('(');
|
||||
String sep = "";
|
||||
for (VariableElement p : ctor.getParameters()) {
|
||||
sb.append(sep).append(p.getSimpleName());
|
||||
sep = ", ";
|
||||
}
|
||||
sb.append(");\n }\n}\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String methodName(String prefix, String property) {
|
||||
return prefix.isEmpty() ? property : prefix + capitalize(property);
|
||||
}
|
||||
|
||||
private static String capitalize(String s) {
|
||||
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
|
||||
}
|
||||
|
||||
private void error(Element where, String message) {
|
||||
messager.printMessage(Diagnostic.Kind.ERROR, message, where);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.builder;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Asks {@link BuilderProcessor} to generate a fluent builder for the annotated class or record.
|
||||
*
|
||||
* <p>The builder is built from the parameters of the type's single non-private constructor (for a
|
||||
* record, that is the canonical constructor). SOURCE retention: only the compiler ever needs to see
|
||||
* this annotation, so it leaves no trace in the compiled class file.
|
||||
*/
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.SOURCE)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface GenerateBuilder {
|
||||
|
||||
/** Name of the generated class. Empty means {@code <SimpleName>Builder}. */
|
||||
String name() default "";
|
||||
|
||||
/** Prefix for setter-style methods, e.g. {@code "with"} gives {@code withName(..)}. Empty means none. */
|
||||
String prefix() default "";
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
com.ankurm.builder.BuilderProcessor
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.ankurm.builder;
|
||||
|
||||
import javax.annotation.processing.Processor;
|
||||
import javax.tools.Diagnostic;
|
||||
import javax.tools.DiagnosticCollector;
|
||||
import javax.tools.FileObject;
|
||||
import javax.tools.ForwardingJavaFileManager;
|
||||
import javax.tools.JavaCompiler;
|
||||
import javax.tools.JavaFileManager;
|
||||
import javax.tools.JavaFileObject;
|
||||
import javax.tools.SimpleJavaFileObject;
|
||||
import javax.tools.StandardJavaFileManager;
|
||||
import javax.tools.ToolProvider;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Compiles Java source held in strings, entirely in memory, and keeps everything javac produced. */
|
||||
final class Compilation {
|
||||
|
||||
final boolean success;
|
||||
final List<Diagnostic<? extends JavaFileObject>> diagnostics;
|
||||
/** Generated .java files by binary class name. */
|
||||
final Map<String, String> generatedSources;
|
||||
/** Compiled classes by binary class name. */
|
||||
final Map<String, byte[]> classes;
|
||||
|
||||
private Compilation(boolean success, List<Diagnostic<? extends JavaFileObject>> d,
|
||||
Map<String, String> sources, Map<String, byte[]> classes) {
|
||||
this.success = success;
|
||||
this.diagnostics = d;
|
||||
this.generatedSources = sources;
|
||||
this.classes = classes;
|
||||
}
|
||||
|
||||
/** Location of the compiled GenerateBuilder annotation, so the in-memory compile can see it. */
|
||||
static String annotationClasspath() throws Exception {
|
||||
return Path.of(GenerateBuilder.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString();
|
||||
}
|
||||
|
||||
static Compilation compile(Map<String, String> sources, List<Processor> processors, List<String> extraOptions)
|
||||
throws Exception {
|
||||
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
|
||||
DiagnosticCollector<JavaFileObject> diags = new DiagnosticCollector<>();
|
||||
StandardJavaFileManager std = compiler.getStandardFileManager(diags, null, StandardCharsets.UTF_8);
|
||||
Map<String, String> generated = new LinkedHashMap<>();
|
||||
Map<String, byte[]> classes = new LinkedHashMap<>();
|
||||
|
||||
JavaFileManager fm = new ForwardingJavaFileManager<>(std) {
|
||||
@Override
|
||||
public JavaFileObject getJavaFileForOutput(Location location, String className,
|
||||
JavaFileObject.Kind kind, FileObject sibling) {
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
return new SimpleJavaFileObject(URI.create("mem:///" + className.replace('.', '/') + kind.extension), kind) {
|
||||
/** javac reads generated sources back in the next round. */
|
||||
@Override
|
||||
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
|
||||
return bytes.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.io.OutputStream openOutputStream() {
|
||||
return new java.io.FilterOutputStream(bytes) {
|
||||
@Override
|
||||
public void close() throws java.io.IOException {
|
||||
super.close();
|
||||
if (kind == Kind.SOURCE) {
|
||||
generated.put(className, bytes.toString(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
classes.put(className, bytes.toByteArray());
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
List<JavaFileObject> units = new ArrayList<>();
|
||||
sources.forEach((name, text) -> units.add(new SimpleJavaFileObject(
|
||||
URI.create("string:///" + name.replace('.', '/') + ".java"), JavaFileObject.Kind.SOURCE) {
|
||||
@Override
|
||||
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
|
||||
return text;
|
||||
}
|
||||
}));
|
||||
|
||||
List<String> options = new ArrayList<>(List.of("-classpath", annotationClasspath(), "--release", "25"));
|
||||
options.addAll(extraOptions);
|
||||
JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diags, options, null, units);
|
||||
if (processors != null) {
|
||||
task.setProcessors(processors);
|
||||
}
|
||||
boolean ok = task.call();
|
||||
return new Compilation(ok, diags.getDiagnostics(), generated, classes);
|
||||
}
|
||||
|
||||
String render(Diagnostic<? extends JavaFileObject> d) {
|
||||
String where = d.getSource() == null ? "" : " " + d.getSource().getName().replace("string:///", "")
|
||||
+ ":" + d.getLineNumber();
|
||||
return d.getKind() + where + ": " + d.getMessage(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.builder;
|
||||
|
||||
import javax.annotation.processing.AbstractProcessor;
|
||||
import javax.annotation.processing.RoundEnvironment;
|
||||
import javax.annotation.processing.SupportedAnnotationTypes;
|
||||
import javax.lang.model.element.TypeElement;
|
||||
import java.util.Set;
|
||||
|
||||
/** A processor that forgot @SupportedSourceVersion and getSupportedSourceVersion(). Used to capture javac's warning. */
|
||||
@SupportedAnnotationTypes("*")
|
||||
final class LazyProcessor extends AbstractProcessor {
|
||||
@Override
|
||||
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
package com.ankurm.builder;
|
||||
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Order;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
|
||||
import javax.tools.Diagnostic;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests the processor by running javac in memory. Doubles as the transcript writer for
|
||||
* output/09-processor-harness.txt: every line printed there is also asserted here.
|
||||
*/
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class ProcessorCompileTest {
|
||||
|
||||
private static final StringBuilder TRANSCRIPT = new StringBuilder();
|
||||
|
||||
private static final String PERSON = """
|
||||
package com.acme;
|
||||
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
import java.util.List;
|
||||
|
||||
@GenerateBuilder
|
||||
public record Person(String name, int age, List<String> nicknames) { }
|
||||
""";
|
||||
|
||||
private static final String MAIN = """
|
||||
package com.acme;
|
||||
|
||||
public class Main {
|
||||
public static void main(String[] args) {
|
||||
Person p = new PersonBuilder().name("Asha").age(34).build();
|
||||
System.out.println(p);
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
private static void say(String line) {
|
||||
TRANSCRIPT.append(line).append('\n');
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(1)
|
||||
void generatesBuilderAndTheGeneratedSourceCompilesInTheNextRound() throws Exception {
|
||||
Compilation c = Compilation.compile(
|
||||
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
|
||||
List.of(new BuilderProcessor()), List.of());
|
||||
assertTrue(c.success, () -> c.diagnostics.toString());
|
||||
assertEquals(List.of("com.acme.PersonBuilder"), List.copyOf(c.generatedSources.keySet()));
|
||||
String src = c.generatedSources.get("com.acme.PersonBuilder");
|
||||
assertTrue(src.contains("public final class PersonBuilder"));
|
||||
assertTrue(src.contains("public PersonBuilder name(java.lang.String name)"));
|
||||
assertTrue(src.contains("public PersonBuilder addToNicknames(java.lang.String element)"));
|
||||
assertTrue(src.contains("public com.acme.Person build()"));
|
||||
assertTrue(c.classes.containsKey("com.acme.PersonBuilder"));
|
||||
assertTrue(c.classes.containsKey("com.acme.Main"), "Main uses a class that only exists after round 1");
|
||||
say("== 1. in-memory compile: Person + Main, BuilderProcessor supplied directly");
|
||||
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
|
||||
say("classes=" + new java.util.TreeSet<>(c.classes.keySet()));
|
||||
say("");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(2)
|
||||
void badTargetsAreRejectedWithErrorsPointingAtTheSource() throws Exception {
|
||||
Map<String, String> cases = new LinkedHashMap<>();
|
||||
cases.put("interface", """
|
||||
package com.acme;
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
@GenerateBuilder
|
||||
public interface Shape { }
|
||||
""");
|
||||
cases.put("abstract class", """
|
||||
package com.acme;
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
@GenerateBuilder
|
||||
public abstract class Animal { }
|
||||
""");
|
||||
cases.put("two constructors", """
|
||||
package com.acme;
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
@GenerateBuilder
|
||||
public class Point {
|
||||
public Point() { }
|
||||
public Point(int x) { }
|
||||
}
|
||||
""");
|
||||
cases.put("generic class", """
|
||||
package com.acme;
|
||||
import com.ankurm.builder.GenerateBuilder;
|
||||
@GenerateBuilder
|
||||
public class Box<T> {
|
||||
public Box(T value) { }
|
||||
}
|
||||
""");
|
||||
say("== 2. error cases: javac diagnostics produced via Messager");
|
||||
for (var entry : cases.entrySet()) {
|
||||
String name = entry.getValue().lines().filter(l -> l.contains(" class ") || l.contains("interface ")
|
||||
|| l.contains("record ")).findFirst().orElseThrow();
|
||||
String typeName = name.replaceAll(".*(class|interface) (\\w+).*", "$2");
|
||||
Compilation c = Compilation.compile(Map.of("com.acme." + typeName, entry.getValue()),
|
||||
List.of(new BuilderProcessor()), List.of());
|
||||
assertFalse(c.success, entry.getKey());
|
||||
var errors = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.ERROR).toList();
|
||||
assertEquals(1, errors.size(), entry.getKey());
|
||||
assertEquals(4L, errors.get(0).getLineNumber(), "error is attached to the type declaration line");
|
||||
assertTrue(c.generatedSources.isEmpty());
|
||||
say(entry.getKey() + " -> " + c.render(errors.get(0)));
|
||||
}
|
||||
say("");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(3)
|
||||
void roundsAreVisibleThroughTheDebugOption() throws Exception {
|
||||
Compilation c = Compilation.compile(
|
||||
Map.of("com.acme.Person", PERSON),
|
||||
List.of(new BuilderProcessor()), List.of("-Abuilder.debug=true"));
|
||||
assertTrue(c.success);
|
||||
List<String> notes = c.diagnostics.stream()
|
||||
.filter(d -> d.getKind() == Diagnostic.Kind.NOTE)
|
||||
.map(d -> d.getMessage(null)).toList();
|
||||
assertEquals(3, notes.size(), notes::toString);
|
||||
assertTrue(notes.get(0).startsWith("builder round 1: annotations=[com.ankurm.builder.GenerateBuilder]"));
|
||||
assertTrue(notes.get(0).endsWith("processingOver=false"));
|
||||
assertTrue(notes.get(1).startsWith("builder round 2: annotations=[]"));
|
||||
assertTrue(notes.get(1).contains("roots=[com.acme.PersonBuilder]"));
|
||||
assertTrue(notes.get(2).startsWith("builder round 3: annotations=[]"));
|
||||
assertTrue(notes.get(2).endsWith("processingOver=true"));
|
||||
say("== 3. rounds, with -Abuilder.debug=true");
|
||||
notes.forEach(ProcessorCompileTest::say);
|
||||
say("");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(4)
|
||||
void processorIsDiscoveredFromMetaInfServicesOnTheProcessorPath() throws Exception {
|
||||
Path classes = Path.of(Compilation.annotationClasspath());
|
||||
assertTrue(Files.isDirectory(classes), "expected target/classes; got " + classes);
|
||||
assertTrue(Files.exists(classes.resolve("META-INF/services/javax.annotation.processing.Processor")));
|
||||
Compilation c = Compilation.compile(
|
||||
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
|
||||
null, List.of("-processorpath", classes.toString()));
|
||||
assertTrue(c.success, () -> c.diagnostics.toString());
|
||||
assertTrue(c.generatedSources.containsKey("com.acme.PersonBuilder"));
|
||||
say("== 4. no setProcessors(): discovered through META-INF/services via -processorpath");
|
||||
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
|
||||
say("");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(5)
|
||||
void procNoneMeansNoGeneratedClassAndTheCallerFails() throws Exception {
|
||||
Path classes = Path.of(Compilation.annotationClasspath());
|
||||
Compilation c = Compilation.compile(
|
||||
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
|
||||
null, List.of("-processorpath", classes.toString(), "-proc:none"));
|
||||
assertFalse(c.success);
|
||||
assertTrue(c.generatedSources.isEmpty());
|
||||
var errors = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
|
||||
.map(d -> c.render(d).lines().findFirst().orElseThrow()).toList();
|
||||
assertTrue(errors.stream().anyMatch(e -> e.contains("cannot find symbol")), errors::toString);
|
||||
say("== 5. -proc:none: the processor never runs");
|
||||
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
|
||||
errors.forEach(ProcessorCompileTest::say);
|
||||
say("");
|
||||
}
|
||||
|
||||
@Test
|
||||
@Order(6)
|
||||
void aProcessorWithoutSupportedSourceVersionGetsAWarning() throws Exception {
|
||||
Compilation c = Compilation.compile(
|
||||
Map.of("com.acme.Empty", "package com.acme; public class Empty { }"),
|
||||
List.of(new LazyProcessor()), List.of());
|
||||
assertTrue(c.success);
|
||||
var warnings = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.WARNING
|
||||
|| d.getKind() == Diagnostic.Kind.MANDATORY_WARNING).map(d -> c.render(d)).toList();
|
||||
assertFalse(warnings.isEmpty(), "expected a source-version warning");
|
||||
say("== 6. a processor that forgot @SupportedSourceVersion");
|
||||
warnings.forEach(ProcessorCompileTest::say);
|
||||
say("");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void writeTranscript() throws Exception {
|
||||
String dir = System.getProperty("output.dir");
|
||||
if (dir != null) {
|
||||
Files.createDirectories(Path.of(dir));
|
||||
Files.writeString(Path.of(dir, "09-processor-harness.txt"), TRANSCRIPT.toString());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in output/. Needs a JDK 25 and Maven 3.9+ on the PATH. Run from anywhere.
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
ROOT="$PWD"
|
||||
OUT="$ROOT/output"
|
||||
mkdir -p "$OUT"
|
||||
strip() { grep -v -E 'JAVA_TOOL_OPTIONS|^WARNING: (A terminally|sun\.misc|Please consider)' | sed "s#$ROOT/##g"; }
|
||||
|
||||
echo "[1/7] build + tests (writes output/09-processor-harness.txt)"
|
||||
mvn -B -q clean test 2>&1 | strip | tail -5
|
||||
# 10: surefire summaries
|
||||
{ for f in processor/target/surefire-reports/*.txt demo/target/surefire-reports/*.txt; do cat "$f"; echo; done; } > "$OUT/10-tests.txt"
|
||||
|
||||
D=demo/src/main/java/com/ankurm/demo
|
||||
CLS=demo/target/classes
|
||||
PROC=processor/target/classes
|
||||
TMP="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
echo "[2/7] reflection + retention demos"
|
||||
java -cp "$CLS" com.ankurm.demo.RetentionDemo 2>&1 | strip > "$OUT/01-retention-reflection.txt"
|
||||
java -cp "$CLS" com.ankurm.demo.RuntimeValidatorDemo 2>&1 | strip > "$OUT/02-runtime-validator.txt"
|
||||
{
|
||||
for c in 'RetentionDemo$Base'; do
|
||||
echo "javap -v -p -cp demo/target/classes com.ankurm.demo.$c (filtered)"
|
||||
javap -v -p -cp "$CLS" "com.ankurm.demo.$c" 2>&1 | grep -E 'Runtime(In)?[Vv]isibleAnnotations:|^ com\.ankurm'
|
||||
echo "occurrences of SourceOnly in the class file: $(javap -v -p -cp "$CLS" "com.ankurm.demo.$c" 2>&1 | grep -c SourceOnly)"
|
||||
done
|
||||
} > "$OUT/03-javap-retention.txt"
|
||||
|
||||
echo "[3/7] generated source + builder usage"
|
||||
cp demo/target/generated-sources/annotations/com/ankurm/demo/PersonBuilder.java "$OUT/04-generated-PersonBuilder.java.txt"
|
||||
java -cp "$CLS" com.ankurm.demo.BuilderUsageDemo 2>&1 | strip > "$OUT/05-builder-usage.txt"
|
||||
|
||||
echo "[4/7] compile error via Messager"
|
||||
{
|
||||
echo '$ javac -processorpath processor/target/classes -d <tmp> bad/Shape.java'
|
||||
javac -cp "$PROC" -processorpath "$PROC" -d "$TMP" bad/Shape.java 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]}"
|
||||
} > "$OUT/06-compile-error.txt"
|
||||
|
||||
{
|
||||
echo '$ javac -cp demo/target/classes -d <tmp> bad/WrongTarget.java'
|
||||
javac -cp "$CLS" -d "$TMP/t" bad/WrongTarget.java 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]}"
|
||||
} > "$OUT/11-wrong-target.txt"
|
||||
|
||||
echo "[5/7] JDK 25 and the processor on the classpath only"
|
||||
S="$D/Person.java $D/Pizza.java $D/BuilderUsageDemo.java"
|
||||
{
|
||||
echo '$ javac -version'
|
||||
javac -version 2>&1 | strip
|
||||
echo
|
||||
echo '$ javac -cp processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java'
|
||||
javac -cp "$PROC" -d "$TMP/a" $S 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]}"
|
||||
echo
|
||||
echo '$ javac -Xlint:all -cp processor/target/classes -d <tmp> Person.java'
|
||||
javac -Xlint:all -cp "$PROC" -d "$TMP/b" "$D/Person.java" 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]} (no output at all: nothing warned that a processor was skipped)"
|
||||
echo
|
||||
echo '$ javac -cp processor/target/classes -proc:full -d <tmp> Person.java Pizza.java BuilderUsageDemo.java'
|
||||
javac -cp "$PROC" -proc:full -d "$TMP/c" $S 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]}"
|
||||
echo
|
||||
echo '$ javac -cp processor/target/classes -processorpath processor/target/classes -d <tmp> Person.java Pizza.java BuilderUsageDemo.java'
|
||||
javac -cp "$PROC" -processorpath "$PROC" -d "$TMP/d" $S 2>&1 | strip
|
||||
echo "exit=${PIPESTATUS[0]}"
|
||||
} > "$OUT/07-jdk25-proc-default.txt"
|
||||
|
||||
echo "[6/7] Maven: processor on the classpath only"
|
||||
{
|
||||
echo '$ mvn -B clean compile -Pclasspath-only # no annotationProcessorPaths'
|
||||
mvn -B clean compile -Pclasspath-only 2>&1 | strip | grep -E 'Compiling|COMPILATION ERROR|^\[ERROR\] demo/|BUILD' | awk '!s[$0]++' | head -12
|
||||
echo
|
||||
echo '$ mvn -B clean compile -Pclasspath-proc-full # same, plus <proc>full</proc>'
|
||||
mvn -B clean compile -Pclasspath-proc-full 2>&1 | strip | grep -E 'Compiling|COMPILATION ERROR|^\[ERROR\] demo/|BUILD' | awk '!s[$0]++' | head -12
|
||||
} > "$OUT/08-maven-classpath-only.txt"
|
||||
|
||||
echo "[7/7] restore a normal build"
|
||||
mvn -B -q clean test 2>&1 | strip | tail -5
|
||||
echo "Done. output/ regenerated."
|
||||
Reference in New Issue
Block a user