New posts

This commit is contained in:
2026-11-06 21:43:25 +05:30
parent dfd3a830cd
commit d7d5689f05
40 changed files with 1359 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>annotation-processor</artifactId>
<version>1.0</version>
</parent>
<artifactId>builder-processor</artifactId>
<name>builder-processor</name>
<description>The @GenerateBuilder annotation and the annotation processor that implements it.</description>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<!-- This module ships META-INF/services/javax.annotation.processing.Processor, which
names a class that does not exist yet while we are compiling it. Switch annotation
processing off for this module's own compilation. -->
<proc>none</proc>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,181 @@
package com.ankurm.builder;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.annotation.processing.SupportedOptions;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.Writer;
import java.util.List;
import java.util.Set;
/**
* Generates a fluent builder for every type annotated with {@link GenerateBuilder}.
*
* <p>Pass {@code -Abuilder.debug=true} to javac to see one NOTE per processing round.
*/
@SupportedAnnotationTypes("com.ankurm.builder.GenerateBuilder")
@SupportedOptions("builder.debug")
public final class BuilderProcessor extends AbstractProcessor {
private Elements elements;
private Types types;
private Filer filer;
private Messager messager;
private boolean debug;
private int round;
@Override
public synchronized void init(ProcessingEnvironment env) {
super.init(env);
this.elements = env.getElementUtils();
this.types = env.getTypeUtils();
this.filer = env.getFiler();
this.messager = env.getMessager();
this.debug = "true".equals(env.getOptions().get("builder.debug"));
}
/** Without this, AbstractProcessor answers RELEASE_6 and javac warns. */
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latestSupported();
}
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
round++;
if (debug) {
messager.printMessage(Diagnostic.Kind.NOTE,
"builder round " + round + ": annotations=" + annotations
+ " roots=" + roundEnv.getRootElements()
+ " processingOver=" + roundEnv.processingOver());
}
for (Element element : roundEnv.getElementsAnnotatedWith(GenerateBuilder.class)) {
generate(element);
}
return true; // we claim GenerateBuilder: no other processor needs to see it
}
private void generate(Element element) {
if (element.getKind() != ElementKind.CLASS && element.getKind() != ElementKind.RECORD) {
error(element, "@GenerateBuilder can only be applied to a class or a record, not to "
+ element.getKind().toString().toLowerCase());
return;
}
TypeElement type = (TypeElement) element;
if (type.getModifiers().contains(Modifier.ABSTRACT)) {
error(type, "@GenerateBuilder cannot be applied to abstract class " + type.getSimpleName());
return;
}
if (!type.getTypeParameters().isEmpty()) {
error(type, "@GenerateBuilder does not support generic types such as " + type.getSimpleName());
return;
}
if (type.getNestingKind().isNested()) {
error(type, "@GenerateBuilder supports top-level types only; " + type.getSimpleName() + " is nested");
return;
}
List<ExecutableElement> ctors = ElementFilter.constructorsIn(type.getEnclosedElements()).stream()
.filter(c -> !c.getModifiers().contains(Modifier.PRIVATE))
.toList();
if (ctors.size() != 1) {
error(type, type.getSimpleName() + " must have exactly one non-private constructor, but has "
+ ctors.size());
return;
}
ExecutableElement ctor = ctors.get(0);
GenerateBuilder config = type.getAnnotation(GenerateBuilder.class);
String pkg = elements.getPackageOf(type).getQualifiedName().toString();
String builderName = config.name().isEmpty() ? type.getSimpleName() + "Builder" : config.name();
String source = render(pkg, builderName, type, ctor, config.prefix());
String qualified = pkg.isEmpty() ? builderName : pkg + "." + builderName;
try {
JavaFileObject file = filer.createSourceFile(qualified, type); // originating element
try (Writer w = file.openWriter()) {
w.write(source);
}
} catch (IOException e) {
error(type, "could not write " + qualified + ": " + e.getMessage());
}
}
private String render(String pkg, String builderName, TypeElement type, ExecutableElement ctor, String prefix) {
TypeMirror listType = types.erasure(elements.getTypeElement("java.util.List").asType());
StringBuilder sb = new StringBuilder();
if (!pkg.isEmpty()) {
sb.append("package ").append(pkg).append(";\n\n");
}
sb.append("// Generated by ").append(BuilderProcessor.class.getName()).append(" - do not edit.\n");
sb.append("public final class ").append(builderName).append(" {\n\n");
for (VariableElement p : ctor.getParameters()) {
sb.append(" private ").append(p.asType()).append(' ').append(p.getSimpleName()).append(";\n");
}
for (VariableElement p : ctor.getParameters()) {
String n = p.getSimpleName().toString();
sb.append("\n public ").append(builderName).append(' ').append(methodName(prefix, n))
.append('(').append(p.asType()).append(' ').append(n).append(") {\n")
.append(" this.").append(n).append(" = ").append(n).append(";\n")
.append(" return this;\n }\n");
// Types in action: for a List<E> parameter, also generate addTo<Name>(E).
if (p.asType().getKind() == TypeKind.DECLARED
&& types.isAssignable(types.erasure(p.asType()), listType)) {
List<? extends TypeMirror> args = ((DeclaredType) p.asType()).getTypeArguments();
if (args.size() == 1 && args.get(0).getKind() == TypeKind.DECLARED) {
String e = args.get(0).toString();
sb.append("\n public ").append(builderName).append(" addTo").append(capitalize(n))
.append('(').append(e).append(" element) {\n")
.append(" java.util.List<").append(e).append("> copy = this.").append(n)
.append(" == null ? new java.util.ArrayList<>() : new java.util.ArrayList<>(this.")
.append(n).append(");\n")
.append(" copy.add(element);\n")
.append(" this.").append(n).append(" = copy;\n")
.append(" return this;\n }\n");
}
}
}
sb.append("\n public ").append(type.getQualifiedName()).append(" build() {\n")
.append(" return new ").append(type.getQualifiedName()).append('(');
String sep = "";
for (VariableElement p : ctor.getParameters()) {
sb.append(sep).append(p.getSimpleName());
sep = ", ";
}
sb.append(");\n }\n}\n");
return sb.toString();
}
private static String methodName(String prefix, String property) {
return prefix.isEmpty() ? property : prefix + capitalize(property);
}
private static String capitalize(String s) {
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
private void error(Element where, String message) {
messager.printMessage(Diagnostic.Kind.ERROR, message, where);
}
}
@@ -0,0 +1,26 @@
package com.ankurm.builder;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Asks {@link BuilderProcessor} to generate a fluent builder for the annotated class or record.
*
* <p>The builder is built from the parameters of the type's single non-private constructor (for a
* record, that is the canonical constructor). SOURCE retention: only the compiler ever needs to see
* this annotation, so it leaves no trace in the compiled class file.
*/
@Documented
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.TYPE)
public @interface GenerateBuilder {
/** Name of the generated class. Empty means {@code <SimpleName>Builder}. */
String name() default "";
/** Prefix for setter-style methods, e.g. {@code "with"} gives {@code withName(..)}. Empty means none. */
String prefix() default "";
}
@@ -0,0 +1 @@
com.ankurm.builder.BuilderProcessor
@@ -0,0 +1,108 @@
package com.ankurm.builder;
import javax.annotation.processing.Processor;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.FileObject;
import javax.tools.ForwardingJavaFileManager;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileManager;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Compiles Java source held in strings, entirely in memory, and keeps everything javac produced. */
final class Compilation {
final boolean success;
final List<Diagnostic<? extends JavaFileObject>> diagnostics;
/** Generated .java files by binary class name. */
final Map<String, String> generatedSources;
/** Compiled classes by binary class name. */
final Map<String, byte[]> classes;
private Compilation(boolean success, List<Diagnostic<? extends JavaFileObject>> d,
Map<String, String> sources, Map<String, byte[]> classes) {
this.success = success;
this.diagnostics = d;
this.generatedSources = sources;
this.classes = classes;
}
/** Location of the compiled GenerateBuilder annotation, so the in-memory compile can see it. */
static String annotationClasspath() throws Exception {
return Path.of(GenerateBuilder.class.getProtectionDomain().getCodeSource().getLocation().toURI()).toString();
}
static Compilation compile(Map<String, String> sources, List<Processor> processors, List<String> extraOptions)
throws Exception {
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diags = new DiagnosticCollector<>();
StandardJavaFileManager std = compiler.getStandardFileManager(diags, null, StandardCharsets.UTF_8);
Map<String, String> generated = new LinkedHashMap<>();
Map<String, byte[]> classes = new LinkedHashMap<>();
JavaFileManager fm = new ForwardingJavaFileManager<>(std) {
@Override
public JavaFileObject getJavaFileForOutput(Location location, String className,
JavaFileObject.Kind kind, FileObject sibling) {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
return new SimpleJavaFileObject(URI.create("mem:///" + className.replace('.', '/') + kind.extension), kind) {
/** javac reads generated sources back in the next round. */
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return bytes.toString(StandardCharsets.UTF_8);
}
@Override
public java.io.OutputStream openOutputStream() {
return new java.io.FilterOutputStream(bytes) {
@Override
public void close() throws java.io.IOException {
super.close();
if (kind == Kind.SOURCE) {
generated.put(className, bytes.toString(StandardCharsets.UTF_8));
} else {
classes.put(className, bytes.toByteArray());
}
}
};
}
};
}
};
List<JavaFileObject> units = new ArrayList<>();
sources.forEach((name, text) -> units.add(new SimpleJavaFileObject(
URI.create("string:///" + name.replace('.', '/') + ".java"), JavaFileObject.Kind.SOURCE) {
@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return text;
}
}));
List<String> options = new ArrayList<>(List.of("-classpath", annotationClasspath(), "--release", "25"));
options.addAll(extraOptions);
JavaCompiler.CompilationTask task = compiler.getTask(null, fm, diags, options, null, units);
if (processors != null) {
task.setProcessors(processors);
}
boolean ok = task.call();
return new Compilation(ok, diags.getDiagnostics(), generated, classes);
}
String render(Diagnostic<? extends JavaFileObject> d) {
String where = d.getSource() == null ? "" : " " + d.getSource().getName().replace("string:///", "")
+ ":" + d.getLineNumber();
return d.getKind() + where + ": " + d.getMessage(null);
}
}
@@ -0,0 +1,16 @@
package com.ankurm.builder;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedAnnotationTypes;
import javax.lang.model.element.TypeElement;
import java.util.Set;
/** A processor that forgot @SupportedSourceVersion and getSupportedSourceVersion(). Used to capture javac's warning. */
@SupportedAnnotationTypes("*")
final class LazyProcessor extends AbstractProcessor {
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
return false;
}
}
@@ -0,0 +1,205 @@
package com.ankurm.builder;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import javax.tools.Diagnostic;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests the processor by running javac in memory. Doubles as the transcript writer for
* output/09-processor-harness.txt: every line printed there is also asserted here.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProcessorCompileTest {
private static final StringBuilder TRANSCRIPT = new StringBuilder();
private static final String PERSON = """
package com.acme;
import com.ankurm.builder.GenerateBuilder;
import java.util.List;
@GenerateBuilder
public record Person(String name, int age, List<String> nicknames) { }
""";
private static final String MAIN = """
package com.acme;
public class Main {
public static void main(String[] args) {
Person p = new PersonBuilder().name("Asha").age(34).build();
System.out.println(p);
}
}
""";
private static void say(String line) {
TRANSCRIPT.append(line).append('\n');
}
@Test
@Order(1)
void generatesBuilderAndTheGeneratedSourceCompilesInTheNextRound() throws Exception {
Compilation c = Compilation.compile(
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
List.of(new BuilderProcessor()), List.of());
assertTrue(c.success, () -> c.diagnostics.toString());
assertEquals(List.of("com.acme.PersonBuilder"), List.copyOf(c.generatedSources.keySet()));
String src = c.generatedSources.get("com.acme.PersonBuilder");
assertTrue(src.contains("public final class PersonBuilder"));
assertTrue(src.contains("public PersonBuilder name(java.lang.String name)"));
assertTrue(src.contains("public PersonBuilder addToNicknames(java.lang.String element)"));
assertTrue(src.contains("public com.acme.Person build()"));
assertTrue(c.classes.containsKey("com.acme.PersonBuilder"));
assertTrue(c.classes.containsKey("com.acme.Main"), "Main uses a class that only exists after round 1");
say("== 1. in-memory compile: Person + Main, BuilderProcessor supplied directly");
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
say("classes=" + new java.util.TreeSet<>(c.classes.keySet()));
say("");
}
@Test
@Order(2)
void badTargetsAreRejectedWithErrorsPointingAtTheSource() throws Exception {
Map<String, String> cases = new LinkedHashMap<>();
cases.put("interface", """
package com.acme;
import com.ankurm.builder.GenerateBuilder;
@GenerateBuilder
public interface Shape { }
""");
cases.put("abstract class", """
package com.acme;
import com.ankurm.builder.GenerateBuilder;
@GenerateBuilder
public abstract class Animal { }
""");
cases.put("two constructors", """
package com.acme;
import com.ankurm.builder.GenerateBuilder;
@GenerateBuilder
public class Point {
public Point() { }
public Point(int x) { }
}
""");
cases.put("generic class", """
package com.acme;
import com.ankurm.builder.GenerateBuilder;
@GenerateBuilder
public class Box<T> {
public Box(T value) { }
}
""");
say("== 2. error cases: javac diagnostics produced via Messager");
for (var entry : cases.entrySet()) {
String name = entry.getValue().lines().filter(l -> l.contains(" class ") || l.contains("interface ")
|| l.contains("record ")).findFirst().orElseThrow();
String typeName = name.replaceAll(".*(class|interface) (\\w+).*", "$2");
Compilation c = Compilation.compile(Map.of("com.acme." + typeName, entry.getValue()),
List.of(new BuilderProcessor()), List.of());
assertFalse(c.success, entry.getKey());
var errors = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.ERROR).toList();
assertEquals(1, errors.size(), entry.getKey());
assertEquals(4L, errors.get(0).getLineNumber(), "error is attached to the type declaration line");
assertTrue(c.generatedSources.isEmpty());
say(entry.getKey() + " -> " + c.render(errors.get(0)));
}
say("");
}
@Test
@Order(3)
void roundsAreVisibleThroughTheDebugOption() throws Exception {
Compilation c = Compilation.compile(
Map.of("com.acme.Person", PERSON),
List.of(new BuilderProcessor()), List.of("-Abuilder.debug=true"));
assertTrue(c.success);
List<String> notes = c.diagnostics.stream()
.filter(d -> d.getKind() == Diagnostic.Kind.NOTE)
.map(d -> d.getMessage(null)).toList();
assertEquals(3, notes.size(), notes::toString);
assertTrue(notes.get(0).startsWith("builder round 1: annotations=[com.ankurm.builder.GenerateBuilder]"));
assertTrue(notes.get(0).endsWith("processingOver=false"));
assertTrue(notes.get(1).startsWith("builder round 2: annotations=[]"));
assertTrue(notes.get(1).contains("roots=[com.acme.PersonBuilder]"));
assertTrue(notes.get(2).startsWith("builder round 3: annotations=[]"));
assertTrue(notes.get(2).endsWith("processingOver=true"));
say("== 3. rounds, with -Abuilder.debug=true");
notes.forEach(ProcessorCompileTest::say);
say("");
}
@Test
@Order(4)
void processorIsDiscoveredFromMetaInfServicesOnTheProcessorPath() throws Exception {
Path classes = Path.of(Compilation.annotationClasspath());
assertTrue(Files.isDirectory(classes), "expected target/classes; got " + classes);
assertTrue(Files.exists(classes.resolve("META-INF/services/javax.annotation.processing.Processor")));
Compilation c = Compilation.compile(
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
null, List.of("-processorpath", classes.toString()));
assertTrue(c.success, () -> c.diagnostics.toString());
assertTrue(c.generatedSources.containsKey("com.acme.PersonBuilder"));
say("== 4. no setProcessors(): discovered through META-INF/services via -processorpath");
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
say("");
}
@Test
@Order(5)
void procNoneMeansNoGeneratedClassAndTheCallerFails() throws Exception {
Path classes = Path.of(Compilation.annotationClasspath());
Compilation c = Compilation.compile(
Map.of("com.acme.Person", PERSON, "com.acme.Main", MAIN),
null, List.of("-processorpath", classes.toString(), "-proc:none"));
assertFalse(c.success);
assertTrue(c.generatedSources.isEmpty());
var errors = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.ERROR)
.map(d -> c.render(d).lines().findFirst().orElseThrow()).toList();
assertTrue(errors.stream().anyMatch(e -> e.contains("cannot find symbol")), errors::toString);
say("== 5. -proc:none: the processor never runs");
say("success=" + c.success + " generated=" + c.generatedSources.keySet());
errors.forEach(ProcessorCompileTest::say);
say("");
}
@Test
@Order(6)
void aProcessorWithoutSupportedSourceVersionGetsAWarning() throws Exception {
Compilation c = Compilation.compile(
Map.of("com.acme.Empty", "package com.acme; public class Empty { }"),
List.of(new LazyProcessor()), List.of());
assertTrue(c.success);
var warnings = c.diagnostics.stream().filter(d -> d.getKind() == Diagnostic.Kind.WARNING
|| d.getKind() == Diagnostic.Kind.MANDATORY_WARNING).map(d -> c.render(d)).toList();
assertFalse(warnings.isEmpty(), "expected a source-version warning");
say("== 6. a processor that forgot @SupportedSourceVersion");
warnings.forEach(ProcessorCompileTest::say);
say("");
}
@AfterAll
static void writeTranscript() throws Exception {
String dir = System.getProperty("output.dir");
if (dir != null) {
Files.createDirectories(Path.of(dir));
Files.writeString(Path.of(dir, "09-processor-harness.txt"), TRANSCRIPT.toString());
}
}
}