diff --git a/README.md b/README.md index f16f92d2..88cf9437 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A zero-reflection Java annotation processor that generates fluent, type-safe bui - [Advanced Features](#advanced-features) - [External Type Builder Example](#external-type-builder-example) - [Builder Scoping Example](#builder-scoping-example) + - [MapStruct Example](#mapstruct-example) - [Performance Measurement](#performance-measurement) - [Contributing](#contributing) - [License](#license) @@ -76,6 +77,7 @@ Value semantics (`equals`, `hashCode`, `toString`) and generating brand-new immu - **Annotation Preservation**: Validation annotations are automatically copied to builder methods - **With Interface Pattern**: Type-safe object modifications using generated With interfaces - **Jackson Support**: Supporting Jackson deserialization via `@JsonPOJOBuilder` and optional generation of `SimpleModule`s (one per package) (both need to be enabled) +- **MapStruct Support**: Generated builders are automatically detected by [MapStruct](https://mapstruct.org/) via a `BuilderProvider` SPI when simple-builders-processor and mapstruct-processor share the annotation processor path - **External Type Builders**: `@SimpleBuilderFor` generates builders for types that cannot be annotated - for example classes from third-party libraries - **JavaDoc Usage Examples**: Generated builder methods include auto-generated usage examples in their JavaDoc (per-method fluent snippets plus a class-level example), so IDE tooltips show exactly how to use each builder @@ -489,6 +491,14 @@ A runnable example demonstrating package-scoped builder generation and usage: - **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 +### MapStruct Example + +A runnable example of the MapStruct `BuilderProvider` integration - no configuration needed beyond putting mapstruct-processor on the same annotation processor path. A bundled `AccessorNamingStrategy` marks the generated helper methods (`add2*`, `*Update`, `Supplier`/`Consumer`/`format` overloads, `conditional(...)`) as non-setters, honoring per-bean naming configuration like `setterSuffix`, so only direct property setters participate in bean mapping - no phantom "unmapped target property" warnings. + +- **Mapper**: [`PersonDtoMapper.java`](example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java) - Plain `@Mapper` interface; MapStruct resolves `PersonDtoBuilder` automatically +- **Generated implementation**: [`PersonDtoMapperImpl.java`](example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java) - Uses `PersonDtoBuilder.create()`, the plain setter overloads, and `build()` +- **Tests**: [`MapStructIntegrationTest.java`](example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java) - Verifies end-to-end mapping through the generated builder + These examples serve as both documentation and integration tests for the annotation processor. ## Performance Measurement diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d8e8cea9..e89ae3bb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1048,6 +1048,27 @@ This is highly recommended to ensure deterministic output location and avoid spl --- +#### `usingMapStructIntegration` + +**Default**: `ENABLED` | **Compiler Option**: `-Asimplebuilder.usingMapStructIntegration=ENABLED|DISABLED` + +Controls the MapStruct SPI adapters bundled in the processor jar (`MapStructBuilderProvider` and `MapStructAccessorNamingStrategy`, registered via `META-INF/services`). They make MapStruct auto-detect generated builders when `simple-builders-processor` and `mapstruct-processor` share the annotation processor path — no annotation attribute exists because the SPIs are discovered globally by MapStruct itself. + +The SPIs read this option from the options map MapStruct hands them, with the same `-D` > `-A` > bare-option precedence as every option. MapStruct only forwards declared options into SPI environments, so the processor jar also ships an `AdditionalSupportedOptionsProvider` declaring both spellings — `-Asimplebuilder.usingMapStructIntegration` and the bare `-AusingMapStructIntegration` reach the SPIs, and `-D` works too since system properties are JVM-global (the only mechanism when only the SPI jar is on the processor path). + +**When DISABLED**: The provider returns no builder candidates and the naming strategy keeps the stock MapStruct behaviour, so generated builders are treated like ordinary classes. + +**Scope note**: `BuilderProcessor` generates in exactly one round — the first round carrying its annotations. Beans whose types first appear in a later round (emitted by other processors after that round) get no builder, and only beans the processor plans in that round are paired with their builder through the SPIs; builders produced by earlier compilations are not discovered. + +**Example**: +```xml + + -Asimplebuilder.usingMapStructIntegration=DISABLED + +``` + +--- + ### Documentation #### `generateJavaDoc` @@ -1692,6 +1713,7 @@ methodAccess = AccessModifier.PRIVATE -Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED -Asimplebuilder.generateJacksonModule=ENABLED|DISABLED -Asimplebuilder.jacksonModulePackage=com.your.package +-Asimplebuilder.usingMapStructIntegration=ENABLED|DISABLED # Documentation -Asimplebuilder.generateJavaDoc=ENABLED|DISABLED diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java new file mode 100644 index 00000000..031f0cd7 --- /dev/null +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java @@ -0,0 +1,30 @@ +package org.javahelpers.simple.builders.example; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.processing.Generated; + +@Generated( + value = "org.mapstruct.ap.MappingProcessor" +) +public class PersonDtoMapperImpl implements PersonDtoMapper { + + @Override + public PersonDto copy(PersonDto source) { + if ( source == null ) { + return null; + } + + PersonDtoBuilder personDto = PersonDtoBuilder.create(); + + personDto.birthdate( source.getBirthdate() ); + personDto.mannschaft( source.getMannschaft() ); + personDto.name( source.getName() ); + List list = source.getNickNames(); + if ( list != null ) { + personDto.nickNames( new ArrayList( list ) ); + } + + return personDto.build(); + } +} diff --git a/example/pom.xml b/example/pom.xml index fa1cd68b..1e889fa2 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -18,6 +18,7 @@ 3.21.0 6.1.3 2.22.3 + 1.6.3 3.16.0 3.2.0 @@ -64,6 +65,13 @@ ${jackson-databind.version} + + + org.mapstruct + mapstruct + ${mapstruct.version} + + @@ -114,9 +122,17 @@ example-custom-generator ${project.version} + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + -Averbose=${simplebuilder.verbose} + + -Amapstruct.suppressGeneratorTimestamp=true + -Amapstruct.suppressGeneratorVersionInfoComment=true diff --git a/example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java b/example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java new file mode 100644 index 00000000..55068cad --- /dev/null +++ b/example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java @@ -0,0 +1,33 @@ +/* + * 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 with the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.example; + +import org.mapstruct.Mapper; + +/** Maps {@link PersonDto} using the generated {@code PersonDtoBuilder} discovered by MapStruct. */ +@Mapper +public interface PersonDtoMapper { + + PersonDto copy(PersonDto source); +} diff --git a/example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java b/example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java new file mode 100644 index 00000000..52e28338 --- /dev/null +++ b/example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java @@ -0,0 +1,77 @@ +/* + * 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 with the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; + +/** + * Verifies that MapStruct discovers and uses the generated {@code PersonDtoBuilder} through the + * {@code MapStructBuilderProvider} SPI shipped in simple-builders-processor. + */ +class MapStructIntegrationTest { + + private static final Path GENERATED_MAPPER_IMPL = + Path.of( + "generated-example-builder", + "org", + "javahelpers", + "simple", + "builders", + "example", + "PersonDtoMapperImpl.java"); + + @Test + void shouldMapPersonDtoUsingGeneratedBuilder() { + PersonDto source = PersonDtoBuilder.create() + .name("Alice") + .birthdate(LocalDate.of(1990, 1, 1)) + .build(); + + PersonDto copy = Mappers.getMapper(PersonDtoMapper.class).copy(source); + + assertNotNull(copy); + assertEquals("Alice", copy.getName()); + assertEquals(LocalDate.of(1990, 1, 1), copy.getBirthdate()); + } + + @Test + void shouldGenerateMapperUsingPersonDtoBuilder() throws IOException { + String mapperImpl = Files.readString(GENERATED_MAPPER_IMPL); + assertTrue( + mapperImpl.contains("PersonDtoBuilder.create()"), + "MapStruct mapper implementation should use PersonDtoBuilder.create()"); + assertTrue( + mapperImpl.contains(".build()"), + "MapStruct mapper implementation should finish via build()"); + } +} diff --git a/processor/pom.xml b/processor/pom.xml index c76d301e..e64e5962 100644 --- a/processor/pom.xml +++ b/processor/pom.xml @@ -63,6 +63,7 @@ 6.1.3 0.23.0 2.22.3 + 1.6.3 17 @@ -125,6 +126,15 @@ ${roaster.version} runtime + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + provided + true + @@ -154,6 +164,14 @@ test + + + org.mapstruct + mapstruct + ${mapstruct.version} + test + + com.fasterxml.jackson.core 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 779c2fe2..d0117fb8 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 @@ -62,6 +62,7 @@ import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors; import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; +import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator; @@ -109,6 +110,8 @@ public synchronized void init(ProcessingEnvironment processingEnv) { BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger); logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); + SimpleBuildersSpiIntegration.initCompilation(processingEnv.getElementUtils()); + this.context = new ProcessingContext(logger, globalConfig, processingEnv); this.codeGenerator = new RoasterCodeGenerator(context, processingEnv); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig); @@ -131,11 +134,14 @@ public synchronized void init(ProcessingEnvironment processingEnv) { } /** - * Always returns {@code false}, so this method intentionally never claims annotations (hence the - * {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and {@link - * SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template annotations; - * claiming would therefore hide every annotation in the round — including foreign ones like - * MapStruct's {@code @Mapper} — from later processors. + * Generates all builders in exactly one round — the first round carrying simple-builders + * annotations — and marks the SPI registry final when that round ends. + * + *

Always returns {@code false}, so this method intentionally never claims annotations (hence + * the {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and + * {@link SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template + * annotations; claiming would therefore hide every annotation in the round — including foreign + * ones like MapStruct's {@code @Mapper} — from later processors. */ @Override @SuppressWarnings("java:S3516") @@ -147,10 +153,10 @@ public boolean process(Set annotations, RoundEnvironment // Generate Jackson Module if processing is over and feature is enabled if (roundEnv.processingOver()) { + SimpleBuildersSpiIntegration.finishCompilation(); generateJacksonModules(context.getPerformanceTracker()); return false; } - PerformanceTracker tracker = context.getPerformanceTracker(); context.info("simple-builders: PROCESSING ROUND START"); @@ -182,6 +188,13 @@ public boolean process(Set annotations, RoundEnvironment context.debug( "simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.", elementsToGenerate.size(), sortedElements.size()); + if (SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration( + processingEnv.getElementUtils()) + || (sortedElements.isEmpty() && sortedHolders.isEmpty())) { + // simple-builders generates in exactly one round — the first round carrying its + // annotations; elements first appearing in later rounds get no builder. + return false; + } registerGeneratedTypes(elementsToGenerate); int successfulGenerations = generateBuilders(elementsToGenerate, tracker); @@ -195,6 +208,8 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); + // The generating round is done — the registry is final for SPI adapters from here on. + SimpleBuildersSpiIntegration.finishCompilation(); return false; } @@ -522,13 +537,20 @@ private void registerGeneratedTypes(List elementsToGenerate) if (!(elementToGenerate.element() instanceof TypeElement targetType)) { continue; } - scopeResolver.registerGeneratedBuilder( - new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()), + TypeName beanType = JavaLangMapper.mapToTypeName(targetType, context); + TypeName builderType = builderTypeName( targetType, effectiveBuilderPackage( elementToGenerate.reportingElement(), elementToGenerate.config()), - elementToGenerate.config())); + elementToGenerate.config()); + scopeResolver.registerGeneratedBuilder(beanType, builderType); + scopeResolver + .resolveGeneratedBuilder(targetType) + .ifPresent( + resolvedBuilder -> + SimpleBuildersSpiIntegration.registerBuilder( + beanType, resolvedBuilder, elementToGenerate.config().getSetterSuffix())); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java new file mode 100644 index 00000000..e27ca206 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -0,0 +1,190 @@ +/* + * 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 with 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 java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; +import javax.lang.model.util.Elements; +import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; +import org.javahelpers.simple.builders.processor.model.type.TypeName; + +/** + * The builders {@link BuilderProcessor} generates, published to SPI adapters of other frameworks + * sharing the annotation processor path (and therefore the classloader). Each entry carries the + * resolved builder's type, creation and build method names plus the bean's configured {@code + * setterSuffix}, so adapters do not scan candidates or read annotation configuration themselves. + * + *

The lifecycle reports where {@link BuilderProcessor}'s builder generation stands and is + * transitioned by that processor alone — SPI adapters only ever read this holder, they never + * initialize or mutate it. All state is compilation-scoped: javac initializes each processor lazily + * when its turn in a round comes, so before {@link BuilderProcessor} has run its {@code init()} + * nothing static can be trusted — values may be leftovers of a previous compilation in a long-lived + * JVM (Gradle daemon, incremental builds). Adapters therefore pass their own {@link Elements} + * instance to every read: javac hands every processor of one compilation the same {@code Elements} + * instance, so a mismatch means this holder still describes an older run. + */ +public final class SimpleBuildersSpiIntegration { + + /** + * Where {@link BuilderProcessor}'s builder generation stands in the current compilation, as the + * SPI adapters observe it. + */ + public enum State { + /** + * No processor init happened in this compilation yet — static content may be a previous run's, + * and the registry must be treated as possibly still filling. + */ + INIT, + /** + * {@code BuilderProcessor} initialized: its single generating round may not have run yet — the + * registry must be treated as possibly still filling. + */ + PROCESSING, + /** + * The generating round ran: the registry is final for this compilation. {@code + * BuilderProcessor} generates in exactly one round — the first round carrying its annotations — + * so elements first appearing in later rounds get no builder. + */ + FINISHED + } + + /** + * One bean-to-builder pair resolved at registration time: the type this processor emits for + * {@code beanType} plus its creation/build method names and the bean's {@code setterSuffix} + * naming option — flattened to names so SPI adapters never touch the resolution model. + */ + public record PublishedBuilder( + TypeName beanType, + TypeName builderType, + String creationMethodName, + String buildMethodName, + String setterSuffix) {} + + private static volatile State state = State.INIT; + + /** The {@link Elements} of the compilation this holder's content describes. */ + private static final AtomicReference compilationElements = new AtomicReference<>(); + + /** Builders published for the current compilation, keyed by bean qualified name. */ + private static final Map BY_BEAN = new HashMap<>(); + + /** The same entries keyed by builder qualified name. */ + private static final Map BY_BUILDER = new HashMap<>(); + + private SimpleBuildersSpiIntegration() {} + + /** Starts a new compilation: clears the registry and marks generation as running. */ + static void initCompilation(Elements elements) { + BY_BEAN.clear(); + BY_BUILDER.clear(); + compilationElements.set(elements); + state = State.PROCESSING; + } + + /** + * Marks the registry as final: {@link BuilderProcessor}'s generating round ran (or the + * compilation ended without one) — no more builders will be published. + */ + static void finishCompilation() { + state = State.FINISHED; + } + + /** + * Whether {@code observed} belongs to the compilation this holder currently describes — {@code + * false} while it still carries a previous run's leftovers. javac creates one {@link Elements} + * per compilation and shares it between all processors, so reference identity is a reliable + * staleness check that never requires the SPI adapters to write anything. + */ + static boolean isCurrentCompilation(Elements observed) { + return observed != null && observed == compilationElements.get(); + } + + /** The lifecycle state of the current compilation. */ + static State state() { + return state; + } + + /** + * Whether {@link BuilderProcessor} finished generating builders for the compilation {@code + * observed} belongs to — {@code false} while this holder still describes another run. + * + *

The guard is not optional: this holder's statics live in the processor-path classloader, + * which build tools reuse across compilations (Gradle daemon, in-process javac, IDE builds, + * repeated compiles in one JVM). In a new compilation's first round — before {@link + * BuilderProcessor}'s {@code init()} had its turn — {@code state} can still be the previous run's + * {@link State#FINISHED} and the registry still holds its entries. Trusting them would claim + * beans by qualified name they were never planned with here and answer misses as final instead of + * deferring. javac hands every processor of one compilation the same {@link Elements} instance + * and a different one per compilation, so identity is the read-only, order-independent boundary + * marker — an SPI-side reset cannot work, because SPI init order against {@link + * BuilderProcessor}'s init is path-order dependent. + */ + public static boolean isSimpleBuildersFinishedForIntegration(Elements observed) { + return isCurrentCompilation(observed) && state == State.FINISHED; + } + + /** + * Publishes the builder {@link BuilderProcessor} resolved for {@code beanType} in this + * compilation — generated builders always create via a static factory. + */ + static void registerBuilder(TypeName beanType, ResolvedBuilder builder, String setterSuffix) { + if (!(builder.funcForEmptyBuilder() instanceof StaticFactoryCall factory)) { + return; + } + PublishedBuilder publishedBuilder = + new PublishedBuilder( + beanType, + builder.typeName(), + factory.methodName(), + builder.buildMethodName(), + setterSuffix); + BY_BEAN.put(publishedBuilder.beanType().getFullQualifiedName(), publishedBuilder); + BY_BUILDER.put(publishedBuilder.builderType().getFullQualifiedName(), publishedBuilder); + } + + /** + * The builder published for {@code beanType}'s qualified name in the compilation {@code observed} + * belongs to — empty while this holder still describes another run, so stale entries can never + * claim a bean. + */ + public static Optional builderFor(String beanQualifiedName, Elements observed) { + return isCurrentCompilation(observed) + ? Optional.ofNullable(BY_BEAN.get(beanQualifiedName)) + : Optional.empty(); + } + + /** + * The published builder with the given qualified type name in the compilation {@code observed} + * belongs to — empty while this holder still describes another run. + */ + public static Optional builderByName( + String builderQualifiedName, Elements observed) { + return isCurrentCompilation(observed) + ? Optional.ofNullable(BY_BUILDER.get(builderQualifiedName)) + : Optional.empty(); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 31373962..0c0dd081 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -136,6 +136,29 @@ public Optional resolveUsableBuilderType(TypeElement referenced referencedType.getQualifiedName().toString(), fqn -> resolve(referencedType)); } + /** + * The builder contract of a type whose builder this processor registered for the current round — + * always the generated {@code create()} factory for the empty path and the constructor for the + * copy path. Only the SPI integrations consume this, to publish the emitted contract. + * + *

Unlike {@link #resolveUsableBuilderType(TypeElement)} this does not read the per-element + * configuration and is safe to call during generation-plan registration. + * + * @param referencedType the type element being referenced + * @return the resolved builder, or empty if no generated builder is registered for the type + */ + public Optional resolveGeneratedBuilder(TypeElement referencedType) { + TypeName referencedTypeName = JavaLangMapper.mapToTypeName(referencedType, context); + return generatedBuilders + .findBuilder(referencedTypeName) + .map( + builder -> + new ResolvedBuilder( + builder, + new BuilderInstantiation.StaticFactoryCall("create"), + new BuilderInstantiation.ConstructorCall())); + } + /** * Checks whether a builder may be generated for the given element under the generation scope of * the resolved configuration. 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 8ccc5a9a..40836913 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 @@ -40,6 +40,7 @@ import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; @@ -398,6 +399,30 @@ private static boolean hasNoParameters(ExecutableElement method) { return method.getParameters().isEmpty(); } + /** + * Finds a parameterless method with the given name directly declared on the type that carries all + * {@code requiredModifiers}. + * + * @param type the type element to inspect + * @param name the simple method name + * @param requiredModifiers modifiers the method must declare (e.g. {@code PUBLIC}, {@code + * STATIC}) + * @return the matching method, or empty if none is declared + */ + public static Optional findMethodWithoutParameters( + TypeElement type, String name, Modifier... requiredModifiers) { + for (Element member : type.getEnclosedElements()) { + if (member.getKind() == ElementKind.METHOD + && member instanceof ExecutableElement method + && method.getSimpleName().contentEquals(name) + && method.getParameters().isEmpty() + && method.getModifiers().containsAll(List.of(requiredModifiers))) { + return Optional.of(method); + } + } + return Optional.empty(); + } + private static boolean hasParameters( ExecutableElement method, List expectedParameterTypes) { List parameters = method.getParameters(); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java new file mode 100644 index 00000000..d7790f8c --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java @@ -0,0 +1,153 @@ +/* + * 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 with 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.mapstruct; + +import com.google.auto.service.AutoService; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.mapstruct.ap.spi.AccessorNamingStrategy; +import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; +import org.mapstruct.ap.spi.MapStructProcessingEnvironment; +import org.mapstruct.ap.spi.MethodType; +import org.mapstruct.ap.spi.TypeHierarchyErroneousException; + +/** + * MapStruct {@link AccessorNamingStrategy} that hides the generated helper methods of + * simple-builders builders from bean mapping. + * + *

Generated builders carry convenience methods MapStruct must not treat as property setters: + * differently-named helpers ({@code add2}, {@code Update}, {@code conditional(...)}) + * would surface as phantom "unmapped target property" warnings, and same-named helper overloads + * ({@code (Supplier)}, {@code (String, Object...)}, {@code (Consumer)}) + * compete with the direct setter. For methods declared on a builder published by {@code + * BuilderProcessor} this strategy returns {@link MethodType#OTHER} for everything that is not the + * direct property setter ({@code } taking a single argument of the field + * type). The setter suffix and the bean of each builder come from the published {@link + * PublishedBuilder} descriptors, so builders produced by earlier compilations and foreign types + * keep the {@link DefaultAccessorNamingStrategy} behaviour. + */ +@AutoService(AccessorNamingStrategy.class) +public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy { + + private Map processorOptions = Map.of(); + + /** Direct setters (setter name → field type) by builder qualified name. */ + private final Map> directSettersCache = new HashMap<>(); + + @Override + public void init(MapStructProcessingEnvironment processingEnvironment) { + super.init(processingEnvironment); + Map options = processingEnvironment.getOptions(); + processorOptions = options == null ? Map.of() : options; + } + + @Override + public MethodType getMethodType(ExecutableElement method) { + MethodType methodType = super.getMethodType(method); + if (!isIntegrationEnabled()) { + // The integration is switched off — everything keeps the default classification. + return methodType; + } + if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { + // Only write accessors can conflict with generated helpers — everything else keeps the + // default classification untouched. + return methodType; + } + if (!(method.getEnclosingElement() instanceof TypeElement builderType)) { + return methodType; + } + Map directSetters = + directSettersCache.computeIfAbsent( + builderType.getQualifiedName().toString(), ignored -> directSettersOf(builderType)); + if (directSetters.isEmpty() || isDirectSetter(method, directSetters)) { + return methodType; + } + return MethodType.OTHER; + } + + /** + * Whether the integration is switched on for this compilation — read from the options map + * MapStruct hands the SPI ({@code -A} arguments reach it because {@link + * MapStructAdditionalSupportedOptionsProvider} declares them), defaulting to enabled. + */ + private boolean isIntegrationEnabled() { + return CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, processorOptions, true); + } + + /** + * Whether {@code method} is the direct property setter: it carries the configured setter name of + * a bean field and takes exactly one argument assignable to that field's declared type. + */ + private boolean isDirectSetter(ExecutableElement method, Map directSetters) { + TypeMirror fieldType = directSetters.get(method.getSimpleName().toString()); + return fieldType != null + && method.getParameters().size() == 1 + && typeUtils.isSameType(method.getParameters().get(0).asType(), fieldType); + } + + /** + * The direct property setters (setter name → field type) of the bean {@code builderType} was + * published for — empty when {@code builderType} is not a simple-builders builder of this + * compilation (the lookup is empty while the holder still describes a previous run, so stale + * entries can never match here either). + */ + private Map directSettersOf(TypeElement builderType) { + Optional published = + SimpleBuildersSpiIntegration.builderByName( + builderType.getQualifiedName().toString(), elementUtils); + if (published.isEmpty()) { + return Map.of(); + } + TypeElement beanElement = + elementUtils.getTypeElement(published.get().beanType().getFullQualifiedName()); + if (beanElement == null) { + // The published bean is not emitted yet — another processor may produce it in a later + // round, so the classification retries once the type exists. + if (!SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils)) { + throw new TypeHierarchyErroneousException(builderType.asType()); + } + return Map.of(); + } + Map directSetters = new HashMap<>(); + for (VariableElement field : ElementFilter.fieldsIn(elementUtils.getAllMembers(beanElement))) { + if (!field.getModifiers().contains(Modifier.STATIC)) { + directSetters.put( + field.getSimpleName().toString() + published.get().setterSuffix(), field.asType()); + } + } + return directSetters; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java new file mode 100644 index 00000000..f8385aee --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.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/or sell + * copies of the Software, and to permit persons with 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.mapstruct; + +import com.google.auto.service.AutoService; +import java.util.Set; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; +import org.mapstruct.ap.spi.AdditionalSupportedOptionsProvider; + +/** + * Declares the simple-builders options the MapStruct SPIs read. MapStruct filters the options map + * it hands to SPI environments down to the names {@link AdditionalSupportedOptionsProvider}s + * declare — without this provider {@code -Asimplebuilder.usingMapStructIntegration} would never + * reach them. + */ +@AutoService(AdditionalSupportedOptionsProvider.class) +public class MapStructAdditionalSupportedOptionsProvider + implements AdditionalSupportedOptionsProvider { + + @Override + public Set getAdditionalSupportedOptions() { + return Set.of( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument(), + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getOptionName()); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java new file mode 100644 index 00000000..914a5fa8 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java @@ -0,0 +1,245 @@ +/* + * 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 with 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.mapstruct; + +import com.google.auto.service.AutoService; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; +import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; +import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.mapstruct.ap.spi.BuilderInfo; +import org.mapstruct.ap.spi.BuilderProvider; +import org.mapstruct.ap.spi.MapStructProcessingEnvironment; +import org.mapstruct.ap.spi.TypeHierarchyErroneousException; + +/** + * MapStruct {@link BuilderProvider} SPI that makes MapStruct use builders generated by + * simple-builders. + * + *

MapStruct's default provider only considers {@code public static} methods on the bean type + * itself as builder-creation candidates. simple-builders keeps the factory on the generated builder + * class ({@code PersonDtoBuilder.create()}), so beans are paired with builders exclusively through + * the registry {@code BuilderProcessor} publishes — a constant-time lookup of already-resolved + * {@link PublishedBuilder} descriptors (builder type, creation and build method, setter suffix), + * covering custom packages and {@code @SimpleBuilderFor} targets. Builders produced by earlier + * compilations are not discovered: only the beans the processor plans in the current run get + * builder mapping. + * + *

{@code BuilderProcessor} generates in exactly one round — the first round carrying its + * annotations — and marks the registry final when it ends. A lookup miss before that point defers + * the mapper via {@link TypeHierarchyErroneousException}, MapStruct's own retry mechanism, for + * every bean: no bean-side marker is needed, so {@code @SimpleBuilderFor} targets resolve too. Once + * final, a miss is definitive: a bean marked for generation is reported as a warning, a foreign + * bean just maps without a builder. A published builder whose type is not emitted yet defers until + * the registry is final. + * + *

The provider is registered via {@code META-INF/services} and is only loaded when + * simple-builders-processor and mapstruct-processor share the annotation processor path. The + * integration can be switched off entirely with {@code + * -Asimplebuilder.usingMapStructIntegration=DISABLED} or the {@code -D} JVM system property — the + * {@code -A} argument reaches SPI environments because {@link + * MapStructAdditionalSupportedOptionsProvider} declares it. + */ +@AutoService(BuilderProvider.class) +public class MapStructBuilderProvider implements BuilderProvider { + + private Elements elementUtils; + private Map processorOptions = Map.of(); + + /** Resolved builder infos by bean qualified name; only positive results are cached. */ + private final Map builderInfoCache = new HashMap<>(); + + @Override + public void init(MapStructProcessingEnvironment processingEnvironment) { + this.elementUtils = processingEnvironment.getElementUtils(); + Map options = processingEnvironment.getOptions(); + processorOptions = options == null ? Map.of() : options; + } + + @Override + public BuilderInfo findBuilderInfo(TypeMirror type) { + if (!isIntegrationEnabled()) { + return null; + } + if (!(type instanceof DeclaredType declaredType) + || !(declaredType.asElement() instanceof TypeElement beanElement)) { + return null; + } + BuilderInfo cached = builderInfoCache.get(beanElement.getQualifiedName().toString()); + if (cached != null) { + return cached; + } + BuilderInfo builderInfo = createBuilderInfo(beanElement); + if (builderInfo != null) { + builderInfoCache.put(beanElement.getQualifiedName().toString(), builderInfo); + } + return builderInfo; + } + + /** + * Whether the integration is switched on for this compilation — read from the options map + * MapStruct hands the SPI ({@code -A} arguments reach it because {@link + * MapStructAdditionalSupportedOptionsProvider} declares them), defaulting to enabled. + */ + private boolean isIntegrationEnabled() { + return CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, processorOptions, true); + } + + /** + * Resolves the generated builder for {@code beanElement} through the registry {@code + * BuilderProcessor} publishes — the list decides alone which type is claimed. + */ + private BuilderInfo createBuilderInfo(TypeElement beanElement) { + Optional published = publishedFor(beanElement); + TypeElement builderElement = findBuilderElement(beanElement, published); + if (builderElement == null) { + return null; + } + ExecutableElement creationMethod = creationMethod(builderElement, published.orElseThrow()); + Optional buildMethod = buildMethod(builderElement, published.orElseThrow()); + if (creationMethod == null || buildMethod.isEmpty()) { + return null; + } + return new BuilderInfo.Builder() + .builderCreationMethod(creationMethod) + .buildMethod(List.of(buildMethod.orElseThrow())) + .build(); + } + + /** + * The element of the builder published for {@code beanElement}, waiting for a pending + * registration or emission via {@link TypeHierarchyErroneousException} — MapStruct's own deferral + * mechanism. {@code null} for foreign beans and for misses that are definitive. + */ + private TypeElement findBuilderElement( + TypeElement beanElement, Optional published) { + if (published.isEmpty()) { + if (!isFinished()) { + // The generating round may not have run yet — any bean may still be registered, so + // defer the mapper for a retry once the registry is final. + throw new TypeHierarchyErroneousException(beanElement.asType()); + } + // The registry is final: a marked bean without an entry was skipped by planning. + if (isBuilderGenerationTarget(beanElement)) { + warnMarkedBeanWithoutRegisteredBuilder(beanElement); + } + return null; + } + TypeElement builderElement = + elementUtils.getTypeElement(published.get().builderType().getFullQualifiedName()); + if (builderElement == null) { + // The type is written with the generating round's sources and materializes in the next + // round — defer until it exists. + throw new TypeHierarchyErroneousException(beanElement.asType()); + } + return builderElement; + } + + /** + * Whether builder generation is final for the compilation this provider's {@code elementUtils} + * belongs to — {@code false} while the holder still describes a previous run on a reused JVM. + */ + private boolean isFinished() { + return SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils); + } + + /** + * The builder published for {@code beanElement} in this provider's compilation — empty while the + * holder still describes a previous run, so stale entries can never claim a bean. + */ + private Optional publishedFor(TypeElement beanElement) { + return SimpleBuildersSpiIntegration.builderFor( + beanElement.getQualifiedName().toString(), elementUtils); + } + + /** Warns that a bean marked for generation got no registered builder in this compilation. */ + private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { + // SPI environments expose no Messager — a logger is the only channel a build shows. + System.getLogger(MapStructBuilderProvider.class.getName()) + .log( + System.Logger.Level.WARNING, + "simple-builders: no generated builder was registered for the marked bean '" + + beanElement.getQualifiedName() + + "'; MapStruct maps it without a builder."); + } + + /** + * The published creation method on the resolved builder — a {@code public static} parameterless + * factory whose name the descriptor carries ({@code create} for generated builders). + */ + private ExecutableElement creationMethod(TypeElement builderElement, PublishedBuilder published) { + return JavaLangAnalyser.findMethodWithoutParameters( + builderElement, published.creationMethodName(), Modifier.PUBLIC, Modifier.STATIC) + .orElse(null); + } + + /** + * The published build method on the resolved builder — the {@code public} parameterless instance + * method named by the descriptor ({@code build} for generated builders). + */ + private Optional buildMethod( + TypeElement builderElement, PublishedBuilder published) { + return JavaLangAnalyser.findMethodWithoutParameters( + builderElement, published.buildMethodName(), Modifier.PUBLIC); + } + + /** + * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a + * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). The marker + * only decides the finished-state warning — deferral and claiming never depend on it. + */ + private boolean isBuilderGenerationTarget(TypeElement beanElement) { + if (JavaLangAnalyser.findAnnotation(beanElement, Ignore4BuilderGeneration.class).isPresent()) { + return false; + } + if (JavaLangAnalyser.findAnnotation(beanElement, SimpleBuilder.class).isPresent()) { + return true; + } + for (AnnotationMirror mirror : beanElement.getAnnotationMirrors()) { + // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined + // one): its type is meta-annotated with @SimpleBuilder.Template. + if (mirror.getAnnotationType().asElement() instanceof TypeElement annotationType + && JavaLangAnalyser.findAnnotation(annotationType, SimpleBuilder.Template.class) + .isPresent()) { + return true; + } + } + return false; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java new file mode 100644 index 00000000..2fbb3c30 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java @@ -0,0 +1,28 @@ +/* + * 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 with 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. + */ + +/** + * Integration of simple-builders with MapStruct: makes generated builders discoverable by + * MapStruct's builder detection via the {@code org.mapstruct.ap.spi.BuilderProvider} SPI. + */ +package org.javahelpers.simple.builders.processor.mapstruct; 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 6fa81777..8dfa6118 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 @@ -138,6 +138,13 @@ public enum CompilerArgumentsEnum { /** Option for Jackson Module generation. */ GENERATE_JACKSON_MODULE("generateJacksonModule", optionState(Builder::generateJacksonModule)), + /** + * Option for the MapStruct SPI integration. Processor-level: it switches the SPI adapters + * discovered by MapStruct itself, so it is read directly via {@link CompilerArgumentsReader} + * rather than applied to a {@link BuilderConfiguration.Builder}. + */ + USING_MAPSTRUCT_INTEGRATION("usingMapStructIntegration"), + /** Option for Javadoc generation on the generated builder. */ GENERATE_JAVADOC("generateJavaDoc", optionState(Builder::generateJavaDoc)), 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 42ed27ab..178b0a4c 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,6 +24,7 @@ package org.javahelpers.simple.builders.processor.processing; +import java.util.Map; import java.util.stream.Stream; import javax.annotation.processing.ProcessingEnvironment; import org.apache.commons.lang3.Strings; @@ -62,17 +63,29 @@ public CompilerArgumentsReader(ProcessingEnvironment processingEnv) { * @return the value of the compiler argument, or null if not set */ public String readValue(CompilerArgumentsEnum argument) { + return readValue(argument, processingEnv.getOptions()); + } + + /** + * Reads the value of a compiler argument from an explicit options map — the same lookup SPI + * adapters can use on the options map their host framework hands them. + * + * @param argument the compiler argument enum to read + * @param options the compiler options map to search + * @return the value of the compiler argument, or null if not set + */ + public static String readValue(CompilerArgumentsEnum argument, Map options) { // Try the -D JVM system property first (e.g., -Dsimplebuilder.verbose) String value = System.getProperty(argument.getCompilerArgument()); // Then the -A compiler argument (e.g., -Asimplebuilder.verbose) if (value == null) { - value = processingEnv.getOptions().get(argument.getCompilerArgument()); + value = options.get(argument.getCompilerArgument()); } // Finally the bare option name for backward compatibility (e.g., -Averbose) if (value == null) { - value = processingEnv.getOptions().get(argument.getOptionName()); + value = options.get(argument.getOptionName()); } return value; @@ -87,8 +100,32 @@ public String readValue(CompilerArgumentsEnum argument) { * @return true if the value is "true" (case-insensitive), false otherwise */ public boolean readBooleanValue(CompilerArgumentsEnum argument) { - String value = readValue(argument); - return Strings.CI.equalsAny(value, "true", "enabled"); + return readBooleanValue(argument, processingEnv.getOptions(), false); + } + + /** + * Reads the value of a compiler argument as a boolean with an explicit default — the same + * resolution SPI adapters can use on the options map their host framework hands them. + * + * @param argument the compiler argument enum to read + * @param options the compiler options map to search + * @param defaultValue the result for an unset or unrecognizable value + * @return "true"/"enabled" resolves to true, "false"/"disabled" to false, anything else to {@code + * defaultValue} + */ + public static boolean readBooleanValue( + CompilerArgumentsEnum argument, Map options, boolean defaultValue) { + String value = readValue(argument, options); + if (value == null || value.isEmpty()) { + return defaultValue; + } + if (Strings.CI.equalsAny(value, "true", "enabled")) { + return true; + } + if (Strings.CI.equalsAny(value, "false", "disabled")) { + return false; + } + return defaultValue; } /** 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 212e7fce..c1928de6 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 @@ -197,6 +197,59 @@ void readBooleanValue_InvalidValues_ReturnsFalse(String value) { "Should return false for: " + value); } + /** Test: the static readBooleanValue resolves an options map without an environment. */ + @Test + void readBooleanValue_ExplicitOptionsMap_ResolvesLikeEnvironment() { + assertTrue( + CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, Map.of(), true)); + + assertFalse( + CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, + Map.of("usingMapStructIntegration", "DISABLED"), + true)); + + assertTrue( + CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, + Map.of("simplebuilder.usingMapStructIntegration", "invalid"), + true)); + } + + /** Test: the static readBooleanValue honors the -D > -A > bare-option precedence. */ + @Test + void readBooleanValue_ExplicitOptionsMap_SystemPropertyWins() { + System.setProperty("simplebuilder.usingMapStructIntegration", "DISABLED"); + try { + assertFalse( + CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, + Map.of( + "simplebuilder.usingMapStructIntegration", + "ENABLED", + "usingMapStructIntegration", + "ENABLED"), + true)); + } finally { + System.clearProperty("simplebuilder.usingMapStructIntegration"); + } + } + + /** Test: the prefixed compiler argument beats the bare option name. */ + @Test + void readBooleanValue_ExplicitOptionsMap_CompilerArgumentBeatsBareOption() { + assertFalse( + CompilerArgumentsReader.readBooleanValue( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, + Map.of( + "simplebuilder.usingMapStructIntegration", + "DISABLED", + "usingMapStructIntegration", + "ENABLED"), + true)); + } + /** Test: readBuilderConfiguration with no arguments returns all UNSET/DEFAULT values. */ @Test void readBuilderConfiguration_NoArguments_ReturnsDefaults() { diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java new file mode 100644 index 00000000..8d46d70c --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -0,0 +1,196 @@ +/* + * 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 with 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.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.assertNoWarningContaining; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler; +import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; +import javax.annotation.processing.Processor; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; +import org.mapstruct.ap.MappingProcessor; + +/** + * Integration test for the MapStruct SPI implementations: {@code MapStructBuilderProvider} supplies + * the builder, {@code MapStructAccessorNamingStrategy} hides the generated helper methods so + * MapStruct neither binds them nor reports them as unmapped target properties. + */ +class MapStructSpiIntegrationTest { + + @Test + void mapStruct_shouldUseGeneratedBuilder() { + Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper()); + assertThat(compilation).succeeded(); + + String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); + assertTrue( + mapperImpl.contains("PersonDtoBuilder.create()"), + "MapStruct should instantiate the generated builder"); + assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); + } + + @Test + void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() { + // MapStruct processing the mapper before BuilderProcessor's generating round must not lose + // the builder: the bean defers via TypeHierarchyErroneousException until the registry is + // final and holds the planned builder + Compilation compilation = + mapStructCompiler(new MappingProcessor(), new BuilderProcessor()) + .compile(personDto(), personDtoMapper()); + assertThat(compilation).succeeded(); + + String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); + assertTrue( + mapperImpl.contains("PersonDtoBuilder.create()"), + "Reversed processor order must still bind the generated builder"); + assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); + } + + @Test + void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { + Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper()); + assertThat(compilation).succeeded(); + + assertNoWarningContaining(compilation, "unmapped target property"); + } + + @Test + void mapStruct_disabledIntegration_shouldMapViaSetters() { + // Our AdditionalSupportedOptionsProvider declares the option, so MapStruct forwards the -A + // value into the SPI environment's options + Compilation compilation = + mapStructCompiler() + .withOptions("-Asimplebuilder.usingMapStructIntegration=DISABLED") + .compile(mutableDto(), mutableDtoMapper()); + assertThat(compilation).succeeded(); + + String mapperImpl = loadGeneratedSource(compilation, "MutableDtoMapperImpl"); + assertFalse( + mapperImpl.contains("MutableDtoBuilder"), + "Disabled integration must leave the generated builder unused"); + assertTrue(mapperImpl.contains(".setName("), "MapStruct should fall back to setter mapping"); + } + + /** A javac compiler with BuilderProcessor ahead of MapStruct and stable mapper output. */ + private static Compiler mapStructCompiler() { + return mapStructCompiler(new BuilderProcessor(), new MappingProcessor()); + } + + /** A javac compiler with the given processors in invocation order and stable mapper output. */ + private static Compiler mapStructCompiler(Processor... processors) { + return createCompiler(processors) + .withOptions( + "-Amapstruct.suppressGeneratorTimestamp=true", + "-Amapstruct.suppressGeneratorVersionInfoComment=true"); + } + + private static JavaFileObject personDto() { + return ProcessorTestUtils.forSource( + """ + package test; + + import java.util.List; + import java.util.Optional; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class PersonDto { + private String name; + private List nicknames; + private Optional email; + + public String getName() { + return name; + } + + public List getNicknames() { + return nicknames; + } + + public Optional getEmail() { + return email; + } + } + """); + } + + private static JavaFileObject personDtoMapper() { + return ProcessorTestUtils.forSource( + """ + package test; + + import org.mapstruct.Mapper; + + @Mapper + public interface PersonDtoMapper { + + PersonDto copy(PersonDto source); + } + """); + } + + private static JavaFileObject mutableDto() { + return ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MutableDto { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + """); + } + + private static JavaFileObject mutableDtoMapper() { + return ProcessorTestUtils.forSource( + """ + package test; + + import org.mapstruct.Mapper; + + @Mapper + public interface MutableDtoMapper { + + MutableDto copy(MutableDto source); + } + """); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java new file mode 100644 index 00000000..b15ee1a0 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -0,0 +1,370 @@ +/* + * 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.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.RoundEnvironment; +import javax.annotation.processing.SupportedAnnotationTypes; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.ElementFilter; +import javax.lang.model.util.Elements; +import javax.lang.model.util.Types; +import javax.tools.JavaFileObject; +import org.javahelpers.simple.builders.processor.mapstruct.MapStructAccessorNamingStrategy; +import org.javahelpers.simple.builders.processor.mapstruct.MapStructBuilderProvider; +import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils; +import org.junit.jupiter.api.Test; +import org.mapstruct.ap.spi.BuilderInfo; +import org.mapstruct.ap.spi.MapStructProcessingEnvironment; +import org.mapstruct.ap.spi.MethodType; +import org.mapstruct.ap.spi.TypeHierarchyErroneousException; + +/** + * Exercises both MapStruct SPIs from inside a real {@code javac} run: a probe processor drives them + * against the elements the same compilation emits — covering paths MapStruct's own call sites never + * reach in the integration tests (method classification on the builder type, deferral while the + * published builder is not emitted yet, marker scans of foreign, ignored and template beans, cache + * hits, and lookups after the registry finalized). + */ +class MapStructSpiProbeTest { + + @Test + void probe_shouldDriveSpiAgainstEmittedElements() { + Compilation compilation = + Compiler.javac() + .withProcessors(new SpiProbeProcessor(), new BuilderProcessor()) + .compile(personDto(), foreignDto(), ignoredDto(), templateDto()); + assertThat(compilation).succeeded(); + + ProbeResults results = ProbeResults.instance; + + // While the registry is unpublished every lookup defers — no marker is needed, so marked, + // template, foreign and opted-out beans alike wait for the generating round. + assertInstanceOf(TypeHierarchyErroneousException.class, results.unregisteredDeferral); + assertInstanceOf(TypeHierarchyErroneousException.class, results.templateDeferral); + assertInstanceOf(TypeHierarchyErroneousException.class, results.foreignUnpublishedDeferral); + assertInstanceOf(TypeHierarchyErroneousException.class, results.ignoredUnpublishedDeferral); + + // Once the registry is final, foreign and opted-out beans are never claimed. + assertNull(results.foreignRegistered); + assertNull(results.ignoredRegistered); + assertNull(results.foreignWhenFinished); + assertNull(results.noType); + + // Resolution once the builder exists resolves the published contract methods and caches. + assertNotNull(results.builderInfo); + assertEquals( + "create", results.builderInfo.getBuilderCreationMethod().getSimpleName().toString()); + assertEquals( + "build", + results.builderInfo.getBuildMethods().iterator().next().getSimpleName().toString()); + assertEquals(results.builderInfo, results.cachedBuilderInfo); + + // The naming strategy keeps only direct property setters visible as write accessors. + assertEquals(MethodType.SETTER, results.methodTypes.get("name")); + assertEquals(MethodType.OTHER, results.methodTypes.get("nameUpdate")); + assertEquals(MethodType.OTHER, results.methodTypes.get("build")); + assertEquals(MethodType.OTHER, results.methodTypes.get("create")); + + // A marked bean outside the generation scope is skipped by planning: it defers while the + // registry may still fill and resolves to no builder — reported as a warning — once the + // generating round is done. The in-scope bean forces a second round so the post-registration + // lookup runs. + Compilation skippedCompile = + Compiler.javac() + .withProcessors(new SpiProbeProcessor(true), new BuilderProcessor()) + .withOptions("-Asimplebuilder.builderGenerationPackages=scoped") + .compile(personDto(), scopedDto()); + assertThat(skippedCompile).succeeded(); + ProbeResults skipped = ProbeResults.instance; + assertInstanceOf(TypeHierarchyErroneousException.class, skipped.skippedBeanDeferred); + assertNull(skipped.skippedBeanRegistered); + assertNull(skipped.skippedBeanFinished); + } + + /** Records the SPI outcomes of one probe compilation for assertions after it. */ + private static final class ProbeResults { + static final ProbeResults instance = new ProbeResults(); + + final Map methodTypes = new LinkedHashMap<>(); + Throwable unregisteredDeferral; + Throwable templateDeferral; + Throwable skippedBeanDeferred; + Throwable foreignUnpublishedDeferral; + Throwable ignoredUnpublishedDeferral; + BuilderInfo skippedBeanRegistered; + BuilderInfo skippedBeanFinished; + BuilderInfo foreignRegistered; + BuilderInfo ignoredRegistered; + BuilderInfo foreignWhenFinished; + BuilderInfo noType; + BuilderInfo builderInfo; + BuilderInfo cachedBuilderInfo; + + void reset() { + methodTypes.clear(); + unregisteredDeferral = null; + templateDeferral = null; + skippedBeanDeferred = null; + foreignUnpublishedDeferral = null; + ignoredUnpublishedDeferral = null; + skippedBeanRegistered = null; + skippedBeanFinished = null; + foreignRegistered = null; + ignoredRegistered = null; + foreignWhenFinished = null; + noType = null; + builderInfo = null; + cachedBuilderInfo = null; + } + } + + /** + * Drives {@link MapStructBuilderProvider} and {@link MapStructAccessorNamingStrategy} like + * MapStruct would — one SPI instance per compilation, lookups on the mapped bean while its + * builder is pending, then method classification once the builder type exists. Runs ahead of + * {@link BuilderProcessor} in the first round so beans are probed before registration. In {@code + * skippedMode} it probes the marked bean that the scoped {@link BuilderProcessor} never plans — + * deferral while unpublished plus the finished outcome. + */ + @SupportedAnnotationTypes("*") + public static final class SpiProbeProcessor extends AbstractProcessor { + + private final MapStructBuilderProvider provider = new MapStructBuilderProvider(); + private final MapStructAccessorNamingStrategy naming = new MapStructAccessorNamingStrategy(); + private final boolean skippedMode; + private boolean namingProbed; + + SpiProbeProcessor() { + this(false); + } + + SpiProbeProcessor(boolean skippedMode) { + this.skippedMode = skippedMode; + } + + @Override + public synchronized void init(javax.annotation.processing.ProcessingEnvironment env) { + super.init(env); + ProbeResults.instance.reset(); + provider.init(spiEnvironment()); + naming.init(spiEnvironment()); + } + + private MapStructProcessingEnvironment spiEnvironment() { + return new MapStructProcessingEnvironment() { + @Override + public Elements getElementUtils() { + return processingEnv.getElementUtils(); + } + + @Override + public Types getTypeUtils() { + return processingEnv.getTypeUtils(); + } + + @Override + public Map getOptions() { + return Map.of(); + } + }; + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + ProbeResults results = ProbeResults.instance; + if (roundEnv.processingOver()) { + if (skippedMode) { + results.skippedBeanFinished = lookup(provider, "test.PersonDto"); + } else { + results.foreignWhenFinished = lookup(provider, "test.ForeignDto"); + } + return false; + } + + TypeElement bean = processingEnv.getElementUtils().getTypeElement("test.PersonDto"); + if (bean == null) { + return false; + } + + if (skippedMode) { + // The bean is marked but out of the generation scope: it defers while the registry + // may still fill, then resolves to no builder once the generating round is done. + if (results.skippedBeanDeferred == null) { + results.skippedBeanDeferred = lookupExpectingDeferral(bean); + } else { + results.skippedBeanRegistered = lookup(provider, "test.PersonDto"); + } + return false; + } + + if (results.unregisteredDeferral == null) { + // Probed before BuilderProcessor planned the bean — a marked bean without a registry + // entry must defer like a mapper running ahead of the generating round. + results.unregisteredDeferral = lookupExpectingDeferral(bean); + results.foreignUnpublishedDeferral = lookupExpectingDeferral(element("test.ForeignDto")); + results.ignoredUnpublishedDeferral = lookupExpectingDeferral(element("test.IgnoredDto")); + results.templateDeferral = lookupExpectingDeferral(element("test.MinimalDto")); + return false; + } + + TypeElement builder = processingEnv.getElementUtils().getTypeElement("test.PersonDtoBuilder"); + if (builder == null) { + // Published but not emitted yet — the lookup must defer until the type exists. + lookupExpectingDeferral(bean); + return false; + } + + if (results.builderInfo != null) { + return false; + } + results.builderInfo = provider.findBuilderInfo(bean.asType()); + results.cachedBuilderInfo = provider.findBuilderInfo(bean.asType()); + results.foreignRegistered = lookup(provider, "test.ForeignDto"); + results.ignoredRegistered = lookup(provider, "test.IgnoredDto"); + results.noType = + provider.findBuilderInfo( + processingEnv.getTypeUtils().getNoType(javax.lang.model.type.TypeKind.NONE)); + + if (!namingProbed) { + namingProbed = true; + for (ExecutableElement method : ElementFilter.methodsIn(builder.getEnclosedElements())) { + results.methodTypes.put(method.getSimpleName().toString(), naming.getMethodType(method)); + } + } + return false; + } + + private TypeElement element(String qualifiedName) { + return processingEnv.getElementUtils().getTypeElement(qualifiedName); + } + + private Throwable lookupExpectingDeferral(TypeElement bean) { + try { + provider.findBuilderInfo(bean.asType()); + return null; + } catch (RuntimeException deferred) { + return deferred; + } + } + + private BuilderInfo lookup(MapStructBuilderProvider provider, String qualifiedName) { + Element element = processingEnv.getElementUtils().getTypeElement(qualifiedName); + TypeMirror type = element == null ? null : element.asType(); + return type == null ? null : provider.findBuilderInfo(type); + } + } + + private static JavaFileObject personDto() { + return ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class PersonDto { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + """); + } + + private static JavaFileObject foreignDto() { + return ProcessorTestUtils.forSource( + """ + package test; + + public class ForeignDto { + private String name; + } + """); + } + + private static JavaFileObject ignoredDto() { + 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 IgnoredDto { + private String name; + } + """); + } + + private static JavaFileObject scopedDto() { + return ProcessorTestUtils.forSource( + """ + package scoped; + + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class ScopedDto { + private String name; + } + """); + } + + private static JavaFileObject templateDto() { + return ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleMinimalBuilder; + + @SimpleMinimalBuilder + public class MinimalDto { + private String name; + } + """); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java new file mode 100644 index 00000000..ec15d772 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -0,0 +1,139 @@ +/* + * 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 with 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.assertTrue; + +import java.lang.reflect.Proxy; +import java.util.Optional; +import javax.lang.model.util.Elements; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.State; +import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; +import org.javahelpers.simple.builders.processor.model.type.TypeName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current compilation + * (identified by its {@link Elements}), and the registry exposes exactly the published descriptors. + */ +class SimpleBuildersSpiIntegrationTest { + + private static final Elements ELEMENTS = fakeElements(); + + private static final TypeName PERSON_BEAN = new TypeName("test", "PersonDto"); + + private static final ResolvedBuilder PERSON_RESOLVED = + new ResolvedBuilder( + new TypeName("test", "PersonDtoBuilder"), + new StaticFactoryCall("create"), + Optional.empty(), + "build"); + + private static final PublishedBuilder PERSON = + new PublishedBuilder(PERSON_BEAN, PERSON_RESOLVED.typeName(), "create", "build", ""); + + /** A stand-in {@link Elements}; javac identity is what matters, never its methods. */ + private static Elements fakeElements() { + return (Elements) + Proxy.newProxyInstance( + SimpleBuildersSpiIntegrationTest.class.getClassLoader(), + new Class[] {Elements.class}, + (proxy, method, args) -> { + throw new UnsupportedOperationException(); + }); + } + + @AfterEach + void reset() { + SimpleBuildersSpiIntegration.initCompilation(null); + } + + @Test + void lifecycle_transitions() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); + assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); + assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(ELEMENTS)); + + SimpleBuildersSpiIntegration.finishCompilation(); + assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); + assertTrue(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(ELEMENTS)); + // The finished answer is scoped to the compilation it was asked about — a foreign + // compilation sees the holder as not finished even in State.FINISHED. + assertFalse( + SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(fakeElements())); + } + + @Test + void staleness_onlyOwnCompilationIsCurrent() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); + assertTrue(SimpleBuildersSpiIntegration.isCurrentCompilation(ELEMENTS)); + // A different Elements instance belongs to another compilation — the holder must answer + // as not current until its initCompilation ran with that instance. + assertFalse(SimpleBuildersSpiIntegration.isCurrentCompilation(fakeElements())); + assertFalse(SimpleBuildersSpiIntegration.isCurrentCompilation(null)); + } + + @Test + void registry_publishesBothDirections() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).isEmpty()); + assertTrue( + SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", ELEMENTS).isEmpty()); + + SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, ""); + + assertEquals( + PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).orElseThrow()); + assertEquals( + PERSON, + SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", ELEMENTS) + .orElseThrow()); + // Lookups are scoped to the compilation asked about — a foreign compilation sees nothing. + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", fakeElements()).isEmpty()); + assertTrue( + SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", fakeElements()) + .isEmpty()); + assertEquals("test.PersonDtoBuilder", PERSON.builderType().getFullQualifiedName()); + assertEquals("create", PERSON.creationMethodName()); + assertEquals("build", PERSON.buildMethodName()); + assertEquals("", PERSON.setterSuffix()); + } + + @Test + void registry_clearedOnNextCompilation() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); + SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, ""); + + Elements nextCompilation = fakeElements(); + SimpleBuildersSpiIntegration.initCompilation(nextCompilation); + assertTrue( + SimpleBuildersSpiIntegration.builderFor("test.PersonDto", nextCompilation).isEmpty()); + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).isEmpty()); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java index 7b67fa84..e0bbf582 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java @@ -6,9 +6,12 @@ import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; +import javax.annotation.processing.Processor; import javax.tools.JavaFileObject; import org.apache.commons.lang3.Strings; import org.javahelpers.simple.builders.processor.BuilderProcessor; +import org.junit.jupiter.api.Assertions; /** * Utilities to simplify annotation-processor tests by reducing boilerplate for building sources, @@ -37,7 +40,21 @@ private ProcessorTestUtils() {} * @return a Compiler instance configured with BuilderProcessor and optional verbose output */ public static Compiler createCompiler() { - Compiler compiler = Compiler.javac().withProcessors(new BuilderProcessor()); + return createCompiler(new BuilderProcessor()); + } + + /** + * Creates a configured {@link Compiler} instance with the given processors in invocation order. + * + *

Use this overload when a test compiles with several annotation processors whose relative + * order matters (e.g. {@code BuilderProcessor} alongside the MapStruct {@code MappingProcessor}). + * Verbose handling matches {@link #createCompiler()}. + * + * @param processors the processors to register, in the order javac invokes them + * @return a Compiler instance configured with the given processors and optional verbose output + */ + public static Compiler createCompiler(Processor... processors) { + Compiler compiler = Compiler.javac().withProcessors(processors); // Check for verbose flag from Maven property if (isVerboseEnabled()) { @@ -47,6 +64,22 @@ public static Compiler createCompiler() { return compiler; } + /** + * Asserts that none of the compilation's warnings contains the given text (case-insensitive). + * + * @param compilation the compilation result to check + * @param text the text no warning message may contain + */ + public static void assertNoWarningContaining(Compilation compilation, String text) { + String warnings = + compilation.warnings().stream() + .map(diagnostic -> diagnostic.getMessage(null)) + .collect(Collectors.joining("\n")); + Assertions.assertFalse( + warnings.toLowerCase().contains(text.toLowerCase()), + "No warning containing '" + text + "' expected, got: " + warnings); + } + /** * Checks if verbose mode is enabled via system properties. *