From d7d5689f05f2fae03f058c401f48d12f6fe5efc9 Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Fri, 6 Nov 2026 21:43:25 +0530 Subject: [PATCH] New posts --- README.md | 1 + annotation-processor/README.md | 40 ++++ annotation-processor/bad/Shape.java | 7 + annotation-processor/bad/WrongTarget.java | 6 + annotation-processor/demo/pom.xml | 92 ++++++++ .../com/ankurm/demo/BuilderUsageDemo.java | 24 ++ .../src/main/java/com/ankurm/demo/Person.java | 10 + .../src/main/java/com/ankurm/demo/Pizza.java | 24 ++ .../java/com/ankurm/demo/RetentionDemo.java | 62 ++++++ .../com/ankurm/demo/RuntimeValidatorDemo.java | 37 ++++ .../ankurm/demo/annotations/ClassOnly.java | 9 + .../com/ankurm/demo/annotations/Level.java | 3 + .../com/ankurm/demo/annotations/NotBlank.java | 13 ++ .../com/ankurm/demo/annotations/Role.java | 12 + .../com/ankurm/demo/annotations/Roles.java | 10 + .../ankurm/demo/annotations/SourceOnly.java | 9 + .../com/ankurm/demo/annotations/Tracked.java | 23 ++ .../com/ankurm/demo/GeneratedBuilderTest.java | 50 +++++ .../ankurm/demo/RetentionReflectionTest.java | 53 +++++ .../output/01-retention-reflection.txt | 14 ++ .../output/02-runtime-validator.txt | 2 + .../output/03-javap-retention.txt | 6 + .../04-generated-PersonBuilder.java.txt | 35 +++ .../output/05-builder-usage.txt | 3 + .../output/06-compile-error.txt | 6 + .../output/07-jdk25-proc-default.txt | 30 +++ .../output/08-maven-classpath-only.txt | 13 ++ .../output/09-processor-harness.txt | 26 +++ annotation-processor/output/10-tests.txt | 15 ++ .../output/11-wrong-target.txt | 6 + annotation-processor/pom.xml | 53 +++++ annotation-processor/processor/pom.xml | 44 ++++ .../com/ankurm/builder/BuilderProcessor.java | 181 ++++++++++++++++ .../com/ankurm/builder/GenerateBuilder.java | 26 +++ .../javax.annotation.processing.Processor | 1 + .../java/com/ankurm/builder/Compilation.java | 108 +++++++++ .../com/ankurm/builder/LazyProcessor.java | 16 ++ .../ankurm/builder/ProcessorCompileTest.java | 205 ++++++++++++++++++ annotation-processor/scripts/run-all.sh | 83 +++++++ pom.xml | 1 + 40 files changed, 1359 insertions(+) create mode 100644 annotation-processor/README.md create mode 100644 annotation-processor/bad/Shape.java create mode 100644 annotation-processor/bad/WrongTarget.java create mode 100644 annotation-processor/demo/pom.xml create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/Person.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/Pizza.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/RetentionDemo.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/RuntimeValidatorDemo.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/ClassOnly.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Level.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/NotBlank.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Role.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Roles.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/SourceOnly.java create mode 100644 annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Tracked.java create mode 100644 annotation-processor/demo/src/test/java/com/ankurm/demo/GeneratedBuilderTest.java create mode 100644 annotation-processor/demo/src/test/java/com/ankurm/demo/RetentionReflectionTest.java create mode 100644 annotation-processor/output/01-retention-reflection.txt create mode 100644 annotation-processor/output/02-runtime-validator.txt create mode 100644 annotation-processor/output/03-javap-retention.txt create mode 100644 annotation-processor/output/04-generated-PersonBuilder.java.txt create mode 100644 annotation-processor/output/05-builder-usage.txt create mode 100644 annotation-processor/output/06-compile-error.txt create mode 100644 annotation-processor/output/07-jdk25-proc-default.txt create mode 100644 annotation-processor/output/08-maven-classpath-only.txt create mode 100644 annotation-processor/output/09-processor-harness.txt create mode 100644 annotation-processor/output/10-tests.txt create mode 100644 annotation-processor/output/11-wrong-target.txt create mode 100644 annotation-processor/pom.xml create mode 100644 annotation-processor/processor/pom.xml create mode 100644 annotation-processor/processor/src/main/java/com/ankurm/builder/BuilderProcessor.java create mode 100644 annotation-processor/processor/src/main/java/com/ankurm/builder/GenerateBuilder.java create mode 100644 annotation-processor/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor create mode 100644 annotation-processor/processor/src/test/java/com/ankurm/builder/Compilation.java create mode 100644 annotation-processor/processor/src/test/java/com/ankurm/builder/LazyProcessor.java create mode 100644 annotation-processor/processor/src/test/java/com/ankurm/builder/ProcessorCompileTest.java create mode 100644 annotation-processor/scripts/run-all.sh diff --git a/README.md b/README.md index b655842..0cba8ec 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/annotation-processor/README.md b/annotation-processor/README.md new file mode 100644 index 0000000..4c4c2ee --- /dev/null +++ b/annotation-processor/README.md @@ -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 `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`. diff --git a/annotation-processor/bad/Shape.java b/annotation-processor/bad/Shape.java new file mode 100644 index 0000000..658578d --- /dev/null +++ b/annotation-processor/bad/Shape.java @@ -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(); +} diff --git a/annotation-processor/bad/WrongTarget.java b/annotation-processor/bad/WrongTarget.java new file mode 100644 index 0000000..fe5cdb4 --- /dev/null +++ b/annotation-processor/bad/WrongTarget.java @@ -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 { +} diff --git a/annotation-processor/demo/pom.xml b/annotation-processor/demo/pom.xml new file mode 100644 index 0000000..f109588 --- /dev/null +++ b/annotation-processor/demo/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + com.ankurm + annotation-processor + 1.0 + + + builder-demo + builder-demo + A consumer of @GenerateBuilder, plus the retention / reflection demos. + + + + + com.ankurm + builder-processor + ${project.version} + provided + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + com.ankurm + builder-processor + ${project.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + + + + classpath-only + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + + + + + + classpath-proc-full + + + + org.apache.maven.plugins + maven-compiler-plugin + + + full + + + + + + + diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java new file mode 100644 index 0000000..45c41aa --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/BuilderUsageDemo.java @@ -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()); + } +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/Person.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/Person.java new file mode 100644 index 0000000..1ea5788 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/Person.java @@ -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 nicknames) { +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/Pizza.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/Pizza.java new file mode 100644 index 0000000..632ba16 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/Pizza.java @@ -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 toppings; + + public Pizza(String size, boolean extraCheese, List 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 + "]"; + } +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/RetentionDemo.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/RetentionDemo.java new file mode 100644 index 0000000..abf6b2a --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/RetentionDemo.java @@ -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(); + } + +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/RuntimeValidatorDemo.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/RuntimeValidatorDemo.java new file mode 100644 index 0000000..a6405c8 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/RuntimeValidatorDemo.java @@ -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 validate(Object o) throws IllegalAccessException { + List 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("a@b.c", "Asha", null))); + System.out.println(validate(new Signup("", " ", null))); + } +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/ClassOnly.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/ClassOnly.java new file mode 100644 index 0000000..1c51102 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/ClassOnly.java @@ -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 { +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Level.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Level.java new file mode 100644 index 0000000..5940798 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Level.java @@ -0,0 +1,3 @@ +package com.ankurm.demo.annotations; + +public enum Level { LOW, NORMAL, HIGH } diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/NotBlank.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/NotBlank.java new file mode 100644 index 0000000..ad78bc7 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/NotBlank.java @@ -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"; +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Role.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Role.java new file mode 100644 index 0000000..cb852cc --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Role.java @@ -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(); +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Roles.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Roles.java new file mode 100644 index 0000000..55a6130 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Roles.java @@ -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(); +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/SourceOnly.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/SourceOnly.java new file mode 100644 index 0000000..d62a52b --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/SourceOnly.java @@ -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 { +} diff --git a/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Tracked.java b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Tracked.java new file mode 100644 index 0000000..6bfd002 --- /dev/null +++ b/annotation-processor/demo/src/main/java/com/ankurm/demo/annotations/Tracked.java @@ -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 {}; +} diff --git a/annotation-processor/demo/src/test/java/com/ankurm/demo/GeneratedBuilderTest.java b/annotation-processor/demo/src/test/java/com/ankurm/demo/GeneratedBuilderTest.java new file mode 100644 index 0000000..b3d3d09 --- /dev/null +++ b/annotation-processor/demo/src/test/java/com/ankurm/demo/GeneratedBuilderTest.java @@ -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 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); + } +} diff --git a/annotation-processor/demo/src/test/java/com/ankurm/demo/RetentionReflectionTest.java b/annotation-processor/demo/src/test/java/com/ankurm/demo/RetentionReflectionTest.java new file mode 100644 index 0000000..7c443aa --- /dev/null +++ b/annotation-processor/demo/src/test/java/com/ankurm/demo/RetentionReflectionTest.java @@ -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)); + } +} diff --git a/annotation-processor/output/01-retention-reflection.txt b/annotation-processor/output/01-retention-reflection.txt new file mode 100644 index 0000000..3d5a2dd --- /dev/null +++ b/annotation-processor/output/01-retention-reflection.txt @@ -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] diff --git a/annotation-processor/output/02-runtime-validator.txt b/annotation-processor/output/02-runtime-validator.txt new file mode 100644 index 0000000..45b5469 --- /dev/null +++ b/annotation-processor/output/02-runtime-validator.txt @@ -0,0 +1,2 @@ +[] +[email: must not be blank, name: name is required] diff --git a/annotation-processor/output/03-javap-retention.txt b/annotation-processor/output/03-javap-retention.txt new file mode 100644 index 0000000..03f9e5b --- /dev/null +++ b/annotation-processor/output/03-javap-retention.txt @@ -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 diff --git a/annotation-processor/output/04-generated-PersonBuilder.java.txt b/annotation-processor/output/04-generated-PersonBuilder.java.txt new file mode 100644 index 0000000..c4a0a73 --- /dev/null +++ b/annotation-processor/output/04-generated-PersonBuilder.java.txt @@ -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 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 nicknames) { + this.nicknames = nicknames; + return this; + } + + public PersonBuilder addToNicknames(java.lang.String element) { + java.util.List 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); + } +} diff --git a/annotation-processor/output/05-builder-usage.txt b/annotation-processor/output/05-builder-usage.txt new file mode 100644 index 0000000..2dc3166 --- /dev/null +++ b/annotation-processor/output/05-builder-usage.txt @@ -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] diff --git a/annotation-processor/output/06-compile-error.txt b/annotation-processor/output/06-compile-error.txt new file mode 100644 index 0000000..b7712ef --- /dev/null +++ b/annotation-processor/output/06-compile-error.txt @@ -0,0 +1,6 @@ +$ javac -processorpath processor/target/classes -d 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 diff --git a/annotation-processor/output/07-jdk25-proc-default.txt b/annotation-processor/output/07-jdk25-proc-default.txt new file mode 100644 index 0000000..b9e8288 --- /dev/null +++ b/annotation-processor/output/07-jdk25-proc-default.txt @@ -0,0 +1,30 @@ +$ javac -version +javac 25.0.5 + +$ javac -cp processor/target/classes -d 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 Person.java +exit=0 (no output at all: nothing warned that a processor was skipped) + +$ javac -cp processor/target/classes -proc:full -d Person.java Pizza.java BuilderUsageDemo.java +exit=0 + +$ javac -cp processor/target/classes -processorpath processor/target/classes -d Person.java Pizza.java BuilderUsageDemo.java +exit=0 diff --git a/annotation-processor/output/08-maven-classpath-only.txt b/annotation-processor/output/08-maven-classpath-only.txt new file mode 100644 index 0000000..2eff7c8 --- /dev/null +++ b/annotation-processor/output/08-maven-classpath-only.txt @@ -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 full +[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 diff --git a/annotation-processor/output/09-processor-harness.txt b/annotation-processor/output/09-processor-harness.txt new file mode 100644 index 0000000..4c8601b --- /dev/null +++ b/annotation-processor/output/09-processor-harness.txt @@ -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' + diff --git a/annotation-processor/output/10-tests.txt b/annotation-processor/output/10-tests.txt new file mode 100644 index 0000000..3010433 --- /dev/null +++ b/annotation-processor/output/10-tests.txt @@ -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 + diff --git a/annotation-processor/output/11-wrong-target.txt b/annotation-processor/output/11-wrong-target.txt new file mode 100644 index 0000000..8ec43c3 --- /dev/null +++ b/annotation-processor/output/11-wrong-target.txt @@ -0,0 +1,6 @@ +$ javac -cp demo/target/classes -d bad/WrongTarget.java +bad/WrongTarget.java:4: error: annotation interface not applicable to this kind of declaration +@NotBlank +^ +1 error +exit=1 diff --git a/annotation-processor/pom.xml b/annotation-processor/pom.xml new file mode 100644 index 0000000..6d09e11 --- /dev/null +++ b/annotation-processor/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + annotation-processor + pom + annotation-processor + Custom annotations and a @GenerateBuilder annotation processor: the processor (builder-processor) and a consumer that uses it (builder-demo). + + + processor + demo + + + + 6.1.3 + 3.16.0 + 3.6.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + ${compiler-plugin.version} + + 25 + + + + org.apache.maven.plugins + maven-surefire-plugin + ${surefire.version} + + + ${project.basedir}/../output + + + + + + + diff --git a/annotation-processor/processor/pom.xml b/annotation-processor/processor/pom.xml new file mode 100644 index 0000000..aa004f3 --- /dev/null +++ b/annotation-processor/processor/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + + com.ankurm + annotation-processor + 1.0 + + + builder-processor + builder-processor + The @GenerateBuilder annotation and the annotation processor that implements it. + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + none + + + + org.apache.maven.plugins + maven-surefire-plugin + + + + diff --git a/annotation-processor/processor/src/main/java/com/ankurm/builder/BuilderProcessor.java b/annotation-processor/processor/src/main/java/com/ankurm/builder/BuilderProcessor.java new file mode 100644 index 0000000..d1de235 --- /dev/null +++ b/annotation-processor/processor/src/main/java/com/ankurm/builder/BuilderProcessor.java @@ -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}. + * + *

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 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 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 parameter, also generate addTo(E). + if (p.asType().getKind() == TypeKind.DECLARED + && types.isAssignable(types.erasure(p.asType()), listType)) { + List 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); + } +} diff --git a/annotation-processor/processor/src/main/java/com/ankurm/builder/GenerateBuilder.java b/annotation-processor/processor/src/main/java/com/ankurm/builder/GenerateBuilder.java new file mode 100644 index 0000000..3f674c9 --- /dev/null +++ b/annotation-processor/processor/src/main/java/com/ankurm/builder/GenerateBuilder.java @@ -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. + * + *

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 Builder}. */ + String name() default ""; + + /** Prefix for setter-style methods, e.g. {@code "with"} gives {@code withName(..)}. Empty means none. */ + String prefix() default ""; +} diff --git a/annotation-processor/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/annotation-processor/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000..266890c --- /dev/null +++ b/annotation-processor/processor/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +com.ankurm.builder.BuilderProcessor diff --git a/annotation-processor/processor/src/test/java/com/ankurm/builder/Compilation.java b/annotation-processor/processor/src/test/java/com/ankurm/builder/Compilation.java new file mode 100644 index 0000000..f3489ff --- /dev/null +++ b/annotation-processor/processor/src/test/java/com/ankurm/builder/Compilation.java @@ -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> diagnostics; + /** Generated .java files by binary class name. */ + final Map generatedSources; + /** Compiled classes by binary class name. */ + final Map classes; + + private Compilation(boolean success, List> d, + Map sources, Map 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 sources, List processors, List extraOptions) + throws Exception { + JavaCompiler compiler = ToolProvider.getSystemJavaCompiler(); + DiagnosticCollector diags = new DiagnosticCollector<>(); + StandardJavaFileManager std = compiler.getStandardFileManager(diags, null, StandardCharsets.UTF_8); + Map generated = new LinkedHashMap<>(); + Map 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 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 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 d) { + String where = d.getSource() == null ? "" : " " + d.getSource().getName().replace("string:///", "") + + ":" + d.getLineNumber(); + return d.getKind() + where + ": " + d.getMessage(null); + } +} diff --git a/annotation-processor/processor/src/test/java/com/ankurm/builder/LazyProcessor.java b/annotation-processor/processor/src/test/java/com/ankurm/builder/LazyProcessor.java new file mode 100644 index 0000000..b19b2f2 --- /dev/null +++ b/annotation-processor/processor/src/test/java/com/ankurm/builder/LazyProcessor.java @@ -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 annotations, RoundEnvironment roundEnv) { + return false; + } +} diff --git a/annotation-processor/processor/src/test/java/com/ankurm/builder/ProcessorCompileTest.java b/annotation-processor/processor/src/test/java/com/ankurm/builder/ProcessorCompileTest.java new file mode 100644 index 0000000..6c89bd8 --- /dev/null +++ b/annotation-processor/processor/src/test/java/com/ankurm/builder/ProcessorCompileTest.java @@ -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 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 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 { + 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 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()); + } + } + +} diff --git a/annotation-processor/scripts/run-all.sh b/annotation-processor/scripts/run-all.sh new file mode 100644 index 0000000..dd0702a --- /dev/null +++ b/annotation-processor/scripts/run-all.sh @@ -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 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 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 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 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 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 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 full' + 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." diff --git a/pom.xml b/pom.xml index fae17cb..7f58b4a 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ immutable cow sorting + annotation-processor