diff --git a/README.md b/README.md index 13f18bfd..9ac603f5 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ A zero-reflection Java annotation processor that generates fluent, type-safe bui - [Elementary Builder Example](#elementary-builder-example) - [Full-Featured Examples](#full-featured-examples) - [Advanced Features](#advanced-features) + - [Builder Scoping Example](#builder-scoping-example) - [Performance Measurement](#performance-measurement) - [Contributing](#contributing) - [License](#license) @@ -465,6 +466,16 @@ Examples demonstrating special annotations and nested object relationships: - **Mannschaft DTO**: [`MannschaftDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/MannschaftDto.java) and [`MannschaftDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/MannschaftDtoBuilder.java) - Demonstrates `@IgnoreInBuilder` annotation to exclude specific setter methods from the generated builder, plus Set collections with nested objects - **Default Values**: [`ProductWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/ProductWithDefaults.java) (record) and [`OrderWithDefaults.java`](example/src/main/java/org/javahelpers/simple/builders/example/OrderWithDefaults.java) (class) - Demonstrate `@Default` annotation for unset builder fields +### Builder Scoping Example + +A runnable example demonstrating package-scoped builder generation and usage: + +- **Source DTO**: [`ScopedOwnerDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java) - Configures both package scopes inline and demonstrates the generation-scope, usage-scope, and out-of-scope field cases +- **Trusted helper**: [`TrustedHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/scoping/TrustedHelperDto.java) - In-generation-scope helper whose builder is referenced as a builder consumer +- **Library helper**: [`library/LibraryHelperDto.java`](example/src/main/java/org/javahelpers/simple/builders/example/library/LibraryHelperDto.java) - Annotated but outside the generation scope, so no builder exists and the owner falls back to a plain setter +- **Generated Builder**: [`ScopedOwnerDtoBuilder.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java) - Shows the consumer overload for `trusted` and plain setters for `library` and `sponsor` +- **Tests**: [`ScopedOwnerDtoBuilderTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilderTest.java) - Asserts the generated API shape + These examples serve as both documentation and integration tests for the annotation processor. ## Performance Measurement diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java index 5e70c7b0..1ee84f7b 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/Ignore4BuilderGeneration.java @@ -40,6 +40,11 @@ * generation for the exact type it is placed on and does not cascade to further subclasses, which * may therefore still get a builder from an inherited {@code @SimpleBuilder} or template * annotation. + * + *

This is the per-type opt-out. To restrict generation to whole packages instead (including + * types whose {@code @SimpleBuilder} is inherited or applied via a template), use {@code + * builderGenerationPackages} in {@link SimpleBuilder.Options} or the {@code + * simplebuilder.builderGenerationPackages} compiler option. */ @Target(ElementType.TYPE) @Retention(RetentionPolicy.CLASS) diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 3bce51bc..90aa27d0 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -60,6 +60,8 @@ *

  • Collection Helpers: generateVarArgsHelpers, usingArrayListBuilder, * usingArrayListBuilderWithElementBuilders, usingHashSetBuilder, * usingHashSetBuilderWithElementBuilders, usingHashMapBuilder (all default: true) + *
  • Builder Scoping: builderGenerationPackages, builderUsagePackages, builderUsageSuffix + * (default: empty) *
  • Integration: generateWithInterface (default: true) *
  • Documentation: generateJavaDoc (default: true) * @@ -679,6 +681,56 @@ */ OptionState generateJavaDoc() default OptionState.UNSET; + // === Builder Scoping === + /** + * Comma-separated list of packages for which builders are generated.
    + * Subpackages are included automatically and matching ignores case. When non-empty, builder + * generation is restricted to DTOs whose package equals or is a subpackage of a listed package. + * Types in this scope are trusted to have their builder generated in the same compilation and + * may be referenced without a type-existence search; they are automatically usable as helpers + * and do not need to be listed in {@link #builderUsagePackages()}. + * + *

    On a single class this is mainly useful to opt back in: annotation options take + * precedence over the compiler option, so a type can include its own package even when the + * global scope excludes it. Set on a {@link Template} annotation or a base class ({@code + * SimpleBuilder} is {@code @Inherited}), it instead filters which inheriting types get a + * builder. To exclude a single type, prefer {@link Ignore4BuilderGeneration}. + * + *

    Example: {@code "com.example.dto, com.example.shared"} + * + *

    Default: "" (empty - builders are generated for all {@code @SimpleBuilder} annotated DTOs, + * no type search)
    + * Compiler option: -Asimplebuilder.builderGenerationPackages + * + * @return the packages for which builders are generated + */ + String builderGenerationPackages() default ""; + + /** + * Comma-separated list of packages whose builders may be used as helper methods for other DTOs. + *
    + * Subpackages are included automatically and matching ignores case. The processor constructs + * the candidate builder name using {@link #builderUsageSuffix()} (or {@link #builderSuffix()} + * if not configured) and verifies that a class with that name exists on the classpath or was + * generated in the current processing round. This allows referencing builders generated with + * custom template annotations or different suffixes. If the builder type cannot be resolved, + * the field falls back to a plain setter. + * + *

    Packages in {@link #builderGenerationPackages()} are automatically part of the usage scope + * and never need to be repeated here. When this option is empty, builders from any package may + * be referenced; once set, only packages listed here (and generation-scope types) may provide + * builder helpers. + * + *

    Example: {@code "com.example.library, com.example.external"} + * + *

    Default: "" (empty - all types may be referenced as builders; the builder type is not + * verified to exist on the classpath, preserving backward-compatible behavior)
    + * Compiler option: -Asimplebuilder.builderUsagePackages + * + * @return the packages whose builders may be used as helpers + */ + String builderUsagePackages() default ""; + // === Naming === /** * Suffix to append to the DTO name to generate the builder class name.
    @@ -702,6 +754,35 @@ */ String builderSuffix() default "Builder"; + /** + * Suffix to append to the DTO name when looking up a builder from the usage scope.
    + * The processor constructs the candidate builder name using this suffix and verifies that a + * class with that name exists on the classpath. If empty, the value of {@link #builderSuffix()} + * is used. This allows referencing builders that were generated with a different suffix (e.g. + * by another module using "Factory" as suffix) without changing the suffix used for own builder + * generation. + * + *

    Example: + * + *

    {@code
    +     * @SimpleBuilder(options = @SimpleBuilder.Options(
    +     *     builderSuffix = "Builder",
    +     *     builderUsageSuffix = "Factory"
    +     * ))
    +     * public class OwnerDto {
    +     *     // Own builder: OwnerDtoBuilder
    +     *     // Referenced builders from usage scope: looked up as *Factory
    +     * }
    +     * }
    + * + * Default: "" (empty - falls back to {@link #builderSuffix()})
    + * Compiler option: -Asimplebuilder.builderUsageSuffix + * + * @return the suffix for usage-scope builder class names, or empty to use {@link + * #builderSuffix()} + */ + String builderUsageSuffix() default ""; + /** * Suffix to append to setter method names in the generated builder.
    * For example, with suffix "with", a field named "name" will generate "withName()".
    diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cba0f1c5..568e4fc0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -18,6 +18,7 @@ Simple-builders supports fine-grained configuration through the `@SimpleBuilder. - [Conditional Logic](#conditional-logic) - [Access Control](#access-control) - [Collection Helpers](#collection-helpers) + - [Builder Scoping](#builder-scoping) - [Component Filtering](#component-filtering) - [Integration](#integration) - [Documentation](#documentation) @@ -665,6 +666,55 @@ Generates methods using `HashMapBuilder` for fluent Map construction. --- +### Builder Scoping + +#### `builderGenerationPackages` + +**Default**: `""` (empty, unchanged behavior) | **Compiler Option**: +`-Asimplebuilder.builderGenerationPackages=package1,package2` + +Restricts builder generation to annotated DTOs in the listed packages. Packages are +comma-separated, each listed package includes all of its subpackages, and matching ignores +case. Types in the generation scope are trusted to have their builders generated in the +current compilation, so references to their builders do not require a type search. + +This option is an allowlist for whole packages: once set, builders are generated only inside +the listed packages and every other package is excluded implicitly. Excluding a single +package while generating everywhere else is not expressible; to exclude a single class, +use `@Ignore4BuilderGeneration` instead. The scope also applies to types that carry +`@SimpleBuilder` only through inheritance or a template annotation. + +#### `builderUsagePackages` + +**Default**: `""` (empty, unchanged behavior) | **Compiler Option**: +`-Asimplebuilder.builderUsagePackages=package1,package2` + +Controls which packages may provide builders as nested builder helpers. Packages are +comma-separated, each listed package includes all of its subpackages, and matching ignores +case. + +The processor constructs the candidate builder name using `builderUsageSuffix` +(or `builderSuffix` if not configured) and verifies the builder contract: a +constructor accepting the referenced type and a no-arg `build()` method returning +it. Any class with the expected name and a matching contract qualifies, allowing +references to builders generated with custom template annotations, external tools, +or different suffixes. If the candidate builder cannot be found, the field falls +back to a plain setter. + +Packages listed in `builderGenerationPackages` are automatically included in the usage +scope — their builders are generated in the same compilation and don't need to be listed +here. + +When `builderUsagePackages` is empty, builders from any package may be referenced (the +behavior before scoping existed). When set, only packages listed in `builderUsagePackages` +(and generation-scope types) may provide builder helpers — types outside both scopes fall +back to a plain setter. + +The `example` module contains a runnable demo in package +`org.javahelpers.simple.builders.example.scoping` ([`ScopedOwnerDto.java`](../example/src/main/java/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDto.java)). +It demonstrates a generation-scope builder consumer, a usage-scope missing-builder fallback, and +an out-of-scope plain setter in [`ScopedOwnerDtoBuilder.java`](../example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java). + ### Component Filtering #### `deactivateGenerationComponents` @@ -912,6 +962,39 @@ public class PersonDto { } --- +#### `builderUsageSuffix` + +**Default**: `""` (empty — falls back to `builderSuffix`) | **Compiler Option**: +`-Asimplebuilder.builderUsageSuffix=CustomSuffix` + +Customizes the suffix used when looking up builders from the usage scope. The processor +constructs the candidate builder name as `referencedType.getSimpleName() + builderUsageSuffix`. +If empty, `builderSuffix` is used instead. This allows referencing builders that were generated +with a different suffix (e.g. by another module using `"Factory"` as suffix) without changing +the suffix used for own builder generation. + +The candidate class must provide a constructor accepting the referenced type and a no-arg +`build()` method returning it. The contract check is annotation-agnostic, so builders +generated with custom template annotations or external tools are supported. If the +candidate class does not exist or does not satisfy this contract, the field falls back +to a plain setter. + +**Example**: +```java +@SimpleBuilder.Options( + builderSuffix = "Builder", // own builders: *Builder + builderUsageSuffix = "Factory" // usage-scope builders: *Factory +) +public class OwnerDto { + private ExternalDto external; +} + +// Own builder: OwnerDtoBuilder +// External builder looked up as: ExternalDtoFactory (not ExternalDtoBuilder) +``` + +--- + #### `setterSuffix` **Default**: `""` (empty) | **Compiler Option**: `-Asimplebuilder.setterSuffix=customPrefix` @@ -952,7 +1035,7 @@ processing. A summary report is logged to the compiler output at the end of proc **When enabled**: A hierarchical performance report is printed to the compiler log, including: - Total processing time and average time per class -- Phase breakdown (Configuration Resolution, Builder Definition Extraction, DTO Mapping, Code Generation) +- Phase breakdown (Element Collection, Configuration Resolution, Builder Definition Extraction, DTO Mapping, Code Generation) - Top 20 slowest classes with field and collection counts - Top 5 slowest MethodGenerators and BuilderEnhancers @@ -1418,6 +1501,10 @@ methodAccess = AccessModifier.PRIVATE # Component Filtering -Asimplebuilder.deactivateGenerationComponents=pattern1,pattern2,... +# Builder Scoping +-Asimplebuilder.builderGenerationPackages=package1,package2 +-Asimplebuilder.builderUsagePackages=package1,package2 + # Integration & Annotations -Asimplebuilder.generateWithInterface=ENABLED|DISABLED -Asimplebuilder.implementsBuilderBase=ENABLED|DISABLED @@ -1477,6 +1564,10 @@ methodAccess = AccessModifier.PRIVATE usingHashSetBuilder = OptionState.ENABLED, usingHashSetBuilderWithElementBuilders = OptionState.ENABLED, usingHashMapBuilder = OptionState.ENABLED, + + // Builder Scoping + builderGenerationPackages = "com.example.dto", + builderUsagePackages = "com.example.library", // Integration & Annotations generateWithInterface = OptionState.ENABLED, diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d76cc883..07c4c765 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -240,6 +240,7 @@ For complete documentation, see [DEBUG_LOGGING.md](DEBUG_LOGGING.md). ========== Compilation Diagnostics ========== --- NOTES --- [DEBUG] simple-builders: Processing round started. Found 1 annotated elements. +[DEBUG] simple-builders: 1 of 1 annotated element(s) are inside the builderGenerationPackages scope. [DEBUG] Processing element: Project [DEBUG] ├─ Extracting builder definition from: test.Project [DEBUG] │ ├─ Builder will be generated as: test.ProjectBuilder diff --git a/docs/CUSTOMIZING.md b/docs/CUSTOMIZING.md index b9ff2b8d..c8f7d95d 100644 --- a/docs/CUSTOMIZING.md +++ b/docs/CUSTOMIZING.md @@ -28,6 +28,10 @@ Simple-builders is designed to be extensible through custom generators and enhan All generators are managed by a unified `GeneratorRegistry` that loads both method generators and builder enhancers from a single service file, automatically separating them based on their type (using Java's sealed interface feature) +Builder usage decisions are centralized in `BuilderScopeResolver`. Custom generators and enhancers +do not need any changes: they continue to rely on `TypeName.getBuilderType().isPresent()` when +checking whether a builder is available. + ## Generator Interface Simple-builders uses a sealed `Generator` interface hierarchy that supports two types of functionality: diff --git a/docs/DEBUG_LOGGING.md b/docs/DEBUG_LOGGING.md index 6ceec929..57f65ef5 100644 --- a/docs/DEBUG_LOGGING.md +++ b/docs/DEBUG_LOGGING.md @@ -92,6 +92,7 @@ When debug logging is enabled, you'll see detailed output with visual separators ``` [INFO] simple-builders: PROCESSING ROUND START [INFO] [DEBUG] simple-builders: Processing round started. Found 3 annotated elements. +[INFO] [DEBUG] simple-builders: 3 of 3 annotated element(s) are inside the builderGenerationPackages scope. [INFO] [DEBUG] Processing element: PersonDto [INFO] [DEBUG] ├─ Extracting builder definition from: org.example.PersonDto [INFO] [DEBUG] │ ├─ Builder will be generated as: org.example.PersonDtoBuilder @@ -123,6 +124,9 @@ When debug logging is enabled, you'll see detailed output with visual separators [INFO] [DEBUG] ├─ Code generation for builder: CustomerDtoBuilder [INFO] [DEBUG] │ └─ Successfully generated builder: CustomerDtoBuilder [INFO] simple-builders: Successfully generated 3 builder(s) in this processing round +[INFO] simple-builders: PROCESSING ROUND START +[INFO] [DEBUG] simple-builders: Processing round started. Found 0 annotated elements. +[INFO] [DEBUG] simple-builders: 0 of 0 annotated element(s) are inside the builderGenerationPackages scope. ``` **Note**: Debug messages are prefixed with `[DEBUG]` and use `Diagnostic.Kind.OTHER` which appears as `[INFO]` in Maven output. diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java new file mode 100644 index 00000000..ee9dbe65 --- /dev/null +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java @@ -0,0 +1,355 @@ +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; +import org.javahelpers.simple.builders.example.SponsorDto; +import org.javahelpers.simple.builders.example.library.LibraryHelperDto; + +/** + * Builder for {@code org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto}. + *

    + * This builder provides a fluent API for creating instances of + * org.javahelpers.simple.builders.example.scoping.ScopedOwnerDto 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
    + * ScopedOwnerDto result = ScopedOwnerDtoBuilder.create()
    + *     .library(new LibraryHelperDto())
    + *     .library(LibraryHelperDto::new)
    + *     .sponsor(new SponsorDto())
    + *     .sponsor(SponsorDto::new)
    + *     .trusted(new TrustedHelperDto())
    + *     .trusted(TrustedHelperDto::new)
    + *     .trusted(trustedHelperDtoBuilder -> trustedHelperDtoBuilder)
    + *     .build();
    + * }
    + */ +@Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") +@BuilderImplementation(forClass = ScopedOwnerDto.class) +public class ScopedOwnerDtoBuilder implements IBuilderBase { + + /** + * Tracked value for library: library. + */ + private TrackedValue library = unsetValue(); + /** + * Tracked value for sponsor: sponsor. + */ + private TrackedValue sponsor = unsetValue(); + /** + * Tracked value for trusted: trusted. + */ + private TrackedValue 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 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 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 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 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: + * + *

      + *
    1. 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). + *
    2. 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. + *
    3. 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 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 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: * *

      - *
    1. {@code CompilerArgumentsEnum} — add the enum constant. - *
    2. This method — read the value and set it on the builder. + *
    3. {@code CompilerArgumentsEnum} — add the enum constant with its applier. *
    4. {@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}. - *
    5. {@code BuilderConfigurationReader} — handle annotation-side extraction in {@code - * extractOptionsFromAnnotationMirror}. *
    6. {@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 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))); } }