From aecb5ae2c1082cfcf9566ad6badbbf6cf74c786a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:39:27 +0000 Subject: [PATCH 01/18] feat: add @SimpleBuilderFor for generating builders of external types (#293) Introduce @SimpleBuilderFor on a holder class to generate builders for types that cannot carry @SimpleBuilder themselves (e.g. third-party library classes). The builders are generated in the holder's package, configured via the annotation's options attribute and compiler options. - New @SimpleBuilderFor annotation in core with Class[] value and optional SimpleBuilder.Options options - BuilderProcessor expands holders into generation targets; builder name collisions and @Ignore4BuilderGeneration targets are skipped with warnings on the holder - Explicitly declared types always get a builder, even outside builderGenerationPackages (contradictory config warns) - Builders generated in the same round are now trusted and exempt from builderUsagePackages - a self-generated builder is always used - Member selection (constructors, setters, getters) now respects member accessibility from the generated builder's package, so generated code only calls members it may legally call - Clear compile-time diagnostics when no accessible constructor or no accessible target type exists - Jackson module defaults to the generated builder's package - Example module: holder + external type + test; also enable surefire so the module's tests actually execute (they silently ran 0 before) - Tests: SimpleBuilderForTest with 12 cases; updated scope tests for the trusted-builder semantics and the new round-start log line Co-Authored-By: Andreas Igel --- README.md | 13 +- .../core/annotations/SimpleBuilderFor.java | 118 ++++ docs/CONFIGURATION.md | 27 + docs/CONTRIBUTING.md | 2 +- docs/DEBUG_LOGGING.md | 4 +- .../example/ExternalAddressBuilder.java | 560 ++++++++++++++++++ .../scoping/ScopedOwnerDtoBuilder.java | 25 +- .../example/ExternalTypeBuilders.java | 38 ++ .../example/external/ExternalAddress.java | 62 ++ .../example/ExternalAddressBuilderTest.java | 65 ++ .../scoping/ScopedOwnerDtoBuilderTest.java | 4 +- .../builders/processor/BuilderProcessor.java | 216 ++++++- .../analysis/BuilderScopeResolver.java | 54 +- .../processor/analysis/JavaLangAnalyser.java | 25 +- .../integration/JacksonModuleGenerator.java | 2 +- .../BuilderConfigurationReader.java | 32 + .../processing/BuilderDefinitionCreator.java | 32 +- .../processing/ProcessingContext.java | 70 +++ .../processor/BuilderProcessorTest.java | 6 +- .../processor/BuilderScopeResolverTest.java | 14 +- .../processor/SimpleBuilderForTest.java | 338 +++++++++++ 21 files changed, 1641 insertions(+), 66 deletions(-) create mode 100644 core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java create mode 100644 example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java create mode 100644 example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java create mode 100644 example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java create mode 100644 example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.java create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java diff --git a/README.md b/README.md index 9ac603f5..e98bafba 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A zero-reflection Java annotation processor that generates fluent, type-safe bui - [Elementary Builder Example](#elementary-builder-example) - [Full-Featured Examples](#full-featured-examples) - [Advanced Features](#advanced-features) + - [External Type Builder Example](#external-type-builder-example) - [Builder Scoping Example](#builder-scoping-example) - [Performance Measurement](#performance-measurement) - [Contributing](#contributing) @@ -73,6 +74,7 @@ Value semantics (`equals`, `hashCode`, `toString`) and generating brand-new immu - **Annotation Preservation**: Validation annotations are automatically copied to builder methods - **With Interface Pattern**: Type-safe object modifications using generated With interfaces - **Jackson Support**: Supporting Jackson deserialization via `@JsonPOJOBuilder` and optional generation of `SimpleModule`s (one per package) (both need to be enabled) +- **External Type Builders**: `@SimpleBuilderFor` generates builders for types that cannot be annotated - for example classes from third-party libraries - **JavaDoc Usage Examples**: Generated builder methods include auto-generated usage examples in their JavaDoc (per-method fluent snippets plus a class-level example), so IDE tooltips show exactly how to use each builder ## Requirements @@ -466,6 +468,15 @@ Examples demonstrating special annotations and nested object relationships: - **Mannschaft DTO**: [`MannschaftDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/MannschaftDto.java) and [`MannschaftDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/MannschaftDtoBuilder.java) - Demonstrates `@IgnoreInBuilder` annotation to exclude specific setter methods from the generated builder, plus Set collections with nested objects - **Default Values**: [`ProductWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java) (record) and [`OrderWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java) (class) - Demonstrate `@Default` annotation for unset builder fields +### External Type Builder Example + +A runnable example of `@SimpleBuilderFor`, which generates builders for types that cannot carry `@SimpleBuilder` themselves: + +- **Holder**: [`ExternalTypeBuilders.java`](example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java) - Declares `@SimpleBuilderFor(ExternalAddress.class)`; generated builders land in the holder's package +- **External type**: [`external/ExternalAddress.java`](example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java) - Plain class simulating third-party code, no annotations +- **Generated Builder**: [`ExternalAddressBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java) +- **Tests**: [`ExternalAddressBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.java) + ### Builder Scoping Example A runnable example demonstrating package-scoped builder generation and usage: @@ -473,7 +484,7 @@ A runnable example demonstrating package-scoped builder generation and usage: - **Source DTO**: [`ScopedOwnerDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java) - Configures both package scopes inline and demonstrates the generation-scope, usage-scope, and out-of-scope field cases - **Trusted helper**: [`TrustedHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java) - In-generation-scope helper whose builder is referenced as a builder consumer - **Library helper**: [`library/LibraryHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java) - Annotated but outside the generation scope, so no builder exists and the owner falls back to a plain setter -- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows the consumer overload for `trusted` and plain setters for `library` and `sponsor` +- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows builder consumers for `trusted` and `sponsor` (generated in the same round, always trusted) and a plain setter for `library` - **Tests**: [`ScopedOwnerDtoBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java) - Asserts the generated API shape These examples serve as both documentation and integration tests for the annotation processor. 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 new file mode 100644 index 00000000..dfc05cb9 --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java @@ -0,0 +1,118 @@ +/* + * 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; + +/** + * Annotation to generate builders for types that cannot or should not be modified, such as classes + * from third-party libraries. + * + *

Place this annotation on a dedicated holder class and list the external types in {@link + * #value()}. For every listed type a builder is generated following the same naming and generation + * conventions as for {@link SimpleBuilder} annotated classes, without changing the target type and + * without runtime reflection. + * + *

The generated builder is placed in the package of the class carrying this annotation. Only + * members of the target type that are accessible from that package (e.g. public constructors and + * setters, or package-visible members when the holder shares the target's package) are used for + * builder generation. If no suitable construction mechanism is available, generation fails with a + * compile-time error. + * + *

Example: + * + *

{@code
+ * package com.vendor.api;
+ *
+ * public class ExternalUser {
+ *     public ExternalUser(String name, String email) {
+ *         // ...
+ *     }
+ * }
+ *
+ * package com.example;
+ *
+ * @SimpleBuilderFor(ExternalUser.class)
+ * class ExternalBuilders {
+ * }
+ *
+ * // Generated usage:
+ * ExternalUser user = ExternalUserBuilder.create()
+ *     .name("Ada")
+ *     .email("ada@example.com")
+ *     .build();
+ * }
+ * + *

Multiple external types can be listed in a single annotation and {@link #options()} may be + * omitted, in which case the compiler defaults apply: + * + *

{@code
+ * @SimpleBuilderFor({ExternalUser.class, ExternalOrder.class})
+ * class ExternalBuilders {
+ * }
+ * }
+ * + *

Configuration uses the existing {@link SimpleBuilder.Options} model and may be overridden via + * compiler options: + * + *

{@code
+ * @SimpleBuilderFor(
+ *     value = ExternalUser.class,
+ *     options = @SimpleBuilder.Options(
+ *         builderSuffix = "Factory"
+ *     )
+ * )
+ * class ExternalBuilders {
+ * }
+ * }
+ * + * @see SimpleBuilder + * @see SimpleBuilder.Options + * @see Ignore4BuilderGeneration + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.CLASS) +public @interface SimpleBuilderFor { + + /** + * The types for which builders are generated. Every listed type must be resolvable on the + * classpath or in the current compilation and must be constructible through accessible Java APIs + * (e.g. an accessible constructor). At least one type is required. + * + * @return the external types to generate builders for + */ + Class[] value(); + + /** + * Configuration options for the generated builders, reusing the {@link SimpleBuilder.Options} + * model. When omitted, the compiler defaults apply. + * + * @return the configuration options, or default (all UNSET) if not specified + */ + SimpleBuilder.Options options() default @SimpleBuilder.Options; +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 55350546..4dbd20d7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -8,6 +8,7 @@ Simple-builders supports fine-grained configuration through the `@SimpleBuilder. - [Annotation Configuration](#annotation-configuration) - [Template Annotations](#template-annotations) - [Excluding Types from Builder Generation](#excluding-types-from-builder-generation) +- [Generating Builders for External Types](#generating-builders-for-external-types) - [Compiler Options](#compiler-options) - [Maven Configuration](#maven-configuration) - [Gradle Configuration](#gradle-configuration) @@ -169,6 +170,32 @@ public class IgnoredDto extends ParentDto { A type marked with `@Ignore4BuilderGeneration` is treated as having **no builder available**. Other builders that reference it will fall back to plain setters instead of emitting nested-builder consumers. The annotation is intentionally **not** `@Inherited`, so it only suppresses the exact type it is placed on and does not cascade to further subclasses. +## Generating Builders for External Types + +`@SimpleBuilder` has to be placed on the type itself, which is not possible for types you cannot modify - for example classes or records from third-party libraries. `@SimpleBuilderFor` covers this case: put it on a holder class in your own code and list the types a builder is generated for. + +```java +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + +@SimpleBuilderFor( + value = {ExternalUser.class, ExternalOrder.class}, + options = @SimpleBuilder.Options(builderSuffix = "Factory")) +public class ExternalBuilders { + // Generates ExternalUserFactory and ExternalOrderFactory into this package +} +``` + +Behavior notes: + +- **Builder location**: the generated builders are placed in the package of the holder class, not in the external type's package. +- **Configuration**: only the `options` attribute of `@SimpleBuilderFor` (optional, defaults to compiler defaults) and project-wide compiler options apply. The external type's own annotations are not consulted, because it is treated as foreign code. +- **Explicit declaration wins over scopes**: a type listed in `@SimpleBuilderFor` always gets a builder, even when its builder package is outside `builderGenerationPackages` (a warning is issued for that contradictory configuration). Builders generated this way are trusted like any other builder from the same compilation - referencing builders consume them regardless of `builderUsagePackages`. +- **Accessibility**: the target type must be constructible through accessible Java APIs from the builder's package - it must be visible and have an accessible constructor. Members (setters, getters) that are not accessible from the builder's package, such as package-private members of a foreign package, are silently left out of the builder. If no accessible constructor exists, generation fails with a clear compile-time diagnostic (a warning, or an error in strict mode). +- **Opt-out**: listing a type annotated with `@Ignore4BuilderGeneration` is skipped with a warning. +- **Not inherited**: `@SimpleBuilderFor` is not `@Inherited` and the holder class itself never gets a builder. +- **Conflicts**: if a builder with the same name is already generated (direct annotation or another holder), the `@SimpleBuilderFor` entry is skipped with a warning. + ## Compiler Options Set project-wide defaults via compiler options. These apply to all builders unless overridden by annotations. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 07c4c765..763e4aa9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -239,7 +239,7 @@ For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). ``` ========== Compilation Diagnostics ========== --- NOTES --- -[DEBUG] simple-builders: Processing round started. Found 1 annotated elements. +[DEBUG] simple-builders: Processing round started. Found 1 annotated elements and 0 @SimpleBuilderFor holders. [DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the builderGenerationPackages scope. [DEBUG] Processing element: Project [DEBUG] ├─ Extracting builder definition from: test.Project diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index bb07e2d9..5146be98 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -91,7 +91,7 @@ When debug logging is enabled, you'll see detailed output with visual separators ``` [INFO] simple-builders: PROCESSING ROUND START -[INFO] [DEBUG] simple-builders: Processing round started. Found 3 annotated elements. +[INFO] [DEBUG] simple-builders: Processing round started. Found 3 annotated elements and 0 @SimpleBuilderFor holders. [INFO] [DEBUG] simple-builders: 3 of 3 annotated element(s) are inside the builderGenerationPackages scope. [INFO] [DEBUG] Processing element: PersonDto [INFO] [DEBUG] ├─ Extracting builder definition from: org.example.PersonDto @@ -126,7 +126,7 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] [DEBUG] │ └─ Successfully generated builder: CustomerDtoBuilder [INFO] simple-builders: Successfully generated 3 builder(s) in this processing round [INFO] simple-builders: PROCESSING ROUND START -[INFO] [DEBUG] simple-builders: Processing round started. Found 0 annotated elements. +[INFO] [DEBUG] simple-builders: Processing round started. Found 0 annotated elements and 0 @SimpleBuilderFor holders. [INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. ``` diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java new file mode 100644 index 00000000..f077e84e --- /dev/null +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java @@ -0,0 +1,560 @@ +package org.javahelpers.simple.builders.example; + +import static org.javahelpers.simple.builders.core.util.TrackedValue.changedValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.initialValue; +import static org.javahelpers.simple.builders.core.util.TrackedValue.unsetValue; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; +import javax.annotation.processing.Generated; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.javahelpers.simple.builders.core.annotations.BuilderImplementation; +import org.javahelpers.simple.builders.core.interfaces.IBuilderBase; +import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; +import org.javahelpers.simple.builders.core.util.TrackedValue; +import org.javahelpers.simple.builders.example.external.ExternalAddress; + +/** + * Builder for {@code org.javahelpers.simple.builders.example.external.ExternalAddress}. + *

+ * This builder provides a fluent API for creating instances of + * org.javahelpers.simple.builders.example.external.ExternalAddress with method chaining and validation. Use the static + * {@code create()} method to obtain a new builder instance, configure the desired properties using the setter methods, + * and then call {@code build()} to create the final DTO. + * + *

Example:

+ * + *
{@code
+ * ExternalAddress result = ExternalAddressBuilder.create()
+ *     .city("example value")
+ *     .city("Hello %s", "World")
+ *     .city(() -> "example value")
+ *     .city(sb -> sb.append("text"))
+ *     .cityUpdate(String::trim)
+ *     .street("example value")
+ *     .street("Hello %s", "World")
+ *     .street(() -> "example value")
+ *     .street(sb -> sb.append("text"))
+ *     .streetUpdate(String::trim)
+ *     .zipCode("example value")
+ *     .zipCode("Hello %s", "World")
+ *     .zipCode(() -> "example value")
+ *     .zipCode(sb -> sb.append("text"))
+ *     .zipCodeUpdate(String::trim)
+ *     .build();
+ * }
+ */ +@Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") +@BuilderImplementation(forClass = ExternalAddress.class) +public class ExternalAddressBuilder implements IBuilderBase { + + /** + * Tracked value for city: city. + */ + private TrackedValue city = unsetValue(); + /** + * Tracked value for street: street. + */ + private TrackedValue street = unsetValue(); + /** + * Tracked value for zipCode: zipCode. + */ + private TrackedValue zipCode = unsetValue(); + + /** + * Empty constructor of builder for {@code org.javahelpers.simple.builders.example.external.ExternalAddress}. + */ + public ExternalAddressBuilder() { + } + + /** + * Initialisation of builder for {@code org.javahelpers.simple.builders.example.external.ExternalAddress} by a + * instance. + * + * @param instance object instance for initialisiation + */ + public ExternalAddressBuilder(ExternalAddress instance) { + this.city = initialValue(instance.getCity()); + this.street = initialValue(instance.getStreet()); + this.zipCode = initialValue(instance.getZipCode()); + } + + /** + * Creating a new builder for {@code org.javahelpers.simple.builders.example.external.ExternalAddress}. + * + *

Example:

+ * + *
{@code
+   * ExternalAddressBuilder builder = ExternalAddressBuilder.create();
+   * }
+ * + * @return builder for {@code org.javahelpers.simple.builders.example.external.ExternalAddress} + */ + public static ExternalAddressBuilder create() { + return new ExternalAddressBuilder(); + } + + /** + * Sets the value for city. + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *

Example:

+ * + *
{@code
+   * builder.city("example value");
+   * }
+ * + * @param city city + * @return current instance of builder + */ + public ExternalAddressBuilder city(String city) { + this.city = changedValue(city); + return this; + } + + /** + * Sets the value for city by executing the provided consumer. + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *

Example:

+ * + *
{@code
+   * builder.city(sb -> sb.append("text"));
+   * }
+ * + * @param cityStringBuilderConsumer consumer providing an instance of city + * @return current instance of builder + */ + public ExternalAddressBuilder city(Consumer cityStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + cityStringBuilderConsumer.accept(builder); + this.city = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for city by invoking the provided supplier. + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *

Example:

+ * + *
{@code
+   * builder.city(() -> "example value");
+   * }
+ * + * @param citySupplier supplier for city + * @return current instance of builder + */ + public ExternalAddressBuilder city(Supplier citySupplier) { + this.city = changedValue(citySupplier.get()); + return this; + } + + /** + * Sets the String value for city by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *

Example:

+ * + *
{@code
+   * builder.city("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public ExternalAddressBuilder city(String format, Object... args) { + this.city = changedValue(String.format(format, args)); + return this; + } + + /** + * Updates the current value of city in place by applying the given operator, instead of reading it out, + * changing it and setting it again. Useful for adjustments relative to the current value, e.g. trimming, + * upper-casing, clamping or incrementing, and in combination with the With copy-and-modify flow. The + * value must have been set before (directly or via an existing instance). + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *

Example:

+ * + *
{@code
+   * builder.city("example value").cityUpdate(String::trim);
+   * }
+ * + * @param cityUpdater operator applied to the current value; its result becomes the new value + * @return current instance of builder + * @throws IllegalStateException if city has not been set yet + */ + public ExternalAddressBuilder cityUpdate(UnaryOperator cityUpdater) { + if (!this.city.isSet()) { + throw new IllegalStateException("Cannot update 'city' before it is set"); + } + this.city = changedValue(cityUpdater.apply(this.city.value())); + return this; + } + + /** + * Sets the value for street. + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *

Example:

+ * + *
{@code
+   * builder.street("example value");
+   * }
+ * + * @param street street + * @return current instance of builder + */ + public ExternalAddressBuilder street(String street) { + this.street = changedValue(street); + return this; + } + + /** + * Sets the value for street by executing the provided consumer. + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *

Example:

+ * + *
{@code
+   * builder.street(sb -> sb.append("text"));
+   * }
+ * + * @param streetStringBuilderConsumer consumer providing an instance of street + * @return current instance of builder + */ + public ExternalAddressBuilder street(Consumer streetStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + streetStringBuilderConsumer.accept(builder); + this.street = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for street by invoking the provided supplier. + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *

Example:

+ * + *
{@code
+   * builder.street(() -> "example value");
+   * }
+ * + * @param streetSupplier supplier for street + * @return current instance of builder + */ + public ExternalAddressBuilder street(Supplier streetSupplier) { + this.street = changedValue(streetSupplier.get()); + return this; + } + + /** + * Sets the String value for street by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *

Example:

+ * + *
{@code
+   * builder.street("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public ExternalAddressBuilder street(String format, Object... args) { + this.street = changedValue(String.format(format, args)); + return this; + } + + /** + * Updates the current value of street in place by applying the given operator, instead of reading it + * out, changing it and setting it again. Useful for adjustments relative to the current value, e.g. trimming, + * upper-casing, clamping or incrementing, and in combination with the With copy-and-modify flow. The + * value must have been set before (directly or via an existing instance). + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *

Example:

+ * + *
{@code
+   * builder.street("example value").streetUpdate(String::trim);
+   * }
+ * + * @param streetUpdater operator applied to the current value; its result becomes the new value + * @return current instance of builder + * @throws IllegalStateException if street has not been set yet + */ + public ExternalAddressBuilder streetUpdate(UnaryOperator streetUpdater) { + if (!this.street.isSet()) { + throw new IllegalStateException("Cannot update 'street' before it is set"); + } + this.street = changedValue(streetUpdater.apply(this.street.value())); + return this; + } + + /** + * Validates that the city field is not null or empty. + *

+ * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if city is null or empty + */ + ExternalAddressBuilder validateCity() { + if (!city.isSet() || city.value().trim().isEmpty()) { + throw new IllegalArgumentException("City cannot be null or empty"); + } + return this; + } + + /** + * Validates that the street field is not null or empty. + *

+ * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if street is null or empty + */ + ExternalAddressBuilder validateStreet() { + if (!street.isSet() || street.value().trim().isEmpty()) { + throw new IllegalArgumentException("Street cannot be null or empty"); + } + return this; + } + + /** + * Validates that the zipCode field is not null or empty. + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + * @return this builder instance for chaining + * @throws IllegalArgumentException if zipCode is null or empty + */ + ExternalAddressBuilder validateZipCode() { + if (!zipCode.isSet() || zipCode.value().trim().isEmpty()) { + throw new IllegalArgumentException("ZipCode cannot be null or empty"); + } + return this; + } + + /** + * Sets the value for zipCode. + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *

Example:

+ * + *
{@code
+   * builder.zipCode("example value");
+   * }
+ * + * @param zipCode zipCode + * @return current instance of builder + */ + public ExternalAddressBuilder zipCode(String zipCode) { + this.zipCode = changedValue(zipCode); + return this; + } + + /** + * Sets the value for zipCode by executing the provided consumer. + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *

Example:

+ * + *
{@code
+   * builder.zipCode(sb -> sb.append("text"));
+   * }
+ * + * @param zipCodeStringBuilderConsumer consumer providing an instance of zipCode + * @return current instance of builder + */ + public ExternalAddressBuilder zipCode(Consumer zipCodeStringBuilderConsumer) { + StringBuilder builder = new StringBuilder(); + zipCodeStringBuilderConsumer.accept(builder); + this.zipCode = changedValue(builder.toString()); + return this; + } + + /** + * Sets the value for zipCode by invoking the provided supplier. + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *

Example:

+ * + *
{@code
+   * builder.zipCode(() -> "example value");
+   * }
+ * + * @param zipCodeSupplier supplier for zipCode + * @return current instance of builder + */ + public ExternalAddressBuilder zipCode(Supplier zipCodeSupplier) { + this.zipCode = changedValue(zipCodeSupplier.get()); + return this; + } + + /** + * Sets the String value for zipCode by using String.format(format, args). See + * {@link String#format(String, Object...)} for details. + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *

Example:

+ * + *
{@code
+   * builder.zipCode("Hello %s", "World");
+   * }
+ * + * @param format A format string + * @param args Arguments referenced by the format specifiers in the format string. + * @return current instance of builder + */ + public ExternalAddressBuilder zipCode(String format, Object... args) { + this.zipCode = changedValue(String.format(format, args)); + return this; + } + + /** + * Updates the current value of zipCode in place by applying the given operator, instead of reading it + * out, changing it and setting it again. Useful for adjustments relative to the current value, e.g. trimming, + * upper-casing, clamping or incrementing, and in combination with the With copy-and-modify flow. The + * value must have been set before (directly or via an existing instance). + *

+ * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *

Example:

+ * + *
{@code
+   * builder.zipCode("example value").zipCodeUpdate(String::trim);
+   * }
+ * + * @param zipCodeUpdater operator applied to the current value; its result becomes the new value + * @return current instance of builder + * @throws IllegalStateException if zipCode has not been set yet + */ + public ExternalAddressBuilder zipCodeUpdate(UnaryOperator zipCodeUpdater) { + if (!this.zipCode.isSet()) { + throw new IllegalStateException("Cannot update 'zipCode' before it is set"); + } + this.zipCode = changedValue(zipCodeUpdater.apply(this.zipCode.value())); + return this; + } + + /** + * Conditionally applies builder modifications if the condition is true. + * + * @param condition the condition to evaluate + * @param yesCondition the consumer to apply if condition is true + * @return this builder instance + */ + public ExternalAddressBuilder conditional(BooleanSupplier condition, Consumer yesCondition) { + return conditional(condition, yesCondition, null); + } + + /** + * Conditionally applies builder modifications based on a condition evaluation. + * + * @param condition the condition to evaluate + * @param trueCase the consumer to apply if condition is true + * @param falseCase the consumer to apply if condition is false (can be null) + * @return this builder instance + */ + public ExternalAddressBuilder conditional(BooleanSupplier condition, Consumer trueCase, + Consumer falseCase) { + if (condition.getAsBoolean()) { + trueCase.accept(this); + } else if (falseCase != null) { + falseCase.accept(this); + } + return this; + } + + /** + * Builds the configured DTO instance. + * + *

Example:

+ * + *
{@code
+   * ExternalAddress result = builder.build();
+   * }
+ */ + @Override + public ExternalAddress build() { + ExternalAddress result = new ExternalAddress(); + this.city.ifSet(result::setCity); + this.street.ifSet(result::setStreet); + this.zipCode.ifSet(result::setZipCode); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("city", this.city) + .append("street", this.street) + .append("zipCode", this.zipCode) + .toString(); + } + + /** + * Interface that can be implemented by the DTO to provide fluent modification methods. + */ + public interface With { + /** + * Initializes a builder from an instance of this class, using methods of this builder to change values and returns + * the new built object. + * + * @param b the consumer to apply modifications + * @return the modified instance + */ + default ExternalAddress with(Consumer b) { + ExternalAddressBuilder builder; + try { + builder = new ExternalAddressBuilder(ExternalAddress.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'ExternalAddressBuilder.With' should only be implemented by classes, which could be casted to 'ExternalAddress'", + ex); + } + b.accept(builder); + return builder.build(); + } + + /** + * Creates a builder initialized from this instance. + * + * @return a builder initialized with this instance's values + */ + default ExternalAddressBuilder with() { + try { + return new ExternalAddressBuilder(ExternalAddress.class.cast(this)); + } catch (ClassCastException ex) { + throw new IllegalArgumentException( + "The interface 'ExternalAddressBuilder.With' should only be implemented by classes, which could be casted to 'ExternalAddress'", + ex); + } + } + } +} \ No newline at end of file diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java index d098f5fb..9557e1d1 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java @@ -14,6 +14,7 @@ import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; import org.javahelpers.simple.builders.core.util.TrackedValue; import org.javahelpers.simple.builders.example.SponsorDto; +import org.javahelpers.simple.builders.example.SponsorDtoBuilder; import org.javahelpers.simple.builders.example.library.LibraryHelperDto; /** @@ -32,6 +33,7 @@ * .library(LibraryHelperDto::new) * .sponsor(new SponsorDto()) * .sponsor(SponsorDto::new) + * .sponsor(sponsorDtoBuilder -> sponsorDtoBuilder) * .trusted(new TrustedHelperDto()) * .trusted(TrustedHelperDto::new) * .trusted(trustedHelperDtoBuilder -> trustedHelperDtoBuilder) @@ -180,17 +182,25 @@ public ScopedOwnerDtoBuilder sponsor(SponsorDto sponsor) { } /** - * Sets the value for sponsor by executing the provided consumer. + * Sets the value for sponsor using a builder consumer that produces the value. *

* Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)} * - * @param sponsorConsumer consumer providing an instance of sponsor + *

Example:

+ * + *
{@code
+   * builder.sponsor(sponsorDtoBuilder -> sponsorDtoBuilder);
+   * }
+ * + * @param sponsorBuilderConsumer consumer providing an instance of a builder for sponsor * @return current instance of builder */ - public ScopedOwnerDtoBuilder sponsor(Consumer sponsorConsumer) { - SponsorDto consumer = this.sponsor.isSet() ? this.sponsor.value() : new SponsorDto(); - sponsorConsumer.accept(consumer); - this.sponsor = changedValue(consumer); + public ScopedOwnerDtoBuilder sponsor(Consumer sponsorBuilderConsumer) { + SponsorDtoBuilder builder = this.sponsor.isSet() + ? new SponsorDtoBuilder(this.sponsor.value()) + : new SponsorDtoBuilder(); + sponsorBuilderConsumer.accept(builder); + this.sponsor = changedValue(builder.build()); return this; } @@ -217,7 +227,8 @@ public ScopedOwnerDtoBuilder sponsor(Supplier sponsorSupplier) { * Updates the current value of sponsor in place by applying the given operator, instead of reading it * out, changing it and setting it again. Useful for adjustments relative to the current value, e.g. trimming, * upper-casing, clamping or incrementing, and in combination with the With copy-and-modify flow. The - * value must have been set before (directly or via an existing instance). + * value must have been set before (directly or via an existing instance). For changing multiple values of a nested + * DTO, prefer the builder-consumer helper {@link #sponsor(Consumer)}. *

* Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)} * diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java b/example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java new file mode 100644 index 00000000..f3d50841 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java @@ -0,0 +1,38 @@ +/* + * 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.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; +import org.javahelpers.simple.builders.example.external.ExternalAddress; + +/** + * Holder class declaring builders for types that cannot carry {@code @SimpleBuilder} themselves - + * for example classes from third-party libraries. + * + *

The generated builders are placed in the package of this holder class, here {@code + * org.javahelpers.simple.builders.example}. + */ +@SimpleBuilderFor(ExternalAddress.class) +public class ExternalTypeBuilders {} diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java b/example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java new file mode 100644 index 00000000..bdb617de --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java @@ -0,0 +1,62 @@ +/* + * 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.example.external; + +/** + * Simulates a type from an external library that cannot be annotated with {@code @SimpleBuilder}: + * a plain class with a public constructor and JavaBean accessors. + */ +public class ExternalAddress { + + private String street; + private String city; + private String zipCode; + + public ExternalAddress() {} + + public String getStreet() { + return street; + } + + public void setStreet(String street) { + this.street = street; + } + + public String getCity() { + return city; + } + + public void setCity(String city) { + this.city = city; + } + + public String getZipCode() { + return zipCode; + } + + public void setZipCode(String zipCode) { + this.zipCode = zipCode; + } +} diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.java new file mode 100644 index 00000000..8dda55b4 --- /dev/null +++ b/example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.java @@ -0,0 +1,65 @@ +/* + * 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.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.javahelpers.simple.builders.example.external.ExternalAddress; +import org.junit.jupiter.api.Test; + +/** + * Demonstrates using a builder generated via {@code @SimpleBuilderFor} for a type that cannot be + * annotated directly. + */ +class ExternalAddressBuilderTest { + + @Test + void buildsExternalType() { + ExternalAddress address = + ExternalAddressBuilder.create() + .street("Main Street 1") + .city("Springfield") + .zipCode("12345") + .build(); + + assertEquals("Main Street 1", address.getStreet()); + assertEquals("Springfield", address.getCity()); + assertEquals("12345", address.getZipCode()); + } + + @Test + void initializesBuilderFromInstance() { + ExternalAddress original = new ExternalAddress(); + original.setStreet("Main Street 1"); + original.setCity("Springfield"); + original.setZipCode("12345"); + + ExternalAddress copy = new ExternalAddressBuilder(original).city("Shelbyville").build(); + + assertEquals("Main Street 1", copy.getStreet()); + assertEquals("Shelbyville", copy.getCity()); + assertEquals("12345", copy.getZipCode()); + } +} diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java index f7c6bee8..7fa021f5 100644 --- a/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java +++ b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java @@ -42,10 +42,12 @@ class ScopedOwnerDtoBuilderTest { @Test void exposesScopedBuilderConsumerOverloads() { assertTrue(hasBuilderConsumerMethod("trusted", TrustedHelperDtoBuilder.class.getName())); + // SponsorDto's builder is generated in the same compilation, so it is trusted and used + // regardless of the usage scope + assertTrue(hasBuilderConsumerMethod("sponsor", SponsorDtoBuilder.class.getName())); assertFalse( hasBuilderConsumerMethod( "library", "org.javahelpers.simple.builders.example.library.LibraryHelperDtoBuilder")); - assertFalse(hasBuilderConsumerMethod("sponsor", SponsorDtoBuilder.class.getName())); assertTrue(hasMethod("trusted", TrustedHelperDto.class)); assertTrue(hasMethod("library", LibraryHelperDto.class)); 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 e324c169..2ab58578 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 @@ -34,8 +34,10 @@ import com.google.auto.service.AutoService; import java.util.ArrayList; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -45,10 +47,14 @@ import javax.annotation.processing.SupportedAnnotationTypes; import javax.lang.model.SourceVersion; import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeMirror; 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.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -57,6 +63,7 @@ import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.BuilderToGenerationTypeMapper; import org.javahelpers.simple.builders.processor.model.core.GenerationTargetClassDto; +import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameList; import org.javahelpers.simple.builders.processor.model.type.TypeNameMap; import org.javahelpers.simple.builders.processor.model.type.TypeNameSet; @@ -131,21 +138,27 @@ public boolean process(Set annotations, RoundEnvironment tracker.startPhase(); Set elementsToProcess = collectElementsToProcess(annotations, roundEnv); + Set externalTypeHolders = collectExternalTypeHolders(roundEnv); // Sort elements alphabetically by simple name for deterministic processing List sortedElements = elementsToProcess.stream() .sorted(Comparator.comparing(element -> element.getSimpleName().toString())) .toList(); + List sortedHolders = + externalTypeHolders.stream() + .sorted(Comparator.comparing(element -> element.getSimpleName().toString())) + .toList(); tracker.endPhase(PHASE_ELEMENT_COLLECTION); context.debug( - "simple-builders: Processing round started. Found %d annotated elements.", - elementsToProcess.size()); + "simple-builders: Processing round started. Found %d annotated elements and %d @SimpleBuilderFor holders.", + elementsToProcess.size(), externalTypeHolders.size()); // Resolve configuration and apply generation scopes before processing any builder. This lets // the scope resolver know every builder that will be generated in this round. List elementsToGenerate = - resolveGenerationPlan(sortedElements, context.getConfigurationReader(), tracker); + resolveGenerationPlan( + sortedElements, sortedHolders, context.getConfigurationReader(), tracker); context.debug( "simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.", elementsToGenerate.size(), sortedElements.size()); @@ -187,6 +200,14 @@ private void generateJacksonModules(PerformanceTracker tracker) { context.resetIndentation(); } + /** + * Collects all elements annotated with {@code @SimpleBuilderFor} in this round. These are holder + * classes declaring external types a builder is generated for. + */ + private Set collectExternalTypeHolders(RoundEnvironment roundEnv) { + return new HashSet<>(roundEnv.getElementsAnnotatedWith(SimpleBuilderFor.class)); + } + /** * Collects all elements to process in this round: any element annotated with an annotation that * is meta-annotated with {@code @SimpleBuilder.Template} (including {@code @SimpleBuilder} @@ -219,11 +240,17 @@ private Set collectElementsToProcess( /** * Resolves the configuration per element and applies the {@code builderGenerationPackages} scope, - * returning the elements that will have a builder generated in this round. + * returning the elements that will have a builder generated in this round. External types listed + * in {@code @SimpleBuilderFor} are expanded afterwards so a directly annotated DTO always wins + * over an external-type request for the same builder name. */ private List resolveGenerationPlan( - List sortedElements, BuilderConfigurationReader reader, PerformanceTracker tracker) { + List sortedElements, + List sortedHolders, + BuilderConfigurationReader reader, + PerformanceTracker tracker) { List elementsToGenerate = new ArrayList<>(); + Set plannedBuilderNames = new HashSet<>(); for (Element annotatedElement : sortedElements) { context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); try { @@ -234,7 +261,9 @@ private List resolveGenerationPlan( if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { continue; } - elementsToGenerate.add(new ElementToGenerate(annotatedElement, config)); + plannedBuilderNames.add(builderQualifiedName(annotatedElement, null, config)); + elementsToGenerate.add( + new ElementToGenerate(annotatedElement, config, null, annotatedElement)); } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. @@ -244,22 +273,154 @@ private List resolveGenerationPlan( context.debugEndOperation(); } } + + for (Element holder : sortedHolders) { + context.debugStartOperation("Processing @SimpleBuilderFor holder: " + holder.getSimpleName()); + try { + elementsToGenerate.addAll( + resolveExternalTypeTargets(holder, reader, plannedBuilderNames, tracker)); + } catch (BuilderException ex) { + context.reportBasedOnStrictMode( + holder, "simple-builders: Failed to generate builder - %s", ex.getMessage()); + } finally { + context.debugEndOperation(); + } + } return elementsToGenerate; } + /** + * 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 holder's + * package. + */ + private List resolveExternalTypeTargets( + Element holder, + BuilderConfigurationReader reader, + Set plannedBuilderNames, + PerformanceTracker tracker) + throws BuilderException { + AnnotationMirror simpleBuilderForMirror = + JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class) + .orElseThrow( + () -> + new BuilderException( + holder, "No @SimpleBuilderFor annotation found on '%s'", holder)); + + List targets = extractExternalTargetTypes(holder, simpleBuilderForMirror); + 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.resolveExternalConfiguration(holder, simpleBuilderForMirror); + tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); + + String builderPackage = context.getPackageName(holder); + List result = new ArrayList<>(); + for (TypeElement target : targets) { + if (JavaLangAnalyser.findAnnotation(target, Ignore4BuilderGeneration.class).isPresent()) { + context.warning( + holder, + "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - opted out via @Ignore4BuilderGeneration", + target.getQualifiedName(), + holder.getSimpleName()); + continue; + } + // An explicit declaration in @SimpleBuilderFor always generates a builder - the + // builderGenerationPackages scope only filters annotated types. A scope that would + // exclude an explicitly named type is a contradictory configuration, so warn. + if (!config.builderGenerationPackages().isEmpty() + && !config.builderGenerationPackages().includes(builderPackage)) { + context.warning( + holder, + "simple-builders: @SimpleBuilderFor on '%s' generates builder for '%s' in package '%s', which is outside builderGenerationPackages - the explicit declaration takes precedence", + holder.getSimpleName(), + target.getQualifiedName(), + builderPackage); + } + String builderName = builderQualifiedName(target, builderPackage, config); + if (!plannedBuilderNames.add(builderName)) { + context.warning( + holder, + "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", + target.getQualifiedName(), + holder.getSimpleName(), + builderName); + continue; + } + result.add(new ElementToGenerate(target, config, builderPackage, holder)); + } + return result; + } + + /** + * Reads the {@code value} attribute of a {@code @SimpleBuilderFor} annotation mirror and resolves + * each entry to the {@link TypeElement} the builder is generated for. + */ + private List extractExternalTargetTypes(Element holder, AnnotationMirror mirror) + throws BuilderException { + List targets = new ArrayList<>(); + for (Map.Entry entry : + context.getElementValuesWithDefaults(mirror).entrySet()) { + if (!entry.getKey().getSimpleName().contentEquals("value")) { + continue; + } + if (!(entry.getValue().getValue() instanceof List values)) { + continue; + } + for (Object item : values) { + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; + Element resolved = + typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; + if (!(resolved instanceof TypeElement targetType)) { + throw new BuilderException( + holder, + "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", + typeValue, + holder.getSimpleName()); + } + targets.add(targetType); + } + } + return targets; + } + + /** Computes the qualified name of the builder a given target type would produce. */ + private String builderQualifiedName( + Element target, String builderPackage, BuilderConfiguration config) { + String packageName = builderPackage != null ? builderPackage : context.getPackageName(target); + String simpleName = target.getSimpleName() + config.getBuilderSuffix(); + return packageName.isEmpty() ? simpleName : packageName + "." + simpleName; + } + /** * Registers the types whose builders will be generated this round with the scope resolver, so it - * can trust them without a type search. + * can trust them without a type search. The actual builder type name is registered, which may + * differ from the target's package for {@code @SimpleBuilderFor} targets. */ private void registerGeneratedTypes(List elementsToGenerate) { - context - .getBuilderScopeResolver() - .registerGeneratedTypes( - elementsToGenerate.stream() - .map(ElementToGenerate::element) - .filter(TypeElement.class::isInstance) - .map(TypeElement.class::cast) - .toList()); + Map generatedBuilders = new HashMap<>(); + for (ElementToGenerate elementToGenerate : elementsToGenerate) { + if (!(elementToGenerate.element() instanceof TypeElement targetType)) { + continue; + } + String builderPackage = + elementToGenerate.builderPackage() != null + ? elementToGenerate.builderPackage() + : context.getPackageName(targetType); + generatedBuilders.put( + targetType.getQualifiedName().toString(), + new TypeName( + builderPackage, + targetType.getSimpleName() + elementToGenerate.config().getBuilderSuffix())); + } + context.getBuilderScopeResolver().registerGeneratedBuilders(generatedBuilders); } /** Generates a builder for each planned element and returns the number of successes. */ @@ -271,13 +432,15 @@ private int generateBuilders( context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); tracker.startClass(annotatedElement.getSimpleName().toString()); try { - process(annotatedElement, elementToGenerate.config()); + process(annotatedElement, elementToGenerate.config(), elementToGenerate.builderPackage()); successfulGenerations++; } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. context.reportBasedOnStrictMode( - annotatedElement, "simple-builders: Failed to generate builder - %s", ex.getMessage()); + elementToGenerate.reportingElement(), + "simple-builders: Failed to generate builder - %s", + ex.getMessage()); } finally { context.debugEndOperation(); } @@ -300,9 +463,10 @@ public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); } - private void process(Element annotatedElement, BuilderConfiguration config) + private void process(Element annotatedElement, BuilderConfiguration config, String builderPackage) throws BuilderException { context.initConfigurationForProcessingTarget(config); + context.initBuilderPackageForProcessingTarget(builderPackage); PerformanceTracker tracker = context.getPerformanceTracker(); // Track Builder Definition Extraction tracker.startPhase(); @@ -348,7 +512,21 @@ private void process(Element annotatedElement, BuilderConfiguration config) builderDef.getBuilderTypeName().getClassName()); } - private record ElementToGenerate(Element element, BuilderConfiguration config) {} + /** + * A type a builder is generated for. + * + * @param element the type element to generate the builder for + * @param config the resolved builder configuration + * @param builderPackage the package the builder is generated into, or {@code null} to use the + * target type's own package + * @param reportingElement the element diagnostics are reported on - the {@code @SimpleBuilderFor} + * holder for external types, otherwise the type itself + */ + private record ElementToGenerate( + Element element, + BuilderConfiguration config, + String builderPackage, + Element reportingElement) {} /** * Checks whether the provided SourceVersion is at least Java 17 in a backwards compatible way. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 0f9bc265..5f7afb97 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -25,11 +25,9 @@ import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.Set; import javax.lang.model.element.Element; import javax.lang.model.element.TypeElement; import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; @@ -55,7 +53,7 @@ public final class BuilderScopeResolver { private final ProcessingContext context; private BuilderConfiguration cachedConfiguration; private PackageScopes usagePackages = PackageScopes.unscoped(); - private Set generatedTypeNames = Set.of(); + private Map generatedBuilderTypes = Map.of(); private final Map> resolvedBuilderTypes = new HashMap<>(); /** @@ -80,12 +78,14 @@ public BuilderScopeResolver(ProcessingContext context) { *

The decision follows these rules: * *

    + *
  1. If the referenced type's builder is generated in the current processing round (registered + * via {@link #registerGeneratedTypes} or {@link #registerGeneratedBuilders}), the + * registered builder name is returned immediately — trusted without a classpath lookup or + * contract check, and exempt from the usage scope: a builder this processor generates is + * always used. *
  2. If the usage scope is set and the referenced type's package is not in it, no builder may * be referenced. The usage scope includes generation-scope packages automatically. When the * scope is empty, any package is allowed (backward compatibility). - *
  3. If the referenced type's builder is generated in the current processing round (registered - * via {@link #registerGeneratedTypes}), the candidate builder (using {@code builderSuffix}) - * is returned immediately — trusted without a classpath lookup or contract check. *
  4. Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: a constructor accepting @@ -146,11 +146,27 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu * @param generatedTypes types whose builders will be generated in this round */ public void registerGeneratedTypes(Collection generatedTypes) { - Set registeredTypeNames = new HashSet<>(); + Map registeredTypes = new HashMap<>(); for (TypeElement generatedType : generatedTypes) { - registeredTypeNames.add(generatedType.getQualifiedName().toString()); + registeredTypes.put( + generatedType.getQualifiedName().toString(), + JavaLangMapper.createBuilderTypeName( + generatedType, context, context.getConfiguration().getBuilderSuffix())); } - generatedTypeNames = registeredTypeNames; + registerGeneratedBuilders(registeredTypes); + } + + /** + * Registers the builder type names generated for the given target types in the current processing + * round. Use this overload when the generated builder does not follow the default naming in the + * target type's own package - for example for {@code @SimpleBuilderFor} targets, whose builders + * are generated in the package of the annotated holder class. + * + * @param generatedBuilders map from target type qualified name to the generated builder's type + * name + */ + public void registerGeneratedBuilders(Map generatedBuilders) { + generatedBuilderTypes = new HashMap<>(generatedBuilders); resolvedBuilderTypes.clear(); } @@ -162,6 +178,15 @@ private Optional resolve(TypeElement referencedType) { String referencedTypeFqn = referencedType.getQualifiedName().toString(); String packageName = context.getPackageName(referencedType); + // Types whose builders are generated in the current processing round are trusted + // immediately — our own generators always produce the builder contract, so no + // classpath lookup or contract check is needed. Being generated by this processor also + // makes them exempt from the usage scope: an explicitly generated builder is always used. + TypeName generatedBuilder = generatedBuilderTypes.get(referencedTypeFqn); + if (generatedBuilder != null) { + return Optional.of(generatedBuilder); + } + // The usage scope determines whether a type is eligible to be referenced as a builder // helper. When empty, any package is allowed (backward compatibility). When set, only // packages in the scope qualify. The scope already includes generation-scope packages. @@ -169,17 +194,6 @@ private Optional resolve(TypeElement referencedType) { return Optional.empty(); } - // Types whose builders are generated in the current processing round are trusted - // immediately — our own generators always produce the builder contract, so no - // classpath lookup or contract check is needed. The candidate uses builderSuffix - // because that is what our own generators produce. - if (generatedTypeNames.contains(referencedTypeFqn)) { - TypeName candidate = - JavaLangMapper.createBuilderTypeName( - referencedType, context, context.getConfiguration().getBuilderSuffix()); - return Optional.of(candidate); - } - // For types not generated in this round, look up the candidate on the classpath using // builderUsageSuffix (which falls back to builderSuffix if not configured) and verify // the builder contract. 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 9da8610c..ad23ad0d 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 @@ -486,7 +486,8 @@ public static Optional findGetterForField( String name = candidate.getSimpleName().toString(); if (Strings.CI.equalsAny(name, fieldName, "is" + fieldName, "get" + fieldName) && candidate.getParameters().isEmpty() - && context.isSameType(candidate.getReturnType(), fieldTypeMirror)) { + && context.isSameType(candidate.getReturnType(), fieldTypeMirror) + && context.isMemberAccessibleFromBuilderPackage(candidate, dtoType)) { return Optional.of(candidate); } } @@ -550,7 +551,8 @@ public static Optional findSetterForField( for (ExecutableElement candidate : methods) { if (candidate.getSimpleName().contentEquals(setterName) && candidate.getParameters().size() == 1 - && candidate.getReturnType().getKind() == VOID) { + && candidate.getReturnType().getKind() == VOID + && context.isMemberAccessibleFromBuilderPackage(candidate, dtoType)) { return Optional.of(candidate); } } @@ -570,7 +572,9 @@ public static Optional findSetterForField( public static Optional findConstructorForBuilder( TypeElement annotatedType, ProcessingContext context) { List ctors = - ElementFilter.constructorsIn(context.getAllMembers(annotatedType)); + ElementFilter.constructorsIn(context.getAllMembers(annotatedType)).stream() + .filter(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor, annotatedType)) + .toList(); // First, check if any constructor is annotated with @SimpleBuilderConstructor for (ExecutableElement ctor : ctors) { @@ -591,4 +595,19 @@ public static Optional findConstructorForBuilder( } return (selected != null && maxParams > 0) ? Optional.of(selected) : Optional.empty(); } + + /** + * Checks whether the given type has at least one constructor that is accessible from the package + * the generated builder is written to. A type without an accessible constructor cannot be + * instantiated by generated code. + * + * @param typeElement the type element to check + * @param context the processing context providing access to elements and types utilities + * @return {@code true} if an accessible constructor exists + */ + public static boolean hasAccessibleConstructor( + TypeElement typeElement, ProcessingContext context) { + return ElementFilter.constructorsIn(context.getAllMembers(typeElement)).stream() + .anyMatch(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor, typeElement)); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java index dd52fd1d..9bb9a81a 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/integration/JacksonModuleGenerator.java @@ -99,7 +99,7 @@ public void addEntry(BuilderDefinitionDto builderDef, Element sourceElement) { private String getTargetPackage(BuilderConfiguration config, BuilderDefinitionDto builderDef) { String targetPackage = config.getJacksonModulePackage(); if (targetPackage == null) { - targetPackage = builderDef.getBuildingTargetTypeName().getPackageName(); + targetPackage = builderDef.getBuilderTypeName().getPackageName(); } return targetPackage; } 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 26e4b395..ca0114af 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 @@ -139,6 +139,38 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder return result; } + /** + * Resolves the complete builder configuration for a type listed in {@code @SimpleBuilderFor}. + * + *

    Unlike {@link #resolveConfiguration(Element)}, no template annotations of the target type + * 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 given + * {@code @SimpleBuilderFor} annotation mirror. + * + * @param element the element associated with this configuration (used for validation messages) + * @param simpleBuilderForMirror the {@code @SimpleBuilderFor} annotation mirror carrying the + * {@code options} attribute + * @return the fully resolved configuration with all sources merged + */ + public BuilderConfiguration resolveExternalConfiguration( + Element element, AnnotationMirror simpleBuilderForMirror) throws BuilderException { + String elementName = element.getSimpleName().toString(); + logger.debugStartOperation( + "Resolving configuration for @SimpleBuilderFor target: %s", elementName); + + BuilderConfiguration optionsConfig = extractOptionsFromAnnotationMirror(simpleBuilderForMirror); + + BuilderConfiguration result = + BuilderConfiguration.DEFAULT.merge(globalConfiguration).merge(optionsConfig); + + // Validate access modifiers and warn about problematic configurations + validateAccessModifiers(element, result); + + logger.debugEndOperation("Resulting configuration resolved: %s", result.toString()); + return result; + } + /** * Reads the highest-priority template configuration for the element in the requested scope. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index fc1b80cc..ee486faa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -95,6 +95,25 @@ public static BuilderDefinitionDto extractFromElement( context.debugStartOperation( "Extracting builder definition from: %s", annotatedType.getQualifiedName()); + // The generated builder is a separate top-level class; it can only reference the target + // type itself and constructors that are accessible from the builder's package. + if (!context.isMemberAccessibleFromBuilderPackage(annotatedType, annotatedType)) { + throw new BuilderException( + annotatedElement, + "The type '%s' is not accessible from the package '%s' its builder is generated in. " + + "Only types constructible through accessible Java APIs can get a builder.", + annotatedType.getQualifiedName(), + context.getBuilderPackageName(annotatedType)); + } + if (!JavaLangAnalyser.hasAccessibleConstructor(annotatedType, context)) { + throw new BuilderException( + annotatedElement, + "No accessible constructor found on '%s'. A builder can only be generated for types " + + "that can be constructed through accessible Java APIs (e.g. a public or " + + "package-visible constructor reachable from the generated builder).", + annotatedType.getQualifiedName()); + } + BuilderDefinitionDto result = initializeBuilderDefinition(annotatedType, context); // Track field names to resolve conflicts during field creation @@ -439,14 +458,15 @@ private static BuilderDefinitionDto initializeBuilderDefinition( TypeElement annotatedType, ProcessingContext context) { BuilderDefinitionDto result = new BuilderDefinitionDto(); String packageName = context.getPackageName(annotatedType); + String builderPackageName = context.getBuilderPackageName(annotatedType); String simpleClassName = annotatedType.getSimpleName().toString(); String builderSuffix = context.getConfiguration().getBuilderSuffix(); - result.setBuilderTypeName(new TypeName(packageName, simpleClassName + builderSuffix)); + result.setBuilderTypeName(new TypeName(builderPackageName, simpleClassName + builderSuffix)); result.setBuildingTargetTypeName(new TypeName(packageName, simpleClassName)); result.setConfiguration(context.getConfiguration()); context.debug( - "Builder will be generated as: %s.%s", packageName, simpleClassName + builderSuffix); + "Builder will be generated as: %s.%s", builderPackageName, simpleClassName + builderSuffix); // Extract generics from the annotated type via mapper (stream-based) JavaLangMapper.map2GenericParameterDtos(annotatedType, context).forEach(result::addGeneric); @@ -528,7 +548,7 @@ private static List extractSetterFields( for (ExecutableElement mth : methods) { context.debugStartOperation("Analyzing method: %s", mth.toString()); - if (isMethodRelevantForBuilder(mth, context)) { + if (isMethodRelevantForBuilder(mth, annotatedType, context)) { // Extract the original field name from the setter method (before any renaming) String methodName = mth.getSimpleName().toString(); String originalFieldName = @@ -581,7 +601,7 @@ private static void logFieldAddition(FieldDto field, ProcessingContext context) } private static boolean isMethodRelevantForBuilder( - ExecutableElement mth, ProcessingContext context) { + ExecutableElement mth, TypeElement annotatedType, ProcessingContext context) { if (!hasNoThrowablesDeclared(mth)) { context.debug("Skipping: declares throwables"); return false; @@ -602,6 +622,10 @@ private static boolean isMethodRelevantForBuilder( context.debug("Skipping: is static"); return false; } + if (!context.isMemberAccessibleFromBuilderPackage(mth, annotatedType)) { + context.debug("Skipping: not accessible from the generated builder's package"); + return false; + } return true; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index 414a364b..6f43a4c0 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -25,8 +25,13 @@ package org.javahelpers.simple.builders.processor.processing; import java.util.List; +import java.util.Map; import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeMirror; @@ -61,6 +66,7 @@ public final class ProcessingContext { private final BuilderScopeResolver builderScopeResolver; private GeneratorRegistry generatorRegistry; private BuilderConfiguration configurationForProcessingTarget; + private String builderPackageForProcessingTarget; /** * Creates a new processing context. @@ -112,6 +118,70 @@ public BuilderConfiguration getConfiguration() { return this.configurationForProcessingTarget; } + /** + * Sets the package the generated builder is written to for the current processing target. + * + *

    When {@code null}, the builder is generated in the package of the processed type itself (the + * default for {@code @SimpleBuilder} targets). For {@code @SimpleBuilderFor} targets the package + * of the holder class is passed, so generated builders stay in user-controlled packages even for + * types from foreign packages. + * + * @param builderPackage the package for the generated builder, or {@code null} to use the + * processed type's own package + */ + public void initBuilderPackageForProcessingTarget(String builderPackage) { + // Verbatim storage: an empty string is a valid builder package (the default package). + this.builderPackageForProcessingTarget = builderPackage; + } + + /** + * Gets the package the builder for the given target is generated in: the explicit builder package + * of the current processing target, or the target's own package when none is set. + * + * @param targetElement the type the builder is generated for + * @return the qualified package name of the generated builder + */ + public String getBuilderPackageName(Element targetElement) { + return builderPackageForProcessingTarget != null + ? builderPackageForProcessingTarget + : getPackageName(targetElement); + } + + /** + * Checks whether a member (constructor, method) is accessible from the package the generated + * builder is written to. + * + *

    Public members are always accessible. Private members are never accessible - the generated + * builder is a separate top-level class. Package-private and protected members are only + * accessible when the member's declaring package equals the builder package (protected access + * through inheritance does not apply, as the builder does not extend the target type). + * + * @param member the member to check + * @param targetElement the type the builder is generated for, used to resolve the effective + * builder package when no explicit builder package is set + * @return {@code true} if generated code in the builder package may call the member + */ + public boolean isMemberAccessibleFromBuilderPackage(Element member, Element targetElement) { + if (member.getModifiers().contains(Modifier.PUBLIC)) { + return true; + } + if (member.getModifiers().contains(Modifier.PRIVATE)) { + return false; + } + return getPackageName(member).equals(getBuilderPackageName(targetElement)); + } + + /** + * Returns the annotation values of an annotation mirror, including default values. + * + * @param annotationMirror the annotation mirror to read + * @return the annotation's element values keyed by their method element + */ + public Map getElementValuesWithDefaults( + AnnotationMirror annotationMirror) { + return elementUtils.getElementValuesWithDefaults(annotationMirror); + } + /** * Gets the configuration reader for reading builder configurations. * diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 18e7bc17..3662d30f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -99,7 +99,8 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "[DEBUG] └─ Initialized GeneratorRegistry with 15 method generators and 9 builder", // Round 1 — start "simple-builders: PROCESSING ROUND START", - "[DEBUG] simple-builders: Processing round started. Found 1 annotated elements.", + "[DEBUG] simple-builders: Processing round started. Found 1 annotated elements and 0" + + " @SimpleBuilderFor holders.", // Round 1 — configuration resolution "[DEBUG] Processing element: VerboseTest", "[DEBUG] ├─ Resolving configuration for element: VerboseTest", @@ -164,7 +165,8 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "simple-builders: Successfully generated 1 builder(s) in this processing round", // Round 2 — no new elements "simple-builders: PROCESSING ROUND START", - "[DEBUG] simple-builders: Processing round started. Found 0 annotated elements.", + "[DEBUG] simple-builders: Processing round started. Found 0 annotated elements and 0" + + " @SimpleBuilderFor holders.", "[DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the" + " builderGenerationPackages scope."); } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index c7bcd27c..0aad523f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -32,6 +32,7 @@ import com.google.testing.compile.Compilation; import com.google.testing.compile.Compiler; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -117,8 +118,11 @@ public class LibHelper { public LibHelper() {} } assertThat(compilation).succeeded(); // With usage scope "other" (not "lib") and no registration → empty assertEquals(Optional.empty(), ResolverProbeProcessor.beforeRegistration); - // Registration alone is not enough — the type must also be in the usage scope - assertEquals(Optional.empty(), ResolverProbeProcessor.afterRegistration); + // Registered types are trusted and exempt from the usage scope — a builder this + // processor generates is always used + assertEquals( + "lib.LibHelperBuilder", + ResolverProbeProcessor.afterRegistration.get().getFullQualifiedName()); // With usage scope "lib" but no registration → empty (builder not on classpath) assertEquals(Optional.empty(), ResolverProbeProcessor.usageBeforeRegistration); // With usage scope "lib" AND registration → builder resolved @@ -270,7 +274,7 @@ public boolean process(Set annotations, RoundEnvironment first = resolver.resolveUsableBuilderType(helper); second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior - resolver.registerGeneratedTypes(List.of()); + resolver.registerGeneratedBuilders(Map.of()); context.initConfigurationForProcessingTarget(configuration("other", "OtherBuilder")); afterConfigurationChange = resolver.resolveUsableBuilderType(helper); context.initConfigurationForProcessingTarget(usageOnlyConfiguration("other")); @@ -280,13 +284,13 @@ public boolean process(Set annotations, RoundEnvironment afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); - resolver.registerGeneratedTypes(List.of()); + resolver.registerGeneratedBuilders(Map.of()); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); resolver.registerGeneratedTypes(List.of(helper)); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); - resolver.registerGeneratedTypes(List.of()); + resolver.registerGeneratedBuilders(Map.of()); usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper); // Usage scope with builderUsageSuffix="Factory" context.initConfigurationForProcessingTarget(usageWithSuffixConfiguration("lib", "Factory")); 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 new file mode 100644 index 00000000..23719a98 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java @@ -0,0 +1,338 @@ +/* + * 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.processor; + +import static com.google.testing.compile.CompilationSubject.assertThat; + +import com.google.testing.compile.Compilation; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; + +/** + * End-to-end coverage of {@code @SimpleBuilderFor}: generating builders for external types that + * cannot be annotated directly, declared on a holder class whose package receives the generated + * builders. + */ +class SimpleBuilderForTest { + + @Test + void singleType_GeneratesBuilderInHolderPackage() { + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(externalDto(), holder("test", "Builders", "ext.ExternalUser")); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "ExternalUserBuilder", generated); + ProcessorAsserts.assertContaining( + generated, + "package test;", + "import ext.ExternalUser;", + "public ExternalUserBuilder name(String name)"); + // The holder itself must not get a builder - it carries no template annotation + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "Builders", "The @SimpleBuilderFor holder must not get a builder"); + } + + @Test + void multipleTypes_GeneratesBuilderForEach() { + JavaFileObject holder = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor({ext.ExternalUser.class, ext.ExternalOrder.class}) + public class Builders {} + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler().compile(externalDto(), externalOrder(), holder); + + assertThat(compilation).succeededWithoutWarnings(); + String userBuilder = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"); + String orderBuilder = + ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalOrderBuilder"); + ProcessorAsserts.assertContaining(userBuilder, "package test;"); + ProcessorAsserts.assertContaining( + orderBuilder, "package test;", "public ExternalOrder build()"); + } + + @Test + void options_HonourBuilderSuffix() { + 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(builderSuffix = "Factory")) + public class Builders {} + """); + + Compilation compilation = ProcessorTestUtils.createCompiler().compile(externalDto(), holder); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserFactory"); + ProcessorAsserts.assertContaining( + generated, "public class ExternalUserFactory", "public ExternalUser build()"); + } + + @Test + void noAccessibleConstructor_WarnsAndGeneratesNoBuilder() { + JavaFileObject unconstructable = + ProcessorTestUtils.forSource( + """ + package ext; + public class Singleton { + private Singleton() {} + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(unconstructable, holder("test", "Builders", "ext.Singleton")); + + assertThat(compilation).succeeded(); + assertThat(compilation).hadWarningContaining("Failed to generate builder"); + assertThat(compilation).hadWarningContaining("No accessible constructor"); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "Singleton", "A type without accessible constructor must not get a builder"); + } + + @Test + void unresolvableType_ProducesClearWarning() { + // javac itself rejects naming a package-private type of another package; the processor + // must still degrade gracefully with a clear diagnostic instead of crashing. + JavaFileObject invisible = + ProcessorTestUtils.forSource( + """ + package ext; + class Hidden { + public Hidden() {} + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(invisible, holder("test", "Builders", "ext.Hidden")); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("is not public in ext"); + assertThat(compilation).hadWarningContaining("could not be resolved to a type"); + } + + @Test + void strictMode_GenerationFailureFailsCompilation() { + JavaFileObject unconstructable = + ProcessorTestUtils.forSource( + """ + package ext; + public class Singleton { + private Singleton() {} + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions("-Asimplebuilder.strict=true") + .compile(unconstructable, holder("test", "Builders", "ext.Singleton")); + + assertThat(compilation).failed(); + assertThat(compilation).hadErrorContaining("Failed to generate builder"); + assertThat(compilation).hadErrorContaining("No accessible constructor"); + } + + @Test + void inaccessibleMembers_AreNotExposedInBuilder() { + JavaFileObject external = + ProcessorTestUtils.forSource( + """ + package ext; + public class Mixed { + private String name; + private String secret; + public Mixed() {} + public void setName(String name) { this.name = name; } + void setSecret(String secret) { this.secret = secret; } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(external, holder("test", "Builders", "ext.Mixed")); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "MixedBuilder"); + ProcessorAsserts.assertContaining(generated, "public MixedBuilder name(String name)"); + // The package-private setter of the foreign package cannot be called from the builder + ProcessorAsserts.assertNotContaining(generated, "secret("); + } + + @Test + void generatedBuilder_IsUsedByOtherGeneratedBuilders() { + JavaFileObject dto = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import ext.ExternalUser; + @SimpleBuilder + public class OrderDto { + private ExternalUser user; + public ExternalUser getUser() { return user; } + public void setUser(ExternalUser user) { this.user = user; } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(externalDto(), dto, holder("test", "Builders", "ext.ExternalUser")); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "OrderDtoBuilder"); + ProcessorAsserts.assertContaining( + generated, "userBuilderConsumer", "ExternalUserBuilder builder"); + } + + @Test + void ignore4BuilderGenerationOnTarget_SkipsWithWarning() { + JavaFileObject optedOut = + ProcessorTestUtils.forSource( + """ + package ext; + import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + @Ignore4BuilderGeneration + public class OptedOut { + public OptedOut() {} + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(optedOut, holder("test", "Builders", "ext.OptedOut")); + + assertThat(compilation).succeeded(); + assertThat(compilation).hadWarningContaining("@Ignore4BuilderGeneration"); + ProcessorAsserts.assertNoBuilderGenerated( + compilation, "OptedOut", "An opted-out type must not get a builder"); + } + + @Test + void builderNameCollision_OnlyOneBuilderGeneratedWithWarning() { + JavaFileObject secondHolder = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor(ext.ExternalUser.class) + public class MoreBuilders {} + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(externalDto(), holder("test", "Builders", "ext.ExternalUser"), secondHolder); + + 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;"); + } + + @Test + void generationScope_DoesNotBlockExplicitDeclarationButWarns() { + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions("-Asimplebuilder.builderGenerationPackages=other.pkg") + .compile(externalDto(), holder("test", "Builders", "ext.ExternalUser")); + + assertThat(compilation).succeeded(); + assertThat(compilation).hadWarningContaining("outside builderGenerationPackages"); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"); + ProcessorAsserts.assertContaining(generated, "package test;"); + } + + @Test + void recordTarget_GeneratesBuilder() { + JavaFileObject externalRecord = + ProcessorTestUtils.forSource( + """ + package ext; + public record ExternalPoint(int x, int y) {} + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .compile(externalRecord, holder("test", "Builders", "ext.ExternalPoint")); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalPointBuilder"); + ProcessorAsserts.assertContaining(generated, "package test;", "public ExternalPoint build()"); + } + + private static JavaFileObject externalDto() { + return ProcessorTestUtils.forSource( + """ + package ext; + public class ExternalUser { + private String name; + private int age; + public ExternalUser() {} + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public int getAge() { return age; } + public void setAge(int age) { this.age = age; } + } + """); + } + + private static JavaFileObject externalOrder() { + 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 holder(String pkg, String className, String targetType) { + return ProcessorTestUtils.forSource( + """ + package %s; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; + @SimpleBuilderFor(%s.class) + public class %s {} + """ + .formatted(pkg, targetType, className)); + } +} From b9d1ac1a1635507dcdb5ae31a871ec5adcad6263 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:52:57 +0000 Subject: [PATCH 02/18] revert example pom surefire fix (moved to separate PR) Co-Authored-By: Andreas Igel --- example/pom.xml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/example/pom.xml b/example/pom.xml index e3e78260..a7735e6f 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,7 +21,6 @@ 3.16.0 3.2.0 - 3.6.0 ${java.version} ${java.version} @@ -50,12 +49,6 @@ ${junit-jupiter.version} test - - org.junit.jupiter - junit-jupiter-engine - ${junit-jupiter.version} - test - @@ -69,13 +62,6 @@ - - - org.apache.maven.plugins - maven-surefire-plugin - ${plugin.maven.surefire.version} - - org.apache.maven.plugins From 341b812cc0a28640aba89fb8b5dc5349b2ee8132 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:21:03 +0000 Subject: [PATCH 03/18] refactor: address review - split round-start log lines, derive builder package from reporting element, tighten docs Co-Authored-By: Andreas Igel --- docs/CONFIGURATION.md | 12 ++---- docs/CONTRIBUTING.md | 4 +- docs/DEBUG_LOGGING.md | 8 +++- .../builders/processor/BuilderProcessor.java | 42 ++++++++++++------- .../processor/BuilderProcessorTest.java | 10 +++-- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4dbd20d7..2f72704b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -186,15 +186,9 @@ public class ExternalBuilders { } ``` -Behavior notes: - -- **Builder location**: the generated builders are placed in the package of the holder class, not in the external type's package. -- **Configuration**: only the `options` attribute of `@SimpleBuilderFor` (optional, defaults to compiler defaults) and project-wide compiler options apply. The external type's own annotations are not consulted, because it is treated as foreign code. -- **Explicit declaration wins over scopes**: a type listed in `@SimpleBuilderFor` always gets a builder, even when its builder package is outside `builderGenerationPackages` (a warning is issued for that contradictory configuration). Builders generated this way are trusted like any other builder from the same compilation - referencing builders consume them regardless of `builderUsagePackages`. -- **Accessibility**: the target type must be constructible through accessible Java APIs from the builder's package - it must be visible and have an accessible constructor. Members (setters, getters) that are not accessible from the builder's package, such as package-private members of a foreign package, are silently left out of the builder. If no accessible constructor exists, generation fails with a clear compile-time diagnostic (a warning, or an error in strict mode). -- **Opt-out**: listing a type annotated with `@Ignore4BuilderGeneration` is skipped with a warning. -- **Not inherited**: `@SimpleBuilderFor` is not `@Inherited` and the holder class itself never gets a builder. -- **Conflicts**: if a builder with the same name is already generated (direct annotation or another holder), the `@SimpleBuilderFor` entry is skipped with a warning. +The generated builders are placed in the package of the holder class. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted; the external type's own annotations are not consulted. + +The target type must be constructible through accessible Java APIs from the holder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. `@Ignore4BuilderGeneration` targets are skipped, `@SimpleBuilderFor` is not `@Inherited`, and the holder class itself never gets a builder. ## Compiler Options diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 763e4aa9..59d666fe 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -239,7 +239,9 @@ For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). ``` ========== Compilation Diagnostics ========== --- NOTES --- -[DEBUG] simple-builders: Processing round started. Found 1 annotated elements and 0 @SimpleBuilderFor holders. +[DEBUG] simple-builders: Processing round started. +[DEBUG] simple-builders: Found 1 annotated elements. +[DEBUG] simple-builders: No @SimpleBuilderFor types detected. [DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the builderGenerationPackages scope. [DEBUG] Processing element: Project [DEBUG] ├─ Extracting builder definition from: test.Project diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index 5146be98..696af3cc 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -91,7 +91,9 @@ When debug logging is enabled, you'll see detailed output with visual separators ``` [INFO] simple-builders: PROCESSING ROUND START -[INFO] [DEBUG] simple-builders: Processing round started. Found 3 annotated elements and 0 @SimpleBuilderFor holders. +[INFO] [DEBUG] simple-builders: Processing round started. +[INFO] [DEBUG] simple-builders: Found 3 annotated elements. +[INFO] [DEBUG] simple-builders: No @SimpleBuilderFor types detected. [INFO] [DEBUG] simple-builders: 3 of 3 annotated element(s) are inside the builderGenerationPackages scope. [INFO] [DEBUG] Processing element: PersonDto [INFO] [DEBUG] ├─ Extracting builder definition from: org.example.PersonDto @@ -126,7 +128,9 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] [DEBUG] │ └─ Successfully generated builder: CustomerDtoBuilder [INFO] simple-builders: Successfully generated 3 builder(s) in this processing round [INFO] simple-builders: PROCESSING ROUND START -[INFO] [DEBUG] simple-builders: Processing round started. Found 0 annotated elements and 0 @SimpleBuilderFor holders. +[INFO] [DEBUG] simple-builders: Processing round started. +[INFO] [DEBUG] simple-builders: Found 0 annotated elements. +[INFO] [DEBUG] simple-builders: No @SimpleBuilderFor types detected. [INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. ``` 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 2ab58578..a5309c17 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 @@ -150,9 +150,15 @@ public boolean process(Set annotations, RoundEnvironment .toList(); tracker.endPhase(PHASE_ELEMENT_COLLECTION); - context.debug( - "simple-builders: Processing round started. Found %d annotated elements and %d @SimpleBuilderFor holders.", - elementsToProcess.size(), externalTypeHolders.size()); + context.debug("simple-builders: Processing round started."); + context.debug("simple-builders: Found %d annotated elements.", elementsToProcess.size()); + if (externalTypeHolders.isEmpty()) { + context.debug("simple-builders: No @SimpleBuilderFor types detected."); + } else { + context.debug( + "simple-builders: Found %d type(s) for generation with @SimpleBuilderFor.", + externalTypeHolders.size()); + } // Resolve configuration and apply generation scopes before processing any builder. This lets // the scope resolver know every builder that will be generated in this round. @@ -262,8 +268,7 @@ private List resolveGenerationPlan( continue; } plannedBuilderNames.add(builderQualifiedName(annotatedElement, null, config)); - elementsToGenerate.add( - new ElementToGenerate(annotatedElement, config, null, annotatedElement)); + elementsToGenerate.add(new ElementToGenerate(annotatedElement, config, annotatedElement)); } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. @@ -354,7 +359,7 @@ private List resolveExternalTypeTargets( builderName); continue; } - result.add(new ElementToGenerate(target, config, builderPackage, holder)); + result.add(new ElementToGenerate(target, config, holder)); } return result; } @@ -411,9 +416,9 @@ private void registerGeneratedTypes(List elementsToGenerate) continue; } String builderPackage = - elementToGenerate.builderPackage() != null - ? elementToGenerate.builderPackage() - : context.getPackageName(targetType); + elementToGenerate.reportingElement() == targetType + ? context.getPackageName(targetType) + : context.getPackageName(elementToGenerate.reportingElement()); generatedBuilders.put( targetType.getQualifiedName().toString(), new TypeName( @@ -432,7 +437,7 @@ private int generateBuilders( context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); tracker.startClass(annotatedElement.getSimpleName().toString()); try { - process(annotatedElement, elementToGenerate.config(), elementToGenerate.builderPackage()); + process(annotatedElement, elementToGenerate.config(), builderPackageOf(elementToGenerate)); successfulGenerations++; } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still @@ -517,16 +522,21 @@ private void process(Element annotatedElement, BuilderConfiguration config, Stri * * @param element the type element to generate the builder for * @param config the resolved builder configuration - * @param builderPackage the package the builder is generated into, or {@code null} to use the - * target type's own package * @param reportingElement the element diagnostics are reported on - the {@code @SimpleBuilderFor} * holder for external types, otherwise the type itself */ private record ElementToGenerate( - Element element, - BuilderConfiguration config, - String builderPackage, - Element reportingElement) {} + Element element, BuilderConfiguration config, Element reportingElement) {} + + /** + * The package the builder is generated into: the holder's package for {@code @SimpleBuilderFor} + * targets, {@code null} (meaning the target's own package) for directly annotated types. + */ + private String builderPackageOf(ElementToGenerate elementToGenerate) { + return elementToGenerate.reportingElement() == elementToGenerate.element() + ? null + : context.getPackageName(elementToGenerate.reportingElement()); + } /** * Checks whether the provided SourceVersion is at least Java 17 in a backwards compatible way. diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 3662d30f..8e139319 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -99,8 +99,9 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "[DEBUG] └─ Initialized GeneratorRegistry with 15 method generators and 9 builder", // Round 1 — start "simple-builders: PROCESSING ROUND START", - "[DEBUG] simple-builders: Processing round started. Found 1 annotated elements and 0" - + " @SimpleBuilderFor holders.", + "[DEBUG] simple-builders: Processing round started.", + "[DEBUG] simple-builders: Found 1 annotated elements.", + "[DEBUG] simple-builders: No @SimpleBuilderFor types detected.", // Round 1 — configuration resolution "[DEBUG] Processing element: VerboseTest", "[DEBUG] ├─ Resolving configuration for element: VerboseTest", @@ -165,8 +166,9 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "simple-builders: Successfully generated 1 builder(s) in this processing round", // Round 2 — no new elements "simple-builders: PROCESSING ROUND START", - "[DEBUG] simple-builders: Processing round started. Found 0 annotated elements and 0" - + " @SimpleBuilderFor holders.", + "[DEBUG] simple-builders: Processing round started.", + "[DEBUG] simple-builders: Found 0 annotated elements.", + "[DEBUG] simple-builders: No @SimpleBuilderFor types detected.", "[DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the" + " builderGenerationPackages scope."); } From 2dd5a3989f4f345c5307911f88713d765b0f2ece Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:36:34 +0000 Subject: [PATCH 04/18] refactor: fix sonar findings - dedupe message literal, split loop exits, concrete map types Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 128 ++++++++++-------- .../processing/ProcessingContext.java | 7 +- 2 files changed, 76 insertions(+), 59 deletions(-) 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 a5309c17..706c6975 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 @@ -82,6 +82,9 @@ @AutoService(Processor.class) @SupportedAnnotationTypes("*") public class BuilderProcessor extends AbstractProcessor { + private static final String MSG_FAILED_TO_GENERATE = + "simple-builders: Failed to generate builder - %s"; + private ProcessingContext context; private ProcessingLogger logger; private RoasterCodeGenerator codeGenerator; @@ -272,8 +275,7 @@ private List resolveGenerationPlan( } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. - context.reportBasedOnStrictMode( - annotatedElement, "simple-builders: Failed to generate builder - %s", ex.getMessage()); + context.reportBasedOnStrictMode(annotatedElement, MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { context.debugEndOperation(); } @@ -285,8 +287,7 @@ private List resolveGenerationPlan( elementsToGenerate.addAll( resolveExternalTypeTargets(holder, reader, plannedBuilderNames, tracker)); } catch (BuilderException ex) { - context.reportBasedOnStrictMode( - holder, "simple-builders: Failed to generate builder - %s", ex.getMessage()); + context.reportBasedOnStrictMode(holder, MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { context.debugEndOperation(); } @@ -329,69 +330,84 @@ private List resolveExternalTypeTargets( String builderPackage = context.getPackageName(holder); List result = new ArrayList<>(); for (TypeElement target : targets) { - if (JavaLangAnalyser.findAnnotation(target, Ignore4BuilderGeneration.class).isPresent()) { - context.warning( - holder, - "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - opted out via @Ignore4BuilderGeneration", - target.getQualifiedName(), - holder.getSimpleName()); - continue; - } - // An explicit declaration in @SimpleBuilderFor always generates a builder - the - // builderGenerationPackages scope only filters annotated types. A scope that would - // exclude an explicitly named type is a contradictory configuration, so warn. - if (!config.builderGenerationPackages().isEmpty() - && !config.builderGenerationPackages().includes(builderPackage)) { - context.warning( - holder, - "simple-builders: @SimpleBuilderFor on '%s' generates builder for '%s' in package '%s', which is outside builderGenerationPackages - the explicit declaration takes precedence", - holder.getSimpleName(), - target.getQualifiedName(), - builderPackage); - } - String builderName = builderQualifiedName(target, builderPackage, config); - if (!plannedBuilderNames.add(builderName)) { - context.warning( - holder, - "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", - target.getQualifiedName(), - holder.getSimpleName(), - builderName); - continue; - } - result.add(new ElementToGenerate(target, config, holder)); + planExternalTarget(target, holder, config, builderPackage, plannedBuilderNames) + .ifPresent(result::add); } return result; } + /** + * Plans a builder for a single type listed in {@code @SimpleBuilderFor}, or reports on the holder + * why no builder is generated for it. An explicit declaration always generates a builder - the + * {@code builderGenerationPackages} scope only filters annotated types, so a scope that would + * exclude an explicitly named type is a contradictory configuration and only warns. + */ + private Optional planExternalTarget( + TypeElement target, + Element holder, + BuilderConfiguration config, + String builderPackage, + Set plannedBuilderNames) { + if (JavaLangAnalyser.findAnnotation(target, Ignore4BuilderGeneration.class).isPresent()) { + context.warning( + holder, + "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - opted out via @Ignore4BuilderGeneration", + target.getQualifiedName(), + holder.getSimpleName()); + return Optional.empty(); + } + if (!config.builderGenerationPackages().isEmpty() + && !config.builderGenerationPackages().includes(builderPackage)) { + context.warning( + holder, + "simple-builders: @SimpleBuilderFor on '%s' generates builder for '%s' in package '%s', which is outside builderGenerationPackages - the explicit declaration takes precedence", + holder.getSimpleName(), + target.getQualifiedName(), + builderPackage); + } + String builderName = builderQualifiedName(target, builderPackage, config); + if (!plannedBuilderNames.add(builderName)) { + context.warning( + holder, + "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", + target.getQualifiedName(), + holder.getSimpleName(), + builderName); + return Optional.empty(); + } + return Optional.of(new ElementToGenerate(target, config, holder)); + } + /** * Reads the {@code value} attribute of a {@code @SimpleBuilderFor} annotation mirror and resolves * each entry to the {@link TypeElement} the builder is generated for. */ private List extractExternalTargetTypes(Element holder, AnnotationMirror mirror) throws BuilderException { - List targets = new ArrayList<>(); - for (Map.Entry entry : + AnnotationValue valueAttribute = null; + for (Map.Entry entry : context.getElementValuesWithDefaults(mirror).entrySet()) { - if (!entry.getKey().getSimpleName().contentEquals("value")) { - continue; - } - if (!(entry.getValue().getValue() instanceof List values)) { - continue; + if (entry.getKey().getSimpleName().contentEquals("value")) { + valueAttribute = entry.getValue(); + break; } - for (Object item : values) { - Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; - Element resolved = - typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; - if (!(resolved instanceof TypeElement targetType)) { - throw new BuilderException( - holder, - "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", - typeValue, - holder.getSimpleName()); - } - targets.add(targetType); + } + List targets = new ArrayList<>(); + if (valueAttribute == null || !(valueAttribute.getValue() instanceof List values)) { + return targets; + } + for (Object item : values) { + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; + Element resolved = + typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; + if (!(resolved instanceof TypeElement targetType)) { + throw new BuilderException( + holder, + "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", + typeValue, + holder.getSimpleName()); } + targets.add(targetType); } return targets; } @@ -443,9 +459,7 @@ private int generateBuilders( // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. context.reportBasedOnStrictMode( - elementToGenerate.reportingElement(), - "simple-builders: Failed to generate builder - %s", - ex.getMessage()); + elementToGenerate.reportingElement(), MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { context.debugEndOperation(); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index 6f43a4c0..efe8818e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -24,6 +24,7 @@ package org.javahelpers.simple.builders.processor.processing; +import java.util.HashMap; import java.util.List; import java.util.Map; import javax.annotation.processing.ProcessingEnvironment; @@ -177,9 +178,11 @@ public boolean isMemberAccessibleFromBuilderPackage(Element member, Element targ * @param annotationMirror the annotation mirror to read * @return the annotation's element values keyed by their method element */ - public Map getElementValuesWithDefaults( + public Map getElementValuesWithDefaults( AnnotationMirror annotationMirror) { - return elementUtils.getElementValuesWithDefaults(annotationMirror); + Map elementValues = new HashMap<>(); + elementUtils.getElementValuesWithDefaults(annotationMirror).forEach(elementValues::put); + return elementValues; } /** From b9d0ff9d6a9bb3226d85bd4502e22a2e52b3b871 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 05:53:56 +0000 Subject: [PATCH 05/18] refactor: extract per-element planning to reduce cognitive complexity Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) 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 706c6975..be6f5648 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 @@ -263,15 +263,8 @@ private List resolveGenerationPlan( for (Element annotatedElement : sortedElements) { context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); try { - tracker.startPhase(); - BuilderConfiguration config = reader.resolveConfiguration(annotatedElement); - tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); - - if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { - continue; - } - plannedBuilderNames.add(builderQualifiedName(annotatedElement, null, config)); - elementsToGenerate.add(new ElementToGenerate(annotatedElement, config, annotatedElement)); + planAnnotatedElement(annotatedElement, reader, plannedBuilderNames, tracker) + .ifPresent(elementsToGenerate::add); } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still // generated. In opt-in strict mode they are promoted to errors that fail the build. @@ -295,6 +288,27 @@ private List resolveGenerationPlan( return elementsToGenerate; } + /** + * Resolves the configuration of one annotated element and plans its builder, or returns empty + * when the element is outside the {@code builderGenerationPackages} scope. + */ + private Optional planAnnotatedElement( + Element annotatedElement, + BuilderConfigurationReader reader, + Set plannedBuilderNames, + PerformanceTracker tracker) + throws BuilderException { + tracker.startPhase(); + BuilderConfiguration config = reader.resolveConfiguration(annotatedElement); + tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); + + if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { + return Optional.empty(); + } + plannedBuilderNames.add(builderQualifiedName(annotatedElement, null, config)); + return Optional.of(new ElementToGenerate(annotatedElement, config, annotatedElement)); + } + /** * 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 holder's @@ -397,21 +411,27 @@ private List extractExternalTargetTypes(Element holder, AnnotationM return targets; } for (Object item : values) { - Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; - Element resolved = - typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; - if (!(resolved instanceof TypeElement targetType)) { - throw new BuilderException( - holder, - "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", - typeValue, - holder.getSimpleName()); - } - targets.add(targetType); + targets.add(resolveExternalTargetType(holder, item)); } return targets; } + /** Resolves one entry of a {@code @SimpleBuilderFor} {@code value} attribute to its type. */ + private TypeElement resolveExternalTargetType(Element holder, Object item) + throws BuilderException { + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; + Element resolved = + typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; + if (!(resolved instanceof TypeElement targetType)) { + throw new BuilderException( + holder, + "Value '%s' in @SimpleBuilderFor on '%s' could not be resolved to a type", + typeValue, + holder.getSimpleName()); + } + return targetType; + } + /** Computes the qualified name of the builder a given target type would produce. */ private String builderQualifiedName( Element target, String builderPackage, BuilderConfiguration config) { From f49d03e919d4b9d2ed8b63f7396b833d58d6b4e8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:31:04 +0000 Subject: [PATCH 06/18] refactor: move conditional inside getPackageName call (sonar S9358) Co-Authored-By: Andreas Igel --- .../simple/builders/processor/BuilderProcessor.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 be6f5648..9f139d4f 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 @@ -451,10 +451,11 @@ private void registerGeneratedTypes(List elementsToGenerate) if (!(elementToGenerate.element() instanceof TypeElement targetType)) { continue; } - String builderPackage = + Element builderPackageAnchor = elementToGenerate.reportingElement() == targetType - ? context.getPackageName(targetType) - : context.getPackageName(elementToGenerate.reportingElement()); + ? targetType + : elementToGenerate.reportingElement(); + String builderPackage = context.getPackageName(builderPackageAnchor); generatedBuilders.put( targetType.getQualifiedName().toString(), new TypeName( From bea5d4c942e1b3397e2e5ed12ad77e06feabe83f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:01:34 +0000 Subject: [PATCH 07/18] feat: allow @SimpleBuilderFor on package-info.java Co-Authored-By: Andreas Igel --- README.md | 2 +- .../core/annotations/SimpleBuilderFor.java | 23 ++++++++++--------- docs/CONFIGURATION.md | 4 ++-- .../processor/SimpleBuilderForTest.java | 20 ++++++++++++++++ 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e98bafba..69ccbb76 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ Examples demonstrating special annotations and nested object relationships: A runnable example of `@SimpleBuilderFor`, which generates builders for types that cannot carry `@SimpleBuilder` themselves: -- **Holder**: [`ExternalTypeBuilders.java`](example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java) - Declares `@SimpleBuilderFor(ExternalAddress.class)`; generated builders land in the holder's package +- **Holder**: [`ExternalTypeBuilders.java`](example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java) - Declares `@SimpleBuilderFor(ExternalAddress.class)`; generated builders land in the holder's package. The annotation may also be placed in `package-info.java` to generate builders into the annotated package itself - **External type**: [`external/ExternalAddress.java`](example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java) - Plain class simulating third-party code, no annotations - **Generated Builder**: [`ExternalAddressBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java) - **Tests**: [`ExternalAddressBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.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 dfc05cb9..d3e52369 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 @@ -33,16 +33,17 @@ * Annotation to generate builders for types that cannot or should not be modified, such as classes * from third-party libraries. * - *

    Place this annotation on a dedicated holder class and list the external types in {@link - * #value()}. For every listed type a builder is generated following the same naming and generation - * conventions as for {@link SimpleBuilder} annotated classes, without changing the target type and - * without runtime reflection. - * - *

    The generated builder is placed in the package of the class carrying this annotation. Only - * members of the target type that are accessible from that package (e.g. public constructors and - * setters, or package-visible members when the holder shares the target's package) are used for - * builder generation. If no suitable construction mechanism is available, generation fails with a - * compile-time error. + *

    Place this annotation on a dedicated holder class or on the package itself (in {@code + * package-info.java}) and list the external types in {@link #value()}. For every listed type a + * builder is generated following the same naming and generation conventions as for {@link + * SimpleBuilder} annotated classes, without changing the target type and without runtime + * reflection. + * + *

    The generated builder is placed in the package of the class or package carrying this + * annotation. Only members of the target type that are accessible from that package (e.g. public + * constructors and setters, or package-visible members when the holder shares the target's package) + * are used for builder generation. If no suitable construction mechanism is available, generation + * fails with a compile-time error. * *

    Example: * @@ -95,7 +96,7 @@ * @see SimpleBuilder.Options * @see Ignore4BuilderGeneration */ -@Target(ElementType.TYPE) +@Target({ElementType.TYPE, ElementType.PACKAGE}) @Retention(RetentionPolicy.CLASS) public @interface SimpleBuilderFor { diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2f72704b..4e9daf68 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -172,7 +172,7 @@ A type marked with `@Ignore4BuilderGeneration` is treated as having **no builder ## Generating Builders for External Types -`@SimpleBuilder` has to be placed on the type itself, which is not possible for types you cannot modify - for example classes or records from third-party libraries. `@SimpleBuilderFor` covers this case: put it on a holder class in your own code and list the types a builder is generated for. +`@SimpleBuilder` has to be placed on the type itself, which is not possible for types you cannot modify - for example classes or records from third-party libraries. `@SimpleBuilderFor` covers this case: put it on a holder class in your own code (or on the package itself in `package-info.java`) and list the types a builder is generated for. ```java import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; @@ -186,7 +186,7 @@ public class ExternalBuilders { } ``` -The generated builders are placed in the package of the holder class. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted; the external type's own annotations are not consulted. +The generated builders are placed in the package of the holder class - or in the annotated package itself when the annotation is declared in `package-info.java`. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted; the external type's own annotations are not consulted. The target type must be constructible through accessible Java APIs from the holder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. `@Ignore4BuilderGeneration` targets are skipped, `@SimpleBuilderFor` is not `@Inherited`, and the holder class itself never gets a builder. 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 23719a98..165c0eee 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 @@ -27,6 +27,7 @@ import static com.google.testing.compile.CompilationSubject.assertThat; import com.google.testing.compile.Compilation; +import com.google.testing.compile.JavaFileObjects; import javax.tools.JavaFileObject; import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts; import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; @@ -296,6 +297,25 @@ public record ExternalPoint(int x, int y) {} ProcessorAsserts.assertContaining(generated, "package test;", "public ExternalPoint build()"); } + @Test + void packageInfo_GeneratesBuildersIntoAnnotatedPackage() { + JavaFileObject packageInfo = + JavaFileObjects.forSourceLines( + "test.package-info", + "@SimpleBuilderFor(ext.ExternalUser.class)", + "package test;", + "", + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor;"); + + Compilation compilation = + ProcessorTestUtils.createCompiler().compile(externalDto(), packageInfo); + + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "ExternalUserBuilder"); + ProcessorAsserts.assertContaining( + generated, "package test;", "public ExternalUserBuilder name(String name)"); + } + private static JavaFileObject externalDto() { return ProcessorTestUtils.forSource( """ From e22d6388a6b0c0c40126de205b1347d7e5229e24 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:18:31 +0000 Subject: [PATCH 08/18] fix: keep builderUsagePackages filter ahead of generated-builder lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated-in-round builders must honor the usage scope like any other candidate: restoring the original resolve() ordering — usage-scope check first, then the registered-builder lookup. The Map-based registration (registerGeneratedBuilders) stays, since @SimpleBuilderFor targets need their builder names resolved in non-default packages. Reverts the scoping example and test expectations to upstream semantics. Co-Authored-By: Andreas Igel --- README.md | 2 +- .../scoping/ScopedOwnerDtoBuilder.java | 25 +++++------------- .../scoping/ScopedOwnerDtoBuilderTest.java | 4 +-- .../analysis/BuilderScopeResolver.java | 26 +++++++++---------- .../processor/BuilderScopeResolverTest.java | 7 ++--- 5 files changed, 23 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 69ccbb76..6a7bde64 100644 --- a/README.md +++ b/README.md @@ -484,7 +484,7 @@ A runnable example demonstrating package-scoped builder generation and usage: - **Source DTO**: [`ScopedOwnerDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java) - Configures both package scopes inline and demonstrates the generation-scope, usage-scope, and out-of-scope field cases - **Trusted helper**: [`TrustedHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java) - In-generation-scope helper whose builder is referenced as a builder consumer - **Library helper**: [`library/LibraryHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java) - Annotated but outside the generation scope, so no builder exists and the owner falls back to a plain setter -- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows builder consumers for `trusted` and `sponsor` (generated in the same round, always trusted) and a plain setter for `library` +- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows the consumer overload for `trusted` and plain setters for `library` and `sponsor` - **Tests**: [`ScopedOwnerDtoBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java) - Asserts the generated API shape These examples serve as both documentation and integration tests for the annotation processor. diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java index 9557e1d1..d098f5fb 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java @@ -14,7 +14,6 @@ import org.javahelpers.simple.builders.core.util.BuilderToStringStyle; import org.javahelpers.simple.builders.core.util.TrackedValue; import org.javahelpers.simple.builders.example.SponsorDto; -import org.javahelpers.simple.builders.example.SponsorDtoBuilder; import org.javahelpers.simple.builders.example.library.LibraryHelperDto; /** @@ -33,7 +32,6 @@ * .library(LibraryHelperDto::new) * .sponsor(new SponsorDto()) * .sponsor(SponsorDto::new) - * .sponsor(sponsorDtoBuilder -> sponsorDtoBuilder) * .trusted(new TrustedHelperDto()) * .trusted(TrustedHelperDto::new) * .trusted(trustedHelperDtoBuilder -> trustedHelperDtoBuilder) @@ -182,25 +180,17 @@ public ScopedOwnerDtoBuilder sponsor(SponsorDto sponsor) { } /** - * Sets the value for sponsor using a builder consumer that produces the value. + * Sets the value for sponsor by executing the provided consumer. *

    * Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)} * - *

    Example:

    - * - *
    {@code
    -   * builder.sponsor(sponsorDtoBuilder -> sponsorDtoBuilder);
    -   * }
    - * - * @param sponsorBuilderConsumer consumer providing an instance of a builder for sponsor + * @param sponsorConsumer consumer providing an instance of sponsor * @return current instance of builder */ - public ScopedOwnerDtoBuilder sponsor(Consumer sponsorBuilderConsumer) { - SponsorDtoBuilder builder = this.sponsor.isSet() - ? new SponsorDtoBuilder(this.sponsor.value()) - : new SponsorDtoBuilder(); - sponsorBuilderConsumer.accept(builder); - this.sponsor = changedValue(builder.build()); + public ScopedOwnerDtoBuilder sponsor(Consumer sponsorConsumer) { + SponsorDto consumer = this.sponsor.isSet() ? this.sponsor.value() : new SponsorDto(); + sponsorConsumer.accept(consumer); + this.sponsor = changedValue(consumer); return this; } @@ -227,8 +217,7 @@ public ScopedOwnerDtoBuilder sponsor(Supplier sponsorSupplier) { * Updates the current value of sponsor in place by applying the given operator, instead of reading it * out, changing it and setting it again. Useful for adjustments relative to the current value, e.g. trimming, * upper-casing, clamping or incrementing, and in combination with the With copy-and-modify flow. The - * value must have been set before (directly or via an existing instance). For changing multiple values of a nested - * DTO, prefer the builder-consumer helper {@link #sponsor(Consumer)}. + * value must have been set before (directly or via an existing instance). *

    * Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)} * diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java index 7fa021f5..f7c6bee8 100644 --- a/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java +++ b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java @@ -42,12 +42,10 @@ class ScopedOwnerDtoBuilderTest { @Test void exposesScopedBuilderConsumerOverloads() { assertTrue(hasBuilderConsumerMethod("trusted", TrustedHelperDtoBuilder.class.getName())); - // SponsorDto's builder is generated in the same compilation, so it is trusted and used - // regardless of the usage scope - assertTrue(hasBuilderConsumerMethod("sponsor", SponsorDtoBuilder.class.getName())); assertFalse( hasBuilderConsumerMethod( "library", "org.javahelpers.simple.builders.example.library.LibraryHelperDtoBuilder")); + assertFalse(hasBuilderConsumerMethod("sponsor", SponsorDtoBuilder.class.getName())); assertTrue(hasMethod("trusted", TrustedHelperDto.class)); assertTrue(hasMethod("library", LibraryHelperDto.class)); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 5f7afb97..c04852d8 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -78,14 +78,13 @@ public BuilderScopeResolver(ProcessingContext context) { *

    The decision follows these rules: * *

      - *
    1. If the referenced type's builder is generated in the current processing round (registered - * via {@link #registerGeneratedTypes} or {@link #registerGeneratedBuilders}), the - * registered builder name is returned immediately — trusted without a classpath lookup or - * contract check, and exempt from the usage scope: a builder this processor generates is - * always used. *
    2. If the usage scope is set and the referenced type's package is not in it, no builder may * be referenced. The usage scope includes generation-scope packages automatically. When the * scope is empty, any package is allowed (backward compatibility). + *
    3. If the referenced type's builder is generated in the current processing round (registered + * via {@link #registerGeneratedTypes} or {@link #registerGeneratedBuilders}), the + * registered builder name is returned immediately — trusted without a classpath lookup or + * contract check. *
    4. Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: a constructor accepting @@ -178,15 +177,6 @@ private Optional resolve(TypeElement referencedType) { String referencedTypeFqn = referencedType.getQualifiedName().toString(); String packageName = context.getPackageName(referencedType); - // Types whose builders are generated in the current processing round are trusted - // immediately — our own generators always produce the builder contract, so no - // classpath lookup or contract check is needed. Being generated by this processor also - // makes them exempt from the usage scope: an explicitly generated builder is always used. - TypeName generatedBuilder = generatedBuilderTypes.get(referencedTypeFqn); - if (generatedBuilder != null) { - return Optional.of(generatedBuilder); - } - // The usage scope determines whether a type is eligible to be referenced as a builder // helper. When empty, any package is allowed (backward compatibility). When set, only // packages in the scope qualify. The scope already includes generation-scope packages. @@ -194,6 +184,14 @@ private Optional resolve(TypeElement referencedType) { return Optional.empty(); } + // Types whose builders are generated in the current processing round are trusted + // immediately — our own generators always produce the builder contract, so no + // classpath lookup or contract check is needed. + TypeName generatedBuilder = generatedBuilderTypes.get(referencedTypeFqn); + if (generatedBuilder != null) { + return Optional.of(generatedBuilder); + } + // For types not generated in this round, look up the candidate on the classpath using // builderUsageSuffix (which falls back to builderSuffix if not configured) and verify // the builder contract. diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 0aad523f..4a8d6206 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -118,11 +118,8 @@ public class LibHelper { public LibHelper() {} } assertThat(compilation).succeeded(); // With usage scope "other" (not "lib") and no registration → empty assertEquals(Optional.empty(), ResolverProbeProcessor.beforeRegistration); - // Registered types are trusted and exempt from the usage scope — a builder this - // processor generates is always used - assertEquals( - "lib.LibHelperBuilder", - ResolverProbeProcessor.afterRegistration.get().getFullQualifiedName()); + // Registration alone is not enough — the type must also be in the usage scope + assertEquals(Optional.empty(), ResolverProbeProcessor.afterRegistration); // With usage scope "lib" but no registration → empty (builder not on classpath) assertEquals(Optional.empty(), ResolverProbeProcessor.usageBeforeRegistration); // With usage scope "lib" AND registration → builder resolved From a7f82e679c93c62f81172c0e2c685212e9ded260 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:07:05 +0000 Subject: [PATCH 09/18] refactor: address review - package-info examples, explicit declaration wins, API cleanup - Example + docs now declare @SimpleBuilderFor on package-info.java (holder class renamed to ExternalBuildersProvider in javadoc examples) - @Ignore4BuilderGeneration on an explicitly listed target no longer suppresses generation: the external type's own annotations are not consulted; docs and test updated accordingly - Round-start logging counts both element kinds identically; the 'nothing found' message only appears when both are empty - resolveExternalConfiguration(Element) locates the annotation mirror itself; the unreachable orElseThrow guard is dropped - BuilderScopeResolver offers a single registerGeneratedBuilders(Map) entry point - builderPackageOf always returns a concrete package (the reporting element's package), so the builder-package slot in ProcessingContext never carries a null sentinel; getBuilderPackageName/ isMemberAccessibleFromBuilderPackage simplified Co-Authored-By: Andreas Igel --- README.md | 2 +- .../core/annotations/SimpleBuilderFor.java | 26 +++---- docs/CONFIGURATION.md | 17 +++-- docs/CONTRIBUTING.md | 2 +- docs/DEBUG_LOGGING.md | 5 +- ...nalTypeBuilders.java => package-info.java} | 16 ++--- .../builders/processor/BuilderProcessor.java | 72 +++++++------------ .../analysis/BuilderScopeResolver.java | 40 ++++------- .../processor/analysis/JavaLangAnalyser.java | 8 +-- .../BuilderConfigurationReader.java | 21 +++--- .../processing/BuilderDefinitionCreator.java | 8 +-- .../processing/ProcessingContext.java | 27 +++---- .../processor/BuilderProcessorTest.java | 5 +- .../processor/BuilderScopeResolverTest.java | 12 ++-- .../processor/SimpleBuilderForTest.java | 11 +-- 15 files changed, 117 insertions(+), 155 deletions(-) rename example/src/main/java/org/javahelpers/simple/builders/example/{ExternalTypeBuilders.java => package-info.java} (83%) diff --git a/README.md b/README.md index 6a7bde64..9f306af1 100644 --- a/README.md +++ b/README.md @@ -472,7 +472,7 @@ Examples demonstrating special annotations and nested object relationships: A runnable example of `@SimpleBuilderFor`, which generates builders for types that cannot carry `@SimpleBuilder` themselves: -- **Holder**: [`ExternalTypeBuilders.java`](example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java) - Declares `@SimpleBuilderFor(ExternalAddress.class)`; generated builders land in the holder's package. The annotation may also be placed in `package-info.java` to generate builders into the annotated package itself +- **Declaration**: [`package-info.java`](example/src/main/java/org/javahelpers/simple/builders/example/package-info.java) - Declares `@SimpleBuilderFor(ExternalAddress.class)` on the package; generated builders land in the annotated package (a holder class may be used instead) - **External type**: [`external/ExternalAddress.java`](example/src/main/java/org/javahelpers/simple/builders/example/external/ExternalAddress.java) - Plain class simulating third-party code, no annotations - **Generated Builder**: [`ExternalAddressBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/ExternalAddressBuilder.java) - **Tests**: [`ExternalAddressBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/ExternalAddressBuilderTest.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 d3e52369..80bb0560 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 @@ -43,23 +43,23 @@ * annotation. Only members of the target type that are accessible from that package (e.g. public * constructors and setters, or package-visible members when the holder shares the target's package) * are used for builder generation. If no suitable construction mechanism is available, generation - * fails with a compile-time error. + * fails with a compile-time error. The target type's own annotations are not consulted - the + * explicit declaration wins, so even an {@link Ignore4BuilderGeneration} on the target does not + * suppress generation. * - *

      Example: + *

      Example, declared on the package in {@code package-info.java} (generates the builder into + * {@code com.example}): * *

      {@code
      - * package com.vendor.api;
      - *
      - * public class ExternalUser {
      - *     public ExternalUser(String name, String email) {
      - *         // ...
      - *     }
      - * }
      - *
      + * @SimpleBuilderFor(ExternalUser.class)
        * package com.example;
      + * }
      + * + *

      or on a dedicated provider class: * + *

      {@code
        * @SimpleBuilderFor(ExternalUser.class)
      - * class ExternalBuilders {
      + * public class ExternalBuildersProvider {
        * }
        *
        * // Generated usage:
      @@ -74,7 +74,7 @@
        *
        * 
      {@code
        * @SimpleBuilderFor({ExternalUser.class, ExternalOrder.class})
      - * class ExternalBuilders {
      + * public class ExternalBuildersProvider {
        * }
        * }
      * @@ -88,7 +88,7 @@ * builderSuffix = "Factory" * ) * ) - * class ExternalBuilders { + * public class ExternalBuildersProvider { * } * }
      * diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 4e9daf68..e5c5a008 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -172,23 +172,22 @@ A type marked with `@Ignore4BuilderGeneration` is treated as having **no builder ## Generating Builders for External Types -`@SimpleBuilder` has to be placed on the type itself, which is not possible for types you cannot modify - for example classes or records from third-party libraries. `@SimpleBuilderFor` covers this case: put it on a holder class in your own code (or on the package itself in `package-info.java`) and list the types a builder is generated for. +`@SimpleBuilder` has to be placed on the type itself, which is not possible for types you cannot modify - for example classes or records from third-party libraries. `@SimpleBuilderFor` covers this case: declare it on the package itself in `package-info.java` (or on a dedicated provider class in your own code) and list the types a builder is generated for. ```java -import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; -import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; - +// package-info.java - generates ExternalUserFactory and ExternalOrderFactory into this package @SimpleBuilderFor( value = {ExternalUser.class, ExternalOrder.class}, options = @SimpleBuilder.Options(builderSuffix = "Factory")) -public class ExternalBuilders { - // Generates ExternalUserFactory and ExternalOrderFactory into this package -} +package com.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; ``` -The generated builders are placed in the package of the holder class - or in the annotated package itself when the annotation is declared in `package-info.java`. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted; the external type's own annotations are not consulted. +The generated builders are placed in the annotated package - or in the package of the provider class when a class is annotated instead. `options` reuses `@SimpleBuilder.Options` and is optional - compiler defaults apply when omitted. -The target type must be constructible through accessible Java APIs from the holder's package: it needs a visible type and an accessible constructor, otherwise generation fails with a compile-time diagnostic. `@Ignore4BuilderGeneration` targets are skipped, `@SimpleBuilderFor` is not `@Inherited`, and the holder class itself never gets a builder. +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. ## Compiler Options diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 59d666fe..e9ad2a4a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -241,7 +241,7 @@ For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). --- NOTES --- [DEBUG] simple-builders: Processing round started. [DEBUG] simple-builders: Found 1 annotated elements. -[DEBUG] simple-builders: No @SimpleBuilderFor types detected. +[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. [DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the builderGenerationPackages scope. [DEBUG] Processing element: Project [DEBUG] ├─ Extracting builder definition from: test.Project diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index 696af3cc..768f863d 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -93,7 +93,7 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] simple-builders: PROCESSING ROUND START [INFO] [DEBUG] simple-builders: Processing round started. [INFO] [DEBUG] simple-builders: Found 3 annotated elements. -[INFO] [DEBUG] simple-builders: No @SimpleBuilderFor types detected. +[INFO] [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. [INFO] [DEBUG] simple-builders: 3 of 3 annotated element(s) are inside the builderGenerationPackages scope. [INFO] [DEBUG] Processing element: PersonDto [INFO] [DEBUG] ├─ Extracting builder definition from: org.example.PersonDto @@ -130,7 +130,8 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] simple-builders: PROCESSING ROUND START [INFO] [DEBUG] simple-builders: Processing round started. [INFO] [DEBUG] simple-builders: Found 0 annotated elements. -[INFO] [DEBUG] simple-builders: No @SimpleBuilderFor types detected. +[INFO] [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. +[INFO] [DEBUG] simple-builders: No elements to process. [INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. ``` diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java b/example/src/main/java/org/javahelpers/simple/builders/example/package-info.java similarity index 83% rename from example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java rename to example/src/main/java/org/javahelpers/simple/builders/example/package-info.java index f3d50841..7775d8b4 100644 --- a/example/src/main/java/org/javahelpers/simple/builders/example/ExternalTypeBuilders.java +++ b/example/src/main/java/org/javahelpers/simple/builders/example/package-info.java @@ -22,17 +22,13 @@ * SOFTWARE. */ -package org.javahelpers.simple.builders.example; - -import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; -import org.javahelpers.simple.builders.example.external.ExternalAddress; - /** - * Holder class declaring builders for types that cannot carry {@code @SimpleBuilder} themselves - - * for example classes from third-party libraries. - * - *

      The generated builders are placed in the package of this holder class, here {@code + * Declares builders for types that cannot carry {@code @SimpleBuilder} themselves - for example + * classes from third-party libraries. The generated builders are placed in this package, {@code * org.javahelpers.simple.builders.example}. */ @SimpleBuilderFor(ExternalAddress.class) -public class ExternalTypeBuilders {} +package org.javahelpers.simple.builders.example; + +import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; +import org.javahelpers.simple.builders.example.external.ExternalAddress; 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 9f139d4f..d4d4b4b8 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 @@ -155,12 +155,11 @@ public boolean process(Set annotations, RoundEnvironment context.debug("simple-builders: Processing round started."); context.debug("simple-builders: Found %d annotated elements.", elementsToProcess.size()); - if (externalTypeHolders.isEmpty()) { - context.debug("simple-builders: No @SimpleBuilderFor types detected."); - } else { - context.debug( - "simple-builders: Found %d type(s) for generation with @SimpleBuilderFor.", - externalTypeHolders.size()); + context.debug( + "simple-builders: Found %d type(s) for generation with @SimpleBuilderFor.", + externalTypeHolders.size()); + if (elementsToProcess.isEmpty() && externalTypeHolders.isEmpty()) { + context.debug("simple-builders: No elements to process."); } // Resolve configuration and apply generation scopes before processing any builder. This lets @@ -320,14 +319,7 @@ private List resolveExternalTypeTargets( Set plannedBuilderNames, PerformanceTracker tracker) throws BuilderException { - AnnotationMirror simpleBuilderForMirror = - JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class) - .orElseThrow( - () -> - new BuilderException( - holder, "No @SimpleBuilderFor annotation found on '%s'", holder)); - - List targets = extractExternalTargetTypes(holder, simpleBuilderForMirror); + List targets = extractExternalTargetTypes(holder); if (targets.isEmpty()) { context.warning( holder, @@ -337,8 +329,7 @@ private List resolveExternalTypeTargets( } tracker.startPhase(); - BuilderConfiguration config = - reader.resolveExternalConfiguration(holder, simpleBuilderForMirror); + BuilderConfiguration config = reader.resolveExternalConfiguration(holder); tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); String builderPackage = context.getPackageName(holder); @@ -353,8 +344,9 @@ private List resolveExternalTypeTargets( /** * Plans a builder for a single type listed in {@code @SimpleBuilderFor}, or reports on the holder * why no builder is generated for it. An explicit declaration always generates a builder - the - * {@code builderGenerationPackages} scope only filters annotated types, so a scope that would - * exclude an explicitly named type is a contradictory configuration and only warns. + * target type's own annotations (such as {@code @Ignore4BuilderGeneration}) are not consulted, + * and the {@code builderGenerationPackages} scope only filters annotated types, so a scope that + * would exclude an explicitly named type is a contradictory configuration and only warns. */ private Optional planExternalTarget( TypeElement target, @@ -362,14 +354,6 @@ private Optional planExternalTarget( BuilderConfiguration config, String builderPackage, Set plannedBuilderNames) { - if (JavaLangAnalyser.findAnnotation(target, Ignore4BuilderGeneration.class).isPresent()) { - context.warning( - holder, - "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - opted out via @Ignore4BuilderGeneration", - target.getQualifiedName(), - holder.getSimpleName()); - return Optional.empty(); - } if (!config.builderGenerationPackages().isEmpty() && !config.builderGenerationPackages().includes(builderPackage)) { context.warning( @@ -393,20 +377,24 @@ private Optional planExternalTarget( } /** - * Reads the {@code value} attribute of a {@code @SimpleBuilderFor} annotation mirror and resolves - * each entry to the {@link TypeElement} the builder is generated for. + * 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. */ - private List extractExternalTargetTypes(Element holder, AnnotationMirror mirror) - throws BuilderException { + private List extractExternalTargetTypes(Element holder) throws BuilderException { + List targets = new ArrayList<>(); + Optional mirror = + JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class); + if (mirror.isEmpty()) { + return targets; + } AnnotationValue valueAttribute = null; for (Map.Entry entry : - context.getElementValuesWithDefaults(mirror).entrySet()) { + context.getElementValuesWithDefaults(mirror.get()).entrySet()) { if (entry.getKey().getSimpleName().contentEquals("value")) { valueAttribute = entry.getValue(); break; } } - List targets = new ArrayList<>(); if (valueAttribute == null || !(valueAttribute.getValue() instanceof List values)) { return targets; } @@ -446,20 +434,15 @@ private String builderQualifiedName( * differ from the target's package for {@code @SimpleBuilderFor} targets. */ private void registerGeneratedTypes(List elementsToGenerate) { - Map generatedBuilders = new HashMap<>(); + Map generatedBuilders = new HashMap<>(); for (ElementToGenerate elementToGenerate : elementsToGenerate) { if (!(elementToGenerate.element() instanceof TypeElement targetType)) { continue; } - Element builderPackageAnchor = - elementToGenerate.reportingElement() == targetType - ? targetType - : elementToGenerate.reportingElement(); - String builderPackage = context.getPackageName(builderPackageAnchor); generatedBuilders.put( - targetType.getQualifiedName().toString(), + new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()), new TypeName( - builderPackage, + context.getPackageName(elementToGenerate.reportingElement()), targetType.getSimpleName() + elementToGenerate.config().getBuilderSuffix())); } context.getBuilderScopeResolver().registerGeneratedBuilders(generatedBuilders); @@ -564,13 +547,12 @@ private record ElementToGenerate( Element element, BuilderConfiguration config, Element reportingElement) {} /** - * The package the builder is generated into: the holder's package for {@code @SimpleBuilderFor} - * targets, {@code null} (meaning the target's own package) for directly annotated types. + * The package the builder is generated into: the package of the element the generation is + * reported on - the {@code @SimpleBuilderFor} holder for external types, the annotated type + * itself otherwise. */ private String builderPackageOf(ElementToGenerate elementToGenerate) { - return elementToGenerate.reportingElement() == elementToGenerate.element() - ? null - : context.getPackageName(elementToGenerate.reportingElement()); + return context.getPackageName(elementToGenerate.reportingElement()); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index c04852d8..029b5c04 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -23,7 +23,6 @@ */ package org.javahelpers.simple.builders.processor.analysis; -import java.util.Collection; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -82,9 +81,8 @@ public BuilderScopeResolver(ProcessingContext context) { * be referenced. The usage scope includes generation-scope packages automatically. When the * scope is empty, any package is allowed (backward compatibility). *

    5. If the referenced type's builder is generated in the current processing round (registered - * via {@link #registerGeneratedTypes} or {@link #registerGeneratedBuilders}), the - * registered builder name is returned immediately — trusted without a classpath lookup or - * contract check. + * via {@link #registerGeneratedBuilders}), the registered builder name is returned + * immediately — trusted without a classpath lookup or contract check. *
    6. Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: a constructor accepting @@ -140,32 +138,20 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu } /** - * Registers the types whose builders are generated in the current processing round. + * Registers the builders generated for the given target types in the current processing round. * - * @param generatedTypes types whose builders will be generated in this round - */ - public void registerGeneratedTypes(Collection generatedTypes) { - Map registeredTypes = new HashMap<>(); - for (TypeElement generatedType : generatedTypes) { - registeredTypes.put( - generatedType.getQualifiedName().toString(), - JavaLangMapper.createBuilderTypeName( - generatedType, context, context.getConfiguration().getBuilderSuffix())); - } - registerGeneratedBuilders(registeredTypes); - } - - /** - * Registers the builder type names generated for the given target types in the current processing - * round. Use this overload when the generated builder does not follow the default naming in the - * target type's own package - for example for {@code @SimpleBuilderFor} targets, whose builders - * are generated in the package of the annotated holder class. + *

      The value is the generated builder's type name, which may differ from the default naming in + * the target type's own package - for example for {@code @SimpleBuilderFor} targets, whose + * builders are generated in the package of the annotated holder class. * - * @param generatedBuilders map from target type qualified name to the generated builder's type - * name + * @param generatedBuilders map from target type name to the generated builder's type name */ - public void registerGeneratedBuilders(Map generatedBuilders) { - generatedBuilderTypes = new HashMap<>(generatedBuilders); + public void registerGeneratedBuilders(Map generatedBuilders) { + Map registeredBuilders = new HashMap<>(); + for (Map.Entry entry : generatedBuilders.entrySet()) { + registeredBuilders.put(entry.getKey().getFullQualifiedName(), entry.getValue()); + } + generatedBuilderTypes = registeredBuilders; resolvedBuilderTypes.clear(); } 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 ad23ad0d..71bda841 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 @@ -487,7 +487,7 @@ public static Optional findGetterForField( if (Strings.CI.equalsAny(name, fieldName, "is" + fieldName, "get" + fieldName) && candidate.getParameters().isEmpty() && context.isSameType(candidate.getReturnType(), fieldTypeMirror) - && context.isMemberAccessibleFromBuilderPackage(candidate, dtoType)) { + && context.isMemberAccessibleFromBuilderPackage(candidate)) { return Optional.of(candidate); } } @@ -552,7 +552,7 @@ public static Optional findSetterForField( if (candidate.getSimpleName().contentEquals(setterName) && candidate.getParameters().size() == 1 && candidate.getReturnType().getKind() == VOID - && context.isMemberAccessibleFromBuilderPackage(candidate, dtoType)) { + && context.isMemberAccessibleFromBuilderPackage(candidate)) { return Optional.of(candidate); } } @@ -573,7 +573,7 @@ public static Optional findConstructorForBuilder( TypeElement annotatedType, ProcessingContext context) { List ctors = ElementFilter.constructorsIn(context.getAllMembers(annotatedType)).stream() - .filter(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor, annotatedType)) + .filter(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor)) .toList(); // First, check if any constructor is annotated with @SimpleBuilderConstructor @@ -608,6 +608,6 @@ public static Optional findConstructorForBuilder( public static boolean hasAccessibleConstructor( TypeElement typeElement, ProcessingContext context) { return ElementFilter.constructorsIn(context.getAllMembers(typeElement)).stream() - .anyMatch(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor, typeElement)); + .anyMatch(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor)); } } 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 ca0114af..a359e967 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 @@ -35,7 +35,9 @@ 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; @@ -140,26 +142,27 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder } /** - * Resolves the complete builder configuration for a type listed in {@code @SimpleBuilderFor}. + * Resolves the complete builder configuration for a {@code @SimpleBuilderFor} holder. * *

      Unlike {@link #resolveConfiguration(Element)}, no template annotations of the target type * 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 given - * {@code @SimpleBuilderFor} annotation mirror. + * defaults, the global compiler arguments, and the {@code options()} of the + * {@code @SimpleBuilderFor} annotation found on the given element. * - * @param element the element associated with this configuration (used for validation messages) - * @param simpleBuilderForMirror the {@code @SimpleBuilderFor} annotation mirror carrying the - * {@code options} attribute + * @param element the {@code @SimpleBuilderFor} holder (used for validation messages) * @return the fully resolved configuration with all sources merged */ - public BuilderConfiguration resolveExternalConfiguration( - Element element, AnnotationMirror simpleBuilderForMirror) throws BuilderException { + public BuilderConfiguration resolveExternalConfiguration(Element element) + throws BuilderException { String elementName = element.getSimpleName().toString(); logger.debugStartOperation( "Resolving configuration for @SimpleBuilderFor target: %s", elementName); - BuilderConfiguration optionsConfig = extractOptionsFromAnnotationMirror(simpleBuilderForMirror); + BuilderConfiguration optionsConfig = + JavaLangAnalyser.findAnnotation(element, SimpleBuilderFor.class) + .map(this::extractOptionsFromAnnotationMirror) + .orElse(null); BuilderConfiguration result = BuilderConfiguration.DEFAULT.merge(globalConfiguration).merge(optionsConfig); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index ee486faa..1fbe1e43 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -97,13 +97,13 @@ public static BuilderDefinitionDto extractFromElement( // The generated builder is a separate top-level class; it can only reference the target // type itself and constructors that are accessible from the builder's package. - if (!context.isMemberAccessibleFromBuilderPackage(annotatedType, annotatedType)) { + if (!context.isMemberAccessibleFromBuilderPackage(annotatedType)) { throw new BuilderException( annotatedElement, "The type '%s' is not accessible from the package '%s' its builder is generated in. " + "Only types constructible through accessible Java APIs can get a builder.", annotatedType.getQualifiedName(), - context.getBuilderPackageName(annotatedType)); + context.getBuilderPackageName()); } if (!JavaLangAnalyser.hasAccessibleConstructor(annotatedType, context)) { throw new BuilderException( @@ -458,7 +458,7 @@ private static BuilderDefinitionDto initializeBuilderDefinition( TypeElement annotatedType, ProcessingContext context) { BuilderDefinitionDto result = new BuilderDefinitionDto(); String packageName = context.getPackageName(annotatedType); - String builderPackageName = context.getBuilderPackageName(annotatedType); + String builderPackageName = context.getBuilderPackageName(); String simpleClassName = annotatedType.getSimpleName().toString(); String builderSuffix = context.getConfiguration().getBuilderSuffix(); result.setBuilderTypeName(new TypeName(builderPackageName, simpleClassName + builderSuffix)); @@ -622,7 +622,7 @@ private static boolean isMethodRelevantForBuilder( context.debug("Skipping: is static"); return false; } - if (!context.isMemberAccessibleFromBuilderPackage(mth, annotatedType)) { + if (!context.isMemberAccessibleFromBuilderPackage(mth)) { context.debug("Skipping: not accessible from the generated builder's package"); return false; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index efe8818e..9639e65e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -122,30 +122,23 @@ public BuilderConfiguration getConfiguration() { /** * Sets the package the generated builder is written to for the current processing target. * - *

      When {@code null}, the builder is generated in the package of the processed type itself (the - * default for {@code @SimpleBuilder} targets). For {@code @SimpleBuilderFor} targets the package - * of the holder class is passed, so generated builders stay in user-controlled packages even for - * types from foreign packages. + *

      For {@code @SimpleBuilder} targets this is the processed type's own package; for + * {@code @SimpleBuilderFor} targets it is the package of the holder, so generated builders stay + * in user-controlled packages even for types from foreign packages. * - * @param builderPackage the package for the generated builder, or {@code null} to use the - * processed type's own package + * @param builderPackage the package for the generated builder */ public void initBuilderPackageForProcessingTarget(String builderPackage) { - // Verbatim storage: an empty string is a valid builder package (the default package). this.builderPackageForProcessingTarget = builderPackage; } /** - * Gets the package the builder for the given target is generated in: the explicit builder package - * of the current processing target, or the target's own package when none is set. + * Gets the package the builder of the current processing target is generated in. * - * @param targetElement the type the builder is generated for * @return the qualified package name of the generated builder */ - public String getBuilderPackageName(Element targetElement) { - return builderPackageForProcessingTarget != null - ? builderPackageForProcessingTarget - : getPackageName(targetElement); + public String getBuilderPackageName() { + return builderPackageForProcessingTarget; } /** @@ -158,18 +151,16 @@ public String getBuilderPackageName(Element targetElement) { * through inheritance does not apply, as the builder does not extend the target type). * * @param member the member to check - * @param targetElement the type the builder is generated for, used to resolve the effective - * builder package when no explicit builder package is set * @return {@code true} if generated code in the builder package may call the member */ - public boolean isMemberAccessibleFromBuilderPackage(Element member, Element targetElement) { + public boolean isMemberAccessibleFromBuilderPackage(Element member) { if (member.getModifiers().contains(Modifier.PUBLIC)) { return true; } if (member.getModifiers().contains(Modifier.PRIVATE)) { return false; } - return getPackageName(member).equals(getBuilderPackageName(targetElement)); + return getPackageName(member).equals(getBuilderPackageName()); } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 8e139319..a1fda06e 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -101,7 +101,7 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "simple-builders: PROCESSING ROUND START", "[DEBUG] simple-builders: Processing round started.", "[DEBUG] simple-builders: Found 1 annotated elements.", - "[DEBUG] simple-builders: No @SimpleBuilderFor types detected.", + "[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor.", // Round 1 — configuration resolution "[DEBUG] Processing element: VerboseTest", "[DEBUG] ├─ Resolving configuration for element: VerboseTest", @@ -168,7 +168,8 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "simple-builders: PROCESSING ROUND START", "[DEBUG] simple-builders: Processing round started.", "[DEBUG] simple-builders: Found 0 annotated elements.", - "[DEBUG] simple-builders: No @SimpleBuilderFor types detected.", + "[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor.", + "[DEBUG] simple-builders: No elements to process.", "[DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the" + " builderGenerationPackages scope."); } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 4a8d6206..00a68752 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -31,7 +31,6 @@ import com.google.testing.compile.Compilation; import com.google.testing.compile.Compiler; -import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -266,8 +265,9 @@ public boolean process(Set annotations, RoundEnvironment context.initConfigurationForProcessingTarget(configuration("lib", "Builder")); BuilderScopeResolver resolver = context.getBuilderScopeResolver(); // Register the type as generated, mirroring the real processor which calls - // registerGeneratedTypes before any resolution happens. - resolver.registerGeneratedTypes(List.of(helper)); + // registerGeneratedBuilders before any resolution happens. + resolver.registerGeneratedBuilders( + Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); first = resolver.resolveUsableBuilderType(helper); second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior @@ -277,13 +277,15 @@ public boolean process(Set annotations, RoundEnvironment context.initConfigurationForProcessingTarget(usageOnlyConfiguration("other")); beforeRegistration = resolver.resolveUsableBuilderType(helper); // Registration alone is not enough — the type must be in scope - resolver.registerGeneratedTypes(List.of(helper)); + resolver.registerGeneratedBuilders( + Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); resolver.registerGeneratedBuilders(Map.of()); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); - resolver.registerGeneratedTypes(List.of(helper)); + resolver.registerGeneratedBuilders( + Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); 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 165c0eee..fa2d85af 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 @@ -222,7 +222,7 @@ public class OrderDto { } @Test - void ignore4BuilderGenerationOnTarget_SkipsWithWarning() { + void ignore4BuilderGenerationOnTarget_ExplicitDeclarationStillGenerates() { JavaFileObject optedOut = ProcessorTestUtils.forSource( """ @@ -238,10 +238,11 @@ public OptedOut() {} ProcessorTestUtils.createCompiler() .compile(optedOut, holder("test", "Builders", "ext.OptedOut")); - assertThat(compilation).succeeded(); - assertThat(compilation).hadWarningContaining("@Ignore4BuilderGeneration"); - ProcessorAsserts.assertNoBuilderGenerated( - compilation, "OptedOut", "An opted-out type must not get a builder"); + // The explicit @SimpleBuilderFor declaration wins - the target type's own annotations + // (including @Ignore4BuilderGeneration) are not consulted + assertThat(compilation).succeededWithoutWarnings(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "OptedOutBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "OptedOutBuilder", generated); } @Test From 6ab16b28ba4a24e92969b534e06302c6c880aff2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:43:46 +0000 Subject: [PATCH 10/18] refactor: TypeName returns and drop redundant empty-round log line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - builderTypeName now returns TypeName instead of a qualified-name String; plannedBuilderNames is Set and the direct-target call site passes the type's own package (no null sentinel) - drop 'No elements to process.' — the unconditional 'Found N ...' counts already report empty rounds Co-Authored-By: Andreas Igel --- docs/DEBUG_LOGGING.md | 1 - .../builders/processor/BuilderProcessor.java | 26 ++++++++----------- .../processor/BuilderProcessorTest.java | 1 - 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index 768f863d..2a056da4 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -131,7 +131,6 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] [DEBUG] simple-builders: Processing round started. [INFO] [DEBUG] simple-builders: Found 0 annotated elements. [INFO] [DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor. -[INFO] [DEBUG] simple-builders: No elements to process. [INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. ``` 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 d4d4b4b8..67827426 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 @@ -158,9 +158,6 @@ public boolean process(Set annotations, RoundEnvironment context.debug( "simple-builders: Found %d type(s) for generation with @SimpleBuilderFor.", externalTypeHolders.size()); - if (elementsToProcess.isEmpty() && externalTypeHolders.isEmpty()) { - context.debug("simple-builders: No elements to process."); - } // Resolve configuration and apply generation scopes before processing any builder. This lets // the scope resolver know every builder that will be generated in this round. @@ -258,7 +255,7 @@ private List resolveGenerationPlan( BuilderConfigurationReader reader, PerformanceTracker tracker) { List elementsToGenerate = new ArrayList<>(); - Set plannedBuilderNames = new HashSet<>(); + Set plannedBuilderNames = new HashSet<>(); for (Element annotatedElement : sortedElements) { context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); try { @@ -294,7 +291,7 @@ private List resolveGenerationPlan( private Optional planAnnotatedElement( Element annotatedElement, BuilderConfigurationReader reader, - Set plannedBuilderNames, + Set plannedBuilderNames, PerformanceTracker tracker) throws BuilderException { tracker.startPhase(); @@ -304,7 +301,8 @@ private Optional planAnnotatedElement( if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { return Optional.empty(); } - plannedBuilderNames.add(builderQualifiedName(annotatedElement, null, config)); + plannedBuilderNames.add( + builderTypeName(annotatedElement, context.getPackageName(annotatedElement), config)); return Optional.of(new ElementToGenerate(annotatedElement, config, annotatedElement)); } @@ -316,7 +314,7 @@ private Optional planAnnotatedElement( private List resolveExternalTypeTargets( Element holder, BuilderConfigurationReader reader, - Set plannedBuilderNames, + Set plannedBuilderNames, PerformanceTracker tracker) throws BuilderException { List targets = extractExternalTargetTypes(holder); @@ -353,7 +351,7 @@ private Optional planExternalTarget( Element holder, BuilderConfiguration config, String builderPackage, - Set plannedBuilderNames) { + Set plannedBuilderNames) { if (!config.builderGenerationPackages().isEmpty() && !config.builderGenerationPackages().includes(builderPackage)) { context.warning( @@ -363,14 +361,14 @@ private Optional planExternalTarget( target.getQualifiedName(), builderPackage); } - String builderName = builderQualifiedName(target, builderPackage, config); + TypeName builderName = builderTypeName(target, builderPackage, config); if (!plannedBuilderNames.add(builderName)) { context.warning( holder, "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", target.getQualifiedName(), holder.getSimpleName(), - builderName); + builderName.getFullQualifiedName()); return Optional.empty(); } return Optional.of(new ElementToGenerate(target, config, holder)); @@ -420,12 +418,10 @@ private TypeElement resolveExternalTargetType(Element holder, Object item) return targetType; } - /** Computes the qualified name of the builder a given target type would produce. */ - private String builderQualifiedName( + /** Computes the type name of the builder a given target type would produce. */ + private TypeName builderTypeName( Element target, String builderPackage, BuilderConfiguration config) { - String packageName = builderPackage != null ? builderPackage : context.getPackageName(target); - String simpleName = target.getSimpleName() + config.getBuilderSuffix(); - return packageName.isEmpty() ? simpleName : packageName + "." + simpleName; + return new TypeName(builderPackage, target.getSimpleName() + config.getBuilderSuffix()); } /** diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index a1fda06e..b3cb6aa2 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -169,7 +169,6 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "[DEBUG] simple-builders: Processing round started.", "[DEBUG] simple-builders: Found 0 annotated elements.", "[DEBUG] simple-builders: Found 0 type(s) for generation with @SimpleBuilderFor.", - "[DEBUG] simple-builders: No elements to process.", "[DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the" + " builderGenerationPackages scope."); } From bf41a4e747eaf51fd92d66dccd32557b4698ff9b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:18:52 +0000 Subject: [PATCH 11/18] refactor: group per-target state into ProcessingTarget record Introduce ProcessingTarget (configuration + builderPackage) as the documented holder for per-target processing state. ProcessingContext now carries a single field instead of two loosely related ones, set once via initProcessingTarget before extraction. The record's javadoc explains why the builder package is captured explicitly (not derivable for @SimpleBuilderFor targets). Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 4 +- .../analysis/BuilderScopeResolver.java | 4 +- .../processing/ProcessingContext.java | 29 ++++--------- .../processing/ProcessingTarget.java | 42 +++++++++++++++++++ .../processor/BuilderScopeResolverTest.java | 17 ++++---- 5 files changed, 64 insertions(+), 32 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java 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 67827426..aba7db7c 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 @@ -71,6 +71,7 @@ import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; +import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -484,8 +485,7 @@ public SourceVersion getSupportedSourceVersion() { private void process(Element annotatedElement, BuilderConfiguration config, String builderPackage) throws BuilderException { - context.initConfigurationForProcessingTarget(config); - context.initBuilderPackageForProcessingTarget(builderPackage); + context.initProcessingTarget(new ProcessingTarget(config, builderPackage)); PerformanceTracker tracker = context.getPerformanceTracker(); // Track Builder Definition Extraction tracker.startPhase(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 029b5c04..65f9f290 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -70,7 +70,7 @@ public BuilderScopeResolver(ProcessingContext context) { *

      This method reads the configuration from the processing context via {@link * org.javahelpers.simple.builders.processor.processing.ProcessingContext#getConfiguration()}. The * caller must ensure that {@link - * org.javahelpers.simple.builders.processor.processing.ProcessingContext#initConfigurationForProcessingTarget} + * org.javahelpers.simple.builders.processor.processing.ProcessingContext#initProcessingTarget} * has been invoked with the owner element's resolved configuration beforehand, so that * per-element {@code builderUsagePackages} overrides are respected. * @@ -111,7 +111,7 @@ public Optional resolveUsableBuilderType(TypeElement referencedType) { *

      Unlike {@link #resolveUsableBuilderType(TypeElement)}, this method takes the configuration * as an explicit parameter rather than reading it from the processing context. This is because it * is called during generation-plan resolution, before {@link - * org.javahelpers.simple.builders.processor.processing.ProcessingContext#initConfigurationForProcessingTarget} + * org.javahelpers.simple.builders.processor.processing.ProcessingContext#initProcessingTarget} * has been invoked for the element, so the context does not yet hold the per-element * configuration. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java index 9639e65e..d053a403 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingContext.java @@ -66,8 +66,7 @@ public final class ProcessingContext { private final String formatterProfile; private final BuilderScopeResolver builderScopeResolver; private GeneratorRegistry generatorRegistry; - private BuilderConfiguration configurationForProcessingTarget; - private String builderPackageForProcessingTarget; + private ProcessingTarget processingTarget; /** * Creates a new processing context. @@ -102,12 +101,13 @@ public ProcessingContext( } /** - * Initializes the configuration for the current processing target. + * Initializes the per-target state ({@link ProcessingTarget}) for the type whose builder is + * currently being generated. Called once per target before extraction starts. * - * @param config the builder configuration for the target being processed + * @param processingTarget the resolved configuration and builder package of the current target */ - public void initConfigurationForProcessingTarget(BuilderConfiguration config) { - this.configurationForProcessingTarget = config; + public void initProcessingTarget(ProcessingTarget processingTarget) { + this.processingTarget = processingTarget; } /** @@ -116,20 +116,7 @@ public void initConfigurationForProcessingTarget(BuilderConfiguration config) { * @return the builder configuration for the target being processed */ public BuilderConfiguration getConfiguration() { - return this.configurationForProcessingTarget; - } - - /** - * Sets the package the generated builder is written to for the current processing target. - * - *

      For {@code @SimpleBuilder} targets this is the processed type's own package; for - * {@code @SimpleBuilderFor} targets it is the package of the holder, so generated builders stay - * in user-controlled packages even for types from foreign packages. - * - * @param builderPackage the package for the generated builder - */ - public void initBuilderPackageForProcessingTarget(String builderPackage) { - this.builderPackageForProcessingTarget = builderPackage; + return this.processingTarget.configuration(); } /** @@ -138,7 +125,7 @@ public void initBuilderPackageForProcessingTarget(String builderPackage) { * @return the qualified package name of the generated builder */ public String getBuilderPackageName() { - return builderPackageForProcessingTarget; + return this.processingTarget.builderPackage(); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java new file mode 100644 index 00000000..4ec0d01a --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/ProcessingTarget.java @@ -0,0 +1,42 @@ +/* + * 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.processor.processing; + +import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; + +/** + * Per-target processing state for the type whose builder is currently being generated. + * + *

      {@link org.javahelpers.simple.builders.processor.BuilderProcessor} sets one instance per + * processed type on the {@link ProcessingContext} before extraction starts, so downstream analysis + * can reach it without threading both values through every call. + * + * @param configuration the resolved builder configuration for the current target + * @param builderPackage the package the generated builder is written to. For {@code @SimpleBuilder} + * targets this is the processed type's own package; for {@code @SimpleBuilderFor} targets it is + * the holder's package. The latter cannot be derived from the processed element itself, which + * is why it is carried explicitly here + */ +public record ProcessingTarget(BuilderConfiguration configuration, String builderPackage) {} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 00a68752..60d85abc 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -42,6 +42,7 @@ import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; +import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; import org.junit.jupiter.api.Test; @@ -262,7 +263,7 @@ public boolean process(Set annotations, RoundEnvironment ProcessingContext context = new ProcessingContext( new ProcessingLogger(processingEnv), BuilderConfiguration.DEFAULT, processingEnv); - context.initConfigurationForProcessingTarget(configuration("lib", "Builder")); + context.initProcessingTarget(new ProcessingTarget(configuration("lib", "Builder"), "")); BuilderScopeResolver resolver = context.getBuilderScopeResolver(); // Register the type as generated, mirroring the real processor which calls // registerGeneratedBuilders before any resolution happens. @@ -272,30 +273,32 @@ public boolean process(Set annotations, RoundEnvironment second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior resolver.registerGeneratedBuilders(Map.of()); - context.initConfigurationForProcessingTarget(configuration("other", "OtherBuilder")); + context.initProcessingTarget( + new ProcessingTarget(configuration("other", "OtherBuilder"), "")); afterConfigurationChange = resolver.resolveUsableBuilderType(helper); - context.initConfigurationForProcessingTarget(usageOnlyConfiguration("other")); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("other"), "")); beforeRegistration = resolver.resolveUsableBuilderType(helper); // Registration alone is not enough — the type must be in scope resolver.registerGeneratedBuilders( Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests - context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); resolver.registerGeneratedBuilders(Map.of()); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); resolver.registerGeneratedBuilders( Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only - context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); resolver.registerGeneratedBuilders(Map.of()); usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper); // Usage scope with builderUsageSuffix="Factory" - context.initConfigurationForProcessingTarget(usageWithSuffixConfiguration("lib", "Factory")); + context.initProcessingTarget( + new ProcessingTarget(usageWithSuffixConfiguration("lib", "Factory"), "")); usageWithSuffix = resolver.resolveUsableBuilderType(helper); // Usage scope with default suffix (no builderUsageSuffix configured) - context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib")); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); usageDefaultSuffix = resolver.resolveUsableBuilderType(helper); captured = true; return false; From 8d9fea9bc105d905b91478cfbc9f34a5b996e219 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:26:28 +0000 Subject: [PATCH 12/18] refactor: move generated-builder registrations into GeneratedBuilders holder The target->builder-name mapping now lives in a dedicated GeneratedBuilders registry (analysis package) with add/find/clear helpers and javadoc documenting why the builder TypeName is stored explicitly (holder-package builders for @SimpleBuilderFor). BuilderScopeResolver owns it and exposes it via generatedBuilders(); mutations clear the resolution cache through an onChange callback. Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 8 +- .../analysis/BuilderScopeResolver.java | 32 ++++--- .../processor/analysis/GeneratedBuilders.java | 83 +++++++++++++++++++ .../processor/BuilderScopeResolverTest.java | 24 +++--- 4 files changed, 114 insertions(+), 33 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java 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 aba7db7c..fb59f8c2 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 @@ -34,7 +34,6 @@ import com.google.auto.service.AutoService; import java.util.ArrayList; import java.util.Comparator; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -55,6 +54,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.processor.analysis.GeneratedBuilders; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -431,18 +431,18 @@ private TypeName builderTypeName( * differ from the target's package for {@code @SimpleBuilderFor} targets. */ private void registerGeneratedTypes(List elementsToGenerate) { - Map generatedBuilders = new HashMap<>(); + GeneratedBuilders generatedBuilders = context.getBuilderScopeResolver().generatedBuilders(); + generatedBuilders.clear(); for (ElementToGenerate elementToGenerate : elementsToGenerate) { if (!(elementToGenerate.element() instanceof TypeElement targetType)) { continue; } - generatedBuilders.put( + generatedBuilders.add( new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()), new TypeName( context.getPackageName(elementToGenerate.reportingElement()), targetType.getSimpleName() + elementToGenerate.config().getBuilderSuffix())); } - context.getBuilderScopeResolver().registerGeneratedBuilders(generatedBuilders); } /** Generates a builder for each planned element and returns the number of successes. */ diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 65f9f290..f4729733 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -52,8 +52,8 @@ public final class BuilderScopeResolver { private final ProcessingContext context; private BuilderConfiguration cachedConfiguration; private PackageScopes usagePackages = PackageScopes.unscoped(); - private Map generatedBuilderTypes = Map.of(); private final Map> resolvedBuilderTypes = new HashMap<>(); + private final GeneratedBuilders generatedBuilders; /** * Creates a new resolver for the given processing context. @@ -62,6 +62,7 @@ public final class BuilderScopeResolver { */ public BuilderScopeResolver(ProcessingContext context) { this.context = context; + this.generatedBuilders = new GeneratedBuilders(resolvedBuilderTypes::clear); } /** @@ -81,8 +82,8 @@ public BuilderScopeResolver(ProcessingContext context) { * be referenced. The usage scope includes generation-scope packages automatically. When the * scope is empty, any package is allowed (backward compatibility). *

    7. If the referenced type's builder is generated in the current processing round (registered - * via {@link #registerGeneratedBuilders}), the registered builder name is returned - * immediately — trusted without a classpath lookup or contract check. + * via {@link #generatedBuilders()}), the registered builder name is returned immediately — + * trusted without a classpath lookup or contract check. *
    8. Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: a constructor accepting @@ -138,21 +139,16 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu } /** - * Registers the builders generated for the given target types in the current processing round. + * Returns the registry of builders generated in the current processing round. * - *

      The value is the generated builder's type name, which may differ from the default naming in - * the target type's own package - for example for {@code @SimpleBuilderFor} targets, whose - * builders are generated in the package of the annotated holder class. + *

      The processor adds an entry per planned builder before resolution starts, so generated + * builders are trusted without a classpath lookup. Mutations invalidate the resolver's per-type + * resolution cache automatically. * - * @param generatedBuilders map from target type name to the generated builder's type name + * @return the generated-builders registry */ - public void registerGeneratedBuilders(Map generatedBuilders) { - Map registeredBuilders = new HashMap<>(); - for (Map.Entry entry : generatedBuilders.entrySet()) { - registeredBuilders.put(entry.getKey().getFullQualifiedName(), entry.getValue()); - } - generatedBuilderTypes = registeredBuilders; - resolvedBuilderTypes.clear(); + public GeneratedBuilders generatedBuilders() { + return generatedBuilders; } private Optional resolve(TypeElement referencedType) { @@ -173,9 +169,9 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - TypeName generatedBuilder = generatedBuilderTypes.get(referencedTypeFqn); - if (generatedBuilder != null) { - return Optional.of(generatedBuilder); + Optional generatedBuilder = generatedBuilders.findBuilder(referencedTypeFqn); + if (generatedBuilder.isPresent()) { + return generatedBuilder; } // For types not generated in this round, look up the candidate on the classpath using diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java new file mode 100644 index 00000000..85b51fde --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java @@ -0,0 +1,83 @@ +/* + * 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.processor.analysis; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.javahelpers.simple.builders.processor.model.type.TypeName; + +/** + * Registry of the builders generated in the current annotation-processing round. + * + *

      Maps each target type's qualified name to the {@link TypeName} of the builder being generated + * for it. The builder name is stored explicitly because it cannot always be derived from the target + * type: {@code @SimpleBuilderFor} targets generate into the holder's package (and may use the + * holder's builder suffix), so the generated builder may live in a different package than the + * default naming convention would suggest. + * + *

      Every mutation notifies the owning {@link BuilderScopeResolver} via the {@code onChange} + * callback so its cached per-type resolutions are dropped and stale builders are never served. + */ +public final class GeneratedBuilders { + private final Map buildersByTargetFqn = new HashMap<>(); + private final Runnable onChange; + + /** + * Creates an empty registry. + * + * @param onChange callback invoked whenever registrations change (add or clear), so the owner can + * invalidate dependent caches + */ + public GeneratedBuilders(Runnable onChange) { + this.onChange = onChange; + } + + /** + * Registers the builder generated in this round for the given target type. + * + * @param targetType the type a builder is generated for + * @param builderType the generated builder's type name + */ + public void add(TypeName targetType, TypeName builderType) { + buildersByTargetFqn.put(targetType.getFullQualifiedName(), builderType); + onChange.run(); + } + + /** + * Returns the builder registered for the referenced type. + * + * @param referencedTypeFqn the qualified name of the referenced type + * @return the generated builder's type name, or empty if the type is not generated this round + */ + public Optional findBuilder(String referencedTypeFqn) { + return Optional.ofNullable(buildersByTargetFqn.get(referencedTypeFqn)); + } + + /** Drops all registrations, e.g. at the start of a new processing round. */ + public void clear() { + buildersByTargetFqn.clear(); + onChange.run(); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 60d85abc..3c5f6d58 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -31,7 +31,6 @@ import com.google.testing.compile.Compilation; import com.google.testing.compile.Compiler; -import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -266,32 +265,35 @@ public boolean process(Set annotations, RoundEnvironment context.initProcessingTarget(new ProcessingTarget(configuration("lib", "Builder"), "")); BuilderScopeResolver resolver = context.getBuilderScopeResolver(); // Register the type as generated, mirroring the real processor which calls - // registerGeneratedBuilders before any resolution happens. - resolver.registerGeneratedBuilders( - Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); + // the GeneratedBuilders registry before any resolution happens. + resolver + .generatedBuilders() + .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); first = resolver.resolveUsableBuilderType(helper); second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior - resolver.registerGeneratedBuilders(Map.of()); + resolver.generatedBuilders().clear(); context.initProcessingTarget( new ProcessingTarget(configuration("other", "OtherBuilder"), "")); afterConfigurationChange = resolver.resolveUsableBuilderType(helper); context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("other"), "")); beforeRegistration = resolver.resolveUsableBuilderType(helper); // Registration alone is not enough — the type must be in scope - resolver.registerGeneratedBuilders( - Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); + resolver + .generatedBuilders() + .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); - resolver.registerGeneratedBuilders(Map.of()); + resolver.generatedBuilders().clear(); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); - resolver.registerGeneratedBuilders( - Map.of(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"))); + resolver + .generatedBuilders() + .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); - resolver.registerGeneratedBuilders(Map.of()); + resolver.generatedBuilders().clear(); usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper); // Usage scope with builderUsageSuffix="Factory" context.initProcessingTarget( From 9dc5de659fa45854bb3811f03cdd7b2d50f300dd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:29:32 +0000 Subject: [PATCH 13/18] refactor: review follow-ups on naming, attribute reading, and registry API - renames: planGenerationOfAnnotatedElement, planGenerationOfTypeByHolder, alreadyPlannedBuilders (with comment explaining its conflict-detection role), results, resolveSimpleBuilderForConfiguration - attribute reading moved to JavaLangAnalyser.findAnnotationAttribute; also tolerates a non-list 'value' attribute value - GeneratedBuilders is now a pure holder keyed by TypeName (add/findBuilder/clear, add returns whether it was a new registration); BuilderScopeResolver gained registerGeneratedBuilder and resetGeneratedBuilders which also drop the resolution cache - replaces the onChange callback Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 69 +++++++++---------- .../analysis/BuilderScopeResolver.java | 39 +++++++---- .../processor/analysis/GeneratedBuilders.java | 42 ++++------- .../processor/analysis/JavaLangAnalyser.java | 21 ++++++ .../BuilderConfigurationReader.java | 2 +- .../processor/BuilderScopeResolverTest.java | 23 +++---- 6 files changed, 107 insertions(+), 89 deletions(-) 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 fb59f8c2..ae889dc5 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 @@ -36,7 +36,6 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -48,13 +47,12 @@ import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; -import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.TypeMirror; 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.processor.analysis.GeneratedBuilders; +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; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -256,11 +254,14 @@ private List resolveGenerationPlan( BuilderConfigurationReader reader, PerformanceTracker tracker) { List elementsToGenerate = new ArrayList<>(); - Set plannedBuilderNames = new HashSet<>(); + // Builder names already planned in this round: needed for conflict detection, so a directly + // annotated type wins over an external-type declaration producing the same builder name + // (the conflicting @SimpleBuilderFor entry is skipped with a warning). + Set alreadyPlannedBuilders = new HashSet<>(); for (Element annotatedElement : sortedElements) { context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); try { - planAnnotatedElement(annotatedElement, reader, plannedBuilderNames, tracker) + planGenerationOfAnnotatedElement(annotatedElement, reader, alreadyPlannedBuilders, tracker) .ifPresent(elementsToGenerate::add); } catch (BuilderException ex) { // By default builder generation failures are warnings so other builders are still @@ -275,7 +276,7 @@ private List resolveGenerationPlan( context.debugStartOperation("Processing @SimpleBuilderFor holder: " + holder.getSimpleName()); try { elementsToGenerate.addAll( - resolveExternalTypeTargets(holder, reader, plannedBuilderNames, tracker)); + planGenerationOfTypeByHolder(holder, reader, alreadyPlannedBuilders, tracker)); } catch (BuilderException ex) { context.reportBasedOnStrictMode(holder, MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { @@ -289,10 +290,10 @@ private List resolveGenerationPlan( * Resolves the configuration of one annotated element and plans its builder, or returns empty * when the element is outside the {@code builderGenerationPackages} scope. */ - private Optional planAnnotatedElement( + private Optional planGenerationOfAnnotatedElement( Element annotatedElement, BuilderConfigurationReader reader, - Set plannedBuilderNames, + Set alreadyPlannedBuilders, PerformanceTracker tracker) throws BuilderException { tracker.startPhase(); @@ -302,7 +303,7 @@ private Optional planAnnotatedElement( if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { return Optional.empty(); } - plannedBuilderNames.add( + alreadyPlannedBuilders.add( builderTypeName(annotatedElement, context.getPackageName(annotatedElement), config)); return Optional.of(new ElementToGenerate(annotatedElement, config, annotatedElement)); } @@ -312,10 +313,10 @@ private Optional planAnnotatedElement( * attribute and plans a builder for each of them. The generated builder is placed in the holder's * package. */ - private List resolveExternalTypeTargets( + private List planGenerationOfTypeByHolder( Element holder, BuilderConfigurationReader reader, - Set plannedBuilderNames, + Set alreadyPlannedBuilders, PerformanceTracker tracker) throws BuilderException { List targets = extractExternalTargetTypes(holder); @@ -328,16 +329,16 @@ private List resolveExternalTypeTargets( } tracker.startPhase(); - BuilderConfiguration config = reader.resolveExternalConfiguration(holder); + BuilderConfiguration config = reader.resolveSimpleBuilderForConfiguration(holder); tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); String builderPackage = context.getPackageName(holder); - List result = new ArrayList<>(); + List results = new ArrayList<>(); for (TypeElement target : targets) { - planExternalTarget(target, holder, config, builderPackage, plannedBuilderNames) - .ifPresent(result::add); + planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders) + .ifPresent(results::add); } - return result; + return results; } /** @@ -352,7 +353,7 @@ private Optional planExternalTarget( Element holder, BuilderConfiguration config, String builderPackage, - Set plannedBuilderNames) { + Set alreadyPlannedBuilders) { if (!config.builderGenerationPackages().isEmpty() && !config.builderGenerationPackages().includes(builderPackage)) { context.warning( @@ -363,7 +364,7 @@ private Optional planExternalTarget( builderPackage); } TypeName builderName = builderTypeName(target, builderPackage, config); - if (!plannedBuilderNames.add(builderName)) { + if (!alreadyPlannedBuilders.add(builderName)) { context.warning( holder, "simple-builders: skipping '%s' declared in @SimpleBuilderFor on '%s' - builder '%s' is already generated elsewhere", @@ -380,24 +381,22 @@ private Optional planExternalTarget( * resolves each entry to the {@link TypeElement} the builder is generated for. */ private List extractExternalTargetTypes(Element holder) throws BuilderException { - List targets = new ArrayList<>(); Optional mirror = JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class); if (mirror.isEmpty()) { - return targets; - } - AnnotationValue valueAttribute = null; - for (Map.Entry entry : - context.getElementValuesWithDefaults(mirror.get()).entrySet()) { - if (entry.getKey().getSimpleName().contentEquals("value")) { - valueAttribute = entry.getValue(); - break; - } + return List.of(); } - if (valueAttribute == null || !(valueAttribute.getValue() instanceof List values)) { - return targets; + Optional valueAttribute = + JavaLangAnalyser.findAnnotationAttribute(mirror.get(), "value", context); + if (valueAttribute.isEmpty()) { + return List.of(); } - for (Object item : values) { + // For an array-valued attribute javac always delivers a list, even for a single entry; + // a plain single value is accepted too for robustness. + Object value = valueAttribute.get().getValue(); + List items = value instanceof List values ? values : List.of(value); + List targets = new ArrayList<>(); + for (Object item : items) { targets.add(resolveExternalTargetType(holder, item)); } return targets; @@ -406,7 +405,7 @@ private List extractExternalTargetTypes(Element holder) throws Buil /** Resolves one entry of a {@code @SimpleBuilderFor} {@code value} attribute to its type. */ private TypeElement resolveExternalTargetType(Element holder, Object item) throws BuilderException { - Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : item; Element resolved = typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; if (!(resolved instanceof TypeElement targetType)) { @@ -431,13 +430,13 @@ private TypeName builderTypeName( * differ from the target's package for {@code @SimpleBuilderFor} targets. */ private void registerGeneratedTypes(List elementsToGenerate) { - GeneratedBuilders generatedBuilders = context.getBuilderScopeResolver().generatedBuilders(); - generatedBuilders.clear(); + BuilderScopeResolver scopeResolver = context.getBuilderScopeResolver(); + scopeResolver.resetGeneratedBuilders(); for (ElementToGenerate elementToGenerate : elementsToGenerate) { if (!(elementToGenerate.element() instanceof TypeElement targetType)) { continue; } - generatedBuilders.add( + scopeResolver.registerGeneratedBuilder( new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()), new TypeName( context.getPackageName(elementToGenerate.reportingElement()), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index f4729733..b2c96064 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -53,7 +53,7 @@ public final class BuilderScopeResolver { private BuilderConfiguration cachedConfiguration; private PackageScopes usagePackages = PackageScopes.unscoped(); private final Map> resolvedBuilderTypes = new HashMap<>(); - private final GeneratedBuilders generatedBuilders; + private final GeneratedBuilders generatedBuilders = new GeneratedBuilders(); /** * Creates a new resolver for the given processing context. @@ -62,7 +62,6 @@ public final class BuilderScopeResolver { */ public BuilderScopeResolver(ProcessingContext context) { this.context = context; - this.generatedBuilders = new GeneratedBuilders(resolvedBuilderTypes::clear); } /** @@ -82,8 +81,8 @@ public BuilderScopeResolver(ProcessingContext context) { * be referenced. The usage scope includes generation-scope packages automatically. When the * scope is empty, any package is allowed (backward compatibility). *

    9. If the referenced type's builder is generated in the current processing round (registered - * via {@link #generatedBuilders()}), the registered builder name is returned immediately — - * trusted without a classpath lookup or contract check. + * via {@link #registerGeneratedBuilder}), the registered builder name is returned + * immediately — trusted without a classpath lookup or contract check. *
    10. Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: a constructor accepting @@ -139,16 +138,30 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu } /** - * Returns the registry of builders generated in the current processing round. + * Registers one builder generated in the current processing round. * - *

      The processor adds an entry per planned builder before resolution starts, so generated - * builders are trusted without a classpath lookup. Mutations invalidate the resolver's per-type - * resolution cache automatically. + *

      The builder type name is stored explicitly because it may differ from the default naming in + * the target type's own package - for example for {@code @SimpleBuilderFor} targets, whose + * builders are generated in the package of the annotated holder. * - * @return the generated-builders registry + *

      Registered builders are trusted during resolution without a classpath lookup. Adding an + * entry also resets the resolution cache so previously resolved results do not go stale. + * + * @param targetType the type a builder is generated for + * @param builderType the generated builder's type name */ - public GeneratedBuilders generatedBuilders() { - return generatedBuilders; + public void registerGeneratedBuilder(TypeName targetType, TypeName builderType) { + generatedBuilders.add(targetType, builderType); + resolvedBuilderTypes.clear(); + } + + /** + * Resets the generated-builders registry and the resolution cache, e.g. at the start of a new + * processing round, so stale registrations and resolutions of the previous round are dropped. + */ + public void resetGeneratedBuilders() { + generatedBuilders.clear(); + resolvedBuilderTypes.clear(); } private Optional resolve(TypeElement referencedType) { @@ -169,7 +182,9 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - Optional generatedBuilder = generatedBuilders.findBuilder(referencedTypeFqn); + Optional generatedBuilder = + generatedBuilders.findBuilder( + new TypeName(packageName, referencedType.getSimpleName().toString())); if (generatedBuilder.isPresent()) { return generatedBuilder; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java index 85b51fde..9a3f62b5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java @@ -31,53 +31,39 @@ /** * Registry of the builders generated in the current annotation-processing round. * - *

      Maps each target type's qualified name to the {@link TypeName} of the builder being generated - * for it. The builder name is stored explicitly because it cannot always be derived from the target - * type: {@code @SimpleBuilderFor} targets generate into the holder's package (and may use the - * holder's builder suffix), so the generated builder may live in a different package than the - * default naming convention would suggest. - * - *

      Every mutation notifies the owning {@link BuilderScopeResolver} via the {@code onChange} - * callback so its cached per-type resolutions are dropped and stale builders are never served. + *

      Maps each target type to the {@link TypeName} of the builder being generated for it. The + * builder name is stored explicitly because it cannot always be derived from the target type: + * {@code @SimpleBuilderFor} targets generate into the holder's package (and may use the holder's + * builder suffix), so the generated builder may live in a different package than the default naming + * convention would suggest. */ public final class GeneratedBuilders { - private final Map buildersByTargetFqn = new HashMap<>(); - private final Runnable onChange; - - /** - * Creates an empty registry. - * - * @param onChange callback invoked whenever registrations change (add or clear), so the owner can - * invalidate dependent caches - */ - public GeneratedBuilders(Runnable onChange) { - this.onChange = onChange; - } + private final Map buildersByTarget = new HashMap<>(); /** * Registers the builder generated in this round for the given target type. * * @param targetType the type a builder is generated for * @param builderType the generated builder's type name + * @return {@code true} if the target was not already registered, {@code false} if a previous + * registration was replaced */ - public void add(TypeName targetType, TypeName builderType) { - buildersByTargetFqn.put(targetType.getFullQualifiedName(), builderType); - onChange.run(); + public boolean add(TypeName targetType, TypeName builderType) { + return buildersByTarget.put(targetType, builderType) == null; } /** * Returns the builder registered for the referenced type. * - * @param referencedTypeFqn the qualified name of the referenced type + * @param referencedType the referenced type a builder may exist for * @return the generated builder's type name, or empty if the type is not generated this round */ - public Optional findBuilder(String referencedTypeFqn) { - return Optional.ofNullable(buildersByTargetFqn.get(referencedTypeFqn)); + public Optional findBuilder(TypeName referencedType) { + return Optional.ofNullable(buildersByTarget.get(referencedType)); } /** Drops all registrations, e.g. at the start of a new processing round. */ public void clear() { - buildersByTargetFqn.clear(); - onChange.run(); + buildersByTarget.clear(); } } 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 71bda841..c9a487e8 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 @@ -30,8 +30,10 @@ import java.lang.annotation.Annotation; import java.util.List; +import java.util.Map; import java.util.Optional; import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -219,6 +221,25 @@ public static Optional findAnnotation( return Optional.empty(); } + /** + * Reads the value of one attribute of an annotation mirror, including default values. + * + * @param annotationMirror the annotation to read + * @param attributeName the simple name of the attribute (e.g. {@code "value"}) + * @param context the processing context providing element utilities + * @return the attribute's value, or empty if the annotation has no such attribute + */ + public static Optional findAnnotationAttribute( + AnnotationMirror annotationMirror, String attributeName, ProcessingContext context) { + for (Map.Entry entry : + context.getElementValuesWithDefaults(annotationMirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals(attributeName)) { + return Optional.of(entry.getValue()); + } + } + return Optional.empty(); + } + /** * Checks whether the given type has a no-arg {@code build()} method returning the expected type. * This is part of the builder contract used when the built value is retrieved. 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 a359e967..9b120397 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 @@ -153,7 +153,7 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder * @param element the {@code @SimpleBuilderFor} holder (used for validation messages) * @return the fully resolved configuration with all sources merged */ - public BuilderConfiguration resolveExternalConfiguration(Element element) + public BuilderConfiguration resolveSimpleBuilderForConfiguration(Element element) throws BuilderException { String elementName = element.getSimpleName().toString(); logger.debugStartOperation( diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 3c5f6d58..f82c557f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java @@ -265,35 +265,32 @@ public boolean process(Set annotations, RoundEnvironment context.initProcessingTarget(new ProcessingTarget(configuration("lib", "Builder"), "")); BuilderScopeResolver resolver = context.getBuilderScopeResolver(); // Register the type as generated, mirroring the real processor which calls - // the GeneratedBuilders registry before any resolution happens. - resolver - .generatedBuilders() - .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); + // registerGeneratedBuilder before any resolution happens. + resolver.registerGeneratedBuilder( + new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); first = resolver.resolveUsableBuilderType(helper); second = resolver.resolveUsableBuilderType(helper); // Clear registration before testing scope-only behavior - resolver.generatedBuilders().clear(); + resolver.resetGeneratedBuilders(); context.initProcessingTarget( new ProcessingTarget(configuration("other", "OtherBuilder"), "")); afterConfigurationChange = resolver.resolveUsableBuilderType(helper); context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("other"), "")); beforeRegistration = resolver.resolveUsableBuilderType(helper); // Registration alone is not enough — the type must be in scope - resolver - .generatedBuilders() - .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); + resolver.registerGeneratedBuilder( + new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); afterRegistration = resolver.resolveUsableBuilderType(helper); // Clear registration for usage-scope classpath lookup tests context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); - resolver.generatedBuilders().clear(); + resolver.resetGeneratedBuilders(); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); - resolver - .generatedBuilders() - .add(new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); + resolver.registerGeneratedBuilder( + new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageAfterRegistration = resolver.resolveUsableBuilderType(helper); // Usage scope without @SimpleBuilder annotation — type existence check only context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); - resolver.generatedBuilders().clear(); + resolver.resetGeneratedBuilders(); usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper); // Usage scope with builderUsageSuffix="Factory" context.initProcessingTarget( From e632b578d260312307fd395ede4f102a7b27a595 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:09:56 +0000 Subject: [PATCH 14/18] fix: address Sonar findings - Objects.requireNonNull on holder/element at entry points - Sonar's null analysis flagged getSimpleName() derefs after the defensive null-check in findAnnotation propagated 'may be null' upstream - lambdas replaced by context::isMemberAccessibleFromBuilderPackage method references - dropped unused annotatedType parameter from isMethodRelevantForBuilder Co-Authored-By: Andreas Igel --- .../simple/builders/processor/BuilderProcessor.java | 2 ++ .../simple/builders/processor/analysis/JavaLangAnalyser.java | 4 ++-- .../processor/processing/BuilderConfigurationReader.java | 2 ++ .../processor/processing/BuilderDefinitionCreator.java | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) 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 ae889dc5..1a448efa 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 @@ -36,6 +36,7 @@ import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -319,6 +320,7 @@ private List planGenerationOfTypeByHolder( Set alreadyPlannedBuilders, PerformanceTracker tracker) throws BuilderException { + Objects.requireNonNull(holder, "holder must not be null"); List targets = extractExternalTargetTypes(holder); if (targets.isEmpty()) { context.warning( 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 c9a487e8..86ab7957 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 @@ -594,7 +594,7 @@ public static Optional findConstructorForBuilder( TypeElement annotatedType, ProcessingContext context) { List ctors = ElementFilter.constructorsIn(context.getAllMembers(annotatedType)).stream() - .filter(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor)) + .filter(context::isMemberAccessibleFromBuilderPackage) .toList(); // First, check if any constructor is annotated with @SimpleBuilderConstructor @@ -629,6 +629,6 @@ public static Optional findConstructorForBuilder( public static boolean hasAccessibleConstructor( TypeElement typeElement, ProcessingContext context) { return ElementFilter.constructorsIn(context.getAllMembers(typeElement)).stream() - .anyMatch(ctor -> context.isMemberAccessibleFromBuilderPackage(ctor)); + .anyMatch(context::isMemberAccessibleFromBuilderPackage); } } 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 9b120397..0121db41 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 @@ -28,6 +28,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.AnnotationValue; @@ -155,6 +156,7 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder */ public BuilderConfiguration resolveSimpleBuilderForConfiguration(Element element) throws BuilderException { + Objects.requireNonNull(element, "element must not be null"); String elementName = element.getSimpleName().toString(); logger.debugStartOperation( "Resolving configuration for @SimpleBuilderFor target: %s", elementName); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index 1fbe1e43..a03effb9 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -548,7 +548,7 @@ private static List extractSetterFields( for (ExecutableElement mth : methods) { context.debugStartOperation("Analyzing method: %s", mth.toString()); - if (isMethodRelevantForBuilder(mth, annotatedType, context)) { + if (isMethodRelevantForBuilder(mth, context)) { // Extract the original field name from the setter method (before any renaming) String methodName = mth.getSimpleName().toString(); String originalFieldName = @@ -601,7 +601,7 @@ private static void logFieldAddition(FieldDto field, ProcessingContext context) } private static boolean isMethodRelevantForBuilder( - ExecutableElement mth, TypeElement annotatedType, ProcessingContext context) { + ExecutableElement mth, ProcessingContext context) { if (!hasNoThrowablesDeclared(mth)) { context.debug("Skipping: declares throwables"); return false; From f1a683afca887f1efa052588936619da73dd6ece Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:11:08 +0000 Subject: [PATCH 15/18] refactor: follow-up on review comments - rename targets to results in extractExternalTargetTypes - drop the non-list 'value' tolerance - javac always delivers Class[] attributes as a list, single-value declarations included - rename to resolveHolderConfiguration - 'For' in the name was misleading Co-Authored-By: Andreas Igel --- .../builders/processor/BuilderProcessor.java | 19 ++++++++----------- .../BuilderConfigurationReader.java | 3 +-- 2 files changed, 9 insertions(+), 13 deletions(-) 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 1a448efa..92441d65 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 @@ -331,7 +331,7 @@ private List planGenerationOfTypeByHolder( } tracker.startPhase(); - BuilderConfiguration config = reader.resolveSimpleBuilderForConfiguration(holder); + BuilderConfiguration config = reader.resolveHolderConfiguration(holder); tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); String builderPackage = context.getPackageName(holder); @@ -390,24 +390,21 @@ private List extractExternalTargetTypes(Element holder) throws Buil } Optional valueAttribute = JavaLangAnalyser.findAnnotationAttribute(mirror.get(), "value", context); - if (valueAttribute.isEmpty()) { - return List.of(); + 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)) { + return results; } - // For an array-valued attribute javac always delivers a list, even for a single entry; - // a plain single value is accepted too for robustness. - Object value = valueAttribute.get().getValue(); - List items = value instanceof List values ? values : List.of(value); - List targets = new ArrayList<>(); for (Object item : items) { - targets.add(resolveExternalTargetType(holder, item)); + results.add(resolveExternalTargetType(holder, item)); } - return targets; + return results; } /** Resolves one entry of a {@code @SimpleBuilderFor} {@code value} attribute to its type. */ private TypeElement resolveExternalTargetType(Element holder, Object item) throws BuilderException { - Object typeValue = item instanceof AnnotationValue value ? value.getValue() : item; + Object typeValue = item instanceof AnnotationValue value ? value.getValue() : null; Element resolved = typeValue instanceof TypeMirror typeMirror ? context.asElement(typeMirror) : null; if (!(resolved instanceof TypeElement targetType)) { 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 0121db41..d82d8b4e 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 @@ -154,8 +154,7 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder * @param element the {@code @SimpleBuilderFor} holder (used for validation messages) * @return the fully resolved configuration with all sources merged */ - public BuilderConfiguration resolveSimpleBuilderForConfiguration(Element element) - throws BuilderException { + public BuilderConfiguration resolveHolderConfiguration(Element element) throws BuilderException { Objects.requireNonNull(element, "element must not be null"); String elementName = element.getSimpleName().toString(); logger.debugStartOperation( From 411f5ad8e3efe2fb4ed290a8e0c93e9fcddef4ce Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:38:12 +0000 Subject: [PATCH 16/18] refactor: address remaining review comments - warn when the 'value' attribute of @SimpleBuilderFor cannot be read, instead of returning silently - javadoc on options(): spell out the UNSET default resolution (compiler argument, then built-in default per Options member) Co-Authored-By: Andreas Igel --- .../simple/builders/core/annotations/SimpleBuilderFor.java | 6 ++++-- .../simple/builders/processor/BuilderProcessor.java | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) 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 80bb0560..12cae2b1 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 @@ -111,9 +111,11 @@ /** * Configuration options for the generated builders, reusing the {@link SimpleBuilder.Options} - * model. When omitted, the compiler defaults apply. + * model. When omitted, all members keep their {@code UNSET} default, so each option resolves as + * documented for the corresponding {@link SimpleBuilder.Options} member — falling back to the + * {@code -Asimplebuilder.*} compiler argument and then to the built-in default listed there. * - * @return the configuration options, or default (all UNSET) if not specified + * @return the configuration options, defaulting to all members {@code UNSET} */ SimpleBuilder.Options options() default @SimpleBuilder.Options; } 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 92441d65..e4bd7dc9 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 @@ -393,6 +393,10 @@ private List extractExternalTargetTypes(Element holder) throws Buil 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)) { + context.warning( + holder, + "simple-builders: could not read the 'value' attribute of @SimpleBuilderFor on '%s' - nothing to generate", + holder.getSimpleName()); return results; } for (Object item : items) { From ffb812af101bbc07594060503de97c6df201f5b2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:40:31 +0000 Subject: [PATCH 17/18] docs: link CONFIGURATION.md sections from javadoc - SimpleBuilderFor: link 'Generating Builders for External Types' and 'Compiler Options' sections - JacksonAnnotationEnhancer: fix stale CONFIGURATION.md link (file moved to docs/, repo moved to java-helpers) Co-Authored-By: Andreas Igel --- .../builders/core/annotations/SimpleBuilderFor.java | 9 ++++++++- .../generators/builder/JacksonAnnotationEnhancer.java | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) 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 12cae2b1..2f87a898 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 @@ -47,6 +47,10 @@ * explicit declaration wins, so even an {@link Ignore4BuilderGeneration} on the target does not * suppress generation. * + *

      See also the + * Generating Builders for External Types section in CONFIGURATION.md. + * *

      Example, declared on the package in {@code package-info.java} (generates the builder into * {@code com.example}): * @@ -113,7 +117,10 @@ * Configuration options for the generated builders, reusing the {@link SimpleBuilder.Options} * model. When omitted, all members keep their {@code UNSET} default, so each option resolves as * documented for the corresponding {@link SimpleBuilder.Options} member — falling back to the - * {@code -Asimplebuilder.*} compiler argument and then to the built-in default listed there. + * {@code -Asimplebuilder.*} compiler argument and then to the built-in default listed there. See + * the + * Compiler Options section in CONFIGURATION.md. * * @return the configuration options, defaulting to all members {@code UNSET} */ diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/JacksonAnnotationEnhancer.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/JacksonAnnotationEnhancer.java index 8cf7b8dd..e38948b5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/JacksonAnnotationEnhancer.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/JacksonAnnotationEnhancer.java @@ -47,7 +47,7 @@ *

      This enhancer is disabled by default and can be activated by setting the configuration flag * {@code usingJacksonDeserializerAnnotation} to {@code ENABLED}. For detailed usage instructions, * see the + * href="https://github.com/java-helpers/simple-builders/blob/main/docs/CONFIGURATION.md#jackson-support"> * Jackson Support section in CONFIGURATION.md. * *

      Example to demonstrate the generated annotation

      From 834969f3ee09ce85fea9f1ad5df0a2a85e3b7a69 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:52:30 +0000 Subject: [PATCH 18/18] fix: rebase onto upstream main and restore example test harness - branch predated #297 and its pom revert dropped the merged surefire / junit-jupiter-engine setup; pom restored to upstream so the example tests keep running - javadoc: state explicitly that @SimpleBuilderFor is not @Inherited Co-Authored-By: Andreas Igel --- .../core/annotations/SimpleBuilderFor.java | 3 ++- example/pom.xml | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) 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 2f87a898..90b17377 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 @@ -45,7 +45,8 @@ * are used for builder generation. If no suitable construction mechanism is available, generation * fails with a compile-time error. The target type's own annotations are not consulted - the * explicit declaration wins, so even an {@link Ignore4BuilderGeneration} on the target does not - * suppress generation. + * suppress generation. This annotation is intentionally not {@code @Inherited}: it declares + * generation for exactly the types listed on the annotated element. * *

      See also the diff --git a/example/pom.xml b/example/pom.xml index a7735e6f..e3e78260 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -21,6 +21,7 @@ 3.16.0 3.2.0 + 3.6.0 ${java.version} ${java.version} @@ -49,6 +50,12 @@ ${junit-jupiter.version} test + + org.junit.jupiter + junit-jupiter-engine + ${junit-jupiter.version} + test + @@ -62,6 +69,13 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + ${plugin.maven.surefire.version} + + org.apache.maven.plugins