trusted = unsetValue();
+
+ /**
+ * Empty constructor of builder for {@code org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto}.
+ */
+ public ScopedOwnerDtoBuilder() {
+ }
+
+ /**
+ * Initialisation of builder for {@code org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto} by a instance.
+ *
+ * @param instance object instance for initialisiation
+ */
+ public ScopedOwnerDtoBuilder(ScopedOwnerDto instance) {
+ this.library = initialValue(instance.getLibrary());
+ this.sponsor = initialValue(instance.getSponsor());
+ this.trusted = initialValue(instance.getTrusted());
+ }
+
+ /**
+ * Creating a new builder for {@code org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto}.
+ *
+ * Example:
+ *
+ * {@code
+ * ScopedOwnerDtoBuilder builder = ScopedOwnerDtoBuilder.create();
+ * }
+ *
+ * @return builder for {@code org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto}
+ */
+ public static ScopedOwnerDtoBuilder create() {
+ return new ScopedOwnerDtoBuilder();
+ }
+
+ /**
+ * Sets the value for library.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setLibrary(LibraryHelperDto) setLibrary(LibraryHelperDto library)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.library(new LibraryHelperDto());
+ * }
+ *
+ * @param library library
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder library(LibraryHelperDto library) {
+ this.library = changedValue(library);
+ return this;
+ }
+
+ /**
+ * Sets the value for library by executing the provided consumer.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setLibrary(LibraryHelperDto) setLibrary(LibraryHelperDto library)}
+ *
+ * @param libraryConsumer consumer providing an instance of library
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder library(Consumer libraryConsumer) {
+ LibraryHelperDto consumer = this.library.isSet() ? this.library.value() : new LibraryHelperDto();
+ libraryConsumer.accept(consumer);
+ this.library = changedValue(consumer);
+ return this;
+ }
+
+ /**
+ * Sets the value for library by invoking the provided supplier.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setLibrary(LibraryHelperDto) setLibrary(LibraryHelperDto library)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.library(LibraryHelperDto::new);
+ * }
+ *
+ * @param librarySupplier supplier for library
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder library(Supplier librarySupplier) {
+ this.library = changedValue(librarySupplier.get());
+ return this;
+ }
+
+ /**
+ * Sets the value for sponsor.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.sponsor(new SponsorDto());
+ * }
+ *
+ * @param sponsor sponsor
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder sponsor(SponsorDto sponsor) {
+ this.sponsor = changedValue(sponsor);
+ return this;
+ }
+
+ /**
+ * Sets the value for sponsor by executing the provided consumer.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)}
+ *
+ * @param sponsorConsumer consumer providing an instance of sponsor
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder sponsor(Consumer sponsorConsumer) {
+ SponsorDto consumer = this.sponsor.isSet() ? this.sponsor.value() : new SponsorDto();
+ sponsorConsumer.accept(consumer);
+ this.sponsor = changedValue(consumer);
+ return this;
+ }
+
+ /**
+ * Sets the value for sponsor by invoking the provided supplier.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setSponsor(SponsorDto) setSponsor(SponsorDto sponsor)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.sponsor(SponsorDto::new);
+ * }
+ *
+ * @param sponsorSupplier supplier for sponsor
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder sponsor(Supplier sponsorSupplier) {
+ this.sponsor = changedValue(sponsorSupplier.get());
+ return this;
+ }
+
+ /**
+ * Sets the value for trusted.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setTrusted(TrustedHelperDto) setTrusted(TrustedHelperDto trusted)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.trusted(new TrustedHelperDto());
+ * }
+ *
+ * @param trusted trusted
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder trusted(TrustedHelperDto trusted) {
+ this.trusted = changedValue(trusted);
+ return this;
+ }
+
+ /**
+ * Sets the value for trusted using a builder consumer that produces the value.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setTrusted(TrustedHelperDto) setTrusted(TrustedHelperDto trusted)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.trusted(trustedHelperDtoBuilder -> trustedHelperDtoBuilder);
+ * }
+ *
+ * @param trustedBuilderConsumer consumer providing an instance of a builder for trusted
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder trusted(Consumer trustedBuilderConsumer) {
+ TrustedHelperDtoBuilder builder = this.trusted.isSet()
+ ? new TrustedHelperDtoBuilder(this.trusted.value())
+ : new TrustedHelperDtoBuilder();
+ trustedBuilderConsumer.accept(builder);
+ this.trusted = changedValue(builder.build());
+ return this;
+ }
+
+ /**
+ * Sets the value for trusted by invoking the provided supplier.
+ *
+ * Generated from setter {@link ScopedOwnerDto#setTrusted(TrustedHelperDto) setTrusted(TrustedHelperDto trusted)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.trusted(TrustedHelperDto::new);
+ * }
+ *
+ * @param trustedSupplier supplier for trusted
+ * @return current instance of builder
+ */
+ public ScopedOwnerDtoBuilder trusted(Supplier trustedSupplier) {
+ this.trusted = changedValue(trustedSupplier.get());
+ 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 ScopedOwnerDtoBuilder 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 ScopedOwnerDtoBuilder 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
+ * ScopedOwnerDto result = builder.build();
+ * }
+ */
+ @Override
+ public ScopedOwnerDto build() {
+ ScopedOwnerDto result = new ScopedOwnerDto();
+ this.library.ifSet(result::setLibrary);
+ this.sponsor.ifSet(result::setSponsor);
+ this.trusted.ifSet(result::setTrusted);
+ 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("library", this.library)
+ .append("sponsor", this.sponsor)
+ .append("trusted", this.trusted)
+ .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 ScopedOwnerDto with(Consumer b) {
+ ScopedOwnerDtoBuilder builder;
+ try {
+ builder = new ScopedOwnerDtoBuilder(ScopedOwnerDto.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException(
+ "The interface 'ScopedOwnerDtoBuilder.With' should only be implemented by classes, which could be casted to 'ScopedOwnerDto'",
+ ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ }
+
+ /**
+ * Creates a builder initialized from this instance.
+ *
+ * @return a builder initialized with this instance's values
+ */
+ default ScopedOwnerDtoBuilder with() {
+ try {
+ return new ScopedOwnerDtoBuilder(ScopedOwnerDto.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException(
+ "The interface 'ScopedOwnerDtoBuilder.With' should only be implemented by classes, which could be casted to 'ScopedOwnerDto'",
+ ex);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/TrustedHelperDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/TrustedHelperDtoBuilder.java
new file mode 100644
index 00000000..bcb27925
--- /dev/null
+++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/TrustedHelperDtoBuilder.java
@@ -0,0 +1,265 @@
+package org.javahelpers.simple.builders.example.scoping;
+
+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 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;
+
+/**
+ * Builder for {@code org.javahelpers.simple.builders.example.scoping.TrustedHelperDto}.
+ *
+ * This builder provides a fluent API for creating instances of
+ * org.javahelpers.simple.builders.example.scoping.TrustedHelperDto 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
+ * TrustedHelperDto result = TrustedHelperDtoBuilder.create()
+ * .name("example value")
+ * .name("Hello %s", "World")
+ * .name(() -> "example value")
+ * .name(sb -> sb.append("text"))
+ * .build();
+ * }
+ */
+@Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor")
+@BuilderImplementation(forClass = TrustedHelperDto.class)
+public class TrustedHelperDtoBuilder implements IBuilderBase {
+
+ /**
+ * Tracked value for name: name.
+ */
+ private TrackedValue name = unsetValue();
+
+ /**
+ * Empty constructor of builder for {@code org.javahelpers.simple.builders.example.scoping.TrustedHelperDto}.
+ */
+ public TrustedHelperDtoBuilder() {
+ }
+
+ /**
+ * Initialisation of builder for {@code org.javahelpers.simple.builders.example.scoping.TrustedHelperDto} by a
+ * instance.
+ *
+ * @param instance object instance for initialisiation
+ */
+ public TrustedHelperDtoBuilder(TrustedHelperDto instance) {
+ this.name = initialValue(instance.getName());
+ }
+
+ /**
+ * Creating a new builder for {@code org.javahelpers.simple.builders.example.scoping.TrustedHelperDto}.
+ *
+ * Example:
+ *
+ * {@code
+ * TrustedHelperDtoBuilder builder = TrustedHelperDtoBuilder.create();
+ * }
+ *
+ * @return builder for {@code org.javahelpers.simple.builders.example.scoping.TrustedHelperDto}
+ */
+ public static TrustedHelperDtoBuilder create() {
+ return new TrustedHelperDtoBuilder();
+ }
+
+ /**
+ * Sets the value for name.
+ *
+ * Generated from setter {@link TrustedHelperDto#setName(String) setName(String name)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.name("example value");
+ * }
+ *
+ * @param name name
+ * @return current instance of builder
+ */
+ public TrustedHelperDtoBuilder name(String name) {
+ this.name = changedValue(name);
+ return this;
+ }
+
+ /**
+ * Sets the value for name by executing the provided consumer.
+ *
+ * Generated from setter {@link TrustedHelperDto#setName(String) setName(String name)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.name(sb -> sb.append("text"));
+ * }
+ *
+ * @param nameStringBuilderConsumer consumer providing an instance of name
+ * @return current instance of builder
+ */
+ public TrustedHelperDtoBuilder name(Consumer nameStringBuilderConsumer) {
+ StringBuilder builder = new StringBuilder();
+ nameStringBuilderConsumer.accept(builder);
+ this.name = changedValue(builder.toString());
+ return this;
+ }
+
+ /**
+ * Sets the value for name by invoking the provided supplier.
+ *
+ * Generated from setter {@link TrustedHelperDto#setName(String) setName(String name)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.name(() -> "example value");
+ * }
+ *
+ * @param nameSupplier supplier for name
+ * @return current instance of builder
+ */
+ public TrustedHelperDtoBuilder name(Supplier nameSupplier) {
+ this.name = changedValue(nameSupplier.get());
+ return this;
+ }
+
+ /**
+ * Sets the String value for name by using String.format(format, args). See
+ * {@link String#format(String, Object...)} for details.
+ *
+ * Generated from setter {@link TrustedHelperDto#setName(String) setName(String name)}
+ *
+ *
Example:
+ *
+ * {@code
+ * builder.name("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 TrustedHelperDtoBuilder name(String format, Object... args) {
+ this.name = changedValue(String.format(format, args));
+ return this;
+ }
+
+ /**
+ * Validates that the name field is not null or empty.
+ *
+ * Generated from setter {@link TrustedHelperDto#setName(String) setName(String name)}
+ *
+ * @return this builder instance for chaining
+ * @throws IllegalArgumentException if name is null or empty
+ */
+ TrustedHelperDtoBuilder validateName() {
+ if (!name.isSet() || name.value().trim().isEmpty()) {
+ throw new IllegalArgumentException("Name cannot be null or empty");
+ }
+ 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 TrustedHelperDtoBuilder 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 TrustedHelperDtoBuilder 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
+ * TrustedHelperDto result = builder.build();
+ * }
+ */
+ @Override
+ public TrustedHelperDto build() {
+ TrustedHelperDto result = new TrustedHelperDto();
+ this.name.ifSet(result::setName);
+ 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("name", this.name).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 TrustedHelperDto with(Consumer b) {
+ TrustedHelperDtoBuilder builder;
+ try {
+ builder = new TrustedHelperDtoBuilder(TrustedHelperDto.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException(
+ "The interface 'TrustedHelperDtoBuilder.With' should only be implemented by classes, which could be casted to 'TrustedHelperDto'",
+ ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ }
+
+ /**
+ * Creates a builder initialized from this instance.
+ *
+ * @return a builder initialized with this instance's values
+ */
+ default TrustedHelperDtoBuilder with() {
+ try {
+ return new TrustedHelperDtoBuilder(TrustedHelperDto.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException(
+ "The interface 'TrustedHelperDtoBuilder.With' should only be implemented by classes, which could be casted to 'TrustedHelperDto'",
+ ex);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java b/example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java
new file mode 100644
index 00000000..7aedca0b
--- /dev/null
+++ b/example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java
@@ -0,0 +1,47 @@
+/*
+ * 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
+ * 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.library;
+
+import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+/**
+ * An annotated library DTO outside the generation scope, simulating a precompiled type without a
+ * builder.
+ */
+@SimpleBuilder(
+ options =
+ @SimpleBuilder.Options(
+ builderGenerationPackages = "org.javahelpers.simple.builders.example.scoping"))
+public class LibraryHelperDto {
+ private String code;
+
+ public String getCode() {
+ return code;
+ }
+
+ public void setCode(String code) {
+ this.code = code;
+ }
+}
diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java b/example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java
new file mode 100644
index 00000000..808e92a3
--- /dev/null
+++ b/example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java
@@ -0,0 +1,70 @@
+/*
+ * 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.scoping;
+
+import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+import org.javahelpers.simple.builders.example.SponsorDto;
+import org.javahelpers.simple.builders.example.library.LibraryHelperDto;
+
+/**
+ * Demonstrates generation-scope, usage-scope, and out-of-scope builder decisions.
+ *
+ * {@link TrustedHelperDto} gets a builder consumer, while {@link LibraryHelperDto} and
+ * {@link SponsorDto} get plain setters because their builders are unavailable or out of scope.
+ */
+@SimpleBuilder(
+ options =
+ @SimpleBuilder.Options(
+ builderGenerationPackages = "org.javahelpers.simple.builders.example.scoping",
+ builderUsagePackages = "org.javahelpers.simple.builders.example.library"))
+public class ScopedOwnerDto {
+ private TrustedHelperDto trusted;
+ private LibraryHelperDto library;
+ private SponsorDto sponsor;
+
+ public TrustedHelperDto getTrusted() {
+ return trusted;
+ }
+
+ public void setTrusted(TrustedHelperDto trusted) {
+ this.trusted = trusted;
+ }
+
+ public LibraryHelperDto getLibrary() {
+ return library;
+ }
+
+ public void setLibrary(LibraryHelperDto library) {
+ this.library = library;
+ }
+
+ public SponsorDto getSponsor() {
+ return sponsor;
+ }
+
+ public void setSponsor(SponsorDto sponsor) {
+ this.sponsor = sponsor;
+ }
+}
diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java b/example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java
new file mode 100644
index 00000000..a9ed698d
--- /dev/null
+++ b/example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java
@@ -0,0 +1,40 @@
+/*
+ * 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.scoping;
+
+import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+@SimpleBuilder
+public class TrustedHelperDto {
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+}
diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java
new file mode 100644
index 00000000..f7c6bee8
--- /dev/null
+++ b/example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java
@@ -0,0 +1,101 @@
+/*
+ * 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.scoping;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Arrays;
+import java.util.function.Consumer;
+import org.javahelpers.simple.builders.example.SponsorDto;
+import org.javahelpers.simple.builders.example.SponsorDtoBuilder;
+import org.javahelpers.simple.builders.example.library.LibraryHelperDto;
+import org.junit.jupiter.api.Test;
+
+class ScopedOwnerDtoBuilderTest {
+
+ @Test
+ void exposesScopedBuilderConsumerOverloads() {
+ assertTrue(hasBuilderConsumerMethod("trusted", TrustedHelperDtoBuilder.class.getName()));
+ assertFalse(
+ hasBuilderConsumerMethod(
+ "library", "org.javahelpers.simple.builders.example.library.LibraryHelperDtoBuilder"));
+ assertFalse(hasBuilderConsumerMethod("sponsor", SponsorDtoBuilder.class.getName()));
+
+ assertTrue(hasMethod("trusted", TrustedHelperDto.class));
+ assertTrue(hasMethod("library", LibraryHelperDto.class));
+ assertTrue(hasMethod("sponsor", SponsorDto.class));
+ }
+
+ @Test
+ void buildsValuesThroughScopedApi() {
+ LibraryHelperDto library = new LibraryHelperDto();
+ library.setCode("library-code");
+ SponsorDto sponsor = new SponsorDto();
+ sponsor.setName("sponsor-name");
+
+ ScopedOwnerDto result =
+ ScopedOwnerDtoBuilder.create()
+ .trusted(builder -> builder.name("trusted-name"))
+ .library(library)
+ .sponsor(sponsor)
+ .build();
+
+ assertEquals("trusted-name", result.getTrusted().getName());
+ assertEquals("library-code", result.getLibrary().getCode());
+ assertEquals("sponsor-name", result.getSponsor().getName());
+ }
+
+ private static boolean hasMethod(String name, Class> parameterType) {
+ return Arrays.stream(ScopedOwnerDtoBuilder.class.getMethods())
+ .anyMatch(
+ method ->
+ method.getName().equals(name)
+ && method.getParameterCount() == 1
+ && method.getParameterTypes()[0].equals(parameterType));
+ }
+
+ private static boolean hasBuilderConsumerMethod(String name, String builderTypeName) {
+ return Arrays.stream(ScopedOwnerDtoBuilder.class.getMethods())
+ .anyMatch(
+ method ->
+ method.getName().equals(name)
+ && method.getParameterCount() == 1
+ && hasConsumerType(method.getGenericParameterTypes()[0], builderTypeName));
+ }
+
+ private static boolean hasConsumerType(Type parameterType, String builderTypeName) {
+ if (!(parameterType instanceof ParameterizedType parameterizedType)
+ || !Consumer.class.equals(parameterizedType.getRawType())) {
+ return false;
+ }
+ Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
+ return actualTypeArguments.length == 1
+ && actualTypeArguments[0].getTypeName().equals(builderTypeName);
+ }
+}
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 9b47ea66..937dbc65 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
@@ -29,6 +29,7 @@
import static org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker.PHASE_CODE_GENERATION;
import static org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker.PHASE_CONFIGURATION_RESOLUTION;
import static org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker.PHASE_DTO_MAPPING;
+import static org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker.PHASE_ELEMENT_COLLECTION;
import com.google.auto.service.AutoService;
import java.util.ArrayList;
@@ -88,7 +89,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) {
// Read global configuration from compiler arguments
CompilerArgumentsReader reader = new CompilerArgumentsReader(processingEnv);
- BuilderConfiguration globalConfig = reader.readBuilderConfiguration();
+ BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger);
logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig);
this.context = new ProcessingContext(logger, globalConfig, processingEnv);
@@ -122,46 +123,83 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
// Generate Jackson Module if processing is over and feature is enabled
if (roundEnv.processingOver()) {
- // Generate performance report at the end of processing
- PerformanceTracker tracker = context.getPerformanceTracker();
- tracker.generateReport(logger);
-
- List moduleClassDefs =
- jacksonModuleGenerator.getModuleDefinitions();
- for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) {
- String packageName = moduleClassDef.getTypeName().getPackageName();
- context.info("Generating Jackson Module in package '%s'", packageName);
- try {
- codeGenerator.generateClass(moduleClassDef);
- } catch (BuilderException e) {
- // By default Jackson module generation failures are warnings. In opt-in strict mode
- // they are promoted to errors that fail the build.
- context.reportBasedOnStrictMode(
- "simple-builders: Error generating Jackson module for package %s: %s",
- packageName, e.getMessage());
- }
- }
- // Reset indentation after Jackson module generation as well
- context.resetIndentation();
+ generateJacksonModules(context.getPerformanceTracker());
return false;
}
- BuilderConfigurationReader reader = context.getConfigurationReader();
+ PerformanceTracker tracker = context.getPerformanceTracker();
+ context.info("simple-builders: PROCESSING ROUND START");
- // Find all elements to process: any element annotated with an annotation that is
- // meta-annotated with @SimpleBuilder.Template. This includes @SimpleBuilder itself, which is
- // a built-in template. Configuration is resolved per-element to handle priority correctly.
- Set elementsToProcess = new HashSet<>();
+ tracker.startPhase();
+ Set elementsToProcess = collectElementsToProcess(annotations, roundEnv);
+ // Sort elements alphabetically by simple name for deterministic processing
+ List sortedElements =
+ elementsToProcess.stream()
+ .sorted(Comparator.comparing(element -> element.getSimpleName().toString()))
+ .toList();
+ tracker.endPhase(PHASE_ELEMENT_COLLECTION);
+
+ context.debug(
+ "simple-builders: Processing round started. Found %d annotated elements.",
+ elementsToProcess.size());
+
+ // 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);
+ context.debug(
+ "simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.",
+ elementsToGenerate.size(), sortedElements.size());
+ registerGeneratedTypes(elementsToGenerate);
+
+ int successfulGenerations = generateBuilders(elementsToGenerate, tracker);
+
+ // Log summary of builder generation
+ if (successfulGenerations > 0) {
+ context.info(
+ "simple-builders: Successfully generated %d builder(s) in this processing round",
+ successfulGenerations);
+ }
+
+ // Reset indentation level at the end of each processing round to prevent cascading errors
+ context.resetIndentation();
+ return true;
+ }
+
+ /** Generates all Jackson modules after the last processing round and reports the metrics. */
+ private void generateJacksonModules(PerformanceTracker tracker) {
+ tracker.generateReport(logger);
+
+ List moduleClassDefs = jacksonModuleGenerator.getModuleDefinitions();
+ for (GenerationTargetClassDto moduleClassDef : moduleClassDefs) {
+ String packageName = moduleClassDef.getTypeName().getPackageName();
+ context.info("Generating Jackson Module in package '%s'", packageName);
+ try {
+ codeGenerator.generateClass(moduleClassDef);
+ } catch (BuilderException e) {
+ // By default Jackson module generation failures are warnings. In opt-in strict mode
+ // they are promoted to errors that fail the build.
+ context.reportBasedOnStrictMode(
+ "simple-builders: Error generating Jackson module for package %s: %s",
+ packageName, e.getMessage());
+ }
+ }
+ // Reset indentation after Jackson module generation as well
+ context.resetIndentation();
+ }
- // Find all annotations meta-annotated with @SimpleBuilder.Template (this includes
- // @SimpleBuilder itself, which is now a built-in template). Each such annotation triggers
- // builder generation for the elements it is applied to.
- List annotationsWithTemplate = extractingAnnotationsWithTemplate(annotations);
- for (TypeElement annotation : annotationsWithTemplate) {
+ /**
+ * 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}
+ * itself), minus elements opted out via {@code @Ignore4BuilderGeneration}.
+ */
+ private Set collectElementsToProcess(
+ Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
+ Set elementsToProcess = new HashSet<>();
+ for (TypeElement annotation : extractingAnnotationsWithTemplate(annotations)) {
elementsToProcess.addAll(roundEnv.getElementsAnnotatedWith(annotation));
}
- // Filter out elements explicitly opted out via @Ignore4BuilderGeneration.
// Only directly-declared annotations are checked: the type itself must carry
// the opt-out; a parent carrying it does not cascade, matching the fact that
// @Ignore4BuilderGeneration is intentionally NOT @Inherited.
@@ -177,32 +215,27 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
}
return false;
});
+ return elementsToProcess;
+ }
- context.info("simple-builders: PROCESSING ROUND START");
- context.debug(
- "simple-builders: Processing round started. Found %d annotated elements.",
- elementsToProcess.size());
-
- // Sort elements alphabetically by simple name for deterministic processing
- List sortedElements =
- elementsToProcess.stream()
- .sorted(Comparator.comparing(element -> element.getSimpleName().toString()))
- .toList();
-
- PerformanceTracker tracker = context.getPerformanceTracker();
- int successfulGenerations = 0;
+ /**
+ * Resolves the configuration per element and applies the {@code builderGenerationPackages} scope,
+ * returning the elements that will have a builder generated in this round.
+ */
+ private List resolveGenerationPlan(
+ List sortedElements, BuilderConfigurationReader reader, PerformanceTracker tracker) {
+ List elementsToGenerate = new ArrayList<>();
for (Element annotatedElement : sortedElements) {
context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName());
- String className = annotatedElement.getSimpleName().toString();
- tracker.startClass(className);
try {
- // Track Configuration Resolution (actual work happens here)
tracker.startPhase();
BuilderConfiguration config = reader.resolveConfiguration(annotatedElement);
tracker.endPhase(PHASE_CONFIGURATION_RESOLUTION);
- context.debug("Configuration resolved: %s", config);
- process(annotatedElement, config);
- successfulGenerations++;
+
+ if (!context.getBuilderScopeResolver().isInGenerationScope(annotatedElement, config)) {
+ continue;
+ }
+ elementsToGenerate.add(new ElementToGenerate(annotatedElement, config));
} 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.
@@ -212,17 +245,45 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
context.debugEndOperation();
}
}
+ return elementsToGenerate;
+ }
- // Log summary of builder generation
- if (successfulGenerations > 0) {
- context.info(
- "simple-builders: Successfully generated %d builder(s) in this processing round",
- successfulGenerations);
- }
+ /**
+ * Registers the types whose builders will be generated this round with the scope resolver, so it
+ * can trust them without a type search.
+ */
+ private void registerGeneratedTypes(List elementsToGenerate) {
+ context
+ .getBuilderScopeResolver()
+ .registerGeneratedTypes(
+ elementsToGenerate.stream()
+ .map(ElementToGenerate::element)
+ .filter(TypeElement.class::isInstance)
+ .map(TypeElement.class::cast)
+ .toList());
+ }
- // Reset indentation level at the end of each processing round to prevent cascading errors
- context.resetIndentation();
- return true;
+ /** Generates a builder for each planned element and returns the number of successes. */
+ private int generateBuilders(
+ List elementsToGenerate, PerformanceTracker tracker) {
+ int successfulGenerations = 0;
+ for (ElementToGenerate elementToGenerate : elementsToGenerate) {
+ Element annotatedElement = elementToGenerate.element();
+ context.debugStartOperation("Processing element: " + annotatedElement.getSimpleName());
+ tracker.startClass(annotatedElement.getSimpleName().toString());
+ try {
+ process(annotatedElement, elementToGenerate.config());
+ 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());
+ } finally {
+ context.debugEndOperation();
+ }
+ }
+ return successfulGenerations;
}
@Override
@@ -288,6 +349,8 @@ private void process(Element annotatedElement, BuilderConfiguration config)
builderDef.getBuilderTypeName().getClassName());
}
+ private record ElementToGenerate(Element element, BuilderConfiguration config) {}
+
/**
* 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
new file mode 100644
index 00000000..0f9bc265
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java
@@ -0,0 +1,241 @@
+/*
+ * 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.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;
+import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
+import org.javahelpers.simple.builders.processor.model.core.PackageScopes;
+import org.javahelpers.simple.builders.processor.model.type.TypeName;
+import org.javahelpers.simple.builders.processor.processing.ProcessingContext;
+
+/**
+ * Central resolver that decides whether a builder may be referenced for a given type.
+ *
+ * This resolver is independent of generator/enhancer code; it only relies on the configured
+ * {@code builderUsagePackages} scope (which includes {@code builderGenerationPackages}
+ * automatically), registered generated types, and the availability of builder types on the
+ * classpath.
+ *
+ *
The resolver is constructed once per processing context and refreshes its parsed package
+ * scopes and per-type results when the target configuration changes. Types whose builders are
+ * generated in the current processing round are registered before resolution.
+ */
+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<>();
+
+ /**
+ * Creates a new resolver for the given processing context.
+ *
+ * @param context the processing context providing configuration and type utilities
+ */
+ public BuilderScopeResolver(ProcessingContext context) {
+ this.context = context;
+ }
+
+ /**
+ * Resolves the builder type to use for the given referenced type, if any.
+ *
+ * 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}
+ * has been invoked with the owner element's resolved configuration beforehand, so that
+ * per-element {@code builderUsagePackages} overrides are respected.
+ *
+ *
The decision follows these rules:
+ *
+ *
+ * - If the usage scope is set and the referenced type's package is not in it, no builder may
+ * be referenced. The usage scope includes generation-scope packages automatically. When the
+ * scope is empty, any package is allowed (backward compatibility).
+ *
- 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.
+ *
- 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
+ * the referenced type and a no-arg {@code build()} method returning it. The contract check
+ * is annotation-agnostic, so builders generated with custom template annotations, external
+ * tools, or different suffixes are supported. The referenced type must not be opted out
+ * with {@code @Ignore4BuilderGeneration}.
+ *
+ *
+ * @param referencedType the type element being referenced as a field or collection element
+ * @return the builder type to reference, or empty if no builder should be referenced
+ */
+ public Optional resolveUsableBuilderType(TypeElement referencedType) {
+ if (referencedType == null) {
+ return Optional.empty();
+ }
+ refreshForConfigurationIfNeeded();
+ return resolvedBuilderTypes.computeIfAbsent(
+ referencedType.getQualifiedName().toString(), fqn -> resolve(referencedType));
+ }
+
+ /**
+ * Checks whether a builder may be generated for the given element under the generation scope of
+ * the resolved configuration.
+ *
+ * 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}
+ * has been invoked for the element, so the context does not yet hold the per-element
+ * configuration.
+ *
+ *
An unscoped {@code builderGenerationPackages} allows every element. Otherwise the element's
+ * package must match the scope; skipped elements are logged at debug level.
+ *
+ * @param element the annotated element to check
+ * @param configuration the configuration resolved for that element
+ * @return true if a builder may be generated for the element
+ */
+ public boolean isInGenerationScope(Element element, BuilderConfiguration configuration) {
+ PackageScopes scopes = configuration.builderGenerationPackages();
+ if (scopes.isEmpty()) {
+ return true;
+ }
+ String packageName = context.getPackageName(element);
+ if (!scopes.includes(packageName)) {
+ context.debug(
+ "Skipping %s: package '%s' is not in builderGenerationPackages",
+ element.getSimpleName(), packageName);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Registers the types whose builders are generated in the current processing round.
+ *
+ * @param generatedTypes types whose builders will be generated in this round
+ */
+ public void registerGeneratedTypes(Collection extends TypeElement> generatedTypes) {
+ Set registeredTypeNames = new HashSet<>();
+ for (TypeElement generatedType : generatedTypes) {
+ registeredTypeNames.add(generatedType.getQualifiedName().toString());
+ }
+ generatedTypeNames = registeredTypeNames;
+ resolvedBuilderTypes.clear();
+ }
+
+ private Optional resolve(TypeElement referencedType) {
+ if (referencedType == null || isIgnoredForBuilderGeneration(referencedType)) {
+ return Optional.empty();
+ }
+
+ String referencedTypeFqn = referencedType.getQualifiedName().toString();
+ String packageName = context.getPackageName(referencedType);
+
+ // The usage scope determines whether a type is eligible to be referenced as a builder
+ // helper. When empty, any package is allowed (backward compatibility). When set, only
+ // packages in the scope qualify. The scope already includes generation-scope packages.
+ if (!usagePackages.isEmpty() && !usagePackages.includes(packageName)) {
+ return Optional.empty();
+ }
+
+ // Types whose builders are generated in the current processing round are trusted
+ // immediately — our own generators always produce the builder contract, so no
+ // classpath lookup or contract check is needed. The candidate uses builderSuffix
+ // because that is what our own generators produce.
+ if (generatedTypeNames.contains(referencedTypeFqn)) {
+ TypeName candidate =
+ JavaLangMapper.createBuilderTypeName(
+ referencedType, context, context.getConfiguration().getBuilderSuffix());
+ return Optional.of(candidate);
+ }
+
+ // For types not generated in this round, look up the candidate on the classpath using
+ // builderUsageSuffix (which falls back to builderSuffix if not configured) and verify
+ // the builder contract.
+ String suffix = context.getConfiguration().getBuilderUsageSuffix();
+ TypeName candidate = JavaLangMapper.createBuilderTypeName(referencedType, context, suffix);
+ return resolveByBuilderContract(candidate, referencedTypeFqn);
+ }
+
+ /**
+ * Looks up the candidate builder type on the classpath and verifies it satisfies the builder
+ * contract: a constructor accepting the referenced type and a no-arg {@code build()} method
+ * returning it. The contract check is annotation-agnostic, so builders generated with custom
+ * template annotations or from external sources are supported as long as they follow the builder
+ * contract. It also avoids false positives like {@code String} → {@code StringBuilder}.
+ *
+ * @param candidate the candidate builder type name to look up
+ * @param expectedType the qualified name of the referenced type the builder must accept and
+ * return
+ * @return the candidate if a matching builder class exists on the classpath, empty otherwise
+ */
+ private Optional resolveByBuilderContract(TypeName candidate, String expectedType) {
+ TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName());
+ if (builderTypeElement == null) {
+ return Optional.empty();
+ }
+ if (!JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) {
+ return Optional.empty();
+ }
+ if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) {
+ return Optional.empty();
+ }
+ return Optional.of(candidate);
+ }
+
+ private void refreshForConfigurationIfNeeded() {
+ BuilderConfiguration configuration = context.getConfiguration();
+ if (Objects.equals(cachedConfiguration, configuration)) {
+ return;
+ }
+ // The effective usage scope combines builderUsagePackages and builderGenerationPackages,
+ // since generation-scope packages are automatically included in the usage scope.
+ PackageScopes generation =
+ configuration == null
+ ? PackageScopes.unscoped()
+ : configuration.builderGenerationPackages();
+ PackageScopes usage =
+ configuration == null ? PackageScopes.unscoped() : configuration.builderUsagePackages();
+ usagePackages = PackageScopes.merge(generation, usage);
+ resolvedBuilderTypes.clear();
+ cachedConfiguration = configuration;
+ }
+
+ private static boolean isIgnoredForBuilderGeneration(TypeElement typeElement) {
+ if (typeElement == null) {
+ return false;
+ }
+ return JavaLangAnalyser.findAnnotation(typeElement, Ignore4BuilderGeneration.class).isPresent();
+ }
+}
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 cad55271..9da8610c 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
@@ -152,19 +152,6 @@ public static boolean isNotStatic(ExecutableElement mth) {
return !mth.getModifiers().contains(STATIC);
}
- /**
- * Checks whether the given {@link TypeElement} declares generic type parameters.
- *
- * @param typeElement the type element to inspect
- * @return {@code true} if the type declares one or more type parameters; {@code false} otherwise
- */
- public static boolean hasGenericTypes(TypeElement typeElement) {
- if (typeElement == null) {
- return false;
- }
- return CollectionUtils.isNotEmpty(typeElement.getTypeParameters());
- }
-
/**
* Checks whether the given {@link ExecutableElement} declares generic type parameters.
*
@@ -232,6 +219,53 @@ public static Optional findAnnotation(
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.
+ *
+ * The check is annotation-agnostic and avoids false positives like {@code StringBuilder} for
+ * {@code String}.
+ *
+ * @param builderType the candidate builder type element to check
+ * @param expectedReturnType the qualified name of the type that {@code build()} must return
+ * @param context the processing context, used to access all members
+ * @return {@code true} if the type declares or inherits a matching {@code build()} method
+ */
+ public static boolean hasBuildMethodReturning(
+ TypeElement builderType, String expectedReturnType, ProcessingContext context) {
+ if (builderType == null) {
+ return false;
+ }
+ return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream()
+ .anyMatch(
+ method ->
+ method.getSimpleName().contentEquals("build")
+ && method.getParameters().isEmpty()
+ && method.getReturnType().getKind() != VOID
+ && method.getReturnType().toString().equals(expectedReturnType));
+ }
+
+ /**
+ * Checks whether the given type has a constructor accepting the expected type. This is part of
+ * the builder contract used when the field already has a value that is passed to the builder.
+ *
+ * @param builderType the candidate builder type element to check
+ * @param expectedType the qualified name of the type the constructor must accept
+ * @param context the processing context, used to access all members
+ * @return {@code true} if the type declares or inherits a matching constructor
+ */
+ public static boolean hasConstructorAccepting(
+ TypeElement builderType, String expectedType, ProcessingContext context) {
+ if (builderType == null) {
+ return false;
+ }
+ return ElementFilter.constructorsIn(context.getAllMembers(builderType)).stream()
+ .anyMatch(
+ constructor ->
+ constructor.getParameters().size() == 1
+ && constructor.getParameters().get(0).asType().toString().equals(expectedType));
+ }
+
/**
* Determines whether a given type element is a functional interface.
*
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
index 9ac9edde..ab54646a 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
@@ -23,15 +23,10 @@
*/
package org.javahelpers.simple.builders.processor.analysis;
-import static javax.lang.model.element.Modifier.DEFAULT;
-import static javax.lang.model.element.Modifier.PROTECTED;
-import static javax.lang.model.element.Modifier.PUBLIC;
import static javax.lang.model.type.TypeKind.*;
import java.util.ArrayList;
import java.util.List;
-import java.util.Optional;
-import java.util.Set;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.Modifier;
@@ -44,8 +39,6 @@
import javax.lang.model.type.TypeMirror;
import javax.lang.model.type.TypeVariable;
import javax.lang.model.util.SimpleTypeVisitor14;
-import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
-import org.javahelpers.simple.builders.core.enums.AccessModifier;
import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto;
import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto;
import org.javahelpers.simple.builders.processor.model.type.GenericParameterDto;
@@ -66,41 +59,6 @@ private JavaLangMapper() {
// Utility class
}
- /**
- * Mapper for {@code java.util.Set} to extract the relevant
- * modifier. If there is a public modifier, this is returned. If there is a protected modifier,
- * this will be returned. Default is returned in all other cases.
- *
- * @param modifier Set of modifiers to be checked
- * @return DEFAULT, PUBLIC or PROTECTED
- */
- public static Modifier mapRelevantModifier(Set modifier) {
- if (modifier.contains(PUBLIC)) {
- return PUBLIC;
- } else if (modifier.contains(PROTECTED)) {
- return PROTECTED;
- }
- return DEFAULT;
- }
-
- /**
- * Maps an AccessModifier enum value to a javax.lang.model.element.Modifier.
- *
- * @param accessModifier the access modifier to map
- * @return the corresponding Modifier, or null for package-private
- */
- public static Modifier mapAccessModifier(AccessModifier accessModifier) {
- if (accessModifier == null) {
- return null;
- }
- return switch (accessModifier) {
- case PUBLIC, DEFAULT -> Modifier.PUBLIC;
- case PROTECTED -> Modifier.PROTECTED;
- case PRIVATE -> Modifier.PRIVATE;
- case PACKAGE_PRIVATE -> null; // Package-private has no explicit modifier
- };
- }
-
/**
* Mapping a Java-Class to a TypeName. This method does not expact sealed or annonymous classes.
*
@@ -202,36 +160,24 @@ public static TypeName map2TypeName(TypeElement typeElement, ProcessingContext c
*/
private static void setBuilderAndConstructorInfo(
TypeName typeName, TypeElement typeElement, ProcessingContext context) {
- setBuilderTypeIfAnnotated(typeName, typeElement, context);
+ setBuilderTypeIfScopeMatches(typeName, typeElement, context);
setEmptyConstructorInfoIfAvailable(typeName, typeElement, context);
- setElementBuilderTypeForGenericCollections(typeName, context);
+ setElementBuilderTypeIfScopeMatches(typeName, context);
}
/**
- * Sets the builder type if the type element has @SimpleBuilder annotation.
+ * Sets the builder type if the type element's package is in the configured builder scope.
*
* @param typeName the TypeName to enhance
* @param typeElement the type element to check
* @param context the processing context
*/
- private static void setBuilderTypeIfAnnotated(
+ private static void setBuilderTypeIfScopeMatches(
TypeName typeName, TypeElement typeElement, ProcessingContext context) {
- // Types explicitly opted out must never be referenced as builders by other DTOs.
- if (JavaLangAnalyser.findAnnotation(typeElement, Ignore4BuilderGeneration.class).isPresent()) {
- return;
- }
-
- Optional foundBuilderAnnotation =
- JavaLangAnalyser.findAnnotation(
- typeElement, org.javahelpers.simple.builders.core.annotations.SimpleBuilder.class);
-
- // Type element must have @SimpleBuilder annotation
- if (foundBuilderAnnotation.isEmpty()) {
- return;
- }
-
- TypeName builderType = createBuilderTypeName(typeElement, context);
- typeName.setBuilderType(builderType);
+ context
+ .getBuilderScopeResolver()
+ .resolveUsableBuilderType(typeElement)
+ .ifPresent(typeName::setBuilderType);
}
/**
@@ -251,12 +197,13 @@ private static void setEmptyConstructorInfoIfAvailable(
}
/**
- * Sets element builder type for generic collections with @SimpleBuilder annotated elements.
+ * Sets the element builder type for generic collections when the element type's package is in the
+ * configured builder scope.
*
* @param typeName the TypeName to enhance
* @param context the processing context
*/
- private static void setElementBuilderTypeForGenericCollections(
+ private static void setElementBuilderTypeIfScopeMatches(
TypeName typeName, ProcessingContext context) {
// Only process generic types
if (!(typeName instanceof TypeNameGeneric genericType)) {
@@ -277,20 +224,11 @@ private static void setElementBuilderTypeForGenericCollections(
return;
}
- // Opted-out element types must never be referenced as element builders.
- if (JavaLangAnalyser.findAnnotation(elementTypeElement, Ignore4BuilderGeneration.class)
- .isPresent()) {
- return;
- }
-
- // Element type must have @SimpleBuilder annotation
- if (!hasSimpleBuilderAnnotation(elementTypeElement)) {
- return;
- }
-
- // Set the element builder type
- TypeName elementBuilderType = createBuilderTypeName(elementTypeElement, context);
- genericType.setElementBuilderType(elementBuilderType);
+ // Resolve usable element builder type through the scope resolver
+ context
+ .getBuilderScopeResolver()
+ .resolveUsableBuilderType(elementTypeElement)
+ .ifPresent(genericType::setElementBuilderType);
}
/**
@@ -307,44 +245,32 @@ private static TypeElement retrieveTypeElementIfExists(
}
/**
- * Checks if a TypeElement has the @SimpleBuilder annotation.
+ * Creates a TypeName for the builder of a given TypeElement using the configured builder suffix.
*
- * @param typeElement the type element to check
- * @return true if the element has @SimpleBuilder annotation
+ * @param typeElement the type element to create builder name for
+ * @param context the processing context
+ * @return the TypeName for the builder
*/
- private static boolean hasSimpleBuilderAnnotation(TypeElement typeElement) {
- Optional annotation =
- JavaLangAnalyser.findAnnotation(
- typeElement, org.javahelpers.simple.builders.core.annotations.SimpleBuilder.class);
- return annotation.isPresent();
+ public static TypeName createBuilderTypeName(TypeElement typeElement, ProcessingContext context) {
+ return createBuilderTypeName(
+ typeElement, context, context.getConfiguration().getBuilderSuffix());
}
/**
- * Creates a TypeName for the builder of a given TypeElement.
+ * Creates a TypeName for the builder of a given TypeElement using an explicit suffix.
*
* @param typeElement the type element to create builder name for
* @param context the processing context
+ * @param suffix the builder class name suffix to append
* @return the TypeName for the builder
*/
- private static TypeName createBuilderTypeName(
- TypeElement typeElement, ProcessingContext context) {
- String builderClassName =
- typeElement.getSimpleName().toString() + context.getConfiguration().getBuilderSuffix();
- String packageName = extractPackageName(typeElement.getQualifiedName().toString());
+ public static TypeName createBuilderTypeName(
+ TypeElement typeElement, ProcessingContext context, String suffix) {
+ String builderClassName = typeElement.getSimpleName().toString() + suffix;
+ String packageName = context.getPackageName(typeElement);
return new TypeName(packageName, builderClassName);
}
- /**
- * Extracts the package name from a qualified class name.
- *
- * @param qualifiedName the fully qualified class name (e.g., "com.example.MyClass")
- * @return the package name (e.g., "com.example"), or empty string if no package
- */
- private static String extractPackageName(String qualifiedName) {
- int lastDot = qualifiedName.lastIndexOf('.');
- return lastDot > 0 ? qualifiedName.substring(0, lastDot) : "";
- }
-
/**
* Sets builder and constructor information on the TypeName.
*
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
index 7d3fad82..e2fe9d5e 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
@@ -27,6 +27,7 @@
import static org.javahelpers.simple.builders.core.enums.AccessModifier.*;
import static org.javahelpers.simple.builders.core.enums.OptionState.*;
+import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
@@ -64,7 +65,14 @@
* @param implementsBuilderBase Implement IBuilderBase interface
* @param generateWithInterface Generate With interface
* @param generateJavaDoc Generate Javadoc comments
+ * @param builderGenerationPackages Packages for which builders are generated; automatically part of
+ * the usage scope (unscoped = no restriction)
+ * @param builderUsagePackages Additional packages whose builders may be used as helpers (unscoped =
+ * all packages if builderGenerationPackages is unscoped as well, otherwise only the generation
+ * scope is usable)
* @param builderSuffix Suffix for builder class name
+ * @param builderUsageSuffix Suffix for builder class name when referencing builders from the usage
+ * scope; null means fall back to {@code builderSuffix}
* @param setterSuffix Suffix for setter method names
* @param formattingMode Formatting mode for generated source code (null = inherit from compiler
* arg)
@@ -96,11 +104,21 @@ public record BuilderConfiguration(
OptionState generateJacksonModule,
OptionState generateJavaDoc,
String jacksonModulePackage,
+ PackageScopes builderGenerationPackages,
+ PackageScopes builderUsagePackages,
String builderSuffix,
+ String builderUsageSuffix,
String setterSuffix,
String formattingMode,
OptionState strict) {
+ public BuilderConfiguration {
+ builderGenerationPackages =
+ builderGenerationPackages == null ? PackageScopes.unscoped() : builderGenerationPackages;
+ builderUsagePackages =
+ builderUsagePackages == null ? PackageScopes.unscoped() : builderUsagePackages;
+ }
+
public static final BuilderConfiguration DEFAULT =
builder()
.generateSupplier(ENABLED)
@@ -128,6 +146,8 @@ public record BuilderConfiguration(
.generateJacksonModule(DISABLED)
.generateJavaDoc(ENABLED)
.jacksonModulePackage(null)
+ .builderGenerationPackages(PackageScopes.unscoped())
+ .builderUsagePackages(PackageScopes.unscoped())
.builderSuffix("Builder")
.setterSuffix("")
.formattingMode(FormattingMode.JDT.getOptionValue())
@@ -236,10 +256,37 @@ public String getJacksonModulePackage() {
return jacksonModulePackage;
}
+ /**
+ * Returns the parsed set of builder generation package scopes.
+ *
+ * @return set of package names and their subpackages, empty if unset
+ */
+ public Set getBuilderGenerationPackagesSet() {
+ return builderGenerationPackages.packages();
+ }
+
+ /**
+ * Returns the parsed set of builder usage package scopes.
+ *
+ * @return set of package names and their subpackages, empty if unset
+ */
+ public Set getBuilderUsagePackagesSet() {
+ return builderUsagePackages.packages();
+ }
+
public String getBuilderSuffix() {
return builderSuffix;
}
+ /**
+ * Returns the suffix used when referencing builders from the usage scope.
+ *
+ * @return the usage-scope builder suffix, or {@link #getBuilderSuffix()} if not configured
+ */
+ public String getBuilderUsageSuffix() {
+ return StringUtils.defaultIfBlank(builderUsageSuffix, builderSuffix);
+ }
+
public String getSetterSuffix() {
return setterSuffix;
}
@@ -317,7 +364,11 @@ public BuilderConfiguration merge(BuilderConfiguration other) {
mergeOptionState(other.generateJacksonModule, this.generateJacksonModule))
.generateJavaDoc(mergeOptionState(other.generateJavaDoc, this.generateJavaDoc))
.jacksonModulePackage(mergeString(other.jacksonModulePackage, this.jacksonModulePackage))
+ .builderGenerationPackages(
+ overrideScopes(other.builderGenerationPackages, this.builderGenerationPackages))
+ .builderUsagePackages(overrideScopes(other.builderUsagePackages, this.builderUsagePackages))
.builderSuffix(mergeString(other.builderSuffix, this.builderSuffix))
+ .builderUsageSuffix(mergeString(other.builderUsageSuffix, this.builderUsageSuffix))
.setterSuffix(mergeString(other.setterSuffix, this.setterSuffix))
.formattingMode(mergeString(other.formattingMode, this.formattingMode))
.strict(mergeOptionState(other.strict, this.strict))
@@ -361,6 +412,22 @@ private static String mergeString(String other, String thisValue) {
return other != null && !other.isEmpty() ? other : thisValue;
}
+ /**
+ * Resolves a package scope value by override: the other configuration takes priority when it is
+ * scoped; unscoped means unset and falls back to this configuration's value.
+ *
+ * This is an override, not a union — when the other configuration specifies any packages, this
+ * configuration's packages are completely replaced. This matches the configuration layering
+ * semantics: annotation-level options override compiler-argument-level options when set.
+ *
+ * @param other the other scopes (higher priority)
+ * @param thisValue the current scopes (lower priority)
+ * @return the resolved scopes
+ */
+ private static PackageScopes overrideScopes(PackageScopes other, PackageScopes thisValue) {
+ return other.isEmpty() ? thisValue : other;
+ }
+
@Override
public String toString() {
return new ConfigToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE)
@@ -385,7 +452,10 @@ public String toString() {
.appendValueIfSet("generateJacksonModule", generateJacksonModule)
.appendValueIfSet("generateJavaDoc", generateJavaDoc)
.appendIfNotEmpty("jacksonModulePackage", jacksonModulePackage)
+ .appendIfNotEmpty("builderGenerationPackages", builderGenerationPackages.toString())
+ .appendIfNotEmpty("builderUsagePackages", builderUsagePackages.toString())
.appendIfNotEmpty("builderSuffix", builderSuffix)
+ .appendIfNotEmpty("builderUsageSuffix", builderUsageSuffix)
.appendIfNotEmpty("setterSuffix", setterSuffix)
.appendIfNotEmpty("formattingMode", formattingMode)
.appendValueIfSet("strict", strict)
@@ -468,11 +538,16 @@ public static class Builder {
// === Naming ===
private String builderSuffix = null;
+ private String builderUsageSuffix = null;
private String setterSuffix = null;
// === Formatting ===
private String formattingMode = null;
+ // === Builder Scoping ===
+ private PackageScopes builderGenerationPackages = null;
+ private PackageScopes builderUsagePackages = null;
+
// === Error Handling ===
private OptionState strict = OptionState.UNSET;
@@ -562,6 +637,26 @@ public Builder jacksonModulePackage(String value) {
return this;
}
+ public Builder builderGenerationPackages(String value) {
+ this.builderGenerationPackages = PackageScopes.parse(value);
+ return this;
+ }
+
+ public Builder builderGenerationPackages(PackageScopes value) {
+ this.builderGenerationPackages = value;
+ return this;
+ }
+
+ public Builder builderUsagePackages(String value) {
+ this.builderUsagePackages = PackageScopes.parse(value);
+ return this;
+ }
+
+ public Builder builderUsagePackages(PackageScopes value) {
+ this.builderUsagePackages = value;
+ return this;
+ }
+
public Builder generateVarArgsHelpers(OptionState value) {
this.generateVarArgsHelpers = value;
return this;
@@ -727,6 +822,11 @@ public Builder builderSuffix(String value) {
return this;
}
+ public Builder builderUsageSuffix(String value) {
+ this.builderUsageSuffix = value == null ? null : value.trim();
+ return this;
+ }
+
public Builder setterSuffix(String value) {
this.setterSuffix = value == null ? null : value.trim();
return this;
@@ -774,7 +874,10 @@ public BuilderConfiguration build() {
generateJacksonModule,
generateJavaDoc,
jacksonModulePackage,
+ builderGenerationPackages,
+ builderUsagePackages,
builderSuffix,
+ builderUsageSuffix,
setterSuffix,
formattingMode,
strict);
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/PackageScopes.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/PackageScopes.java
new file mode 100644
index 00000000..fd27c90f
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/PackageScopes.java
@@ -0,0 +1,159 @@
+/*
+ * 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.model.core;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
+
+/**
+ * Immutable set of package scopes used for builder generation and usage restrictions.
+ *
+ *
A package is in scope when it equals a configured scope or is a subpackage of one; matching
+ * ignores case. An empty scope set is "unscoped" and matches nothing — whether that means "no
+ * restriction" is up to the caller.
+ */
+public final class PackageScopes {
+
+ private static final PackageScopes UNSCOPED = new PackageScopes(Set.of());
+
+ private final Set packages;
+
+ private PackageScopes(Set packages) {
+ this.packages = packages;
+ }
+
+ /**
+ * Returns the unscoped instance holding no packages.
+ *
+ * @return the shared unscoped instance
+ */
+ public static PackageScopes unscoped() {
+ return UNSCOPED;
+ }
+
+ /**
+ * Parses a comma-separated list of package names into scopes.
+ *
+ * @param value the raw configured value, may be null or blank
+ * @return the parsed scopes, unscoped when the value is blank
+ */
+ public static PackageScopes parse(String value) {
+ if (StringUtils.isBlank(value)) {
+ return UNSCOPED;
+ }
+ Set packages =
+ Stream.ofNullable(StringUtils.split(value, ','))
+ .flatMap(Arrays::stream)
+ .map(String::trim)
+ .filter(StringUtils::isNotBlank)
+ .map(packageName -> packageName.toLowerCase(Locale.ROOT))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ return packages.isEmpty() ? UNSCOPED : new PackageScopes(Collections.unmodifiableSet(packages));
+ }
+
+ /**
+ * Returns whether no package scopes are configured.
+ *
+ * @return true if this instance holds no packages
+ */
+ public boolean isEmpty() {
+ return packages.isEmpty();
+ }
+
+ /**
+ * Checks whether the given package is within the configured scopes.
+ *
+ * A package matches when it equals a configured scope or is one of its subpackages. Matching
+ * ignores case. An empty scope set matches nothing.
+ *
+ * @param packageName the package to check
+ * @return true if the package is within one of the configured scopes
+ */
+ public boolean includes(String packageName) {
+ for (String scope : packages) {
+ if (Strings.CI.equals(packageName, scope)
+ || Strings.CI.startsWith(packageName, scope + ".")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Merges two package scopes into a combined scope containing all packages from both.
+ *
+ *
If either scope is empty, the other is returned unchanged. If both are empty, the unscoped
+ * instance is returned.
+ *
+ * @param a the first scope, may be unscoped
+ * @param b the second scope, may be unscoped
+ * @return the merged scope
+ */
+ public static PackageScopes merge(PackageScopes a, PackageScopes b) {
+ if (a.isEmpty()) {
+ return b;
+ }
+ if (b.isEmpty()) {
+ return a;
+ }
+ Set merged = new LinkedHashSet<>(a.packages());
+ merged.addAll(b.packages());
+ return new PackageScopes(Collections.unmodifiableSet(merged));
+ }
+
+ /**
+ * Returns the configured package names in declaration order.
+ *
+ * @return unmodifiable set of package names
+ */
+ public Set packages() {
+ return packages;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ return o instanceof PackageScopes other && packages.equals(other.packages);
+ }
+
+ @Override
+ public int hashCode() {
+ return packages.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return String.join(", ", packages);
+ }
+}
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 da146f06..26e4b395 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
@@ -36,7 +36,6 @@
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import org.javahelpers.simple.builders.core.enums.AccessModifier;
-import org.javahelpers.simple.builders.core.enums.OptionState;
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;
@@ -286,71 +285,28 @@ private BuilderConfiguration parseOptionsFromMirror(AnnotationMirror optionsMirr
BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
- for (Map.Entry extends ExecutableElement, ? extends AnnotationValue> entry :
- values.entrySet()) {
- String name = entry.getKey().getSimpleName().toString();
- Object value = entry.getValue().getValue();
- String enumValue = extractEnumName(value);
-
- switch (name) {
- case "generateFieldSupplier" -> builder.generateSupplier(OptionState.valueOf(enumValue));
- case "generateFieldConsumer" -> builder.generateConsumer(OptionState.valueOf(enumValue));
- case "generateBuilderConsumer" ->
- builder.generateBuilderConsumer(OptionState.valueOf(enumValue));
- case "generateConditionalHelper" ->
- builder.generateConditionalLogic(OptionState.valueOf(enumValue));
- case "builderAccess" -> builder.builderAccess(AccessModifier.valueOf(enumValue));
- case "builderConstructorAccess" ->
- builder.builderConstructorAccess(AccessModifier.valueOf(enumValue));
- case "methodAccess" -> builder.methodAccess(AccessModifier.valueOf(enumValue));
- case "generateVarArgsHelpers" ->
- builder.generateVarArgsHelpers(OptionState.valueOf(enumValue));
- case "generateStringFormatHelpers" ->
- builder.generateStringFormatHelpers(OptionState.valueOf(enumValue));
- case "generateAddToCollectionHelpers" ->
- builder.generateAddToCollectionHelpers(OptionState.valueOf(enumValue));
- case "generateUnboxedOptional" ->
- builder.generateUnboxedOptional(OptionState.valueOf(enumValue));
- case "copyTypeAnnotations" -> builder.copyTypeAnnotations(OptionState.valueOf(enumValue));
- case "usingArrayListBuilder" ->
- builder.usingArrayListBuilder(OptionState.valueOf(enumValue));
- case "usingArrayListBuilderWithElementBuilders" ->
- builder.usingArrayListBuilderWithElementBuilders(OptionState.valueOf(enumValue));
- case "usingHashSetBuilder" -> builder.usingHashSetBuilder(OptionState.valueOf(enumValue));
- case "usingHashSetBuilderWithElementBuilders" ->
- builder.usingHashSetBuilderWithElementBuilders(OptionState.valueOf(enumValue));
- case "usingHashMapBuilder" -> builder.usingHashMapBuilder(OptionState.valueOf(enumValue));
- case "usingGeneratedAnnotation" ->
- builder.usingGeneratedAnnotation(OptionState.valueOf(enumValue));
- case "usingBuilderImplementationAnnotation" ->
- builder.usingBuilderImplementationAnnotation(OptionState.valueOf(enumValue));
- case "implementsBuilderBase" ->
- builder.implementsBuilderBase(OptionState.valueOf(enumValue));
- case "generateWithInterface" ->
- builder.generateWithInterface(OptionState.valueOf(enumValue));
- case "usingJacksonDeserializerAnnotation" ->
- builder.usingJacksonDeserializerAnnotation(OptionState.valueOf(enumValue));
- case "generateJacksonModule" ->
- builder.generateJacksonModule(OptionState.valueOf(enumValue));
- case "generateJavaDoc" -> builder.generateJavaDoc(OptionState.valueOf(enumValue));
- case "jacksonModulePackage" -> builder.jacksonModulePackage(value.toString());
- case "builderSuffix" -> builder.builderSuffix(value.toString());
- case "setterSuffix" -> builder.setterSuffix(value.toString());
- case "formattingMode" -> builder.formattingMode(value.toString());
- default ->
- logger.warning(
- "Unknown configuration option '%s' with value '%s' - ignoring", name, value);
- }
- }
+ values.entrySet().forEach(entry -> applyOption(builder, entry.getKey(), entry.getValue()));
return builder.build();
}
- private String extractEnumName(Object value) {
- String enumString = value.toString();
- return enumString.contains(".")
- ? enumString.substring(enumString.lastIndexOf('.') + 1)
- : enumString;
+ private void applyOption(
+ BuilderConfiguration.Builder builder,
+ ExecutableElement key,
+ AnnotationValue annotationValue) {
+ String name = key.getSimpleName().toString();
+ Object value = annotationValue.getValue();
+ CompilerArgumentsEnum option = CompilerArgumentsEnum.fromOptionName(name);
+ if (isNotABuilderOption(option)) {
+ logger.warning("Unknown configuration option '%s' with value '%s' - ignoring", name, value);
+ return;
+ }
+ option.apply(builder, value, logger);
+ }
+
+ /** Returns whether the option is unknown or not settable via @SimpleBuilder.Options. */
+ private static boolean isNotABuilderOption(CompilerArgumentsEnum option) {
+ return option == null || !option.hasValueApplier();
}
private enum AnnotationScope {
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
index 49be4f39..43970a46 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
@@ -24,6 +24,13 @@
package org.javahelpers.simple.builders.processor.processing;
+import java.util.function.BiConsumer;
+import org.javahelpers.simple.builders.core.enums.AccessModifier;
+import org.javahelpers.simple.builders.core.enums.OptionState;
+import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
+import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration.Builder;
+import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
+
/**
* Enumeration of all builder configuration compiler arguments.
*
@@ -40,91 +47,117 @@
public enum CompilerArgumentsEnum {
// === Field Setter Generation ===
/** Option for field supplier generation. */
- GENERATE_FIELD_SUPPLIER("generateFieldSupplier"),
+ GENERATE_FIELD_SUPPLIER("generateFieldSupplier", optionState(Builder::generateSupplier)),
/** Option for field consumer generation. */
- GENERATE_FIELD_CONSUMER("generateFieldConsumer"),
+ GENERATE_FIELD_CONSUMER("generateFieldConsumer", optionState(Builder::generateConsumer)),
/** Option for builder consumer generation. */
- GENERATE_BUILDER_CONSUMER("generateBuilderConsumer"),
+ GENERATE_BUILDER_CONSUMER(
+ "generateBuilderConsumer", optionState(Builder::generateBuilderConsumer)),
// === Conditional Logic ===
/** Option for conditional helper generation. */
- GENERATE_CONDITIONAL_HELPER("generateConditionalHelper"),
+ GENERATE_CONDITIONAL_HELPER(
+ "generateConditionalHelper", optionState(Builder::generateConditionalLogic)),
// === Access Control ===
/** Option for builder access level. */
- BUILDER_ACCESS("builderAccess"),
+ BUILDER_ACCESS("builderAccess", accessModifier(Builder::builderAccess)),
/** Option for builder constructor access level. */
- BUILDER_CONSTRUCTOR_ACCESS("builderConstructorAccess"),
+ BUILDER_CONSTRUCTOR_ACCESS(
+ "builderConstructorAccess", accessModifier(Builder::builderConstructorAccess)),
/** Option for method access level. */
- METHOD_ACCESS("methodAccess"),
+ METHOD_ACCESS("methodAccess", accessModifier(Builder::methodAccess)),
// === Collection Options ===
/** Option for varargs helper generation. */
- GENERATE_VAR_ARGS_HELPERS("generateVarArgsHelpers"),
+ GENERATE_VAR_ARGS_HELPERS("generateVarArgsHelpers", optionState(Builder::generateVarArgsHelpers)),
/** Option for string format helper generation. */
- GENERATE_STRING_FORMAT_HELPERS("generateStringFormatHelpers"),
+ GENERATE_STRING_FORMAT_HELPERS(
+ "generateStringFormatHelpers", optionState(Builder::generateStringFormatHelpers)),
/** Option for add to collection helper generation. */
- GENERATE_ADD_TO_COLLECTION_HELPERS("generateAddToCollectionHelpers"),
+ GENERATE_ADD_TO_COLLECTION_HELPERS(
+ "generateAddToCollectionHelpers", optionState(Builder::generateAddToCollectionHelpers)),
/** Option for unboxed optional generation. */
- GENERATE_UNBOXED_OPTIONAL("generateUnboxedOptional"),
+ GENERATE_UNBOXED_OPTIONAL(
+ "generateUnboxedOptional", optionState(Builder::generateUnboxedOptional)),
/** Option for copying type annotations. */
- COPY_TYPE_ANNOTATIONS("copyTypeAnnotations"),
+ COPY_TYPE_ANNOTATIONS("copyTypeAnnotations", optionState(Builder::copyTypeAnnotations)),
/** Option for ArrayList builder usage. */
- USING_ARRAY_LIST_BUILDER("usingArrayListBuilder"),
+ USING_ARRAY_LIST_BUILDER("usingArrayListBuilder", optionState(Builder::usingArrayListBuilder)),
/** Option for ArrayList builder with element builders usage. */
- USING_ARRAY_LIST_BUILDER_WITH_ELEMENT_BUILDERS("usingArrayListBuilderWithElementBuilders"),
+ USING_ARRAY_LIST_BUILDER_WITH_ELEMENT_BUILDERS(
+ "usingArrayListBuilderWithElementBuilders",
+ optionState(Builder::usingArrayListBuilderWithElementBuilders)),
/** Option for HashSet builder usage. */
- USING_HASH_SET_BUILDER("usingHashSetBuilder"),
+ USING_HASH_SET_BUILDER("usingHashSetBuilder", optionState(Builder::usingHashSetBuilder)),
/** Option for HashSet builder with element builders usage. */
- USING_HASH_SET_BUILDER_WITH_ELEMENT_BUILDERS("usingHashSetBuilderWithElementBuilders"),
+ USING_HASH_SET_BUILDER_WITH_ELEMENT_BUILDERS(
+ "usingHashSetBuilderWithElementBuilders",
+ optionState(Builder::usingHashSetBuilderWithElementBuilders)),
/** Option for HashMap builder usage. */
- USING_HASH_MAP_BUILDER("usingHashMapBuilder"),
+ USING_HASH_MAP_BUILDER("usingHashMapBuilder", optionState(Builder::usingHashMapBuilder)),
// === Annotations ===
/** Option for using Generated annotation. */
- USING_GENERATED_ANNOTATION("usingGeneratedAnnotation"),
+ USING_GENERATED_ANNOTATION(
+ "usingGeneratedAnnotation", optionState(Builder::usingGeneratedAnnotation)),
/** Option for using BuilderImplementation annotation. */
- USING_BUILDER_IMPLEMENTATION_ANNOTATION("usingBuilderImplementationAnnotation"),
+ USING_BUILDER_IMPLEMENTATION_ANNOTATION(
+ "usingBuilderImplementationAnnotation",
+ optionState(Builder::usingBuilderImplementationAnnotation)),
// === Integration ===
/** Option for implementing IBuilderBase interface. */
- IMPLEMENTS_BUILDER_BASE("implementsBuilderBase"),
+ IMPLEMENTS_BUILDER_BASE("implementsBuilderBase", optionState(Builder::implementsBuilderBase)),
/** Option for With interface generation. */
- GENERATE_WITH_INTERFACE("generateWithInterface"),
+ GENERATE_WITH_INTERFACE("generateWithInterface", optionState(Builder::generateWithInterface)),
/** Option for Jackson support. */
- USING_JACKSON_DESERIALIZER_ANNOTATION("usingJacksonDeserializerAnnotation"),
+ USING_JACKSON_DESERIALIZER_ANNOTATION(
+ "usingJacksonDeserializerAnnotation",
+ optionState(Builder::usingJacksonDeserializerAnnotation)),
/** Option for Jackson Module generation. */
- GENERATE_JACKSON_MODULE("generateJacksonModule"),
+ GENERATE_JACKSON_MODULE("generateJacksonModule", optionState(Builder::generateJacksonModule)),
/** Option for Javadoc generation on the generated builder. */
- GENERATE_JAVADOC("generateJavaDoc"),
+ GENERATE_JAVADOC("generateJavaDoc", optionState(Builder::generateJavaDoc)),
/** Option for Jackson Module package name. */
- JACKSON_MODULE_PACKAGE("jacksonModulePackage"),
+ JACKSON_MODULE_PACKAGE("jacksonModulePackage", string(Builder::jacksonModulePackage)),
+
+ // === Builder Scoping ===
+ /** Option for builder generation packages. */
+ BUILDER_GENERATION_PACKAGES(
+ "builderGenerationPackages", string(Builder::builderGenerationPackages)),
+
+ /** Option for builder usage packages. */
+ BUILDER_USAGE_PACKAGES("builderUsagePackages", string(Builder::builderUsagePackages)),
// === Naming ===
/** Option for builder class name suffix. */
- BUILDER_SUFFIX("builderSuffix"),
+ BUILDER_SUFFIX("builderSuffix", string(Builder::builderSuffix)),
+
+ /** Option for builder class name suffix when referencing usage-scope builders. */
+ BUILDER_USAGE_SUFFIX("builderUsageSuffix", string(Builder::builderUsageSuffix)),
/** Option for setter method name suffix. */
- SETTER_SUFFIX("setterSuffix"),
+ SETTER_SUFFIX("setterSuffix", string(Builder::setterSuffix)),
// === Component Filtering ===
/**
@@ -142,7 +175,7 @@ public enum CompilerArgumentsEnum {
* {@code lightweight}, or {@code none}. See {@link
* org.javahelpers.simple.builders.core.enums.FormattingMode} for details.
*/
- FORMATTING_MODE("formattingMode"),
+ FORMATTING_MODE("formattingMode", string(Builder::formattingMode)),
// === Performance Tracking ===
/** Option for performance tracking during annotation processing. */
@@ -157,7 +190,7 @@ public enum CompilerArgumentsEnum {
* generation failures are reported as compiler errors that fail the build instead of warnings.
* Defaults to disabled (warnings only, build does not fail).
*/
- STRICT("strict");
+ STRICT("strict", optionState(Builder::strict));
/** Compiler option prefix for all simple-builders options. */
private static final String OPTION_PREFIX = "simplebuilder.";
@@ -166,12 +199,42 @@ public enum CompilerArgumentsEnum {
private final String optionName;
/**
- * Constructs a CompilerArgumentsEnum constant.
+ * Applies a raw option value to a {@link BuilderConfiguration.Builder}, or {@code null} for
+ * arguments that are not builder configuration options (e.g. {@code verbose}).
+ */
+ private final OptionApplier builderApplier;
+
+ /**
+ * Constructs a CompilerArgumentsEnum constant for an argument that is not a builder configuration
+ * option.
+ *
+ * These are process-control flags (e.g. {@code VERBOSE}, {@code PERFORMANCE_TRACKING}, {@code
+ * PERFORMANCE_OUTPUT_FILE}, {@code DEACTIVATE_GENERATION_COMPONENTS}) that are read directly via
+ * {@link CompilerArgumentsReader#readValue} or {@link CompilerArgumentsReader#readBooleanValue}
+ * rather than applied to a {@link BuilderConfiguration.Builder}. The {@code null} applier is
+ * intentional: {@link #apply} is a no-op for these constants, and {@link #hasValueApplier()}
+ * returns {@code false} so {@link BuilderConfigurationReader} skips them when parsing annotation
+ * attributes.
*
* @param optionName The option name
*/
CompilerArgumentsEnum(String optionName) {
+ this(optionName, null);
+ }
+
+ /**
+ * Constructs a CompilerArgumentsEnum constant for a builder configuration option.
+ *
+ *
The {@code builderApplier} converts the raw option value (from a compiler argument or
+ * annotation attribute) into the corresponding {@link BuilderConfiguration.Builder} setter call.
+ * {@link #hasValueApplier()} returns {@code true} for these constants.
+ *
+ * @param optionName The option name
+ * @param builderApplier applies the raw option value to the configuration builder
+ */
+ CompilerArgumentsEnum(String optionName, OptionApplier builderApplier) {
this.optionName = optionName;
+ this.builderApplier = builderApplier;
}
/**
@@ -197,17 +260,95 @@ public String getCompilerArgument() {
}
/**
- * Finds a CompilerArgumentsEnum by its compiler argument.
+ * Finds a CompilerArgumentsEnum by its option name (as used in annotation methods).
*
- * @param compilerArgument The compiler argument to search for
- * @return The matching CompilerArgumentsEnum, or null if not found
+ * @param optionName the option name to search for
+ * @return the matching CompilerArgumentsEnum, or {@code null} if not found
*/
- public static CompilerArgumentsEnum fromCompilerArgument(String compilerArgument) {
+ public static CompilerArgumentsEnum fromOptionName(String optionName) {
for (CompilerArgumentsEnum option : values()) {
- if (option.getCompilerArgument().equals(compilerArgument)) {
+ if (option.getOptionName().equals(optionName)) {
return option;
}
}
return null;
}
+
+ /**
+ * Returns whether this enum constant has a value applier that maps the raw option value to a
+ * {@link BuilderConfiguration.Builder} setter.
+ *
+ *
Not all enum constants have an applier. Process-control flags like {@code VERBOSE}, {@code
+ * PERFORMANCE_TRACKING}, {@code PERFORMANCE_OUTPUT_FILE}, and {@code
+ * DEACTIVATE_GENERATION_COMPONENTS} are read directly via {@link
+ * CompilerArgumentsReader#readValue} or {@link CompilerArgumentsReader#readBooleanValue} instead
+ * of being applied to a builder configuration. For these, the single-argument constructor sets
+ * the applier to {@code null}, {@link #apply} is a no-op, and this method returns {@code false}.
+ * This lets {@link BuilderConfigurationReader} skip them when parsing annotation attributes.
+ *
+ * @return {@code true} if this option has a value applier for {@link
+ * BuilderConfiguration.Builder}
+ */
+ public boolean hasValueApplier() {
+ return builderApplier != null;
+ }
+
+ /**
+ * Applies a raw option value to the given configuration builder.
+ *
+ *
Enum-typed annotation attributes arrive qualified (e.g. {@code "...OptionState.ENABLED"})
+ * and are unqualified before parsing; plain values (compiler arguments, string options) are used
+ * as given.
+ *
+ * @param builder the configuration builder to modify
+ * @param rawValue the raw option value (annotation value or compiler-argument string)
+ * @param logger the logger for warnings on unrecognized values, or null to suppress
+ */
+ public void apply(
+ BuilderConfiguration.Builder builder, Object rawValue, ProcessingLogger logger) {
+ if (builderApplier != null) {
+ builderApplier.apply(builder, rawValue, logger);
+ }
+ }
+
+ /**
+ * Extracts the simple enum constant name from a qualified annotation value (e.g. {@code
+ * "...OptionState.ENABLED"} → {@code "ENABLED"}). Unqualified values are returned as given.
+ *
+ * @param value the raw annotation/argument value
+ * @return the simple enum name, or null if the value is null
+ */
+ private static String extractEnumName(Object value) {
+ if (value == null) {
+ return null;
+ }
+ String enumString = value.toString();
+ return enumString.contains(".")
+ ? enumString.substring(enumString.lastIndexOf('.') + 1)
+ : enumString;
+ }
+
+ private static OptionApplier optionState(
+ BiConsumer setter) {
+ return (builder, value, logger) ->
+ setter.accept(builder, OptionValueParsers.parseOptionState(extractEnumName(value), logger));
+ }
+
+ private static OptionApplier accessModifier(
+ BiConsumer setter) {
+ return (builder, value, logger) ->
+ setter.accept(
+ builder, OptionValueParsers.parseAccessModifier(extractEnumName(value), logger));
+ }
+
+ private static OptionApplier string(BiConsumer setter) {
+ return (builder, value, logger) ->
+ setter.accept(builder, value == null ? null : value.toString());
+ }
+
+ /** Functional interface for applying a raw option value with optional logging. */
+ @FunctionalInterface
+ interface OptionApplier {
+ void apply(BuilderConfiguration.Builder builder, Object rawValue, ProcessingLogger logger);
+ }
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
index a34ef3cf..8ad1efbb 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
@@ -24,11 +24,11 @@
package org.javahelpers.simple.builders.processor.processing;
+import java.util.stream.Stream;
import javax.annotation.processing.ProcessingEnvironment;
import org.apache.commons.lang3.Strings;
-import org.javahelpers.simple.builders.core.enums.AccessModifier;
-import org.javahelpers.simple.builders.core.enums.OptionState;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
+import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
/**
* Utility class for reading compiler arguments from the annotation processing environment.
@@ -91,46 +91,6 @@ public boolean readBooleanValue(CompilerArgumentsEnum argument) {
return Strings.CI.equalsAny(value, "true", "enabled");
}
- /**
- * Reads the value of a compiler argument as an OptionState.
- *
- * Returns ENABLED for "true" or "enabled", DISABLED for "false" or "disabled", and UNSET
- * otherwise.
- *
- * @param argument the compiler argument enum to read
- * @return the OptionState value
- */
- public OptionState readOptionState(CompilerArgumentsEnum argument) {
- String value = readValue(argument);
- if (Strings.CI.equalsAny(value, "true", "enabled")) {
- return OptionState.ENABLED;
- } else if (Strings.CI.equalsAny(value, "false", "disabled")) {
- return OptionState.DISABLED;
- }
- return OptionState.UNSET;
- }
-
- /**
- * Reads the value of a compiler argument as an AccessModifier.
- *
- *
Returns the corresponding AccessModifier enum value, or DEFAULT if not set or invalid.
- *
- * @param argument the compiler argument enum to read
- * @return the AccessModifier value, or DEFAULT if not set
- */
- public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) {
- String value = readValue(argument);
- if (Strings.CI.equals(value, "public")) {
- return AccessModifier.PUBLIC;
- } else if (Strings.CI.equals(value, "private")) {
- return AccessModifier.PRIVATE;
- } else if (Strings.CI.equalsAny(value, "package-private", "package_private")) {
- return AccessModifier.PACKAGE_PRIVATE;
- } else {
- return AccessModifier.DEFAULT;
- }
- }
-
/**
* Reads a complete BuilderConfiguration from compiler arguments.
*
@@ -151,18 +111,16 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) {
*
*
All values default to UNSET or DEFAULT if not specified in compiler arguments.
*
- *
Adding a new option: every option in {@link CompilerArgumentsEnum} that represents a
- * configuration value must be read and set here. Omitting it causes the compiler argument to be
- * silently ignored. The full checklist when adding a new option:
+ *
Adding a new option: every {@link CompilerArgumentsEnum} constant is applied; those
+ * without a builder applier are no-ops in {@link CompilerArgumentsEnum#apply}, so wiring a new
+ * option means declaring the applier on the enum constant once — no change is needed here or in
+ * {@code BuilderConfigurationReader}. The remaining checklist when adding a new option:
*
*
- * - {@code CompilerArgumentsEnum} — add the enum constant.
- *
- This method — read the value and set it on the builder.
+ *
- {@code CompilerArgumentsEnum} — add the enum constant with its applier.
*
- {@code BuilderConfiguration} — add the field, builder method, merge logic, and a typed
* accessor (e.g. {@code formattingModeEnum}) if enum conversion is needed. Set the default
* in {@code BuilderConfiguration.DEFAULT}.
- *
- {@code BuilderConfigurationReader} — handle annotation-side extraction in {@code
- * extractOptionsFromAnnotationMirror}.
*
- {@code ProcessingContext} — should NOT need a dedicated field or getter. The resolved
* per-target config ({@code context.getConfiguration()}) and global config ({@code
* context.getConfigurationReader().getGlobalConfiguration()}) carry all option values.
@@ -172,45 +130,10 @@ public AccessModifier readAccessModifier(CompilerArgumentsEnum argument) {
*
* @return a BuilderConfiguration with values read from compiler arguments
*/
- public BuilderConfiguration readBuilderConfiguration() {
- return BuilderConfiguration.builder()
- .generateSupplier(readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER))
- .generateConsumer(readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_CONSUMER))
- .generateBuilderConsumer(readOptionState(CompilerArgumentsEnum.GENERATE_BUILDER_CONSUMER))
- .generateConditionalLogic(
- readOptionState(CompilerArgumentsEnum.GENERATE_CONDITIONAL_HELPER))
- .builderAccess(readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS))
- .builderConstructorAccess(
- readAccessModifier(CompilerArgumentsEnum.BUILDER_CONSTRUCTOR_ACCESS))
- .methodAccess(readAccessModifier(CompilerArgumentsEnum.METHOD_ACCESS))
- .generateVarArgsHelpers(readOptionState(CompilerArgumentsEnum.GENERATE_VAR_ARGS_HELPERS))
- .generateStringFormatHelpers(
- readOptionState(CompilerArgumentsEnum.GENERATE_STRING_FORMAT_HELPERS))
- .generateAddToCollectionHelpers(
- readOptionState(CompilerArgumentsEnum.GENERATE_ADD_TO_COLLECTION_HELPERS))
- .generateUnboxedOptional(readOptionState(CompilerArgumentsEnum.GENERATE_UNBOXED_OPTIONAL))
- .copyTypeAnnotations(readOptionState(CompilerArgumentsEnum.COPY_TYPE_ANNOTATIONS))
- .usingArrayListBuilder(readOptionState(CompilerArgumentsEnum.USING_ARRAY_LIST_BUILDER))
- .usingArrayListBuilderWithElementBuilders(
- readOptionState(CompilerArgumentsEnum.USING_ARRAY_LIST_BUILDER_WITH_ELEMENT_BUILDERS))
- .usingHashSetBuilder(readOptionState(CompilerArgumentsEnum.USING_HASH_SET_BUILDER))
- .usingHashSetBuilderWithElementBuilders(
- readOptionState(CompilerArgumentsEnum.USING_HASH_SET_BUILDER_WITH_ELEMENT_BUILDERS))
- .usingHashMapBuilder(readOptionState(CompilerArgumentsEnum.USING_HASH_MAP_BUILDER))
- .usingGeneratedAnnotation(readOptionState(CompilerArgumentsEnum.USING_GENERATED_ANNOTATION))
- .usingBuilderImplementationAnnotation(
- readOptionState(CompilerArgumentsEnum.USING_BUILDER_IMPLEMENTATION_ANNOTATION))
- .implementsBuilderBase(readOptionState(CompilerArgumentsEnum.IMPLEMENTS_BUILDER_BASE))
- .generateWithInterface(readOptionState(CompilerArgumentsEnum.GENERATE_WITH_INTERFACE))
- .usingJacksonDeserializerAnnotation(
- readOptionState(CompilerArgumentsEnum.USING_JACKSON_DESERIALIZER_ANNOTATION))
- .generateJacksonModule(readOptionState(CompilerArgumentsEnum.GENERATE_JACKSON_MODULE))
- .generateJavaDoc(readOptionState(CompilerArgumentsEnum.GENERATE_JAVADOC))
- .jacksonModulePackage(readValue(CompilerArgumentsEnum.JACKSON_MODULE_PACKAGE))
- .builderSuffix(readValue(CompilerArgumentsEnum.BUILDER_SUFFIX))
- .setterSuffix(readValue(CompilerArgumentsEnum.SETTER_SUFFIX))
- .formattingMode(readValue(CompilerArgumentsEnum.FORMATTING_MODE))
- .strict(readOptionState(CompilerArgumentsEnum.STRICT))
- .build();
+ public BuilderConfiguration readBuilderConfiguration(ProcessingLogger logger) {
+ BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
+ Stream.of(CompilerArgumentsEnum.values())
+ .forEach(option -> option.apply(builder, readValue(option), logger));
+ return builder.build();
}
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsers.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsers.java
new file mode 100644
index 00000000..74fb39c3
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsers.java
@@ -0,0 +1,91 @@
+/*
+ * 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.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.Strings;
+import org.javahelpers.simple.builders.core.enums.AccessModifier;
+import org.javahelpers.simple.builders.core.enums.OptionState;
+import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
+
+/**
+ * Parsers for raw option values coming from compiler arguments or annotation attributes.
+ *
+ *
Parsing is lenient on purpose: unrecognized values fall back to {@link OptionState#UNSET} or
+ * {@link AccessModifier#DEFAULT} so that the regular option precedence (annotation > compiler
+ * argument > default) still applies instead of failing the build on a typo. A warning is logged
+ * when the value is non-blank but unrecognized, so typos are visible without breaking the build.
+ */
+public final class OptionValueParsers {
+
+ private OptionValueParsers() {}
+
+ /**
+ * Parses an option value as {@link OptionState}: {@code "true"}/{@code "enabled"} mean ENABLED,
+ * {@code "false"}/{@code "disabled"} mean DISABLED, anything else (including null) means UNSET.
+ *
+ *
When a non-blank value is not recognized, a warning is logged before falling back to UNSET.
+ *
+ * @param value the raw option value
+ * @param logger the logger for warnings on unrecognized values (must not be null)
+ * @return the parsed OptionState
+ */
+ public static OptionState parseOptionState(String value, ProcessingLogger logger) {
+ if (Strings.CI.equalsAny(value, "true", "enabled")) {
+ return OptionState.ENABLED;
+ } else if (Strings.CI.equalsAny(value, "false", "disabled")) {
+ return OptionState.DISABLED;
+ }
+ if (StringUtils.isNotBlank(value)) {
+ logger.warning("Unrecognized option-state value '%s' - falling back to UNSET", value);
+ }
+ return OptionState.UNSET;
+ }
+
+ /**
+ * Parses an option value as {@link AccessModifier}, returning DEFAULT for unset or invalid
+ * values.
+ *
+ *
When a non-blank value is not recognized, a warning is logged before falling back to
+ * DEFAULT.
+ *
+ * @param value the raw option value
+ * @param logger the logger for warnings on unrecognized values (must not be null)
+ * @return the parsed AccessModifier
+ */
+ public static AccessModifier parseAccessModifier(String value, ProcessingLogger logger) {
+ if (Strings.CI.equals(value, "public")) {
+ return AccessModifier.PUBLIC;
+ } else if (Strings.CI.equals(value, "private")) {
+ return AccessModifier.PRIVATE;
+ } else if (Strings.CI.equalsAny(value, "package-private", "package_private")) {
+ return AccessModifier.PACKAGE_PRIVATE;
+ }
+ if (StringUtils.isNotBlank(value)) {
+ logger.warning("Unrecognized access-modifier value '%s' - falling back to DEFAULT", value);
+ }
+ return AccessModifier.DEFAULT;
+ }
+}
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 44e80152..176ca87e 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
@@ -32,6 +32,7 @@
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
+import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver;
import org.javahelpers.simple.builders.processor.generators.registry.GeneratorRegistry;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.model.type.TypeName;
@@ -53,6 +54,7 @@ public final class ProcessingContext {
private final BuilderConfigurationReader configurationReader;
private final ProcessingEnvironment processingEnv;
private final PerformanceTracker performanceTracker;
+ private final BuilderScopeResolver builderScopeResolver;
private GeneratorRegistry generatorRegistry;
private BuilderConfiguration configurationForProcessingTarget;
@@ -82,6 +84,7 @@ public ProcessingContext(
perfTrackingEnabled
? new ActivePerformanceTracker(perfOutputFile)
: new NoOpPerformanceTracker();
+ this.builderScopeResolver = new BuilderScopeResolver(this);
// GeneratorRegistry will be lazily initialized on first access
}
@@ -140,6 +143,18 @@ public PerformanceTracker getPerformanceTracker() {
return performanceTracker;
}
+ /**
+ * Get the builder scope resolver for deciding whether a builder may be referenced.
+ *
+ *
The resolver is keyed off the current target configuration and is recomputed when the target
+ * configuration changes.
+ *
+ * @return the builder scope resolver
+ */
+ public BuilderScopeResolver getBuilderScopeResolver() {
+ return builderScopeResolver;
+ }
+
/**
* Get the TypeElement for a given qualified class name.
*
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java
index cd64a135..840cefcb 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTracker.java
@@ -67,6 +67,7 @@ public final class ActivePerformanceTracker implements PerformanceTracker {
/** Hardcoded phase hierarchy for report display. Order defines display order. */
private static final List TOP_LEVEL_PHASES =
List.of(
+ PHASE_ELEMENT_COLLECTION,
PHASE_CONFIGURATION_RESOLUTION,
PHASE_BUILDER_DEFINITION_EXTRACTION,
PHASE_DTO_MAPPING,
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/PerformanceTracker.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/PerformanceTracker.java
index b05c66aa..508dbd53 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/PerformanceTracker.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/logging/PerformanceTracker.java
@@ -41,6 +41,7 @@
public interface PerformanceTracker {
// Top-level phases
+ String PHASE_ELEMENT_COLLECTION = "Element Collection";
String PHASE_CONFIGURATION_RESOLUTION = "Configuration Resolution";
String PHASE_BUILDER_DEFINITION_EXTRACTION = "Builder Definition Extraction";
String PHASE_DTO_MAPPING = "DTO Mapping";
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 568301ab..f53a9ec6 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
@@ -86,20 +86,29 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() {
// Then: Compilation succeeds and debug messages are present
assertThat(compilation).succeeded();
- // Verify key debug messages are logged with hierarchical format
- ProcessorAsserts.assertHadNoteContaining(
+ // Assert the complete ordered log output so that ANY change (added, removed, or
+ // modified message) fails this test. When you change logging, update the expected
+ // list below and the documentation in DEBUG_LOGGING.md / CONTRIBUTING.md.
+ ProcessorAsserts.assertNotesInOrder(
compilation,
+ // Processor init
"[DEBUG] Starting BuilderProcessor...",
"[DEBUG] Loaded global configuration from compiler arguments: BuilderConfiguration[]",
"[DEBUG] Initializing generator registry",
"[DEBUG] ├─ Loaded 14 method generators and 9 builder enhancers total",
"[DEBUG] └─ Initialized GeneratorRegistry with 14 method generators and 9 builder",
+ // Round 1 — start
"simple-builders: PROCESSING ROUND START",
"[DEBUG] simple-builders: Processing round started. Found 1 annotated elements.",
+ // Round 1 — configuration resolution
"[DEBUG] Processing element: VerboseTest",
"[DEBUG] ├─ Resolving configuration for element: VerboseTest",
"[DEBUG] │ ├─ Built-in template @SimpleBuilder found in DIRECT scope",
"[DEBUG] │ └─ Resulting configuration resolved: BuilderConfiguration[",
+ "[DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the"
+ + " builderGenerationPackages scope.",
+ // Round 1 — builder definition extraction
+ "[DEBUG] Processing element: VerboseTest",
"[DEBUG] ├─ Extracting builder definition from: test.VerboseTest",
"[DEBUG] │ ├─ Builder will be generated as: test.VerboseTestBuilder",
"[DEBUG] │ ├─ Analysing setters for finding fields",
@@ -115,10 +124,13 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() {
"[DEBUG] │ ├─ Processing class based enhancer",
"[DEBUG] │ │ ├─ Applying: GeneratedAnnotationEnhancer (priority: 120)",
"[DEBUG] │ │ ├─ Applying: BuilderImplementationAnnotationEnhancer (priority: 115)",
+ "[DEBUG] │ │ ├─ Added @BuilderImplementation annotation to builder VerboseTestBuilder",
"[DEBUG] │ │ ├─ Applying: CoreMethodsEnhancer (priority: 100)",
+ "[DEBUG] │ │ ├─ Applying: ConstructorEnhancer (priority: 95)",
"[DEBUG] │ │ ├─ Applying: WithInterfaceEnhancer (priority: 95)",
"[DEBUG] │ │ ├─ Applying: InterfaceEnhancer (priority: 90)",
"[DEBUG] │ │ ├─ Applying: ConditionalEnhancer (priority: 80)",
+ "[DEBUG] │ │ ├─ Added conditional methods to builder VerboseTestBuilder",
"[DEBUG] │ │ ├─ Applying: ClassJavaDocEnhancer (priority: 10)",
"[DEBUG] │ │ └─ Applied 8 builder enhancers",
"[DEBUG] │ ├─ Finalizing builder definition",
@@ -127,6 +139,7 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() {
"[DEBUG] │ │ └─ Finalized: 1 class fields, 5 builder-level methods, 2 constructors",
"[DEBUG] │ ├─ Builder will be generated as: VerboseTestBuilder",
"[DEBUG] │ └─ Builder definition extracted: VerboseTestBuilder",
+ // Round 1 — code generation
"[DEBUG] ├─ Code generation for class: VerboseTestBuilder",
"[DEBUG] │ ├─ JavaClassSource created",
"[DEBUG] │ ├─ Class metadata added",
@@ -146,7 +159,11 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() {
"[DEBUG] ├─ Jackson module entry added",
"[DEBUG] └─ Generated builder with 1 fields and 9 methods for VerboseTestBuilder",
"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: 0 of 0 annotated element(s) are inside the"
+ + " builderGenerationPackages scope.");
}
@Test
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java
new file mode 100644
index 00000000..39957866
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java
@@ -0,0 +1,424 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor;
+
+import static com.google.testing.compile.CompilationSubject.assertThat;
+
+import com.google.testing.compile.Compilation;
+import javax.tools.JavaFileObject;
+import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
+import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end coverage of the builder generation and usage package scopes: which annotated elements
+ * get a builder generated, and whether generated builders consume the builders of their field
+ * types. Resolver internals (caching, type registration) are covered by {@link
+ * BuilderScopeResolverTest}.
+ */
+class BuilderScopeProcessingTest {
+
+ @Test
+ void bothScopesUnset_UsesBuilderOfReferencedType() {
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .compile(dto("test", "ScopeDto", "ReferencedDto"), referencedDto("test"));
+
+ assertThat(compilation).succeeded();
+ assertBuilderConsumer(compilation, "ScopeDtoBuilder", "ReferencedDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "ScopeDtoBuilder"),
+ "public ScopeDtoBuilder referenced(ReferencedDto referenced)");
+ }
+
+ @Test
+ void usageScopeOnly_TrustsReferencedTypeGeneratedInSameCompilation() {
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions("-Asimplebuilder.builderUsagePackages=test")
+ .compile(dto("test", "ScopeDto", "ReferencedDto"), referencedDto("test"));
+
+ assertThat(compilation).succeeded();
+ assertBuilderConsumer(compilation, "ScopeDtoBuilder", "ReferencedDtoBuilder");
+ }
+
+ @Test
+ void generationScope_IncludesExactPackageAndSubpackages() {
+ Compilation exact =
+ ProcessorTestUtils.createCompiler()
+ .withOptions("-Asimplebuilder.builderGenerationPackages=test")
+ .compile(dto("test", "ExactDto", "ReferencedDto"), referencedDto("test"));
+ Compilation subpackage =
+ ProcessorTestUtils.createCompiler()
+ .withOptions("-Asimplebuilder.builderGenerationPackages=test")
+ .compile(dto("test.sub", "SubDto", "ReferencedDto"), referencedDto("test.sub"));
+
+ assertThat(exact).succeeded();
+ assertThat(subpackage).succeeded();
+ assertBuilderConsumer(exact, "ExactDtoBuilder", "ReferencedDtoBuilder");
+ assertBuilderConsumer(subpackage, "SubDtoBuilder", "ReferencedDtoBuilder");
+ }
+
+ @Test
+ void generationScope_ExcludesDtoOutsideScope() {
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions("-Asimplebuilder.builderGenerationPackages=other.pkg")
+ .compile(dto("test", "OutOfScopeDto", "ReferencedDto"), referencedDto("test"));
+
+ assertThat(compilation).succeeded();
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "OutOfScopeDto", "An out-of-scope DTO must not get a builder");
+ }
+
+ @Test
+ void usageScope_RequiresExistingPrecompiledBuilder() {
+ JavaFileObject dto = dto("test", "LibraryUsageDto", "LibraryDto", "lib");
+ JavaFileObject libraryDto = referencedDto("lib", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=lib")
+ .compile(dto, libraryDto);
+
+ assertThat(compilation).succeeded();
+ String generated =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "LibraryUsageDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ generated, "public LibraryUsageDtoBuilder referenced(LibraryDto referenced)");
+ ProcessorAsserts.assertNotContaining(
+ generated, "referencedBuilderConsumer", "LibraryDtoBuilder");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "LibraryDto", "The library type must not be generated in this compilation");
+ }
+
+ @Test
+ void usageScope_ReferencesBuilderWhenGenerationScopeAlsoIncludesLibrary() {
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test,lib",
+ "-Asimplebuilder.builderUsagePackages=lib")
+ .compile(
+ dto("test", "LibraryUsageDto", "LibraryDto", "lib"),
+ referencedDto("lib", "LibraryDto"));
+
+ assertThat(compilation).succeeded();
+ assertBuilderConsumer(compilation, "LibraryUsageDtoBuilder", "LibraryDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ ProcessorTestUtils.loadGeneratedSource(compilation, "LibraryDtoBuilder"),
+ "public LibraryDto build()");
+ }
+
+ @Test
+ void inlineOptions_ConfigureBuilderScopes() {
+ JavaFileObject dto =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder(options = @SimpleBuilder.Options(
+ builderGenerationPackages = "test",
+ builderUsagePackages = "test"
+ ))
+ public class InlineScopeDto {
+ private ReferencedDto referenced;
+ public ReferencedDto getReferenced() { return referenced; }
+ public void setReferenced(ReferencedDto referenced) { this.referenced = referenced; }
+ }
+ """);
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(dto, referencedDto("test"));
+
+ assertThat(compilation).succeeded();
+ assertBuilderConsumer(compilation, "InlineScopeDtoBuilder", "ReferencedDtoBuilder");
+ }
+
+ @Test
+ void optOutTakesPrecedenceOverScopes() {
+ Compilation optedOut =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=test")
+ .compile(dto("test", "OptedOutFieldDto", "OptedOutDto"), optedOutDto());
+ Compilation unannotated =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=test")
+ .compile(dto("test", "UnannotatedFieldDto", "UnannotatedDto"), unannotatedDto());
+
+ assertThat(optedOut).succeeded();
+ assertThat(unannotated).succeeded();
+ assertNoBuilderConsumer(
+ optedOut, "OptedOutFieldDtoBuilder", "OptedOutDto", "OptedOutDtoBuilder");
+ assertNoBuilderConsumer(
+ unannotated, "UnannotatedFieldDtoBuilder", "UnannotatedDto", "UnannotatedDtoBuilder");
+ }
+
+ @Test
+ void usageScope_ReferencesBuilderWithoutSimpleBuilderAnnotation() {
+ JavaFileObject dto = dto("test", "UnannotatedUsageDto", "LibraryDto", "lib");
+ JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto");
+ JavaFileObject libraryDtoBuilder = manualBuilder("lib", "LibraryDtoBuilder", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=lib")
+ .compile(dto, libraryDto, libraryDtoBuilder);
+
+ assertThat(compilation).succeeded();
+ String generated =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "UnannotatedUsageDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ generated, "public UnannotatedUsageDtoBuilder referenced(LibraryDto referenced)");
+ ProcessorAsserts.assertContaining(generated, "referencedBuilderConsumer");
+ ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "LibraryDto", "The library type must not be generated in this compilation");
+ }
+
+ @Test
+ void usageScope_UsesBuilderUsageSuffixForLookup() {
+ JavaFileObject dto = dto("test", "SuffixUsageDto", "LibraryDto", "lib");
+ JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto");
+ JavaFileObject libraryDtoFactory = manualBuilder("lib", "LibraryDtoFactory", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=lib",
+ "-Asimplebuilder.builderUsageSuffix=Factory")
+ .compile(dto, libraryDto, libraryDtoFactory);
+
+ assertThat(compilation).succeeded();
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "SuffixUsageDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ generated, "public SuffixUsageDtoBuilder referenced(LibraryDto referenced)");
+ ProcessorAsserts.assertContaining(generated, "referencedBuilderConsumer");
+ ProcessorAsserts.assertContaining(generated, "LibraryDtoFactory");
+ ProcessorAsserts.assertNotContaining(generated, "LibraryDtoBuilder");
+ ProcessorAsserts.assertNoBuilderGenerated(
+ compilation, "LibraryDto", "The library type must not be generated in this compilation");
+ }
+
+ @Test
+ void usageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotConfigured() {
+ JavaFileObject dto = dto("test", "FallbackSuffixDto", "LibraryDto", "lib");
+ JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto");
+ JavaFileObject libraryDtoBuilder = manualBuilder("lib", "LibraryDtoBuilder", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=lib")
+ .compile(dto, libraryDto, libraryDtoBuilder);
+
+ assertThat(compilation).succeeded();
+ String generated =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "FallbackSuffixDtoBuilder");
+ ProcessorAsserts.assertContaining(generated, "referencedBuilderConsumer");
+ ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder");
+ }
+
+ @Test
+ void usageScope_DoesNotReferenceBuilderWhenClassNotFoundWithUsageSuffix() {
+ JavaFileObject dto = dto("test", "MissingBuilderDto", "LibraryDto", "lib");
+ JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto");
+ JavaFileObject libraryDtoBuilder = manualBuilder("lib", "LibraryDtoBuilder", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler()
+ .withOptions(
+ "-Asimplebuilder.builderGenerationPackages=test",
+ "-Asimplebuilder.builderUsagePackages=lib",
+ "-Asimplebuilder.builderUsageSuffix=Factory")
+ .compile(dto, libraryDto, libraryDtoBuilder);
+
+ assertThat(compilation).succeeded();
+ String generated =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "MissingBuilderDtoBuilder");
+ ProcessorAsserts.assertContaining(
+ generated, "public MissingBuilderDtoBuilder referenced(LibraryDto referenced)");
+ ProcessorAsserts.assertNotContaining(
+ generated, "referencedBuilderConsumer", "LibraryDtoFactory");
+ }
+
+ @Test
+ void inlineOptions_ConfigureBuilderUsageSuffix() {
+ JavaFileObject dto =
+ ProcessorTestUtils.forSource(
+ """
+ package test;
+ import lib.LibraryDto;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder(options = @SimpleBuilder.Options(
+ builderGenerationPackages = "test",
+ builderUsagePackages = "lib",
+ builderUsageSuffix = "Factory"
+ ))
+ public class InlineSuffixDto {
+ private LibraryDto referenced;
+ public LibraryDto getReferenced() { return referenced; }
+ public void setReferenced(LibraryDto referenced) { this.referenced = referenced; }
+ }
+ """);
+ JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto");
+ JavaFileObject libraryDtoFactory = manualBuilder("lib", "LibraryDtoFactory", "LibraryDto");
+
+ Compilation compilation =
+ ProcessorTestUtils.createCompiler().compile(dto, libraryDto, libraryDtoFactory);
+
+ assertThat(compilation).succeeded();
+ String generated =
+ ProcessorTestUtils.loadGeneratedSource(compilation, "InlineSuffixDtoBuilder");
+ ProcessorAsserts.assertContaining(generated, "referencedBuilderConsumer");
+ ProcessorAsserts.assertContaining(generated, "LibraryDtoFactory");
+ }
+
+ private static JavaFileObject unannotatedDto(String packageName, String className) {
+ return ProcessorTestUtils.forSource(
+ """
+ package %s;
+ public class %s { public %s() {} }
+ """
+ .formatted(packageName, className, className));
+ }
+
+ private static JavaFileObject manualBuilder(
+ String packageName, String builderName, String dtoName) {
+ return ProcessorTestUtils.forSource(
+ """
+ package %s;
+ public class %s {
+ private %s value;
+ public %s() {}
+ public %s(%s value) { this.value = value; }
+ public static %s create() { return new %s(); }
+ public %s build() { return value != null ? value : new %s(); }
+ }
+ """
+ .formatted(
+ packageName,
+ builderName,
+ dtoName,
+ builderName,
+ builderName,
+ dtoName,
+ builderName,
+ builderName,
+ dtoName,
+ dtoName));
+ }
+
+ private static JavaFileObject dto(String packageName, String className, String fieldType) {
+ return dto(packageName, className, fieldType, packageName);
+ }
+
+ private static JavaFileObject dto(
+ String packageName, String className, String fieldType, String fieldPackage) {
+ String importLine =
+ packageName.equals(fieldPackage) ? "" : "import " + fieldPackage + "." + fieldType + ";\n";
+ return ProcessorTestUtils.forSource(
+ """
+ package %s;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ %s
+ @SimpleBuilder
+ public class %s {
+ private %s referenced;
+ public %s getReferenced() { return referenced; }
+ public void setReferenced(%s referenced) { this.referenced = referenced; }
+ }
+ """
+ .formatted(packageName, importLine, className, fieldType, fieldType, fieldType));
+ }
+
+ private static JavaFileObject referencedDto(String packageName) {
+ return referencedDto(packageName, "ReferencedDto");
+ }
+
+ private static JavaFileObject referencedDto(String packageName, String className) {
+ return ProcessorTestUtils.forSource(
+ """
+ package %s;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ public class %s { public %s() {} }
+ """
+ .formatted(packageName, className, className));
+ }
+
+ private static JavaFileObject optedOutDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+ import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ @Ignore4BuilderGeneration
+ public class OptedOutDto { public OptedOutDto() {} }
+ """);
+ }
+
+ private static JavaFileObject unannotatedDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+ public class UnannotatedDto { public UnannotatedDto() {} }
+ """);
+ }
+
+ private static void assertBuilderConsumer(
+ Compilation compilation, String builderName, String referencedBuilderName) {
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, builderName);
+ ProcessorAsserts.assertContaining(
+ generated,
+ "referencedBuilderConsumer",
+ referencedBuilderName + " builder",
+ "referencedBuilderConsumer.accept(builder)");
+ }
+
+ private static void assertNoBuilderConsumer(
+ Compilation compilation,
+ String builderName,
+ String fieldTypeName,
+ String referencedBuilderName) {
+ String generated = ProcessorTestUtils.loadGeneratedSource(compilation, builderName);
+ ProcessorAsserts.assertContaining(generated, "referenced(" + fieldTypeName + " referenced)");
+ ProcessorAsserts.assertNotContaining(
+ generated, "referencedBuilderConsumer", referencedBuilderName);
+ }
+}
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
new file mode 100644
index 00000000..c7bcd27c
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java
@@ -0,0 +1,323 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+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;
+import javax.annotation.processing.RoundEnvironment;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.TypeElement;
+import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver;
+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.logging.ProcessingLogger;
+import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Probe-based coverage of {@link BuilderScopeResolver} internals that generated-source assertions
+ * cannot observe: result caching, cache invalidation on configuration change, and per-round
+ * registration of generated types. End-to-end scope behavior visible in generated builders is
+ * covered by {@link BuilderScopeProcessingTest}.
+ */
+class BuilderScopeResolverTest {
+
+ @Test
+ void resolverReturnsEmptyAfterConfigurationChangesToExcludePackage() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ public class LibHelper { public LibHelper() {} }
+ """));
+
+ assertThat(compilation).succeeded();
+ // First resolution with generation scope "lib" and registered → builder found
+ assertEquals("lib.LibHelperBuilder", ResolverProbeProcessor.first.get().getFullQualifiedName());
+ // After clearing registration and changing config to exclude "lib", the resolver returns
+ // empty (not registered, not in scope)
+ assertEquals(Optional.empty(), ResolverProbeProcessor.afterConfigurationChange);
+ // Cache was cleared by the config change, so a new Optional instance is returned
+ assertNotSame(ResolverProbeProcessor.first, ResolverProbeProcessor.afterConfigurationChange);
+ }
+
+ @Test
+ void resolverCachesResolvedOptionalInstancePerReferencedType() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ public class LibHelper { public LibHelper() {} }
+ """));
+
+ assertThat(compilation).succeeded();
+ // Two consecutive calls with the same config return the same cached Optional instance
+ assertSame(ResolverProbeProcessor.first, ResolverProbeProcessor.second);
+ }
+
+ @Test
+ void resolverClearsCacheOnRegistrationAndResolvesUsageScope() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ public class LibHelper { public LibHelper() {} }
+ """));
+
+ assertThat(compilation).succeeded();
+ // With usage scope "other" (not "lib") and no registration → empty
+ assertEquals(Optional.empty(), ResolverProbeProcessor.beforeRegistration);
+ // Registration alone is not enough — the type must also be in the usage scope
+ assertEquals(Optional.empty(), ResolverProbeProcessor.afterRegistration);
+ // With usage scope "lib" but no registration → empty (builder not on classpath)
+ assertEquals(Optional.empty(), ResolverProbeProcessor.usageBeforeRegistration);
+ // With usage scope "lib" AND registration → builder resolved
+ assertEquals(
+ "lib.LibHelperBuilder",
+ ResolverProbeProcessor.usageAfterRegistration.get().getFullQualifiedName());
+ }
+
+ @Test
+ void resolverUsageScope_ResolvesBuilderWithoutSimpleBuilderAnnotation() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelper { public LibHelper() {} }
+ """),
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelperBuilder {
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // Usage scope without @SimpleBuilder annotation — builder resolved by contract check
+ assertEquals(
+ "lib.LibHelperBuilder",
+ ResolverProbeProcessor.usageWithoutAnnotation.get().getFullQualifiedName());
+ }
+
+ @Test
+ void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelper { public LibHelper() {} }
+ """),
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelperFactory {
+ public LibHelperFactory() {}
+ public LibHelperFactory(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // With builderUsageSuffix="Factory", the candidate name uses "Factory"
+ assertEquals(
+ "lib.LibHelperFactory",
+ ResolverProbeProcessor.usageWithSuffix.get().getFullQualifiedName());
+ }
+
+ @Test
+ void resolverUsageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotSet() {
+ ResolverProbeProcessor.reset();
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
+ public class LibHelper { public LibHelper() {} }
+ """),
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelperBuilder {
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // Without builderUsageSuffix, the candidate name uses builderSuffix ("Builder")
+ assertEquals(
+ "lib.LibHelperBuilder",
+ ResolverProbeProcessor.usageDefaultSuffix.get().getFullQualifiedName());
+ }
+
+ private static final class ResolverProbeProcessor extends AbstractProcessor {
+ private static Optional first;
+ private static Optional second;
+ private static Optional afterConfigurationChange;
+ private static Optional beforeRegistration;
+ private static Optional afterRegistration;
+ private static Optional usageBeforeRegistration;
+ private static Optional usageAfterRegistration;
+ private static Optional usageWithoutAnnotation;
+ private static Optional usageWithSuffix;
+ private static Optional usageDefaultSuffix;
+
+ private boolean captured;
+
+ static void reset() {
+ first = null;
+ second = null;
+ afterConfigurationChange = null;
+ beforeRegistration = null;
+ afterRegistration = null;
+ usageBeforeRegistration = null;
+ usageAfterRegistration = null;
+ usageWithoutAnnotation = null;
+ usageWithSuffix = null;
+ usageDefaultSuffix = null;
+ }
+
+ @Override
+ public Set getSupportedAnnotationTypes() {
+ return Set.of("*");
+ }
+
+ @Override
+ public SourceVersion getSupportedSourceVersion() {
+ return SourceVersion.latestSupported();
+ }
+
+ @Override
+ public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
+ if (captured || roundEnv.processingOver()) {
+ return false;
+ }
+ TypeElement helper = processingEnv.getElementUtils().getTypeElement("lib.LibHelper");
+ ProcessingContext context =
+ new ProcessingContext(
+ new ProcessingLogger(processingEnv), BuilderConfiguration.DEFAULT, processingEnv);
+ context.initConfigurationForProcessingTarget(configuration("lib", "Builder"));
+ BuilderScopeResolver resolver = context.getBuilderScopeResolver();
+ // Register the type as generated, mirroring the real processor which calls
+ // registerGeneratedTypes before any resolution happens.
+ resolver.registerGeneratedTypes(List.of(helper));
+ first = resolver.resolveUsableBuilderType(helper);
+ second = resolver.resolveUsableBuilderType(helper);
+ // Clear registration before testing scope-only behavior
+ resolver.registerGeneratedTypes(List.of());
+ context.initConfigurationForProcessingTarget(configuration("other", "OtherBuilder"));
+ afterConfigurationChange = resolver.resolveUsableBuilderType(helper);
+ context.initConfigurationForProcessingTarget(usageOnlyConfiguration("other"));
+ beforeRegistration = resolver.resolveUsableBuilderType(helper);
+ // Registration alone is not enough — the type must be in scope
+ resolver.registerGeneratedTypes(List.of(helper));
+ afterRegistration = resolver.resolveUsableBuilderType(helper);
+ // Clear registration for usage-scope classpath lookup tests
+ context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib"));
+ resolver.registerGeneratedTypes(List.of());
+ usageBeforeRegistration = resolver.resolveUsableBuilderType(helper);
+ resolver.registerGeneratedTypes(List.of(helper));
+ usageAfterRegistration = resolver.resolveUsableBuilderType(helper);
+ // Usage scope without @SimpleBuilder annotation — type existence check only
+ context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib"));
+ resolver.registerGeneratedTypes(List.of());
+ usageWithoutAnnotation = resolver.resolveUsableBuilderType(helper);
+ // Usage scope with builderUsageSuffix="Factory"
+ context.initConfigurationForProcessingTarget(usageWithSuffixConfiguration("lib", "Factory"));
+ usageWithSuffix = resolver.resolveUsableBuilderType(helper);
+ // Usage scope with default suffix (no builderUsageSuffix configured)
+ context.initConfigurationForProcessingTarget(usageOnlyConfiguration("lib"));
+ usageDefaultSuffix = resolver.resolveUsableBuilderType(helper);
+ captured = true;
+ return false;
+ }
+
+ private static BuilderConfiguration configuration(String packageName, String suffix) {
+ return BuilderConfiguration.DEFAULT.merge(
+ BuilderConfiguration.builder()
+ .builderGenerationPackages(packageName)
+ .builderSuffix(suffix)
+ .build());
+ }
+
+ private static BuilderConfiguration usageOnlyConfiguration(String packageName) {
+ return BuilderConfiguration.DEFAULT.merge(
+ BuilderConfiguration.builder().builderUsagePackages(packageName).build());
+ }
+
+ private static BuilderConfiguration usageWithSuffixConfiguration(
+ String packageName, String usageSuffix) {
+ return BuilderConfiguration.DEFAULT.merge(
+ BuilderConfiguration.builder()
+ .builderUsagePackages(packageName)
+ .builderUsageSuffix(usageSuffix)
+ .build());
+ }
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsEnumTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsEnumTest.java
new file mode 100644
index 00000000..dcce370c
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsEnumTest.java
@@ -0,0 +1,94 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025-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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.javahelpers.simple.builders.core.enums.AccessModifier;
+import org.javahelpers.simple.builders.core.enums.OptionState;
+import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum;
+import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link CompilerArgumentsEnum} lookup and value application not covered by {@link
+ * CompilerArgumentsReaderTest}: option-name resolution, the builder-option distinction, qualified
+ * annotation values, and the no-op contract for non-builder options.
+ */
+class CompilerArgumentsEnumTest {
+
+ @Test
+ void fromOptionName_resolvesAndRejects() {
+ assertSame(
+ CompilerArgumentsEnum.BUILDER_ACCESS,
+ CompilerArgumentsEnum.fromOptionName("builderAccess"));
+ assertNull(CompilerArgumentsEnum.fromOptionName("doesNotExist"));
+ }
+
+ @Test
+ void hasValueApplier_distinguishesConfigFromProcessFlags() {
+ assertTrue(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER.hasValueApplier());
+ assertFalse(CompilerArgumentsEnum.VERBOSE.hasValueApplier());
+ assertFalse(CompilerArgumentsEnum.DEACTIVATE_GENERATION_COMPONENTS.hasValueApplier());
+ assertFalse(CompilerArgumentsEnum.PERFORMANCE_TRACKING.hasValueApplier());
+ assertFalse(CompilerArgumentsEnum.PERFORMANCE_OUTPUT_FILE.hasValueApplier());
+ }
+
+ @Test
+ void apply_optionState_acceptsEnumNameAndKeywords() {
+ BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
+ var logger = CapturingProcessingLogger.create().logger();
+ CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER.apply(builder, "OptionState.ENABLED", logger);
+ CompilerArgumentsEnum.GENERATE_FIELD_CONSUMER.apply(builder, "false", logger);
+ BuilderConfiguration config = builder.build();
+ assertEquals(OptionState.ENABLED, config.generateFieldSupplier());
+ assertEquals(OptionState.DISABLED, config.generateFieldConsumer());
+ }
+
+ @Test
+ void apply_accessModifier_andString() {
+ BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
+ var logger = CapturingProcessingLogger.create().logger();
+ CompilerArgumentsEnum.BUILDER_ACCESS.apply(builder, "AccessModifier.PRIVATE", logger);
+ CompilerArgumentsEnum.BUILDER_SUFFIX.apply(builder, "Builder2", logger);
+ BuilderConfiguration config = builder.build();
+ assertEquals(AccessModifier.PRIVATE, config.builderAccess());
+ assertEquals("Builder2", config.builderSuffix());
+ }
+
+ @Test
+ void apply_nonBuilderOption_isIgnored() {
+ BuilderConfiguration.Builder builder = BuilderConfiguration.builder();
+ BuilderConfiguration before = builder.build();
+ CompilerArgumentsEnum.VERBOSE.apply(
+ builder, "true", CapturingProcessingLogger.create().logger());
+ assertEquals(before.generateFieldSupplier(), builder.build().generateFieldSupplier());
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
index d96c25fa..d348a536 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
@@ -28,12 +28,14 @@
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
import javax.annotation.processing.ProcessingEnvironment;
import org.javahelpers.simple.builders.core.enums.AccessModifier;
import org.javahelpers.simple.builders.core.enums.OptionState;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum;
import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader;
+import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger;
import org.javahelpers.simple.builders.processor.testing.ProcessingEnvironmentStub;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -186,187 +188,14 @@ void readBooleanValue_InvalidValues_ReturnsFalse(String value) {
"Should return false for: " + value);
}
- /** Test: readOptionState returns UNSET when value is null. */
- @Test
- void readOptionState_NullValue_ReturnsUnset() {
- ProcessingEnvironment env = ProcessingEnvironmentStub.createEmpty();
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- OptionState.UNSET,
- reader.readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER),
- "Should return UNSET for null value");
- }
-
- /** Test: readOptionState returns UNSET for empty string. */
- @Test
- void readOptionState_EmptyString_ReturnsUnset() {
- Map options = new HashMap<>();
- options.put("simplebuilder.generateFieldSupplier", "");
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- OptionState.UNSET,
- reader.readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER),
- "Should return UNSET for empty string");
- }
-
- /** Test: readOptionState handles case-insensitive "true" and "enabled". */
- @ParameterizedTest
- @ValueSource(strings = {"true", "TRUE", "enabled", "ENABLED", "Enabled"})
- void readOptionState_TrueOrEnabled_ReturnsEnabled(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.generateFieldSupplier", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- OptionState.ENABLED,
- reader.readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER),
- "Should return ENABLED for: " + value);
- }
-
- /** Test: readOptionState handles case-insensitive "false" and "disabled". */
- @ParameterizedTest
- @ValueSource(strings = {"false", "FALSE", "False", "disabled", "DISABLED", "Disabled"})
- void readOptionState_FalseOrDisabled_ReturnsDisabled(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.generateFieldSupplier", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- OptionState.DISABLED,
- reader.readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER),
- "Should return DISABLED for: " + value);
- }
-
- /** Test: readOptionState returns UNSET for invalid values. */
- @ParameterizedTest
- @ValueSource(strings = {"yes", "no", "1", "0", "on", "off", "invalid"})
- void readOptionState_InvalidValues_ReturnsUnset(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.generateFieldSupplier", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- OptionState.UNSET,
- reader.readOptionState(CompilerArgumentsEnum.GENERATE_FIELD_SUPPLIER),
- "Should return UNSET for invalid value: " + value);
- }
-
- /** Test: readAccessModifier returns DEFAULT when value is null. */
- @Test
- void readAccessModifier_NullValue_ReturnsDefault() {
- ProcessingEnvironment env = ProcessingEnvironmentStub.createEmpty();
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.DEFAULT,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return DEFAULT for null value");
- }
-
- /** Test: readAccessModifier returns DEFAULT for empty string. */
- @Test
- void readAccessModifier_EmptyString_ReturnsDefault() {
- Map options = new HashMap<>();
- options.put("simplebuilder.builderAccess", "");
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.DEFAULT,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return DEFAULT for empty string");
- }
-
- /** Test: readAccessModifier handles case-insensitive "public". */
- @ParameterizedTest
- @ValueSource(strings = {"public", "PUBLIC", "Public", "PuBlIc"})
- void readAccessModifier_CaseInsensitivePublic_ReturnsPublic(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.builderAccess", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.PUBLIC,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return PUBLIC for: " + value);
- }
-
- /** Test: readAccessModifier handles case-insensitive "private". */
- @ParameterizedTest
- @ValueSource(strings = {"private", "PRIVATE", "Private", "PrIvAtE"})
- void readAccessModifier_CaseInsensitivePrivate_ReturnsPrivate(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.builderAccess", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.PRIVATE,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return PRIVATE for: " + value);
- }
-
- /** Test: readAccessModifier handles both "package-private" and "package_private". */
- @ParameterizedTest
- @ValueSource(
- strings = {
- "package-private",
- "PACKAGE-PRIVATE",
- "Package-Private",
- "package_private",
- "PACKAGE_PRIVATE",
- "Package_Private"
- })
- void readAccessModifier_PackagePrivateVariants_ReturnsPackagePrivate(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.builderAccess", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.PACKAGE_PRIVATE,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return PACKAGE_PRIVATE for: " + value);
- }
-
- /** Test: readAccessModifier returns DEFAULT for invalid values. */
- @ParameterizedTest
- @ValueSource(strings = {"protected", "default", "package", "invalid", "123"})
- void readAccessModifier_InvalidValues_ReturnsDefault(String value) {
- Map options = new HashMap<>();
- options.put("simplebuilder.builderAccess", value);
-
- ProcessingEnvironment env = ProcessingEnvironmentStub.create(options);
- CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
-
- assertEquals(
- AccessModifier.DEFAULT,
- reader.readAccessModifier(CompilerArgumentsEnum.BUILDER_ACCESS),
- "Should return DEFAULT for invalid value: " + value);
- }
-
/** Test: readBuilderConfiguration with no arguments returns all UNSET/DEFAULT values. */
@Test
void readBuilderConfiguration_NoArguments_ReturnsDefaults() {
ProcessingEnvironment env = ProcessingEnvironmentStub.createEmpty();
CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
- BuilderConfiguration config = reader.readBuilderConfiguration();
+ BuilderConfiguration config =
+ reader.readBuilderConfiguration(CapturingProcessingLogger.create().logger());
assertNotNull(config, "Configuration should not be null");
assertEquals(OptionState.UNSET, config.generateFieldSupplier());
@@ -377,6 +206,12 @@ void readBuilderConfiguration_NoArguments_ReturnsDefaults() {
assertEquals(AccessModifier.DEFAULT, config.getMethodAccess());
assertNull(config.getBuilderSuffix(), "Builder suffix should be null when not set");
assertNull(config.getSetterSuffix(), "Setter suffix should be null when not set");
+ assertTrue(
+ config.getBuilderGenerationPackagesSet().isEmpty(),
+ "Builder generation packages should be empty when not set");
+ assertTrue(
+ config.getBuilderUsagePackagesSet().isEmpty(),
+ "Builder usage packages should be empty when not set");
}
/** Test: readBuilderConfiguration reads all options correctly. */
@@ -394,10 +229,13 @@ void readBuilderConfiguration_AllOptionsSet_ReadsCorrectly() {
.put("simplebuilder.copyTypeAnnotations", "enabled")
.put("simplebuilder.builderSuffix", "Factory")
.put("simplebuilder.setterSuffix", "with")
+ .put("simplebuilder.builderGenerationPackages", "a.b, c.d")
+ .put("simplebuilder.builderUsagePackages", "x.y")
.build();
CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
- BuilderConfiguration config = reader.readBuilderConfiguration();
+ BuilderConfiguration config =
+ reader.readBuilderConfiguration(CapturingProcessingLogger.create().logger());
assertEquals(OptionState.ENABLED, config.generateFieldSupplier());
assertEquals(OptionState.DISABLED, config.generateFieldConsumer());
@@ -409,6 +247,8 @@ void readBuilderConfiguration_AllOptionsSet_ReadsCorrectly() {
assertEquals(OptionState.ENABLED, config.copyTypeAnnotations());
assertEquals("Factory", config.getBuilderSuffix());
assertEquals("with", config.getSetterSuffix());
+ assertEquals(Set.of("a.b", "c.d"), config.getBuilderGenerationPackagesSet());
+ assertEquals(Set.of("x.y"), config.getBuilderUsagePackagesSet());
}
/** Test: readBuilderConfiguration handles mixed valid and invalid values. */
@@ -422,7 +262,8 @@ void readBuilderConfiguration_MixedValidInvalid_HandlesGracefully() {
.build();
CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
- BuilderConfiguration config = reader.readBuilderConfiguration();
+ BuilderConfiguration config =
+ reader.readBuilderConfiguration(CapturingProcessingLogger.create().logger());
assertEquals(
OptionState.UNSET, config.generateFieldSupplier(), "Invalid option should be UNSET");
@@ -446,7 +287,8 @@ void readBuilderConfiguration_EmptyStringValues_HandlesGracefully() {
.build();
CompilerArgumentsReader reader = new CompilerArgumentsReader(env);
- BuilderConfiguration config = reader.readBuilderConfiguration();
+ BuilderConfiguration config =
+ reader.readBuilderConfiguration(CapturingProcessingLogger.create().logger());
assertEquals(OptionState.UNSET, config.generateFieldSupplier(), "Empty should be UNSET");
assertEquals(AccessModifier.DEFAULT, config.getBuilderAccess(), "Empty should be DEFAULT");
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java
index 7f61ecaa..f814da40 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConfigurationProcessingTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.testing.compile.Compilation;
+import java.util.Set;
import javax.tools.JavaFileObject;
import org.javahelpers.simple.builders.core.enums.AccessModifier;
import org.javahelpers.simple.builders.core.enums.FormattingMode;
@@ -84,6 +85,8 @@ void allConfigurationOptions_AccessNamingAndFormatting_MustBeReadable() {
assertEquals("Builder", config.getBuilderSuffix());
assertEquals("", config.getSetterSuffix());
assertEquals("lightweight", config.formattingMode());
+ assertEquals(Set.of("a.b"), config.getBuilderGenerationPackagesSet());
+ assertEquals(Set.of("c.d"), config.getBuilderUsagePackagesSet());
}
private static BuilderConfiguration buildFullyConfigured() {
@@ -124,6 +127,9 @@ private static BuilderConfiguration buildFullyConfigured() {
.setterSuffix("")
// Formatting
.formattingMode("lightweight")
+ // Builder scoping
+ .builderGenerationPackages("a.b")
+ .builderUsagePackages("c.d")
.build();
}
@@ -517,6 +523,8 @@ void configurationMerge_MustRespectPriority() {
.generateConsumer(OptionState.ENABLED)
.builderAccess(AccessModifier.PUBLIC)
.setterSuffix("")
+ .builderGenerationPackages("base.pkg")
+ .builderUsagePackages("base.lib")
.build();
// When: Merge with override configuration
@@ -525,6 +533,7 @@ void configurationMerge_MustRespectPriority() {
.generateSupplier(OptionState.DISABLED) // Override
.generateBuilderConsumer(OptionState.DISABLED) // New value
.setterSuffix("with") // Override setterSuffix
+ .builderGenerationPackages("override.pkg")
// generateConsumer not set, should keep base value
.build();
@@ -546,6 +555,8 @@ void configurationMerge_MustRespectPriority() {
merged.getBuilderAccess(),
"Base value should be kept when override is DEFAULT");
assertEquals("with", merged.getSetterSuffix(), "Override should win for setterSuffix");
+ assertEquals(Set.of("override.pkg"), merged.getBuilderGenerationPackagesSet());
+ assertEquals(Set.of("base.lib"), merged.getBuilderUsagePackagesSet());
}
/** Merge logic test for formattingMode: Annotation value must override compiler arg default. */
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java
index c129ebfc..bb5f84fb 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterSourceFormatterTest.java
@@ -28,24 +28,9 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
import java.util.stream.Stream;
-import javax.annotation.processing.Filer;
-import javax.annotation.processing.Messager;
-import javax.annotation.processing.ProcessingEnvironment;
-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.util.Elements;
-import javax.lang.model.util.Types;
-import javax.tools.Diagnostic;
import org.javahelpers.simple.builders.core.enums.FormattingMode;
-import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
+import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
@@ -65,87 +50,8 @@
*/
class RoasterSourceFormatterTest {
- /** A minimal ProcessingEnvironment stub that provides a no-op Messager. */
- private static final class TestProcessingEnv implements ProcessingEnvironment {
- final TestMessager messager = new TestMessager();
-
- @Override
- public Messager getMessager() {
- return messager;
- }
-
- @Override
- public Map getOptions() {
- return Collections.emptyMap();
- }
-
- @Override
- public Elements getElementUtils() {
- return null;
- }
-
- @Override
- public Types getTypeUtils() {
- return null;
- }
-
- @Override
- public Filer getFiler() {
- return null;
- }
-
- @Override
- public SourceVersion getSourceVersion() {
- return SourceVersion.RELEASE_17;
- }
-
- @Override
- public Locale getLocale() {
- return Locale.getDefault();
- }
- }
-
- /** A minimal Messager that captures warnings. */
- private static final class TestMessager implements Messager {
- final List warnings = new ArrayList<>();
-
- @Override
- public void printMessage(Diagnostic.Kind kind, CharSequence msg) {
- if (kind == Diagnostic.Kind.WARNING) {
- warnings.add(msg.toString());
- }
- }
-
- @Override
- public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e) {
- if (kind == Diagnostic.Kind.WARNING) {
- warnings.add(msg.toString());
- }
- }
-
- @Override
- public void printMessage(
- Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a) {
- if (kind == Diagnostic.Kind.WARNING) {
- warnings.add(msg.toString());
- }
- }
-
- @Override
- public void printMessage(
- Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a, AnnotationValue v) {
- if (kind == Diagnostic.Kind.WARNING) {
- warnings.add(msg.toString());
- }
- }
- }
-
- private TestProcessingEnv createProcessingEnv() {
- return new TestProcessingEnv();
- }
-
private RoasterSourceFormatter createFormatter(FormattingMode mode) {
- return new RoasterSourceFormatter(new ProcessingLogger(createProcessingEnv()), mode);
+ return new RoasterSourceFormatter(CapturingProcessingLogger.create().logger(), mode);
}
@Test
@@ -623,9 +529,9 @@ void format_noneMode_doesNotConvertTabs() {
@Test
void format_jdtMode_withProfile_producesFormattedOutput() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
- RoasterSourceFormatter formatter = new RoasterSourceFormatter(logger, FormattingMode.JDT);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ RoasterSourceFormatter formatter =
+ new RoasterSourceFormatter(capturing.logger(), FormattingMode.JDT);
String input =
"""
package test;
@@ -635,48 +541,46 @@ void format_jdtMode_withProfile_producesFormattedOutput() {
String result = formatter.format(input);
assertNotNull(result, "Format should always return a non-null string");
assertTrue(
- env.messager.warnings.stream().noneMatch(w -> w.contains("JDT formatting requested")),
+ capturing.messages().stream().noneMatch(w -> w.contains("JDT formatting requested")),
"No fallback warning should be logged when formatter profile is available on classpath");
}
@Test
void constructor_jdtMode_missingProfile_logsFallbackWarning() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
- new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml");
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ new RoasterSourceFormatter(capturing.logger(), FormattingMode.JDT, "nonexistent-profile.xml");
assertTrue(
- env.messager.warnings.stream()
+ capturing.messages().stream()
.anyMatch(w -> w.contains("JDT formatting requested") && w.contains("unavailable")),
"JDT mode with missing profile should log fallback warning");
}
@Test
void constructor_lightweightMode_missingProfile_noFallbackWarning() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
- new RoasterSourceFormatter(logger, FormattingMode.LIGHTWEIGHT, "nonexistent-profile.xml");
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ new RoasterSourceFormatter(
+ capturing.logger(), FormattingMode.LIGHTWEIGHT, "nonexistent-profile.xml");
assertTrue(
- env.messager.warnings.stream().noneMatch(w -> w.contains("JDT formatting requested")),
+ capturing.messages().stream().noneMatch(w -> w.contains("JDT formatting requested")),
"LIGHTWEIGHT mode should not log JDT fallback warning even if profile is missing");
}
@Test
void constructor_missingProfile_logsProfileNotFoundWarning() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
- new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml");
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ new RoasterSourceFormatter(capturing.logger(), FormattingMode.JDT, "nonexistent-profile.xml");
assertTrue(
- env.messager.warnings.stream()
+ capturing.messages().stream()
.anyMatch(w -> w.contains("not found") && w.contains("nonexistent-profile.xml")),
"Missing formatter profile should log 'not found' warning with resource name");
}
@Test
void format_jdtMode_missingProfile_fallsBackToLightweight() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
RoasterSourceFormatter formatter =
- new RoasterSourceFormatter(logger, FormattingMode.JDT, "nonexistent-profile.xml");
+ new RoasterSourceFormatter(
+ capturing.logger(), FormattingMode.JDT, "nonexistent-profile.xml");
String input =
"""
package test;
@@ -692,11 +596,11 @@ void format_jdtMode_missingProfile_fallsBackToLightweight() {
@Test
void constructor_malformedProfile_logsLoadFailureWarning() {
- TestProcessingEnv env = createProcessingEnv();
- ProcessingLogger logger = new ProcessingLogger(env);
- new RoasterSourceFormatter(logger, FormattingMode.JDT, "eclipse-java-format-malformed.xml");
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ new RoasterSourceFormatter(
+ capturing.logger(), FormattingMode.JDT, "eclipse-java-format-malformed.xml");
assertTrue(
- env.messager.warnings.stream()
+ capturing.messages().stream()
.anyMatch(
w ->
w.contains("Failed to load")
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/model/core/PackageScopesTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/model/core/PackageScopesTest.java
new file mode 100644
index 00000000..27610eb6
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/model/core/PackageScopesTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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.model.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link PackageScopes}. */
+class PackageScopesTest {
+
+ @Test
+ void unscoped_isEmptyAndMatchesNothing() {
+ PackageScopes scopes = PackageScopes.unscoped();
+ assertTrue(scopes.isEmpty());
+ assertFalse(scopes.includes("com.example"));
+ }
+
+ @Test
+ void parse_emptyOrBlank_returnsUnscoped() {
+ assertSame(PackageScopes.unscoped(), PackageScopes.parse(null));
+ assertSame(PackageScopes.unscoped(), PackageScopes.parse(""));
+ assertSame(PackageScopes.unscoped(), PackageScopes.parse(" "));
+ }
+
+ @Test
+ void parse_trimsNormalizesCaseAndFiltersBlankEntries() {
+ PackageScopes scopes = PackageScopes.parse(" com.Example , , COM.other ");
+ assertFalse(scopes.isEmpty());
+ assertTrue(scopes.includes("com.example"));
+ assertTrue(scopes.includes("com.other"));
+ assertEquals("com.example, com.other", scopes.toString());
+ }
+
+ @Test
+ void includes_matchesExactPackage() {
+ PackageScopes scopes = PackageScopes.parse("com.example");
+ assertTrue(scopes.includes("com.example"));
+ }
+
+ @Test
+ void includes_matchesSubpackage() {
+ PackageScopes scopes = PackageScopes.parse("com.example");
+ assertTrue(scopes.includes("com.example.sub"));
+ assertTrue(scopes.includes("com.example.deep.nested"));
+ }
+
+ @Test
+ void includes_rejectsPackageWithSamePrefixButDifferentSegment() {
+ // This is the critical test: "com.examplefoo" starts with "com.example" as a string,
+ // but is NOT a subpackage. The "." separator in the scope check prevents this false
+ // positive. Removing the "." from scope + "." would break this test.
+ PackageScopes scopes = PackageScopes.parse("com.example");
+ assertFalse(scopes.includes("com.examplefoo"));
+ assertFalse(scopes.includes("com.examples"));
+ assertFalse(scopes.includes("com.exampl"));
+ }
+
+ @Test
+ void includes_isCaseInsensitive() {
+ PackageScopes scopes = PackageScopes.parse("com.Example");
+ assertTrue(scopes.includes("com.example"));
+ assertTrue(scopes.includes("COM.EXAMPLE"));
+ assertTrue(scopes.includes("com.Example.Sub"));
+ }
+
+ @Test
+ void includes_rejectsUnrelatedPackage() {
+ PackageScopes scopes = PackageScopes.parse("com.example");
+ assertFalse(scopes.includes("org.other"));
+ assertFalse(scopes.includes("com"));
+ }
+
+ @Test
+ void merge_withEmptyReturnsOther() {
+ PackageScopes a = PackageScopes.parse("com.example");
+ PackageScopes b = PackageScopes.unscoped();
+ assertSame(a, PackageScopes.merge(a, b));
+ assertSame(a, PackageScopes.merge(b, a));
+ }
+
+ @Test
+ void merge_bothEmptyReturnsUnscoped() {
+ assertSame(
+ PackageScopes.unscoped(),
+ PackageScopes.merge(PackageScopes.unscoped(), PackageScopes.unscoped()));
+ }
+
+ @Test
+ void merge_bothNonEmptyCombinesBoth() {
+ PackageScopes a = PackageScopes.parse("com.example");
+ PackageScopes b = PackageScopes.parse("org.other");
+ PackageScopes merged = PackageScopes.merge(a, b);
+ assertTrue(merged.includes("com.example"));
+ assertTrue(merged.includes("com.example.sub"));
+ assertTrue(merged.includes("org.other"));
+ assertTrue(merged.includes("org.other.deep"));
+ }
+
+ @Test
+ void packages_returnsConfiguredInDeclarationOrder() {
+ PackageScopes scopes = PackageScopes.parse("com.first, com.second");
+ assertEquals(2, scopes.packages().size());
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsersTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsersTest.java
new file mode 100644
index 00000000..ffdc7b0d
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/OptionValueParsersTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.javahelpers.simple.builders.core.enums.AccessModifier;
+import org.javahelpers.simple.builders.core.enums.OptionState;
+import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link OptionValueParsers}, covering parsing correctness and warning logging for
+ * unrecognized non-blank values.
+ */
+class OptionValueParsersTest {
+
+ @Test
+ void parseOptionState_recognizesTrueAndEnabled() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ assertEquals(
+ OptionState.ENABLED, OptionValueParsers.parseOptionState("true", capturing.logger()));
+ assertEquals(
+ OptionState.ENABLED, OptionValueParsers.parseOptionState("enabled", capturing.logger()));
+ assertEquals(
+ OptionState.ENABLED, OptionValueParsers.parseOptionState("TRUE", capturing.logger()));
+ assertEquals(
+ OptionState.ENABLED, OptionValueParsers.parseOptionState("Enabled", capturing.logger()));
+ assertTrue(capturing.messages().isEmpty(), "No warnings for recognized values");
+ }
+
+ @Test
+ void parseOptionState_recognizesFalseAndDisabled() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ assertEquals(
+ OptionState.DISABLED, OptionValueParsers.parseOptionState("false", capturing.logger()));
+ assertEquals(
+ OptionState.DISABLED, OptionValueParsers.parseOptionState("disabled", capturing.logger()));
+ assertEquals(
+ OptionState.DISABLED, OptionValueParsers.parseOptionState("FALSE", capturing.logger()));
+ assertTrue(capturing.messages().isEmpty(), "No warnings for recognized values");
+ }
+
+ @Test
+ void parseOptionState_returnsUnsetForNullOrBlank() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ assertEquals(OptionState.UNSET, OptionValueParsers.parseOptionState(null, capturing.logger()));
+ assertEquals(OptionState.UNSET, OptionValueParsers.parseOptionState("", capturing.logger()));
+ assertEquals(OptionState.UNSET, OptionValueParsers.parseOptionState(" ", capturing.logger()));
+ assertTrue(capturing.messages().isEmpty(), "No warnings for null or blank values");
+ }
+
+ @Test
+ void parseOptionState_logsWarningForUnrecognizedNonBlankValue() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+
+ OptionState result = OptionValueParsers.parseOptionState("invalid", capturing.logger());
+
+ assertEquals(OptionState.UNSET, result);
+ assertTrue(
+ capturing.messages().stream().anyMatch(m -> m.contains("invalid") && m.contains("UNSET")),
+ "Warning should mention the unrecognized value and the fallback");
+ }
+
+ @Test
+ void parseAccessModifier_recognizesPublicPrivatePackagePrivate() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ assertEquals(
+ AccessModifier.PUBLIC,
+ OptionValueParsers.parseAccessModifier("public", capturing.logger()));
+ assertEquals(
+ AccessModifier.PRIVATE,
+ OptionValueParsers.parseAccessModifier("private", capturing.logger()));
+ assertEquals(
+ AccessModifier.PACKAGE_PRIVATE,
+ OptionValueParsers.parseAccessModifier("package-private", capturing.logger()));
+ assertEquals(
+ AccessModifier.PACKAGE_PRIVATE,
+ OptionValueParsers.parseAccessModifier("package_private", capturing.logger()));
+ assertTrue(capturing.messages().isEmpty(), "No warnings for recognized values");
+ }
+
+ @Test
+ void parseAccessModifier_returnsDefaultForNullOrBlank() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ assertEquals(
+ AccessModifier.DEFAULT, OptionValueParsers.parseAccessModifier(null, capturing.logger()));
+ assertEquals(
+ AccessModifier.DEFAULT, OptionValueParsers.parseAccessModifier("", capturing.logger()));
+ assertEquals(
+ AccessModifier.DEFAULT, OptionValueParsers.parseAccessModifier(" ", capturing.logger()));
+ assertTrue(capturing.messages().isEmpty(), "No warnings for null or blank values");
+ }
+
+ @Test
+ void parseAccessModifier_logsWarningForUnrecognizedNonBlankValue() {
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+
+ AccessModifier result = OptionValueParsers.parseAccessModifier("protected", capturing.logger());
+
+ assertEquals(AccessModifier.DEFAULT, result);
+ assertTrue(
+ capturing.messages().stream()
+ .anyMatch(m -> m.contains("protected") && m.contains("DEFAULT")),
+ "Warning should mention the unrecognized value and the fallback");
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java
index d3cba102..709fa08d 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/processing/logging/ActivePerformanceTrackerTest.java
@@ -31,21 +31,7 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import javax.annotation.processing.Filer;
-import javax.annotation.processing.Messager;
-import javax.annotation.processing.ProcessingEnvironment;
-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.util.Elements;
-import javax.lang.model.util.Types;
-import javax.tools.Diagnostic;
+import org.javahelpers.simple.builders.processor.testing.CapturingProcessingLogger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -59,79 +45,6 @@ class ActivePerformanceTrackerTest {
@TempDir Path tempDir;
- /** Creates a ProcessingLogger with a capturing Messager for verification. */
- private ProcessingLogger createLogger(List messages) {
- ProcessingEnvironment env =
- new ProcessingEnvironment() {
- @Override
- public Map getOptions() {
- return Collections.emptyMap();
- }
-
- @Override
- public Messager getMessager() {
- return new CapturingMessager(messages);
- }
-
- @Override
- public Filer getFiler() {
- return null;
- }
-
- @Override
- public Elements getElementUtils() {
- return null;
- }
-
- @Override
- public Types getTypeUtils() {
- return null;
- }
-
- @Override
- public SourceVersion getSourceVersion() {
- return SourceVersion.latest();
- }
-
- @Override
- public Locale getLocale() {
- return Locale.getDefault();
- }
- };
- return new ProcessingLogger(env);
- }
-
- /** Messager that captures all messages into a list for assertion. */
- private static final class CapturingMessager implements Messager {
- private final List messages;
-
- CapturingMessager(List messages) {
- this.messages = messages;
- }
-
- @Override
- public void printMessage(Diagnostic.Kind kind, CharSequence msg) {
- messages.add(kind + ": " + msg);
- }
-
- @Override
- public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e) {
- messages.add(kind + ": " + msg);
- }
-
- @Override
- public void printMessage(
- Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a) {
- messages.add(kind + ": " + msg);
- }
-
- @Override
- public void printMessage(
- Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a, AnnotationValue v) {
- messages.add(kind + ": " + msg);
- }
- }
-
/** Helper to create a tracker, track some data, and generate report with JSON output. */
private JsonNode generateReportAndParseJson(String outputFile) throws IOException {
ActivePerformanceTracker tracker = new ActivePerformanceTracker(outputFile);
@@ -149,8 +62,8 @@ private JsonNode generateReportAndParseJson(String outputFile) throws IOExceptio
tracker.endPhase(PerformanceTracker.PHASE_DTO_MAPPING);
tracker.endClass(3, 1);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
String jsonContent = Files.readString(Path.of(outputFile));
@@ -160,13 +73,14 @@ private JsonNode generateReportAndParseJson(String outputFile) throws IOExceptio
@Test
void generateReport_withNoData_logsBasicReport() {
ActivePerformanceTracker tracker = new ActivePerformanceTracker(null);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("PERFORMANCE REPORT")));
- assertTrue(messages.stream().anyMatch(m -> m.contains("Total classes processed: 0")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("PERFORMANCE REPORT")));
+ assertTrue(
+ capturing.messages().stream().anyMatch(m -> m.contains("Total classes processed: 0")));
}
@Test
@@ -175,12 +89,13 @@ void generateReport_withClassData_logsClassCount() {
tracker.startClass("MyClass");
tracker.endClass(4, 1);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("Total classes processed: 1")));
- assertTrue(messages.stream().anyMatch(m -> m.contains("MyClass")));
+ assertTrue(
+ capturing.messages().stream().anyMatch(m -> m.contains("Total classes processed: 1")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("MyClass")));
}
@Test
@@ -191,12 +106,12 @@ void generateReport_withGeneratorData_logsGeneratorStats() {
tracker.endGenerator("MyGenerator");
tracker.endClass(2, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("MethodGenerators")));
- assertTrue(messages.stream().anyMatch(m -> m.contains("MyGenerator")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("MethodGenerators")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("MyGenerator")));
}
@Test
@@ -207,12 +122,12 @@ void generateReport_withEnhancerData_logsEnhancerStats() {
tracker.endEnhancer("MyEnhancer");
tracker.endClass(2, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("BuilderEnhancers")));
- assertTrue(messages.stream().anyMatch(m -> m.contains("MyEnhancer")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("BuilderEnhancers")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("MyEnhancer")));
}
@Test
@@ -223,16 +138,55 @@ void generateReport_withPhaseData_logsPhaseBreakdown() {
tracker.startPhase();
tracker.endPhase(PerformanceTracker.PHASE_CODE_GENERATION);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("Phase breakdown")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("Phase breakdown")));
assertTrue(
- messages.stream()
+ capturing.messages().stream()
.anyMatch(m -> m.contains(PerformanceTracker.PHASE_CONFIGURATION_RESOLUTION)));
assertTrue(
- messages.stream().anyMatch(m -> m.contains(PerformanceTracker.PHASE_CODE_GENERATION)));
+ capturing.messages().stream()
+ .anyMatch(m -> m.contains(PerformanceTracker.PHASE_CODE_GENERATION)));
+ }
+
+ @Test
+ void generateReport_withElementCollectionPhase_logsPhaseInReport() {
+ ActivePerformanceTracker tracker = new ActivePerformanceTracker(null);
+ tracker.startPhase();
+ tracker.endPhase(PerformanceTracker.PHASE_ELEMENT_COLLECTION);
+
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
+ tracker.generateReport(logger);
+
+ assertTrue(
+ capturing.messages().stream()
+ .anyMatch(m -> m.contains(PerformanceTracker.PHASE_ELEMENT_COLLECTION)),
+ "Text report should contain " + PerformanceTracker.PHASE_ELEMENT_COLLECTION);
+ }
+
+ @Test
+ void jsonReport_phaseBreakdown_containsElementCollectionPhase() throws IOException {
+ Path jsonFile = tempDir.resolve("report-element-collection.json");
+ ActivePerformanceTracker tracker = new ActivePerformanceTracker(jsonFile.toString());
+ tracker.startPhase();
+ tracker.endPhase(PerformanceTracker.PHASE_ELEMENT_COLLECTION);
+
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
+ tracker.generateReport(logger);
+
+ JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
+ JsonNode phases = root.get("phaseBreakdown");
+ assertTrue(
+ phases.has(PerformanceTracker.PHASE_ELEMENT_COLLECTION),
+ "JSON phaseBreakdown should contain " + PerformanceTracker.PHASE_ELEMENT_COLLECTION);
+ JsonNode elementCollection = phases.get(PerformanceTracker.PHASE_ELEMENT_COLLECTION);
+ assertTrue(elementCollection.has("elapsedNanos"));
+ assertTrue(elementCollection.has("elapsedSeconds"));
+ assertTrue(elementCollection.has("percentage"));
}
@Test
@@ -241,11 +195,11 @@ void generateReport_withNullOutputFile_doesNotWriteFile() {
tracker.startClass("MyClass");
tracker.endClass(1, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertFalse(messages.stream().anyMatch(m -> m.contains("JSON report written")));
+ assertFalse(capturing.messages().stream().anyMatch(m -> m.contains("JSON report written")));
}
@Test
@@ -347,8 +301,8 @@ void jsonReport_withNoData_hasEmptyArrays() throws IOException {
Path jsonFile = tempDir.resolve("report-empty.json");
ActivePerformanceTracker tracker = new ActivePerformanceTracker(jsonFile.toString());
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
@@ -378,14 +332,15 @@ void endMethodsWithoutStart_doesNothing() {
tracker.endEnhancer("NonexistentEnhancer");
tracker.endClass(5, 2);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
- assertTrue(messages.stream().anyMatch(m -> m.contains("PERFORMANCE REPORT")));
- assertTrue(messages.stream().anyMatch(m -> m.contains("Total classes processed: 0")));
- assertFalse(messages.stream().anyMatch(m -> m.contains("MethodGenerators")));
- assertFalse(messages.stream().anyMatch(m -> m.contains("BuilderEnhancers")));
+ assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("PERFORMANCE REPORT")));
+ assertTrue(
+ capturing.messages().stream().anyMatch(m -> m.contains("Total classes processed: 0")));
+ assertFalse(capturing.messages().stream().anyMatch(m -> m.contains("MethodGenerators")));
+ assertFalse(capturing.messages().stream().anyMatch(m -> m.contains("BuilderEnhancers")));
}
@Test
@@ -401,17 +356,45 @@ void multipleGenerators_accumulateTimeAndCalls() throws IOException {
tracker.endGenerator("GenB");
tracker.endClass(2, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
JsonNode genStats = root.get("generatorStats");
assertEquals(2, genStats.size());
- JsonNode genA =
- genStats.get(0).get("name").asText().equals("GenA") ? genStats.get(0) : genStats.get(1);
- assertEquals(2, genA.get("calls").asInt());
+ // Find GenA by name (stats are sorted by elapsed time, so index is unpredictable)
+ JsonNode genA = null;
+ for (int i = 0; i < genStats.size(); i++) {
+ if ("GenA".equals(genStats.get(i).get("name").asText())) {
+ genA = genStats.get(i);
+ break;
+ }
+ }
+ assertNotNull(genA, "GenA should be present in generator stats");
+ assertEquals(2, genA.get("calls").asInt(), "GenA was called twice");
+ }
+
+ @Test
+ void jsonReport_escapesSpecialCharactersInNames() throws IOException {
+ Path jsonFile = tempDir.resolve("report-escaping.json");
+ ActivePerformanceTracker tracker = new ActivePerformanceTracker(jsonFile.toString());
+ tracker.startClass("MyClass");
+ tracker.startGenerator();
+ tracker.endGenerator("Gen\"\\\n\t\r\b\fA\u0001");
+ tracker.startClass("MyClass2");
+ tracker.startEnhancer();
+ tracker.endEnhancer("Enh\u0001x");
+ tracker.endClass(2, 0);
+
+ tracker.generateReport(CapturingProcessingLogger.create().logger());
+
+ JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
+ JsonNode genStats = root.get("generatorStats");
+ assertEquals("Gen\"\\\n\t\r\b\fA\u0001", genStats.get(0).get("name").asText());
+ JsonNode enhStats = root.get("enhancerStats");
+ assertEquals("Enh\u0001x", enhStats.get(0).get("name").asText());
}
@Test
@@ -423,8 +406,8 @@ void multiplePhases_accumulateTime() throws IOException {
tracker.startPhase();
tracker.endPhase(PerformanceTracker.PHASE_CONFIGURATION_RESOLUTION);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
@@ -440,19 +423,20 @@ void generateReport_logsWarningOnInvalidPath() {
tracker.startClass("MyClass");
tracker.endClass(1, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
assertTrue(
- messages.stream().anyMatch(m -> m.contains("WARNING") && m.contains("Failed to write")));
+ capturing.messages().stream()
+ .anyMatch(m -> m.contains("WARNING") && m.contains("Failed to write")));
}
@Test
void noOpPerformanceTracker_allMethodsAreNoOps() {
NoOpPerformanceTracker tracker = new NoOpPerformanceTracker();
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.startPhase();
tracker.endPhase("Phase");
@@ -464,7 +448,7 @@ void noOpPerformanceTracker_allMethodsAreNoOps() {
tracker.endClass(1, 0);
tracker.generateReport(logger);
- assertTrue(messages.isEmpty());
+ assertTrue(capturing.messages().isEmpty());
}
@Test
@@ -482,8 +466,8 @@ void jsonReport_generatorAndEnhancerStats_sortedByElapsedDesc() throws IOExcepti
tracker.endEnhancer("FastEnh");
tracker.endClass(2, 0);
- List messages = new ArrayList<>();
- ProcessingLogger logger = createLogger(messages);
+ CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ ProcessingLogger logger = capturing.logger();
tracker.generateReport(logger);
JsonNode root = new ObjectMapper().readTree(Files.readString(jsonFile));
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java
new file mode 100644
index 00000000..14678643
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/CapturingProcessingLogger.java
@@ -0,0 +1,167 @@
+/*
+ * 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.testing;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import javax.annotation.processing.Filer;
+import javax.annotation.processing.Messager;
+import javax.annotation.processing.ProcessingEnvironment;
+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.util.Elements;
+import javax.lang.model.util.Types;
+import javax.tools.Diagnostic;
+import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
+
+/**
+ * Test helper for creating a {@link ProcessingLogger} that captures all emitted messages into a
+ * list for assertion.
+ *
+ * This avoids duplicating {@code CapturingMessager} and {@code createLogger} boilerplate across
+ * test classes. Usage:
+ *
+ *
{@code
+ * CapturingProcessingLogger capturing = CapturingProcessingLogger.create();
+ * ProcessingLogger logger = capturing.logger();
+ * // ... call code that logs ...
+ * assertTrue(capturing.messages().stream().anyMatch(m -> m.contains("expected")));
+ * }
+ */
+public final class CapturingProcessingLogger {
+
+ private final List messages;
+ private final ProcessingLogger logger;
+
+ private CapturingProcessingLogger(List messages, ProcessingLogger logger) {
+ this.messages = messages;
+ this.logger = logger;
+ }
+
+ /**
+ * Creates a new capturing logger with an empty message list.
+ *
+ * @return a new capturing logger instance
+ */
+ public static CapturingProcessingLogger create() {
+ List messages = new ArrayList<>();
+ ProcessingLogger logger = new ProcessingLogger(createCapturingEnvironment(messages));
+ return new CapturingProcessingLogger(messages, logger);
+ }
+
+ /**
+ * Returns the captured messages.
+ *
+ * @return unmodifiable view of the captured messages
+ */
+ public List messages() {
+ return Collections.unmodifiableList(messages);
+ }
+
+ /**
+ * Returns the {@link ProcessingLogger} to pass to production code.
+ *
+ * @return the processing logger
+ */
+ public ProcessingLogger logger() {
+ return logger;
+ }
+
+ /** Creates a ProcessingEnvironment whose Messager captures all messages into the list. */
+ private static ProcessingEnvironment createCapturingEnvironment(List messages) {
+ return new ProcessingEnvironment() {
+ @Override
+ public Map getOptions() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Messager getMessager() {
+ return new CapturingMessager(messages);
+ }
+
+ @Override
+ public Filer getFiler() {
+ return null;
+ }
+
+ @Override
+ public Elements getElementUtils() {
+ return null;
+ }
+
+ @Override
+ public Types getTypeUtils() {
+ return null;
+ }
+
+ @Override
+ public SourceVersion getSourceVersion() {
+ return SourceVersion.latest();
+ }
+
+ @Override
+ public Locale getLocale() {
+ return Locale.getDefault();
+ }
+ };
+ }
+
+ /** Messager that captures all messages into a list. */
+ private static final class CapturingMessager implements Messager {
+ private final List messages;
+
+ CapturingMessager(List messages) {
+ this.messages = messages;
+ }
+
+ @Override
+ public void printMessage(Diagnostic.Kind kind, CharSequence msg) {
+ messages.add(kind + ": " + msg);
+ }
+
+ @Override
+ public void printMessage(Diagnostic.Kind kind, CharSequence msg, Element e) {
+ messages.add(kind + ": " + msg);
+ }
+
+ @Override
+ public void printMessage(
+ Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a) {
+ messages.add(kind + ": " + msg);
+ }
+
+ @Override
+ public void printMessage(
+ Diagnostic.Kind kind, CharSequence msg, Element e, AnnotationMirror a, AnnotationValue v) {
+ messages.add(kind + ": " + msg);
+ }
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java
index ac6c4ecf..5c39bffb 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java
@@ -107,15 +107,30 @@ public static void assertContaining(String generatedCode, String... searches) {
}
/**
- * Asserts that the compilation contains all the specified note messages. Useful for verifying
- * debug or info logging output.
+ * Asserts that the compilation's notes match the expected substrings in order and count.
+ *
+ * Each expected substring is matched against the corresponding note message (by position). The
+ * note count must match exactly, so adding or removing any log message fails the test — making
+ * logging changes visible in code review and prompting documentation updates.
*
* @param compilation the compilation result
- * @param noteMessages the note messages to check for
+ * @param expectedSubstrings the substring each note (in order) must contain; length must equal
+ * the number of notes produced
*/
- public static void assertHadNoteContaining(Compilation compilation, String... noteMessages) {
- for (String noteMessage : noteMessages) {
- assertThat(compilation).hadNoteContaining(noteMessage);
+ public static void assertNotesInOrder(Compilation compilation, String... expectedSubstrings) {
+ List notes = compilation.notes().stream().map(n -> n.getMessage(null)).toList();
+ assertEquals(
+ expectedSubstrings.length,
+ notes.size(),
+ "Log note count changed — update expectedSubstrings and docs. "
+ + "Expected %d, got %d. Actual notes:%n%s"
+ .formatted(expectedSubstrings.length, notes.size(), String.join("%n", notes)));
+ for (int i = 0; i < notes.size(); i++) {
+ int index = i;
+ Assertions.assertTrue(
+ notes.get(i).contains(expectedSubstrings[i]),
+ "Note %d mismatch.%n Expected to contain: %s%n Actual: %s"
+ .formatted(index, expectedSubstrings[index], notes.get(index)));
}
}