diff --git a/README.md b/README.md index 9ac603f5..9f306af1 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: + +- **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) + ### Builder Scoping Example A runnable example demonstrating package-scoped builder generation and usage: 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..90b17377 --- /dev/null +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilderFor.java @@ -0,0 +1,129 @@ +/* + * 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 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. 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. This annotation is intentionally not {@code @Inherited}: it declares + * generation for exactly the types listed on the annotated element. + * + *

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}): + * + *

{@code
+ * @SimpleBuilderFor(ExternalUser.class)
+ * package com.example;
+ * }
+ * + *

or on a dedicated provider class: + * + *

{@code
+ * @SimpleBuilderFor(ExternalUser.class)
+ * public class ExternalBuildersProvider {
+ * }
+ *
+ * // 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})
+ * public class ExternalBuildersProvider {
+ * }
+ * }
+ * + *

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"
+ *     )
+ * )
+ * public class ExternalBuildersProvider {
+ * }
+ * }
+ * + * @see SimpleBuilder + * @see SimpleBuilder.Options + * @see Ignore4BuilderGeneration + */ +@Target({ElementType.TYPE, ElementType.PACKAGE}) +@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, 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. See + * the + * Compiler Options section in CONFIGURATION.md. + * + * @return the configuration options, defaulting to all members {@code UNSET} + */ + SimpleBuilder.Options options() default @SimpleBuilder.Options; +} diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 55350546..e5c5a008 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,25 @@ 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: 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 +// package-info.java - generates ExternalUserFactory and ExternalOrderFactory into this package +@SimpleBuilderFor( + value = {ExternalUser.class, ExternalOrder.class}, + options = @SimpleBuilder.Options(builderSuffix = "Factory")) +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 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 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 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..e9ad2a4a 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. +[DEBUG] simple-builders: Processing round started. +[DEBUG] simple-builders: Found 1 annotated elements. +[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 bb07e2d9..2a056da4 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. +[INFO] [DEBUG] simple-builders: Processing round started. +[INFO] [DEBUG] simple-builders: Found 3 annotated elements. +[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 @@ -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. +[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: 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/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/main/java/org/javahelpers/simple/builders/example/package-info.java b/example/src/main/java/org/javahelpers/simple/builders/example/package-info.java new file mode 100644 index 00000000..7775d8b4 --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/package-info.java @@ -0,0 +1,34 @@ +/* + * 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. + */ + +/** + * 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) +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/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/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..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 @@ -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; @@ -45,10 +46,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.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.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; @@ -57,6 +62,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; @@ -64,6 +70,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; @@ -75,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; @@ -131,21 +141,29 @@ 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."); + context.debug("simple-builders: Found %d annotated elements.", elementsToProcess.size()); context.debug( - "simple-builders: Processing round started. Found %d annotated elements.", - elementsToProcess.size()); + "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. 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 +205,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,27 +245,41 @@ 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<>(); + // 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 { - tracker.startPhase(); - BuilderConfiguration config = reader.resolveConfiguration(annotatedElement); - tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); - - if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) { - continue; - } - elementsToGenerate.add(new ElementToGenerate(annotatedElement, config)); + planGenerationOfAnnotatedElement(annotatedElement, reader, alreadyPlannedBuilders, 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. - context.reportBasedOnStrictMode( - annotatedElement, "simple-builders: Failed to generate builder - %s", ex.getMessage()); + context.reportBasedOnStrictMode(annotatedElement, MSG_FAILED_TO_GENERATE, ex.getMessage()); + } finally { + context.debugEndOperation(); + } + } + + for (Element holder : sortedHolders) { + context.debugStartOperation("Processing @SimpleBuilderFor holder: " + holder.getSimpleName()); + try { + elementsToGenerate.addAll( + planGenerationOfTypeByHolder(holder, reader, alreadyPlannedBuilders, tracker)); + } catch (BuilderException ex) { + context.reportBasedOnStrictMode(holder, MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { context.debugEndOperation(); } @@ -247,19 +287,164 @@ 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 planGenerationOfAnnotatedElement( + Element annotatedElement, + BuilderConfigurationReader reader, + Set alreadyPlannedBuilders, + 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(); + } + alreadyPlannedBuilders.add( + builderTypeName(annotatedElement, context.getPackageName(annotatedElement), 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 + * package. + */ + private List planGenerationOfTypeByHolder( + Element holder, + BuilderConfigurationReader reader, + Set alreadyPlannedBuilders, + PerformanceTracker tracker) + throws BuilderException { + Objects.requireNonNull(holder, "holder must not be null"); + List targets = extractExternalTargetTypes(holder); + if (targets.isEmpty()) { + context.warning( + holder, + "simple-builders: @SimpleBuilderFor on '%s' does not list any types - nothing to generate", + holder.getSimpleName()); + return List.of(); + } + + tracker.startPhase(); + BuilderConfiguration config = reader.resolveHolderConfiguration(holder); + tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION); + + String builderPackage = context.getPackageName(holder); + List results = new ArrayList<>(); + for (TypeElement target : targets) { + planExternalTarget(target, holder, config, builderPackage, alreadyPlannedBuilders) + .ifPresent(results::add); + } + return results; + } + + /** + * 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 + * 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, + Element holder, + BuilderConfiguration config, + String builderPackage, + Set alreadyPlannedBuilders) { + 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); + } + TypeName builderName = builderTypeName(target, builderPackage, config); + if (!alreadyPlannedBuilders.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.getFullQualifiedName()); + return Optional.empty(); + } + return Optional.of(new ElementToGenerate(target, config, holder)); + } + + /** + * 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) throws BuilderException { + Optional mirror = + JavaLangAnalyser.findAnnotation(holder, SimpleBuilderFor.class); + if (mirror.isEmpty()) { + return List.of(); + } + Optional valueAttribute = + JavaLangAnalyser.findAnnotationAttribute(mirror.get(), "value", context); + List results = new ArrayList<>(); + // For an array-valued attribute javac always delivers a list, even for a single entry. + if (valueAttribute.isEmpty() || !(valueAttribute.get().getValue() instanceof List items)) { + 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) { + results.add(resolveExternalTargetType(holder, item)); + } + 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() : 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 type name of the builder a given target type would produce. */ + private TypeName builderTypeName( + Element target, String builderPackage, BuilderConfiguration config) { + return new TypeName(builderPackage, target.getSimpleName() + config.getBuilderSuffix()); + } + /** * 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()); + BuilderScopeResolver scopeResolver = context.getBuilderScopeResolver(); + scopeResolver.resetGeneratedBuilders(); + for (ElementToGenerate elementToGenerate : elementsToGenerate) { + if (!(elementToGenerate.element() instanceof TypeElement targetType)) { + continue; + } + scopeResolver.registerGeneratedBuilder( + new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()), + new TypeName( + context.getPackageName(elementToGenerate.reportingElement()), + targetType.getSimpleName() + elementToGenerate.config().getBuilderSuffix())); + } } /** Generates a builder for each planned element and returns the number of successes. */ @@ -271,13 +456,13 @@ private int generateBuilders( context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName()); tracker.startClass(annotatedElement.getSimpleName().toString()); try { - process(annotatedElement, elementToGenerate.config()); + process(annotatedElement, elementToGenerate.config(), builderPackageOf(elementToGenerate)); 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(), MSG_FAILED_TO_GENERATE, ex.getMessage()); } finally { context.debugEndOperation(); } @@ -300,9 +485,9 @@ 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.initProcessingTarget(new ProcessingTarget(config, builderPackage)); PerformanceTracker tracker = context.getPerformanceTracker(); // Track Builder Definition Extraction tracker.startPhase(); @@ -348,7 +533,25 @@ 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 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, Element reportingElement) {} + + /** + * 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 context.getPackageName(elementToGenerate.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..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 @@ -23,13 +23,10 @@ */ package org.javahelpers.simple.builders.processor.analysis; -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,8 +52,8 @@ public final class BuilderScopeResolver { private final ProcessingContext context; private BuilderConfiguration cachedConfiguration; private PackageScopes usagePackages = PackageScopes.unscoped(); - private Set generatedTypeNames = Set.of(); private final Map> resolvedBuilderTypes = new HashMap<>(); + private final GeneratedBuilders generatedBuilders = new GeneratedBuilders(); /** * Creates a new resolver for the given processing context. @@ -73,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. * @@ -84,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). *

  • 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. + * via {@link #registerGeneratedBuilder}), the registered builder name is returned + * immediately — trusted without a classpath lookup or contract check. *
  • 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 @@ -114,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. * @@ -141,16 +138,29 @@ public boolean isInGenerationScope(Element element, BuilderConfiguration configu } /** - * Registers the types whose builders are generated in the current processing round. + * Registers one builder generated in the current processing round. * - * @param generatedTypes types whose builders will be generated in this round + *

    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. + * + *

    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 void registerGeneratedTypes(Collection generatedTypes) { - Set registeredTypeNames = new HashSet<>(); - for (TypeElement generatedType : generatedTypes) { - registeredTypeNames.add(generatedType.getQualifiedName().toString()); - } - generatedTypeNames = registeredTypeNames; + 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(); } @@ -171,13 +181,12 @@ 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. 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); + // classpath lookup or contract check is needed. + Optional generatedBuilder = + generatedBuilders.findBuilder( + new TypeName(packageName, referencedType.getSimpleName().toString())); + 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..9a3f62b5 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/GeneratedBuilders.java @@ -0,0 +1,69 @@ +/* + * 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 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 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 boolean add(TypeName targetType, TypeName builderType) { + return buildersByTarget.put(targetType, builderType) == null; + } + + /** + * Returns the builder registered for 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(TypeName referencedType) { + return Optional.ofNullable(buildersByTarget.get(referencedType)); + } + + /** Drops all registrations, e.g. at the start of a new processing round. */ + public void clear() { + 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 9da8610c..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 @@ -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. @@ -486,7 +507,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)) { return Optional.of(candidate); } } @@ -550,7 +572,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)) { return Optional.of(candidate); } } @@ -570,7 +593,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(context::isMemberAccessibleFromBuilderPackage) + .toList(); // First, check if any constructor is annotated with @SimpleBuilderConstructor for (ExecutableElement ctor : ctors) { @@ -591,4 +616,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(context::isMemberAccessibleFromBuilderPackage); + } } 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

    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..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 @@ -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; @@ -35,7 +36,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; @@ -139,6 +142,39 @@ public BuilderConfiguration resolveConfiguration(Element element) throws Builder return result; } + /** + * 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 + * {@code @SimpleBuilderFor} annotation found on the given element. + * + * @param element the {@code @SimpleBuilderFor} holder (used for validation messages) + * @return the fully resolved configuration with all sources merged + */ + public BuilderConfiguration resolveHolderConfiguration(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); + + BuilderConfiguration optionsConfig = + JavaLangAnalyser.findAnnotation(element, SimpleBuilderFor.class) + .map(this::extractOptionsFromAnnotationMirror) + .orElse(null); + + 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..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 @@ -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)) { + 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()); + } + 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(); 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); @@ -602,6 +622,10 @@ private static boolean isMethodRelevantForBuilder( context.debug("Skipping: is static"); return false; } + if (!context.isMemberAccessibleFromBuilderPackage(mth)) { + 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..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 @@ -24,9 +24,15 @@ package org.javahelpers.simple.builders.processor.processing; +import java.util.HashMap; 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; @@ -60,7 +66,7 @@ public final class ProcessingContext { private final String formatterProfile; private final BuilderScopeResolver builderScopeResolver; private GeneratorRegistry generatorRegistry; - private BuilderConfiguration configurationForProcessingTarget; + private ProcessingTarget processingTarget; /** * Creates a new processing context. @@ -95,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; } /** @@ -109,7 +116,51 @@ public void initConfigurationForProcessingTarget(BuilderConfiguration config) { * @return the builder configuration for the target being processed */ public BuilderConfiguration getConfiguration() { - return this.configurationForProcessingTarget; + return this.processingTarget.configuration(); + } + + /** + * Gets the package the builder of the current processing target is generated in. + * + * @return the qualified package name of the generated builder + */ + public String getBuilderPackageName() { + return this.processingTarget.builderPackage(); + } + + /** + * 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 + * @return {@code true} if generated code in the builder package may call the member + */ + 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()); + } + + /** + * 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) { + Map elementValues = new HashMap<>(); + elementUtils.getElementValuesWithDefaults(annotationMirror).forEach(elementValues::put); + return elementValues; } /** 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/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 18e7bc17..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 @@ -99,7 +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.", + "[DEBUG] simple-builders: Processing round started.", + "[DEBUG] simple-builders: Found 1 annotated elements.", + "[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", @@ -164,7 +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.", + "[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: 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..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 @@ -31,7 +31,6 @@ import com.google.testing.compile.Compilation; import com.google.testing.compile.Compiler; -import java.util.List; import java.util.Optional; import java.util.Set; import javax.annotation.processing.AbstractProcessor; @@ -42,6 +41,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,37 +262,42 @@ 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 - // registerGeneratedTypes before any resolution happens. - resolver.registerGeneratedTypes(List.of(helper)); + // 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.registerGeneratedTypes(List.of()); - context.initConfigurationForProcessingTarget(configuration("other", "OtherBuilder")); + resolver.resetGeneratedBuilders(); + 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.registerGeneratedTypes(List.of(helper)); + resolver.registerGeneratedBuilder( + 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.registerGeneratedTypes(List.of()); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + resolver.resetGeneratedBuilders(); usageBeforeRegistration = resolver.resolveUsableBuilderType(helper); - resolver.registerGeneratedTypes(List.of(helper)); + resolver.registerGeneratedBuilder( + 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")); - resolver.registerGeneratedTypes(List.of()); + context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "")); + resolver.resetGeneratedBuilders(); 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; 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..fa2d85af --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuilderForTest.java @@ -0,0 +1,359 @@ +/* + * 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 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; +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_ExplicitDeclarationStillGenerates() { + 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")); + + // 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 + 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()"); + } + + @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( + """ + 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)); + } +}