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. + * + *
{@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 IBuilderBasecity: city.
+ */
+ private TrackedValuestreet: street.
+ */
+ private TrackedValuezipCode: zipCode.
+ */
+ private TrackedValue{@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)} + * + *
{@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)} + * + *
{@code
+ * builder.city(sb -> sb.append("text"));
+ * }
+ *
+ * @param cityStringBuilderConsumer consumer providing an instance of city
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder city(Consumercity by invoking the provided supplier.
+ * + * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *
{@code
+ * builder.city(() -> "example value");
+ * }
+ *
+ * @param citySupplier supplier for city
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder city(Suppliercity by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * + * Generated from setter {@link ExternalAddress#setCity(String) setCity(String city)} + * + *
{@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)} + * + *
{@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(UnaryOperatorstreet.
+ * + * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *
{@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)} + * + *
{@code
+ * builder.street(sb -> sb.append("text"));
+ * }
+ *
+ * @param streetStringBuilderConsumer consumer providing an instance of street
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder street(Consumerstreet by invoking the provided supplier.
+ * + * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *
{@code
+ * builder.street(() -> "example value");
+ * }
+ *
+ * @param streetSupplier supplier for street
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder street(Supplierstreet by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * + * Generated from setter {@link ExternalAddress#setStreet(String) setStreet(String street)} + * + *
{@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)} + * + *
{@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+ * 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)} + * + *
{@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)} + * + *
{@code
+ * builder.zipCode(sb -> sb.append("text"));
+ * }
+ *
+ * @param zipCodeStringBuilderConsumer consumer providing an instance of zipCode
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder zipCode(ConsumerzipCode by invoking the provided supplier.
+ * + * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *
{@code
+ * builder.zipCode(() -> "example value");
+ * }
+ *
+ * @param zipCodeSupplier supplier for zipCode
+ * @return current instance of builder
+ */
+ public ExternalAddressBuilder zipCode(SupplierzipCode by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ * + * Generated from setter {@link ExternalAddress#setZipCode(String) setZipCode(String zipCode)} + * + *
{@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)} + * + *
{@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{@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(ConsumerThis 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). *
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 extends TypeElement> generatedTypes) {
- Set 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 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.
*
* 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 {@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 extends TypeElement> 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));
+ }
+}
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.
+ *
+ *