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