From 310eb7de7dad3ad5b71c5e63207fc7c7b9143b53 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:19:58 +0000 Subject: [PATCH] feat(annotations): make @SimpleBuilderFor repeatable Repeatable declarations on the same holder (class or package-info) let each listing carry its own options - e.g. a different packageName per declaration. Adds the SimpleBuilderFors container, a generic findAnnotationInstances lookup in JavaLangAnalyser, and per-instance target/config resolution in BuilderProcessor. Relates to #296 Co-Authored-By: Andreas Igel --- .../core/annotations/SimpleBuilderFor.java | 17 ++++ .../core/annotations/SimpleBuilderFors.java | 49 +++++++++++ docs/CONFIGURATION.md | 13 +++ .../builders/processor/BuilderProcessor.java | 68 ++++++++------- .../processor/analysis/JavaLangAnalyser.java | 42 +++++++++ .../BuilderConfigurationReader.java | 15 ++-- .../processor/SimpleBuilderForTest.java | 86 +++++++++++++++++++ 7 files changed, 252 insertions(+), 38 deletions(-) create mode 100644 core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java index 90b17377..5cc0758f 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java @@ -25,6 +25,7 @@ package org.javahelpers.simple.builders.core.annotations; import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @@ -83,6 +84,21 @@ * } * } * + *

The annotation is {@link Repeatable}: several declarations may be placed on the same holder + * (class or {@code package-info.java}) when the listed types need different {@link #options()}, for + * example a different {@code packageName} for the generated builders: + * + *

{@code
+ * @SimpleBuilderFor(
+ *     value = ExternalUser.class,
+ *     options = @SimpleBuilder.Options(packageName = "com.example.users"))
+ * @SimpleBuilderFor(
+ *     value = ExternalOrder.class,
+ *     options = @SimpleBuilder.Options(packageName = "com.example.orders"))
+ * public class ExternalBuildersProvider {
+ * }
+ * }
+ * *

Configuration uses the existing {@link SimpleBuilder.Options} model and may be overridden via * compiler options: * @@ -103,6 +119,7 @@ */ @Target({ElementType.TYPE, ElementType.PACKAGE}) @Retention(RetentionPolicy.CLASS) +@Repeatable(SimpleBuilderFors.class) public @interface SimpleBuilderFor { /** diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java new file mode 100644 index 00000000..b015d3a0 --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFors.java @@ -0,0 +1,49 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.core.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Container annotation holding several {@link SimpleBuilderFor} declarations on the same element. + * It is applied implicitly when {@link SimpleBuilderFor} is repeated and is not meant to be used + * directly. + * + * @see SimpleBuilderFor + */ +@Target({ElementType.TYPE, ElementType.PACKAGE}) +@Retention(RetentionPolicy.CLASS) +public @interface SimpleBuilderFors { + + /** + * The repeated {@link SimpleBuilderFor} declarations. + * + * @return the contained {@link SimpleBuilderFor} annotations + */ + SimpleBuilderFor[] value(); +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2c595c04..1b6469da 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -189,6 +189,19 @@ The generated builders are placed in the annotated package - or in the package o The target type must be constructible through accessible Java APIs from the builder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. The target type's own annotations are not consulted - the explicit declaration wins, so `@Ignore4BuilderGeneration` on the target does not suppress generation either. `@SimpleBuilderFor` is not `@Inherited`, and the provider class or package itself never gets a builder. +`@SimpleBuilderFor` is `@Repeatable`: several declarations may be placed on the same holder or `package-info.java` when the listed types need different options - for example a different `packageName` per declaration: + +```java +@SimpleBuilderFor( + value = ExternalUser.class, + options = @SimpleBuilder.Options(packageName = "com.example.users")) +@SimpleBuilderFor( + value = ExternalOrder.class, + options = @SimpleBuilder.Options(packageName = "com.example.orders")) +public class ExternalBuildersProvider { +} +``` + ## Compiler Options Set project-wide defaults via compiler options. These apply to all builders unless overridden by annotations. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java index 6fcb5bed..ecf98650 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java @@ -53,6 +53,7 @@ import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors; import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; @@ -207,10 +208,13 @@ private void generateJacksonModules(PerformanceTracker tracker) { /** * Collects all elements annotated with {@code @SimpleBuilderFor} in this round. These are holder - * classes declaring external types a builder is generated for. + * classes declaring external types a builder is generated for. Repeated declarations arrive + * wrapped in the {@code @SimpleBuilderFors} container, so both are collected. */ private Set collectExternalTypeHolders(RoundEnvironment roundEnv) { - return new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class)); + Set holders = new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class)); + holders.addAll(roundEnv.getElementsAnnotatedWith(SimpleBuilderFors.class)); + return holders; } /** @@ -311,9 +315,11 @@ private Optional planGenerationOfAnnotatedElement( } /** - * Expands a {@code @SimpleBuilderFor} holder into the external types listed in its {@code value} - * attribute and plans a builder for each of them. The generated builder is placed in the - * configured {@code packageName} or the holder's package. + * Expands a {@code @SimpleBuilderFor} holder into the external types listed in the {@code value} + * attributes of its annotation instances and plans a builder for each of them. Each instance + * resolves its own configuration, so repeated declarations may place their generated builders + * into different packages via {@code packageName}; without a configured package the holder's + * package is used. */ private List planGenerationOfTypeByHolder( Element holder, @@ -322,24 +328,30 @@ private List planGenerationOfTypeByHolder( PerformanceTracker tracker) throws BuilderException { Objects.requireNonNull(holder, "holder must not be null"); - List targets = extractExternalTargetTypes(holder); - if (targets.isEmpty()) { - context.warning( - holder, - "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate", - holder.getSimpleName()); - return List.of(); - } - - tracker.startPhase(); - BuilderConfiguration config = reader.resolveHolderConfiguration(holder); - tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); + List annotationInstances = + JavaLangAnalyser.findAnnotationInstances( + holder, SimpleBuilderFor.class, SimpleBuilderFors.class, context); - String builderPackage = effectiveBuilderPackage(holder, config); List results = new ArrayList<>(); - for (TypeElement target : targets) { - planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders) - .ifPresent(results::add); + for (AnnotationMirror annotationInstance : annotationInstances) { + List targets = extractExternalTargetTypes(holder, annotationInstance); + if (targets.isEmpty()) { + context.warning( + holder, + "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate", + holder.getSimpleName()); + continue; + } + + tracker.startPhase(); + BuilderConfiguration config = reader.resolveHolderConfiguration(holder, annotationInstance); + tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); + + String builderPackage = effectiveBuilderPackage(holder, config); + for (TypeElement target : targets) { + planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders) + .ifPresent(results::add); + } } return results; } @@ -380,17 +392,13 @@ private Optional planExternalTarget( } /** - * Reads the {@code value} attribute of the {@code @SimpleBuilderFor} annotation on the holder and - * resolves each entry to the {@link TypeElement} the builder is generated for. + * Reads the {@code value} attribute of one {@code @SimpleBuilderFor} annotation instance on the + * holder and resolves each entry to the {@link TypeElement} the builder is generated for. */ - private List extractExternalTargetTypes(Element holder) throws BuilderException { - Optional mirror = - JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class); - if (mirror.isEmpty()) { - return List.of(); - } + private List extractExternalTargetTypes( + Element holder, AnnotationMirror annotationInstance) throws BuilderException { Optional valueAttribute = - JavaLangAnalyser.findAnnotationAttribute(mirror.get(), "value", context); + JavaLangAnalyser.findAnnotationAttribute(annotationInstance, "value", context); List results = new ArrayList<>(); // For an array-valued attribute javac always delivers a list, even for a single entry. if (valueAttribute.isEmpty() || !(valueAttribute.get().getValue() instanceof List items)) { diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index a7f8caa8..560f5628 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -29,6 +29,7 @@ import static javax.lang.model.type.TypeKind.VOID; import java.lang.annotation.Annotation; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; @@ -232,6 +233,47 @@ public static Optional findAnnotation( return Optional.empty(); } + /** + * Finds all instances of a repeatable annotation on an element: direct mirrors plus the entries + * wrapped in its container annotation, in declaration order. + * + * @param element Element to be checked + * @param annotationClass the repeatable annotation to be checked + * @param containerClass the container annotation wrapping repeats of {@code annotationClass} + * @param context the processing context providing element utilities + * @return the list of {@code javax.lang.model.element.AnnotationMirror} instances of + * annotationClass found on the element + */ + public static List findAnnotationInstances( + Element element, + Class annotationClass, + Class containerClass, + ProcessingContext context) { + List instances = new ArrayList<>(); + if (element == null) { + return instances; + } + for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) { + String annotationName = annotationMirror.getAnnotationType().toString(); + if (annotationName.equals(annotationClass.getCanonicalName())) { + instances.add(annotationMirror); + } else if (annotationName.equals(containerClass.getCanonicalName())) { + Optional containerValue = + findAnnotationAttribute(annotationMirror, "value", context); + if (containerValue.isPresent() + && containerValue.get().getValue() instanceof List entries) { + for (Object entry : entries) { + if (entry instanceof AnnotationValue entryValue + && entryValue.getValue() instanceof AnnotationMirror entryMirror) { + instances.add(entryMirror); + } + } + } + } + } + return instances; + } + /** * Reads the value of one attribute of an annotation mirror, including default values. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java index f25ae6c5..ca2ea1db 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderConfigurationReader.java @@ -37,9 +37,7 @@ import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.util.Elements; -import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; import org.javahelpers.simple.builders.core.enums.AccessModifier; -import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -151,21 +149,22 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder * are considered - the type is typically external and must not be modified, so its annotations * (if any) do not participate in configuration. The configuration is composed of the built-in * defaults, the global compiler arguments, and the {@code options()} of the - * {@code @SimpleBuilderFor} annotation found on the given element. + * {@code @SimpleBuilderFor} annotation instance found on the given element. * * @param element the {@code @SimpleBuilderFor} holder (used for validation messages) + * @param annotationInstance one {@code @SimpleBuilderFor} annotation instance on the element; + * repeated declarations each resolve their own configuration * @return the fully resolved configuration with all sources merged */ - public BuilderConfiguration resolveHolderConfiguration(Element element) throws BuilderException { + public BuilderConfiguration resolveHolderConfiguration( + Element element, AnnotationMirror annotationInstance) throws BuilderException { Objects.requireNonNull(element, "element must not be null"); + Objects.requireNonNull(annotationInstance, "annotationInstance must not be null"); String elementName = element.getSimpleName().toString(); logger.debugStartOperation( "Resolving configuration for @SimpleBuilderFor target: %s", elementName); - BuilderConfiguration optionsConfig = - JavaLangAnalyser.findAnnotation(element, SimpleBuilderFor.class) - .map(this::extractOptionsFromAnnotationMirror) - .orElse(null); + BuilderConfiguration optionsConfig = extractOptionsFromAnnotationMirror(annotationInstance); BuilderConfiguration result = BuilderConfiguration.DEFAULT.merge(globalConfiguration).merge(optionsConfig); diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java index b89982e7..bc99e235 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java @@ -411,6 +411,92 @@ void packageName_GlobalCompilerArgumentIsIgnored() { ProcessorAsserts.assertContaining(generated, "package test;"); } + @Test + void repeatable_GeneratesBuildersIntoDifferentPackages() { + JavaFileObject holder = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor( + value = ext.ExternalUser.class, + options = @SimpleBuilder.Options(packageName = "com.example.users")) + @SimpleBuilderFor( + value = ext.ExternalOrder.class, + options = @SimpleBuilder.Options(packageName = "com.example.orders")) + public class Builders {} + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler().compile(externalDto(), externalOrderDto(), holder); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertContaining( + ProcessorTestUtils.loadGeneratedSource(compilation, "users/ExternalUserBuilder"), + "package com.example.users;"); + ProcessorAsserts.assertContaining( + ProcessorTestUtils.loadGeneratedSource(compilation, "orders/ExternalOrderBuilder"), + "package com.example.orders;"); + } + + @Test + void repeatable_OnPackageInfo_GeneratesBuildersIntoAnnotatedPackage() { + JavaFileObject packageInfo = + JavaFileObjects.forSourceLines( + "test.package-info", + "@SimpleBuilderFor(ext.ExternalUser.class)", + "@SimpleBuilderFor(ext.ExternalOrder.class)", + "package test;", + "", + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;"); + + Compilation compilation = + ProcessorTestUtils.createCompiler().compile(externalDto(), externalOrderDto(), packageInfo); + + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertContaining( + ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"), + "package test;"); + ProcessorAsserts.assertContaining( + ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder"), + "package test;"); + } + + @Test + void repeatable_DuplicateType_SecondDeclarationSkippedWithWarning() { + JavaFileObject holder = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor(ext.ExternalUser.class) + @SimpleBuilderFor(ext.ExternalUser.class) + public class Builders {} + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder); + + assertThat(compilation).succeeded(); + assertThat(compilation).hadWarningContaining("already generated elsewhere"); + // Exactly one ExternalUserBuilder was generated in package test + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"); + ProcessorAsserts.assertContaining(generated, "package test;"); + } + + private static JavaFileObject externalOrderDto() { + return ProcessorTestUtils.forSource( + """ + package ext; + public class ExternalOrder { + private String id; + public ExternalOrder() {} + public String getId() { return id; } + public void setId(String id) { this.id = id; } + } + """); + } + private static JavaFileObject externalDto() { return ProcessorTestUtils.forSource( """