New posts

This commit is contained in:
2026-11-06 21:43:25 +05:30
parent dfd3a830cd
commit d7d5689f05
40 changed files with 1359 additions and 0 deletions
+1
View File
@@ -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
+40
View File
@@ -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`.
+7
View File
@@ -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 {
}
+92
View File
@@ -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'
+15
View File
@@ -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
+53
View File
@@ -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>
+44
View File
@@ -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>
@@ -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 "";
}
@@ -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;
}
}
@@ -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());
}
}
}
+83
View File
@@ -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."
+1
View File
@@ -33,6 +33,7 @@
<module>immutable</module>
<module>cow</module>
<module>sorting</module>
<module>annotation-processor</module>
</modules>
<properties>