From 6368ceef55b7e373c4150ce1466d9e2a0ed67e73 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:01:36 +0000 Subject: [PATCH 01/25] Add MapStruct BuilderProvider for generated builders Registers SimpleBuildersBuilderProvider via META-INF/services so MapStruct auto-detects @SimpleBuilder-generated builders when both processors share the annotation processor path. Resolution locates the builder via the @BuilderImplementation annotation (handles custom package/suffix configuration), falls back to the name convention, and defers a round via TypeHierarchyErroneousException when the builder is not generated yet. BuilderProcessor.process() now returns false: it claims all annotation types (*) and returning true starved other processors - MapStruct's MappingProcessor never saw @Mapper. Includes an example PersonDtoMapper plus an integration test asserting the generated mapper uses PersonDtoBuilder.create() and build(). The suppressGeneratorTimestamp/VersionInfoComment processor args keep the committed generated mapper deterministic for the regen check. Implements java-helpers/simple-builders#300. --- README.md | 10 + .../builders/example/PersonDtoMapperImpl.java | 30 ++ example/pom.xml | 16 + .../builders/example/PersonDtoMapper.java | 33 ++ .../example/MapStructIntegrationTest.java | 77 ++++ processor/pom.xml | 10 + .../builders/processor/BuilderProcessor.java | 2 + .../SimpleBuildersBuilderProvider.java | 332 ++++++++++++++++++ .../processor/mapstruct/package-info.java | 28 ++ 9 files changed, 538 insertions(+) create mode 100644 example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoMapperImpl.java create mode 100644 example/src/main/java/org/javahelpers/simple/builders/example/PersonDtoMapper.java create mode 100644 example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java diff --git a/README.md b/README.md index f16f92d2..71748e87 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: + +- **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/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..1e5aacd7 --- /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 SimpleBuildersBuilderProvider} 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..6de14ad0 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 + 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..a7e141b7 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 @@ -195,6 +195,8 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); + // Returning false leaves the annotations unclaimed so other processors on the + // processor path (e.g. MapStruct, AutoService) still see them. return false; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java new file mode 100644 index 00000000..286f8364 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java @@ -0,0 +1,332 @@ +/* + * 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.ArrayList; +import java.util.List; +import java.util.Map; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.PackageElement; +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 javax.lang.model.util.Types; +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 the generated builders are found here by scanning + * the target package for types carrying {@code @BuilderImplementation(forClass = )} and by + * resolving the conventional name {@code } in the target package. + * + *

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. + */ +@AutoService(BuilderProvider.class) +public class SimpleBuildersBuilderProvider implements BuilderProvider { + + private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; + private static final String SIMPLE_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; + private static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; + private static final String BUILDER_IMPLEMENTATION_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.BuilderImplementation"; + private static final String IGNORE_4_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; + + private Elements elementUtils; + private Types typeUtils; + + @Override + public void init(MapStructProcessingEnvironment processingEnvironment) { + this.elementUtils = processingEnvironment.getElementUtils(); + this.typeUtils = processingEnvironment.getTypeUtils(); + } + + @Override + public BuilderInfo findBuilderInfo(TypeMirror type) { + if (!(type instanceof DeclaredType declaredType) + || !(declaredType.asElement() instanceof TypeElement beanElement)) { + return null; + } + + TypeElement builderElement = findBuilderElement(beanElement); + if (builderElement == null) { + if (isBuilderGenerationTarget(beanElement)) { + // The bean is annotated for builder generation but the builder does not exist yet in this + // processing round. Throwing defers mapper generation to the next round. + throw new TypeHierarchyErroneousException(type); + } + return null; + } + + ExecutableElement creationMethod = findCreationMethod(builderElement); + List buildMethods = findBuildMethods(builderElement, type); + if (creationMethod == null || buildMethods.isEmpty()) { + return null; + } + return new BuilderInfo.Builder() + .builderCreationMethod(creationMethod) + .buildMethod(buildMethods) + .build(); + } + + /** + * Locates the generated builder for {@code beanElement}: first by {@code @BuilderImplementation} + * in the candidate packages, then by conventional name {@code }. + */ + private TypeElement findBuilderElement(TypeElement beanElement) { + List packageNames = candidatePackageNames(beanElement); + + // Primary: builders generated by simple-builders carry + // @BuilderImplementation(forClass = ) by default. + for (String packageName : packageNames) { + PackageElement packageElement = elementUtils.getPackageElement(packageName); + if (packageElement == null) { + continue; + } + for (Element member : packageElement.getEnclosedElements()) { + if (member instanceof TypeElement typeElement + && isGeneratedBuilderFor(typeElement, beanElement)) { + return typeElement; + } + } + } + + // Fallback for builders generated with usingBuilderImplementationAnnotation disabled: + // resolve by name and verify the type looks like a builder. + for (String packageName : packageNames) { + for (String suffix : candidateSuffixes(beanElement)) { + TypeElement candidate = + elementUtils.getTypeElement( + qualifiedName(packageName, beanElement.getSimpleName() + suffix)); + if (candidate != null + && findCreationMethod(candidate) != null + && !findBuildMethods(candidate, beanElement.asType()).isEmpty()) { + return candidate; + } + } + } + return null; + } + + /** + * Checks whether {@code candidate} is annotated {@code @BuilderImplementation(forClass = + * beanElement)}. + */ + private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanElement) { + for (AnnotationMirror mirror : candidate.getAnnotationMirrors()) { + if (!qualifiedNameOf(mirror).equals(BUILDER_IMPLEMENTATION_ANNOTATION)) { + continue; + } + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("forClass") + && entry.getValue().getValue() instanceof TypeMirror forClass + && typeUtils.isSameType( + typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType()))) { + return true; + } + } + // @BuilderImplementation present but forClass points at another bean + return false; + } + return false; + } + + /** Packages the generated builder may live in: the bean's package or a configured packageName. */ + private List candidatePackageNames(TypeElement beanElement) { + List packageNames = new ArrayList<>(); + packageNames.add(elementUtils.getPackageOf(beanElement).getQualifiedName().toString()); + for (AnnotationMirror optionsMirror : builderOptionsMirrors(beanElement)) { + String packageName = stringOption(optionsMirror, "packageName"); + if (packageName != null && !packageName.isEmpty() && !packageNames.contains(packageName)) { + packageNames.add(packageName); + } + } + return packageNames; + } + + /** Builder name suffixes to try: explicitly configured ones plus the default. */ + private List candidateSuffixes(TypeElement beanElement) { + List suffixes = new ArrayList<>(); + for (AnnotationMirror optionsMirror : builderOptionsMirrors(beanElement)) { + String suffix = stringOption(optionsMirror, "builderSuffix"); + if (suffix != null && !suffix.isEmpty() && !suffixes.contains(suffix)) { + suffixes.add(suffix); + } + } + if (!suffixes.contains(DEFAULT_BUILDER_SUFFIX)) { + suffixes.add(DEFAULT_BUILDER_SUFFIX); + } + return suffixes; + } + + /** + * Options mirrors relevant for builder generation on {@code beanElement}: {@code options()} of + * {@code @SimpleBuilder} and of {@code @SimpleBuilder.Template} on builder template annotations. + */ + private List builderOptionsMirrors(TypeElement beanElement) { + List optionsMirrors = new ArrayList<>(); + for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { + String annotationName = qualifiedNameOf(mirror); + if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { + addOptionsMirror(mirror, optionsMirrors); + } else { + // A custom builder template: read options of its @SimpleBuilder.Template meta-annotation. + Element annotationType = mirror.getAnnotationType().asElement(); + for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { + if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + addOptionsMirror(metaMirror, optionsMirrors); + } + } + } + } + return optionsMirrors; + } + + private void addOptionsMirror(AnnotationMirror mirror, List optionsMirrors) { + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("options") + && entry.getValue().getValue() instanceof AnnotationMirror optionsMirror) { + optionsMirrors.add(optionsMirror); + } + } + } + + private String stringOption(AnnotationMirror optionsMirror, String name) { + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(optionsMirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals(name)) { + Object value = entry.getValue().getValue(); + if (value instanceof String stringValue) { + return stringValue; + } + } + } + return null; + } + + /** + * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a + * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). + */ + private boolean isBuilderGenerationTarget(TypeElement beanElement) { + List mirrors = elementUtils.getAllAnnotationMirrors(beanElement); + for (AnnotationMirror mirror : mirrors) { + if (qualifiedNameOf(mirror).equals(IGNORE_4_BUILDER_ANNOTATION)) { + return false; + } + } + for (AnnotationMirror mirror : mirrors) { + if (qualifiedNameOf(mirror).equals(SIMPLE_BUILDER_ANNOTATION)) { + return true; + } + // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined + // one): its type is meta-annotated with @SimpleBuilder.Template. + Element annotationType = mirror.getAnnotationType().asElement(); + for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { + if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + return true; + } + } + } + return false; + } + + /** + * A {@code public static} parameterless method on the builder returning the builder type, e.g. + * {@code create()}. + */ + private ExecutableElement findCreationMethod(TypeElement builderElement) { + List candidates = new ArrayList<>(); + for (Element member : builderElement.getEnclosedElements()) { + if (member.getKind() == ElementKind.METHOD + && member instanceof ExecutableElement method + && method.getParameters().isEmpty() + && method.getModifiers().contains(Modifier.PUBLIC) + && method.getModifiers().contains(Modifier.STATIC) + && isBuilderType(method.getReturnType(), builderElement)) { + candidates.add(method); + } + } + // Prefer the simple-builders convention ("create"); otherwise use the single candidate. + for (ExecutableElement candidate : candidates) { + if (candidate.getSimpleName().contentEquals("create")) { + return candidate; + } + } + return candidates.size() == 1 ? candidates.get(0) : null; + } + + /** + * {@code public} parameterless instance methods on the builder returning the bean type, e.g. + * {@code build()}. + */ + private List findBuildMethods( + TypeElement builderElement, TypeMirror beanType) { + List buildMethods = new ArrayList<>(); + for (Element member : builderElement.getEnclosedElements()) { + if (member.getKind() == ElementKind.METHOD + && member instanceof ExecutableElement method + && method.getParameters().isEmpty() + && method.getModifiers().contains(Modifier.PUBLIC) + && !method.getModifiers().contains(Modifier.STATIC) + && typeUtils.isSameType( + typeUtils.erasure(method.getReturnType()), typeUtils.erasure(beanType))) { + buildMethods.add(method); + } + } + return buildMethods; + } + + private boolean isBuilderType(TypeMirror type, TypeElement builderElement) { + return typeUtils.isSameType( + typeUtils.erasure(type), typeUtils.erasure(builderElement.asType())); + } + + private String qualifiedNameOf(AnnotationMirror mirror) { + return ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); + } + + private static String qualifiedName(String packageName, CharSequence simpleName) { + return packageName.isEmpty() ? simpleName.toString() : packageName + "." + simpleName; + } +} 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; From 65154166f06843e89cf61d4b967d5b7c1518c27a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 09:10:57 +0000 Subject: [PATCH 02/25] Hide generated helper methods from MapStruct bean mapping Adds SimpleBuildersAccessorNamingStrategy (a DefaultAccessorNamingStrategy subclass registered via META-INF/services): for methods declared on a simple-builders builder, only the direct property setter - taking a single argument of the field type - stays a write accessor. add2*, *Update, conditional(...) and the Supplier/Consumer/ format overloads return OTHER, eliminating phantom 'Unmapped target property' warnings. Per-bean naming configuration is resolved from the bean's @SimpleBuilder/@SimpleBuilder.Template options mirrors plus the -Asimplebuilder.* processor options; all other types keep the default strategy behavior. Shared annotation-mirror helpers move to package-private AnnotationSupport, now also used by SimpleBuildersBuilderProvider. --- README.md | 2 +- processor/pom.xml | 8 + .../mapstruct/AnnotationSupport.java | 129 ++++++++++++ .../SimpleBuildersAccessorNamingStrategy.java | 184 ++++++++++++++++++ .../SimpleBuildersBuilderProvider.java | 101 ++-------- .../MapStructSpiIntegrationTest.java | 125 ++++++++++++ 6 files changed, 464 insertions(+), 85 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java diff --git a/README.md b/README.md index 71748e87..88cf9437 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ A runnable example demonstrating package-scoped builder generation and usage: ### MapStruct Example -A runnable example of the MapStruct `BuilderProvider` integration - no configuration needed beyond putting mapstruct-processor on the same annotation processor path: +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()` diff --git a/processor/pom.xml b/processor/pom.xml index 6de14ad0..e64e5962 100644 --- a/processor/pom.xml +++ b/processor/pom.xml @@ -164,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/mapstruct/AnnotationSupport.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java new file mode 100644 index 00000000..c9d91923 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java @@ -0,0 +1,129 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons 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 java.util.ArrayList; +import java.util.List; +import java.util.Map; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Elements; + +/** + * Shared annotation-mirror helpers for the MapStruct SPI implementations: locating the + * simple-builders annotations and reading {@code @SimpleBuilder}/{@code @SimpleBuilder.Template} + * options without a {@code ProcessingContext}. + */ +final class AnnotationSupport { + + static final String SIMPLE_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; + static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; + static final String BUILDER_IMPLEMENTATION_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.BuilderImplementation"; + static final String IGNORE_4_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; + + private final Elements elementUtils; + + AnnotationSupport(Elements elementUtils) { + this.elementUtils = elementUtils; + } + + String qualifiedNameOf(AnnotationMirror mirror) { + return ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); + } + + /** + * Options mirrors relevant for builder generation on {@code beanElement}: {@code options()} of + * {@code @SimpleBuilder} and of {@code @SimpleBuilder.Template} on builder template annotations. + */ + List builderOptionsMirrors(TypeElement beanElement) { + List optionsMirrors = new ArrayList<>(); + for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { + String annotationName = qualifiedNameOf(mirror); + if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { + addOptionsMirror(mirror, optionsMirrors); + } else { + // A custom builder template: read options of its @SimpleBuilder.Template meta-annotation. + Element annotationType = mirror.getAnnotationType().asElement(); + for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { + if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + addOptionsMirror(metaMirror, optionsMirrors); + } + } + } + } + return optionsMirrors; + } + + private void addOptionsMirror(AnnotationMirror mirror, List optionsMirrors) { + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("options") + && entry.getValue().getValue() instanceof AnnotationMirror optionsMirror) { + optionsMirrors.add(optionsMirror); + } + } + } + + String stringOption(AnnotationMirror optionsMirror, String name) { + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(optionsMirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals(name)) { + Object value = entry.getValue().getValue(); + if (value instanceof String stringValue) { + return stringValue; + } + } + } + return null; + } + + /** + * The {@code forClass} type of {@code @BuilderImplementation} on {@code builderType}, or {@code + * null} when the annotation is absent. + */ + TypeMirror builderImplementationForClass(TypeElement builderType) { + for (AnnotationMirror mirror : builderType.getAnnotationMirrors()) { + if (!qualifiedNameOf(mirror).equals(BUILDER_IMPLEMENTATION_ANNOTATION)) { + continue; + } + for (Map.Entry entry : + elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + if (entry.getKey().getSimpleName().contentEquals("forClass") + && entry.getValue().getValue() instanceof TypeMirror forClass) { + return forClass; + } + } + return null; + } + return null; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java new file mode 100644 index 00000000..17b6f29b --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java @@ -0,0 +1,184 @@ +/* + * 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.Element; +import javax.lang.model.element.ElementKind; +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.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import org.mapstruct.ap.spi.AccessorNamingStrategy; +import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; +import org.mapstruct.ap.spi.MapStructProcessingEnvironment; +import org.mapstruct.ap.spi.MethodType; + +/** + * 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 generated by simple-builders + * this strategy returns {@link MethodType#OTHER} for everything that is not the direct property + * setter ({@code } taking a single argument of the field type). Every other + * type keeps the {@link DefaultAccessorNamingStrategy} behaviour. + */ +@AutoService(AccessorNamingStrategy.class) +public class SimpleBuildersAccessorNamingStrategy extends DefaultAccessorNamingStrategy { + + private static final String OPTION_PREFIX = "simplebuilder."; + private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; + + private AnnotationSupport annotations; + private Map processorOptions = Map.of(); + private final Map>> directSettersCache = new HashMap<>(); + + @Override + public void init(MapStructProcessingEnvironment processingEnvironment) { + super.init(processingEnvironment); + annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); + Map options = processingEnvironment.getOptions(); + processorOptions = options == null ? Map.of() : options; + } + + @Override + public MethodType getMethodType(ExecutableElement method) { + MethodType methodType = super.getMethodType(method); + if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { + return methodType; + } + if (!(method.getEnclosingElement() instanceof TypeElement builderType)) { + return methodType; + } + Optional> directSetters = + directSettersCache.computeIfAbsent( + builderType.getQualifiedName().toString(), + ignored -> computeDirectSetters(builderType)); + if (directSetters.isEmpty() || isDirectSetter(method, directSetters.get())) { + return methodType; + } + return MethodType.OTHER; + } + + /** + * 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 ({@code setter name -> field type}) of the bean {@code builderType} + * was generated for, or empty when {@code builderType} is not a simple-builders builder. + */ + private Optional> computeDirectSetters(TypeElement builderType) { + TypeElement beanElement = resolveBean(builderType); + if (beanElement == null) { + return Optional.empty(); + } + String setterSuffix = resolveSetterSuffix(beanElement); + Map directSetters = new HashMap<>(); + collectFields(beanElement, setterSuffix, directSetters); + return Optional.of(directSetters); + } + + /** + * The bean a builder was generated for: {@code @BuilderImplementation(forClass = ...)} on the + * builder, else the conventional name {@code } in the builder's package. + */ + private TypeElement resolveBean(TypeElement builderType) { + TypeMirror forClass = annotations.builderImplementationForClass(builderType); + if (forClass instanceof DeclaredType declaredType + && declaredType.asElement() instanceof TypeElement beanElement) { + return beanElement; + } + String simpleName = builderType.getSimpleName().toString(); + String packageName = elementUtils.getPackageOf(builderType).getQualifiedName().toString(); + for (String suffix : builderSuffixCandidates()) { + if (simpleName.endsWith(suffix) && simpleName.length() > suffix.length()) { + TypeElement candidate = + elementUtils.getTypeElement( + packageName.isEmpty() + ? simpleName.substring(0, simpleName.length() - suffix.length()) + : packageName + + "." + + simpleName.substring(0, simpleName.length() - suffix.length())); + if (candidate != null) { + return candidate; + } + } + } + return null; + } + + private String[] builderSuffixCandidates() { + String configured = processorOptions.get(OPTION_PREFIX + "builderSuffix"); + return configured != null && !configured.isEmpty() + ? new String[] {configured, DEFAULT_BUILDER_SUFFIX} + : new String[] {DEFAULT_BUILDER_SUFFIX}; + } + + /** The {@code setterSuffix} configured for {@code beanElement} ({@code ""} by default). */ + private String resolveSetterSuffix(TypeElement beanElement) { + for (var optionsMirror : annotations.builderOptionsMirrors(beanElement)) { + String setterSuffix = annotations.stringOption(optionsMirror, "setterSuffix"); + if (setterSuffix != null) { + return setterSuffix; + } + } + String global = processorOptions.get(OPTION_PREFIX + "setterSuffix"); + return global != null ? global : ""; + } + + private void collectFields( + TypeElement beanElement, String setterSuffix, Map directSetters) { + for (Element member : beanElement.getEnclosedElements()) { + if (member.getKind() == ElementKind.FIELD + && member instanceof VariableElement field + && !field.getModifiers().contains(Modifier.STATIC)) { + directSetters.put(field.getSimpleName().toString() + setterSuffix, field.asType()); + } + } + TypeMirror superclass = beanElement.getSuperclass(); + if (superclass.getKind() == TypeKind.DECLARED + && ((DeclaredType) superclass).asElement() instanceof TypeElement parent + && !parent.getQualifiedName().contentEquals("java.lang.Object")) { + collectFields(parent, setterSuffix, directSetters); + } + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java index 286f8364..bc3ef75f 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java @@ -26,9 +26,7 @@ import com.google.auto.service.AutoService; import java.util.ArrayList; import java.util.List; -import java.util.Map; import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -61,22 +59,16 @@ public class SimpleBuildersBuilderProvider implements BuilderProvider { private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; - private static final String SIMPLE_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; - private static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; - private static final String BUILDER_IMPLEMENTATION_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.BuilderImplementation"; - private static final String IGNORE_4_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; private Elements elementUtils; private Types typeUtils; + private AnnotationSupport annotations; @Override public void init(MapStructProcessingEnvironment processingEnvironment) { this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); + this.annotations = new AnnotationSupport(elementUtils); } @Override @@ -151,31 +143,18 @@ && findCreationMethod(candidate) != null * beanElement)}. */ private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanElement) { - for (AnnotationMirror mirror : candidate.getAnnotationMirrors()) { - if (!qualifiedNameOf(mirror).equals(BUILDER_IMPLEMENTATION_ANNOTATION)) { - continue; - } - for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { - if (entry.getKey().getSimpleName().contentEquals("forClass") - && entry.getValue().getValue() instanceof TypeMirror forClass - && typeUtils.isSameType( - typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType()))) { - return true; - } - } - // @BuilderImplementation present but forClass points at another bean - return false; - } - return false; + TypeMirror forClass = annotations.builderImplementationForClass(candidate); + return forClass != null + && typeUtils.isSameType( + typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType())); } /** Packages the generated builder may live in: the bean's package or a configured packageName. */ private List candidatePackageNames(TypeElement beanElement) { List packageNames = new ArrayList<>(); packageNames.add(elementUtils.getPackageOf(beanElement).getQualifiedName().toString()); - for (AnnotationMirror optionsMirror : builderOptionsMirrors(beanElement)) { - String packageName = stringOption(optionsMirror, "packageName"); + for (AnnotationMirror optionsMirror : annotations.builderOptionsMirrors(beanElement)) { + String packageName = annotations.stringOption(optionsMirror, "packageName"); if (packageName != null && !packageName.isEmpty() && !packageNames.contains(packageName)) { packageNames.add(packageName); } @@ -186,8 +165,8 @@ private List candidatePackageNames(TypeElement beanElement) { /** Builder name suffixes to try: explicitly configured ones plus the default. */ private List candidateSuffixes(TypeElement beanElement) { List suffixes = new ArrayList<>(); - for (AnnotationMirror optionsMirror : builderOptionsMirrors(beanElement)) { - String suffix = stringOption(optionsMirror, "builderSuffix"); + for (AnnotationMirror optionsMirror : annotations.builderOptionsMirrors(beanElement)) { + String suffix = annotations.stringOption(optionsMirror, "builderSuffix"); if (suffix != null && !suffix.isEmpty() && !suffixes.contains(suffix)) { suffixes.add(suffix); } @@ -198,52 +177,6 @@ private List candidateSuffixes(TypeElement beanElement) { return suffixes; } - /** - * Options mirrors relevant for builder generation on {@code beanElement}: {@code options()} of - * {@code @SimpleBuilder} and of {@code @SimpleBuilder.Template} on builder template annotations. - */ - private List builderOptionsMirrors(TypeElement beanElement) { - List optionsMirrors = new ArrayList<>(); - for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { - String annotationName = qualifiedNameOf(mirror); - if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { - addOptionsMirror(mirror, optionsMirrors); - } else { - // A custom builder template: read options of its @SimpleBuilder.Template meta-annotation. - Element annotationType = mirror.getAnnotationType().asElement(); - for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { - if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - addOptionsMirror(metaMirror, optionsMirrors); - } - } - } - } - return optionsMirrors; - } - - private void addOptionsMirror(AnnotationMirror mirror, List optionsMirrors) { - for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { - if (entry.getKey().getSimpleName().contentEquals("options") - && entry.getValue().getValue() instanceof AnnotationMirror optionsMirror) { - optionsMirrors.add(optionsMirror); - } - } - } - - private String stringOption(AnnotationMirror optionsMirror, String name) { - for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(optionsMirror).entrySet()) { - if (entry.getKey().getSimpleName().contentEquals(name)) { - Object value = entry.getValue().getValue(); - if (value instanceof String stringValue) { - return stringValue; - } - } - } - return null; - } - /** * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). @@ -251,19 +184,23 @@ private String stringOption(AnnotationMirror optionsMirror, String name) { private boolean isBuilderGenerationTarget(TypeElement beanElement) { List mirrors = elementUtils.getAllAnnotationMirrors(beanElement); for (AnnotationMirror mirror : mirrors) { - if (qualifiedNameOf(mirror).equals(IGNORE_4_BUILDER_ANNOTATION)) { + if (annotations + .qualifiedNameOf(mirror) + .equals(AnnotationSupport.IGNORE_4_BUILDER_ANNOTATION)) { return false; } } for (AnnotationMirror mirror : mirrors) { - if (qualifiedNameOf(mirror).equals(SIMPLE_BUILDER_ANNOTATION)) { + if (annotations.qualifiedNameOf(mirror).equals(AnnotationSupport.SIMPLE_BUILDER_ANNOTATION)) { return true; } // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined // one): its type is meta-annotated with @SimpleBuilder.Template. Element annotationType = mirror.getAnnotationType().asElement(); for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { - if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + if (annotations + .qualifiedNameOf(metaMirror) + .equals(AnnotationSupport.SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { return true; } } @@ -322,10 +259,6 @@ private boolean isBuilderType(TypeMirror type, TypeElement builderElement) { typeUtils.erasure(type), typeUtils.erasure(builderElement.asType())); } - private String qualifiedNameOf(AnnotationMirror mirror) { - return ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); - } - private static String qualifiedName(String packageName, CharSequence simpleName) { return packageName.isEmpty() ? simpleName.toString() : packageName + "." + simpleName; } 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..c3ac5f9f --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -0,0 +1,125 @@ +/* + * 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.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.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 SimpleBuildersBuilderProvider} + * supplies the builder, {@code SimpleBuildersAccessorNamingStrategy} hides the generated helper + * methods so MapStruct neither binds them nor reports them as unmapped target properties. + */ +class MapStructSpiIntegrationTest { + + private static final JavaFileObject PERSON_DTO = + 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 final JavaFileObject PERSON_DTO_MAPPER = + ProcessorTestUtils.forSource( + """ + package test; + + import org.mapstruct.Mapper; + + @Mapper + public interface PersonDtoMapper { + + PersonDto copy(PersonDto source); + } + """); + + private static Compiler compiler() { + return Compiler.javac() + .withProcessors(new BuilderProcessor(), new MappingProcessor()) + .withOptions( + "-Amapstruct.suppressGeneratorTimestamp=true", + "-Amapstruct.suppressGeneratorVersionInfoComment=true"); + } + + @Test + void mapStruct_shouldUseGeneratedBuilder() { + Compilation compilation = compiler().compile(PERSON_DTO, PERSON_DTO_MAPPER); + assertThat(compilation).succeeded(); + ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); + + 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_shouldNotReportHelpersAsUnmappedTargetProperties() { + Compilation compilation = compiler().compile(PERSON_DTO, PERSON_DTO_MAPPER); + assertThat(compilation).succeeded(); + ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); + + String warnings = + compilation.warnings().stream() + .map(diagnostic -> diagnostic.getMessage(null)) + .reduce("", (left, right) -> left + "\n" + right); + assertFalse( + warnings.toLowerCase().contains("unmapped target property"), + "Generated helper methods (add2*, *Update, Supplier/Consumer overloads) must not " + + "surface as unmapped target properties, got: " + + warnings); + } +} From 37aae7bb0b1d7df3ac9914670b3f2a29c1d54b73 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:41:20 +0000 Subject: [PATCH 03/25] Rename MapStruct SPI classes to carry the framework name MapStructBuilderProvider and MapStructAccessorNamingStrategy make the SPI purpose visible without reading the implementation. --- .../simple/builders/example/MapStructIntegrationTest.java | 2 +- ...mingStrategy.java => MapStructAccessorNamingStrategy.java} | 2 +- ...dersBuilderProvider.java => MapStructBuilderProvider.java} | 2 +- .../builders/processor/MapStructSpiIntegrationTest.java | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/{SimpleBuildersAccessorNamingStrategy.java => MapStructAccessorNamingStrategy.java} (98%) rename processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/{SimpleBuildersBuilderProvider.java => MapStructBuilderProvider.java} (99%) 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 index 1e5aacd7..52e28338 100644 --- a/example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java +++ b/example/src/test/java/org/javahelpers/simple/builders/example/MapStructIntegrationTest.java @@ -36,7 +36,7 @@ /** * Verifies that MapStruct discovers and uses the generated {@code PersonDtoBuilder} through the - * {@code SimpleBuildersBuilderProvider} SPI shipped in simple-builders-processor. + * {@code MapStructBuilderProvider} SPI shipped in simple-builders-processor. */ class MapStructIntegrationTest { diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java similarity index 98% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java index 17b6f29b..75bd9156 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersAccessorNamingStrategy.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java @@ -55,7 +55,7 @@ * type keeps the {@link DefaultAccessorNamingStrategy} behaviour. */ @AutoService(AccessorNamingStrategy.class) -public class SimpleBuildersAccessorNamingStrategy extends DefaultAccessorNamingStrategy { +public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy { private static final String OPTION_PREFIX = "simplebuilder."; private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java similarity index 99% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java index bc3ef75f..2b9d2cca 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/SimpleBuildersBuilderProvider.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java @@ -56,7 +56,7 @@ * simple-builders-processor and mapstruct-processor share the annotation processor path. */ @AutoService(BuilderProvider.class) -public class SimpleBuildersBuilderProvider implements BuilderProvider { +public class MapStructBuilderProvider implements BuilderProvider { private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; 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 index c3ac5f9f..6a23043a 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -36,8 +36,8 @@ import org.mapstruct.ap.MappingProcessor; /** - * Integration test for the MapStruct SPI implementations: {@code SimpleBuildersBuilderProvider} - * supplies the builder, {@code SimpleBuildersAccessorNamingStrategy} hides the generated helper + * 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 { From e4dbd445113b1bde5fc0801d10ddbf89fbd9a41d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:41:34 +0000 Subject: [PATCH 04/25] Add usingMapStructIntegration opt-out for the MapStruct SPIs MapStruct does not forward foreign annotation processor options to SPI environments, so the switch follows the simplebuilder.* convention and prefers the JVM system property: -Dsimplebuilder.usingMapStructIntegration =false restores stock MapStruct behaviour (builder candidates stay hidden and the naming strategy defers to the default). The same option() helper now also makes builderSuffix/setterSuffix reachable inside the SPI. --- docs/CONFIGURATION.md | 18 ++++++ .../MapStructAccessorNamingStrategy.java | 25 +++++++- .../mapstruct/MapStructBuilderProvider.java | 23 ++++++- .../MapStructSpiIntegrationTest.java | 61 ++++++++++++++++++- 4 files changed, 121 insertions(+), 6 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d8e8cea9..95a4dfc0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1048,6 +1048,23 @@ This is highly recommended to ensure deterministic output location and avoid spl --- +#### `usingMapStructIntegration` + +**Default**: `ENABLED` | **System Property**: `-Dsimplebuilder.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. + +Unlike the other options this switch is read inside MapStruct's SPI environment, which does not see the `simplebuilder.*` annotation processor options. Use the `-D` JVM system property form (same precedence rules as other options); the `-A` form is honoured as a fallback where it does reach the SPI environment. + +**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. + +**Example**: +```bash +mvn compile -Dsimplebuilder.usingMapStructIntegration=DISABLED +``` + +--- + ### Documentation #### `generateJavaDoc` @@ -1692,6 +1709,7 @@ methodAccess = AccessModifier.PRIVATE -Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED -Asimplebuilder.generateJacksonModule=ENABLED|DISABLED -Asimplebuilder.jacksonModulePackage=com.your.package +-Dsimplebuilder.usingMapStructIntegration=ENABLED|DISABLED # Documentation -Asimplebuilder.generateJavaDoc=ENABLED|DISABLED 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 index 75bd9156..353747e6 100644 --- 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 @@ -58,11 +58,13 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy { private static final String OPTION_PREFIX = "simplebuilder."; + private static final String OPTION_USING_MAPSTRUCT = OPTION_PREFIX + "usingMapStructIntegration"; private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; private AnnotationSupport annotations; private Map processorOptions = Map.of(); private final Map>> directSettersCache = new HashMap<>(); + private boolean enabled = true; @Override public void init(MapStructProcessingEnvironment processingEnvironment) { @@ -70,11 +72,21 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; + // MapStruct forwards only its own options to SPI environments, so the off-switch follows the + // simplebuilder.* convention and prefers the JVM system property + enabled = + !"false" + .equalsIgnoreCase( + System.getProperty( + OPTION_USING_MAPSTRUCT, processorOptions.get(OPTION_USING_MAPSTRUCT))); } @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); + if (!enabled) { + return methodType; + } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { return methodType; } @@ -147,7 +159,7 @@ private TypeElement resolveBean(TypeElement builderType) { } private String[] builderSuffixCandidates() { - String configured = processorOptions.get(OPTION_PREFIX + "builderSuffix"); + String configured = option(OPTION_PREFIX + "builderSuffix"); return configured != null && !configured.isEmpty() ? new String[] {configured, DEFAULT_BUILDER_SUFFIX} : new String[] {DEFAULT_BUILDER_SUFFIX}; @@ -161,10 +173,19 @@ private String resolveSetterSuffix(TypeElement beanElement) { return setterSuffix; } } - String global = processorOptions.get(OPTION_PREFIX + "setterSuffix"); + String global = option(OPTION_PREFIX + "setterSuffix"); return global != null ? global : ""; } + /** + * Reads a {@code simplebuilder.*} option following the project convention: the JVM system + * property wins over the annotation processor option (which MapStruct does not forward to SPI + * environments). + */ + private String option(String key) { + return System.getProperty(key, processorOptions.get(key)); + } + private void collectFields( TypeElement beanElement, String setterSuffix, Map directSetters) { for (Element member : beanElement.getEnclosedElements()) { 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 index 2b9d2cca..f0949354 100644 --- 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 @@ -26,6 +26,7 @@ import com.google.auto.service.AutoService; import java.util.ArrayList; import java.util.List; +import java.util.Map; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; @@ -53,26 +54,46 @@ * resolving the conventional name {@code } in the target package. * *

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. + * simple-builders-processor and mapstruct-processor share the annotation processor path. The + * integration can be switched off entirely with {@code + * -Dsimplebuilder.usingMapStructIntegration=false} (MapStruct does not forward foreign annotation + * processor options to SPI environments, so the JVM system property is the documented channel). */ @AutoService(BuilderProvider.class) public class MapStructBuilderProvider implements BuilderProvider { private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; + private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; private Elements elementUtils; private Types typeUtils; private AnnotationSupport annotations; + private boolean enabled = true; @Override public void init(MapStructProcessingEnvironment processingEnvironment) { this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); this.annotations = new AnnotationSupport(elementUtils); + Map options = processingEnvironment.getOptions(); + enabled = !isDisabled(options != null ? options.get(OPTION_USING_MAPSTRUCT) : null); + } + + /** + * Whether the integration is switched off, following the {@code simplebuilder.*} convention of + * preferring the JVM system property over the annotation processor option (which MapStruct does + * not forward to SPI environments). + */ + private static boolean isDisabled(String processorOption) { + String value = System.getProperty(OPTION_USING_MAPSTRUCT, processorOption); + return "false".equalsIgnoreCase(value); } @Override public BuilderInfo findBuilderInfo(TypeMirror type) { + if (!enabled) { + return null; + } if (!(type instanceof DeclaredType declaredType) || !(declaredType.asElement() instanceof TypeElement beanElement)) { return null; 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 index 6a23043a..8cfae966 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -36,9 +36,9 @@ 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. + * 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 { @@ -85,6 +85,41 @@ public interface PersonDtoMapper { } """); + private static final JavaFileObject MUTABLE_DTO = + 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 final JavaFileObject MUTABLE_DTO_MAPPER = + ProcessorTestUtils.forSource( + """ + package test; + + import org.mapstruct.Mapper; + + @Mapper + public interface MutableDtoMapper { + + MutableDto copy(MutableDto source); + } + """); + private static Compiler compiler() { return Compiler.javac() .withProcessors(new BuilderProcessor(), new MappingProcessor()) @@ -122,4 +157,24 @@ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { + "surface as unmapped target properties, got: " + warnings); } + + @Test + void mapStruct_disabledIntegration_shouldMapViaSetters() { + // MapStruct does not forward foreign -A options to SPI environments; the off-switch is the + // simplebuilder.* JVM system property (same precedence as in CompilerArgumentsReader) + System.setProperty("simplebuilder.usingMapStructIntegration", "false"); + try { + Compilation compilation = compiler().compile(MUTABLE_DTO, MUTABLE_DTO_MAPPER); + assertThat(compilation).succeeded(); + ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); + + 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"); + } finally { + System.clearProperty("simplebuilder.usingMapStructIntegration"); + } + } } From 6d373c0ecae0f9073d3813a4d542e4938acf20aa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:51:35 +0000 Subject: [PATCH 05/25] Reduce per-bean work in the MapStruct SPI lookups findBuilderInfo runs for every type MapStruct inspects, per mapper and per round, with no caching on MapStruct's side. Resolve builders by conventional name first (a getTypeElement per candidate package/suffix) and run the @BuilderImplementation package scan only as a last resort for beans marked for generation. Naming candidates are resolved in a single options pass, positive BuilderInfo results are cached per bean, and annotation values are read via getElementValues (explicit values only) instead of materializing defaults. Also: the builder candidates now include the global -D builderSuffix so a custom suffix cannot strand a marked bean in endless deferral; the creation method prefers create, then of, then alphabetical order; the naming strategy's name fallback only resolves marked beans (a bean literally named Builder no longer hides its own methods); option resolution is shared via AnnotationSupport.systemOption. --- .../mapstruct/AnnotationSupport.java | 47 ++++- .../MapStructAccessorNamingStrategy.java | 24 +-- .../mapstruct/MapStructBuilderProvider.java | 180 +++++++++--------- 3 files changed, 144 insertions(+), 107 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java index c9d91923..8e5f66cd 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java @@ -37,7 +37,9 @@ /** * Shared annotation-mirror helpers for the MapStruct SPI implementations: locating the * simple-builders annotations and reading {@code @SimpleBuilder}/{@code @SimpleBuilder.Template} - * options without a {@code ProcessingContext}. + * options without a {@code ProcessingContext}. Only explicitly set annotation values are read + * ({@link AnnotationMirror#getElementValues()}) — defaults materialize to the same outcome the + * callers already apply for absent values. */ final class AnnotationSupport { @@ -85,7 +87,7 @@ List builderOptionsMirrors(TypeElement beanElement) { private void addOptionsMirror(AnnotationMirror mirror, List optionsMirrors) { for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + mirror.getElementValues().entrySet()) { if (entry.getKey().getSimpleName().contentEquals("options") && entry.getValue().getValue() instanceof AnnotationMirror optionsMirror) { optionsMirrors.add(optionsMirror); @@ -95,7 +97,7 @@ private void addOptionsMirror(AnnotationMirror mirror, List op String stringOption(AnnotationMirror optionsMirror, String name) { for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(optionsMirror).entrySet()) { + optionsMirror.getElementValues().entrySet()) { if (entry.getKey().getSimpleName().contentEquals(name)) { Object value = entry.getValue().getValue(); if (value instanceof String stringValue) { @@ -116,7 +118,7 @@ TypeMirror builderImplementationForClass(TypeElement builderType) { continue; } for (Map.Entry entry : - elementUtils.getElementValuesWithDefaults(mirror).entrySet()) { + mirror.getElementValues().entrySet()) { if (entry.getKey().getSimpleName().contentEquals("forClass") && entry.getValue().getValue() instanceof TypeMirror forClass) { return forClass; @@ -126,4 +128,41 @@ TypeMirror builderImplementationForClass(TypeElement builderType) { } return null; } + + /** + * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a + * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). + */ + boolean isBuilderGenerationTarget(TypeElement beanElement) { + boolean marked = false; + for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { + String annotationName = qualifiedNameOf(mirror); + if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { + return false; + } + if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { + marked = true; + continue; + } + // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined + // one): its type is meta-annotated with @SimpleBuilder.Template. + for (AnnotationMirror metaMirror : + mirror.getAnnotationType().asElement().getAnnotationMirrors()) { + if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + marked = true; + break; + } + } + } + return marked; + } + + /** + * Reads a {@code simplebuilder.*} option following the project convention: the JVM system + * property wins over the annotation processor option (which MapStruct does not forward to SPI + * environments). + */ + static String systemOption(Map processorOptions, String key) { + return System.getProperty(key, processorOptions.get(key)); + } } 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 index 353747e6..509583a5 100644 --- 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 @@ -77,8 +77,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { enabled = !"false" .equalsIgnoreCase( - System.getProperty( - OPTION_USING_MAPSTRUCT, processorOptions.get(OPTION_USING_MAPSTRUCT))); + AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT)); } @Override @@ -150,7 +149,9 @@ private TypeElement resolveBean(TypeElement builderType) { : packageName + "." + simpleName.substring(0, simpleName.length() - suffix.length())); - if (candidate != null) { + // Only a marked bean may claim the builder — otherwise a bean literally named + // Builder would hide its own non-field methods whenever a bean exists. + if (candidate != null && annotations.isBuilderGenerationTarget(candidate)) { return candidate; } } @@ -158,8 +159,10 @@ private TypeElement resolveBean(TypeElement builderType) { return null; } + /** Candidate builder name suffixes: the globally configured one plus the default. */ private String[] builderSuffixCandidates() { - String configured = option(OPTION_PREFIX + "builderSuffix"); + String configured = + AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "builderSuffix"); return configured != null && !configured.isEmpty() ? new String[] {configured, DEFAULT_BUILDER_SUFFIX} : new String[] {DEFAULT_BUILDER_SUFFIX}; @@ -173,19 +176,12 @@ private String resolveSetterSuffix(TypeElement beanElement) { return setterSuffix; } } - String global = option(OPTION_PREFIX + "setterSuffix"); + String global = + AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "setterSuffix"); return global != null ? global : ""; } - /** - * Reads a {@code simplebuilder.*} option following the project convention: the JVM system - * property wins over the annotation processor option (which MapStruct does not forward to SPI - * environments). - */ - private String option(String key) { - return System.getProperty(key, processorOptions.get(key)); - } - + /** Collects the bean's non-static fields incl. inherited ones into {@code directSetters}. */ private void collectFields( TypeElement beanElement, String setterSuffix, Map directSetters) { for (Element member : beanElement.getEnclosedElements()) { 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 index f0949354..6ecb5daa 100644 --- 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 @@ -25,6 +25,8 @@ import com.google.auto.service.AutoService; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; import java.util.Map; import javax.lang.model.element.AnnotationMirror; @@ -49,9 +51,15 @@ * *

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 the generated builders are found here by scanning - * the target package for types carrying {@code @BuilderImplementation(forClass = )} and by - * resolving the conventional name {@code } in the target package. + * class ({@code PersonDtoBuilder.create()}), so builders are located by conventional name {@code + * } in the candidate packages first — a constant-time lookup for the common + * case. Only for beans marked for builder generation does a package scan for types carrying + * {@code @BuilderImplementation(forClass = )} run as a last resort. + * + *

When a marked bean's builder is not visible yet, {@link TypeHierarchyErroneousException} + * defers the mapper to the next processing round so a builder generated in the same round can still + * be found. Builders generated into packages unreachable from the bean (e.g. an unrelated {@code + * packageName} on a {@code @SimpleBuilderFor} site) cannot be discovered. * *

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 @@ -63,11 +71,17 @@ public class MapStructBuilderProvider implements BuilderProvider { private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; - private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; + private static final String OPTION_PREFIX = "simplebuilder."; + private static final String OPTION_USING_MAPSTRUCT = OPTION_PREFIX + "usingMapStructIntegration"; private Elements elementUtils; private Types typeUtils; private AnnotationSupport annotations; + private Map processorOptions = Map.of(); + + /** Resolved builder infos by bean qualified name; only positive results are cached. */ + private final Map builderInfoCache = new HashMap<>(); + private boolean enabled = true; @Override @@ -76,17 +90,11 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { this.typeUtils = processingEnvironment.getTypeUtils(); this.annotations = new AnnotationSupport(elementUtils); Map options = processingEnvironment.getOptions(); - enabled = !isDisabled(options != null ? options.get(OPTION_USING_MAPSTRUCT) : null); - } - - /** - * Whether the integration is switched off, following the {@code simplebuilder.*} convention of - * preferring the JVM system property over the annotation processor option (which MapStruct does - * not forward to SPI environments). - */ - private static boolean isDisabled(String processorOption) { - String value = System.getProperty(OPTION_USING_MAPSTRUCT, processorOption); - return "false".equalsIgnoreCase(value); + processorOptions = options == null ? Map.of() : options; + enabled = + !"false" + .equalsIgnoreCase( + AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT)); } @Override @@ -98,19 +106,24 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { || !(declaredType.asElement() instanceof TypeElement beanElement)) { return null; } + BuilderInfo cached = builderInfoCache.get(beanElement.getQualifiedName().toString()); + if (cached != null) { + return cached; + } + BuilderInfo builderInfo = createBuilderInfo(beanElement, type); + if (builderInfo != null) { + builderInfoCache.put(beanElement.getQualifiedName().toString(), builderInfo); + } + return builderInfo; + } + private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanType) { TypeElement builderElement = findBuilderElement(beanElement); if (builderElement == null) { - if (isBuilderGenerationTarget(beanElement)) { - // The bean is annotated for builder generation but the builder does not exist yet in this - // processing round. Throwing defers mapper generation to the next round. - throw new TypeHierarchyErroneousException(type); - } return null; } - ExecutableElement creationMethod = findCreationMethod(builderElement); - List buildMethods = findBuildMethods(builderElement, type); + List buildMethods = findBuildMethods(builderElement, beanType); if (creationMethod == null || buildMethods.isEmpty()) { return null; } @@ -121,15 +134,36 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { } /** - * Locates the generated builder for {@code beanElement}: first by {@code @BuilderImplementation} - * in the candidate packages, then by conventional name {@code }. + * Locates the generated builder for {@code beanElement} by conventional name first; for beans + * marked for generation the package scan for {@code @BuilderImplementation} runs as a last + * resort, and a missing builder defers the mapper to the next processing round. */ private TypeElement findBuilderElement(TypeElement beanElement) { - List packageNames = candidatePackageNames(beanElement); + BuilderNaming naming = builderNaming(beanElement); + + // Conventional name first: a constant-time getTypeElement per candidate package and suffix, + // covering every builder generated by simple-builders. + for (String packageName : naming.packageNames()) { + for (String suffix : naming.suffixes()) { + TypeElement candidate = + elementUtils.getTypeElement( + qualifiedName(packageName, beanElement.getSimpleName() + suffix)); + if (candidate != null + && findCreationMethod(candidate) != null + && !findBuildMethods(candidate, beanElement.asType()).isEmpty()) { + return candidate; + } + } + } - // Primary: builders generated by simple-builders carry - // @BuilderImplementation(forClass = ) by default. - for (String packageName : packageNames) { + if (!annotations.isBuilderGenerationTarget(beanElement)) { + return null; + } + + // Last resort for marked beans: scan the candidate packages for a type carrying + // @BuilderImplementation(forClass = ) — covers builders a plain name lookup cannot + // resolve, e.g. when the name was not generated by the naming convention. + for (String packageName : naming.packageNames()) { PackageElement packageElement = elementUtils.getPackageElement(packageName); if (packageElement == null) { continue; @@ -142,21 +176,9 @@ && isGeneratedBuilderFor(typeElement, beanElement)) { } } - // Fallback for builders generated with usingBuilderImplementationAnnotation disabled: - // resolve by name and verify the type looks like a builder. - for (String packageName : packageNames) { - for (String suffix : candidateSuffixes(beanElement)) { - TypeElement candidate = - elementUtils.getTypeElement( - qualifiedName(packageName, beanElement.getSimpleName() + suffix)); - if (candidate != null - && findCreationMethod(candidate) != null - && !findBuildMethods(candidate, beanElement.asType()).isEmpty()) { - return candidate; - } - } - } - return null; + // The bean is annotated for builder generation but the builder does not exist yet in this + // processing round. Throwing defers mapper generation to the next round. + throw new TypeHierarchyErroneousException(beanElement.asType()); } /** @@ -170,64 +192,37 @@ private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanEle typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType())); } - /** Packages the generated builder may live in: the bean's package or a configured packageName. */ - private List candidatePackageNames(TypeElement beanElement) { + /** + * Candidate packages and builder name suffixes for {@code beanElement}, collected from its + * builder options in a single annotation pass. + */ + private BuilderNaming builderNaming(TypeElement beanElement) { List packageNames = new ArrayList<>(); + List suffixes = new ArrayList<>(); packageNames.add(elementUtils.getPackageOf(beanElement).getQualifiedName().toString()); for (AnnotationMirror optionsMirror : annotations.builderOptionsMirrors(beanElement)) { String packageName = annotations.stringOption(optionsMirror, "packageName"); if (packageName != null && !packageName.isEmpty() && !packageNames.contains(packageName)) { packageNames.add(packageName); } - } - return packageNames; - } - - /** Builder name suffixes to try: explicitly configured ones plus the default. */ - private List candidateSuffixes(TypeElement beanElement) { - List suffixes = new ArrayList<>(); - for (AnnotationMirror optionsMirror : annotations.builderOptionsMirrors(beanElement)) { String suffix = annotations.stringOption(optionsMirror, "builderSuffix"); if (suffix != null && !suffix.isEmpty() && !suffixes.contains(suffix)) { suffixes.add(suffix); } } + String globalSuffix = + AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "builderSuffix"); + if (globalSuffix != null && !globalSuffix.isEmpty() && !suffixes.contains(globalSuffix)) { + suffixes.add(globalSuffix); + } if (!suffixes.contains(DEFAULT_BUILDER_SUFFIX)) { suffixes.add(DEFAULT_BUILDER_SUFFIX); } - return suffixes; + return new BuilderNaming(packageNames, suffixes); } - /** - * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a - * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). - */ - private boolean isBuilderGenerationTarget(TypeElement beanElement) { - List mirrors = elementUtils.getAllAnnotationMirrors(beanElement); - for (AnnotationMirror mirror : mirrors) { - if (annotations - .qualifiedNameOf(mirror) - .equals(AnnotationSupport.IGNORE_4_BUILDER_ANNOTATION)) { - return false; - } - } - for (AnnotationMirror mirror : mirrors) { - if (annotations.qualifiedNameOf(mirror).equals(AnnotationSupport.SIMPLE_BUILDER_ANNOTATION)) { - return true; - } - // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined - // one): its type is meta-annotated with @SimpleBuilder.Template. - Element annotationType = mirror.getAnnotationType().asElement(); - for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { - if (annotations - .qualifiedNameOf(metaMirror) - .equals(AnnotationSupport.SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - return true; - } - } - } - return false; - } + /** Naming candidates for locating a bean's builder. */ + private record BuilderNaming(List packageNames, List suffixes) {} /** * A {@code public static} parameterless method on the builder returning the builder type, e.g. @@ -245,13 +240,18 @@ && isBuilderType(method.getReturnType(), builderElement)) { candidates.add(method); } } - // Prefer the simple-builders convention ("create"); otherwise use the single candidate. - for (ExecutableElement candidate : candidates) { - if (candidate.getSimpleName().contentEquals("create")) { - return candidate; + // Prefer the canonical factory names, then fall back alphabetically — deterministic for + // builders declaring several matching factories. + for (String preferred : List.of("create", "of")) { + for (ExecutableElement candidate : candidates) { + if (candidate.getSimpleName().contentEquals(preferred)) { + return candidate; + } } } - return candidates.size() == 1 ? candidates.get(0) : null; + return candidates.stream() + .min(Comparator.comparing(method -> method.getSimpleName().toString())) + .orElse(null); } /** @@ -275,11 +275,13 @@ private List findBuildMethods( return buildMethods; } + /** Whether {@code type} is the builder's own type. */ private boolean isBuilderType(TypeMirror type, TypeElement builderElement) { return typeUtils.isSameType( typeUtils.erasure(type), typeUtils.erasure(builderElement.asType())); } + /** Qualified name of {@code simpleName} inside {@code packageName}. */ private static String qualifiedName(String packageName, CharSequence simpleName) { return packageName.isEmpty() ? simpleName.toString() : packageName + "." + simpleName; } From 50a258bc20ded05b6867a7386f7bdd35bb983e73 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:29:41 +0000 Subject: [PATCH 06/25] Wire usingMapStructIntegration through BuilderProcessor and share planned builder names MapStruct never forwards foreign annotation processor options to SPI environments, so the BuilderProcessor resolves simplebuilder.usingMapStructIntegration itself (same -D over -A precedence as every option) and publishes it together with the bean to builder qualified names it plans to emit to the SPIs sharing the classloader. MapStructIntegration holds that per-compilation state and is reset on every processor init so stale entries cannot leak across Gradle daemon compilations. Both SPIs consult the registry first, giving exact answers for SimpleBuilderFor targets and custom packageName layouts, and fall back to the conventional name lookup for builders generated in earlier runs. The package scan is dropped: the generator always emits the conventional name, so the scan only served exotic hand-written builders. --- docs/CONFIGURATION.md | 12 ++- .../builders/processor/BuilderProcessor.java | 19 ++++ .../MapStructAccessorNamingStrategy.java | 19 ++-- .../mapstruct/MapStructBuilderProvider.java | 84 ++++++---------- .../mapstruct/MapStructIntegration.java | 96 +++++++++++++++++++ .../processing/CompilerArgumentsEnum.java | 7 ++ .../MapStructSpiIntegrationTest.java | 34 +++---- 7 files changed, 185 insertions(+), 86 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 95a4dfc0..bada6336 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1050,17 +1050,19 @@ This is highly recommended to ensure deterministic output location and avoid spl #### `usingMapStructIntegration` -**Default**: `ENABLED` | **System Property**: `-Dsimplebuilder.usingMapStructIntegration=ENABLED|DISABLED` +**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. -Unlike the other options this switch is read inside MapStruct's SPI environment, which does not see the `simplebuilder.*` annotation processor options. Use the `-D` JVM system property form (same precedence rules as other options); the `-A` form is honoured as a fallback where it does reach the SPI environment. +Unlike the other options this switch is consumed inside MapStruct's SPI environment, which does not see `simplebuilder.*` annotation processor options. `BuilderProcessor` therefore resolves the option itself (same `-D` > `-A` precedence as every option) and publishes it to the SPIs sharing the classloader; `-D` also reaches them directly for setups where only the SPI jar is on the 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. **Example**: -```bash -mvn compile -Dsimplebuilder.usingMapStructIntegration=DISABLED +```xml + + -Asimplebuilder.usingMapStructIntegration=DISABLED + ``` --- @@ -1709,7 +1711,7 @@ methodAccess = AccessModifier.PRIVATE -Asimplebuilder.usingJacksonDeserializerAnnotation=ENABLED|DISABLED -Asimplebuilder.generateJacksonModule=ENABLED|DISABLED -Asimplebuilder.jacksonModulePackage=com.your.package --Dsimplebuilder.usingMapStructIntegration=ENABLED|DISABLED +-Asimplebuilder.usingMapStructIntegration=ENABLED|DISABLED # Documentation -Asimplebuilder.generateJavaDoc=ENABLED|DISABLED 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 a7e141b7..ebae0db3 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 @@ -60,11 +60,13 @@ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors; +import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator; +import org.javahelpers.simple.builders.processor.mapstruct.MapStructIntegration; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.BuilderToGenerationTypeMapper; @@ -76,6 +78,7 @@ import org.javahelpers.simple.builders.processor.processing.BuilderConfigurationReader; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; +import org.javahelpers.simple.builders.processor.processing.OptionValueParsers; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; @@ -109,6 +112,14 @@ public synchronized void init(ProcessingEnvironment processingEnv) { BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger); logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); + // Publish the resolved integration switch to the MapStruct SPIs sharing this classloader; + // their environment never sees foreign annotation processor options + String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); + MapStructIntegration.initCompilation( + mapStructOption == null + ? null + : OptionValueParsers.parseOptionState(mapStructOption, logger) != OptionState.DISABLED); + this.context = new ProcessingContext(logger, globalConfig, processingEnv); this.codeGenerator = new RoasterCodeGenerator(context, processingEnv); this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig); @@ -531,6 +542,14 @@ private void registerGeneratedTypes(List elementsToGenerate) effectiveBuilderPackage( elementToGenerate.reportingElement(), elementToGenerate.config()), elementToGenerate.config())); + MapStructIntegration.registerBuilder( + targetType.getQualifiedName().toString(), + builderTypeName( + targetType, + effectiveBuilderPackage( + elementToGenerate.reportingElement(), elementToGenerate.config()), + elementToGenerate.config()) + .getFullQualifiedName()); } } 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 index 509583a5..a442bad2 100644 --- 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 @@ -58,13 +58,11 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy { private static final String OPTION_PREFIX = "simplebuilder."; - private static final String OPTION_USING_MAPSTRUCT = OPTION_PREFIX + "usingMapStructIntegration"; private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; private AnnotationSupport annotations; private Map processorOptions = Map.of(); private final Map>> directSettersCache = new HashMap<>(); - private boolean enabled = true; @Override public void init(MapStructProcessingEnvironment processingEnvironment) { @@ -72,18 +70,12 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; - // MapStruct forwards only its own options to SPI environments, so the off-switch follows the - // simplebuilder.* convention and prefers the JVM system property - enabled = - !"false" - .equalsIgnoreCase( - AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT)); } @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (!enabled) { + if (MapStructIntegration.isDisabled(processorOptions)) { return methodType; } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { @@ -129,10 +121,15 @@ private Optional> computeDirectSetters(TypeElement build } /** - * The bean a builder was generated for: {@code @BuilderImplementation(forClass = ...)} on the - * builder, else the conventional name {@code } in the builder's package. + * The bean a builder was generated for: the registry {@code BuilderProcessor} publishes, then + * {@code @BuilderImplementation(forClass = ...)} on the builder, else the conventional name + * {@code } in the builder's package. */ private TypeElement resolveBean(TypeElement builderType) { + String registered = MapStructIntegration.beanFor(builderType.getQualifiedName().toString()); + if (registered != null) { + return elementUtils.getTypeElement(registered); + } TypeMirror forClass = annotations.builderImplementationForClass(builderType); if (forClass instanceof DeclaredType declaredType && declaredType.asElement() instanceof TypeElement beanElement) { 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 index 6ecb5daa..537d35b4 100644 --- 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 @@ -34,7 +34,6 @@ import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Modifier; -import javax.lang.model.element.PackageElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeMirror; @@ -51,28 +50,27 @@ * *

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 builders are located by conventional name {@code - * } in the candidate packages first — a constant-time lookup for the common - * case. Only for beans marked for builder generation does a package scan for types carrying - * {@code @BuilderImplementation(forClass = )} run as a last resort. + * class ({@code PersonDtoBuilder.create()}), so builders are located in this order: the registry + * {@code BuilderProcessor} publishes for the builders it plans (exact qualified names, incl. custom + * packages and {@code @SimpleBuilderFor} targets), then the conventional name {@code + * } in the candidate packages for builders generated in earlier runs — both + * constant-time lookups. * *

When a marked bean's builder is not visible yet, {@link TypeHierarchyErroneousException} * defers the mapper to the next processing round so a builder generated in the same round can still - * be found. Builders generated into packages unreachable from the bean (e.g. an unrelated {@code - * packageName} on a {@code @SimpleBuilderFor} site) cannot be discovered. + * be found. * *

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 - * -Dsimplebuilder.usingMapStructIntegration=false} (MapStruct does not forward foreign annotation - * processor options to SPI environments, so the JVM system property is the documented channel). + * -Asimplebuilder.usingMapStructIntegration=DISABLED} (resolved by {@code BuilderProcessor}, which + * shares the classloader, or via the {@code -D} JVM system property). */ @AutoService(BuilderProvider.class) public class MapStructBuilderProvider implements BuilderProvider { private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; private static final String OPTION_PREFIX = "simplebuilder."; - private static final String OPTION_USING_MAPSTRUCT = OPTION_PREFIX + "usingMapStructIntegration"; private Elements elementUtils; private Types typeUtils; @@ -82,8 +80,6 @@ public class MapStructBuilderProvider implements BuilderProvider { /** Resolved builder infos by bean qualified name; only positive results are cached. */ private final Map builderInfoCache = new HashMap<>(); - private boolean enabled = true; - @Override public void init(MapStructProcessingEnvironment processingEnvironment) { this.elementUtils = processingEnvironment.getElementUtils(); @@ -91,15 +87,11 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { this.annotations = new AnnotationSupport(elementUtils); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; - enabled = - !"false" - .equalsIgnoreCase( - AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT)); } @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (!enabled) { + if (MapStructIntegration.isDisabled(processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -134,15 +126,26 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanTy } /** - * Locates the generated builder for {@code beanElement} by conventional name first; for beans - * marked for generation the package scan for {@code @BuilderImplementation} runs as a last - * resort, and a missing builder defers the mapper to the next processing round. + * Locates the generated builder for {@code beanElement}: the registry {@code BuilderProcessor} + * publishes first (it knows every builder it plans, incl. custom packages), then the conventional + * name for builders generated in earlier runs. A marked bean whose builder is not visible yet + * defers the mapper to the next processing round. */ private TypeElement findBuilderElement(TypeElement beanElement) { + String registered = MapStructIntegration.builderFor(beanElement.getQualifiedName().toString()); + if (registered != null) { + TypeElement registeredElement = elementUtils.getTypeElement(registered); + if (registeredElement == null) { + // Planned but not yet emitted in this round — defer. + throw new TypeHierarchyErroneousException(beanElement.asType()); + } + return registeredElement; + } + BuilderNaming naming = builderNaming(beanElement); // Conventional name first: a constant-time getTypeElement per candidate package and suffix, - // covering every builder generated by simple-builders. + // covering builders generated in earlier runs or with the integration's registration absent. for (String packageName : naming.packageNames()) { for (String suffix : naming.suffixes()) { TypeElement candidate = @@ -156,40 +159,13 @@ && findCreationMethod(candidate) != null } } - if (!annotations.isBuilderGenerationTarget(beanElement)) { - return null; - } - - // Last resort for marked beans: scan the candidate packages for a type carrying - // @BuilderImplementation(forClass = ) — covers builders a plain name lookup cannot - // resolve, e.g. when the name was not generated by the naming convention. - for (String packageName : naming.packageNames()) { - PackageElement packageElement = elementUtils.getPackageElement(packageName); - if (packageElement == null) { - continue; - } - for (Element member : packageElement.getEnclosedElements()) { - if (member instanceof TypeElement typeElement - && isGeneratedBuilderFor(typeElement, beanElement)) { - return typeElement; - } - } + // The bean is annotated for builder generation but neither the registry nor the naming + // convention resolved a builder — it is not emitted yet in this processing round. Throwing + // defers mapper generation to the next round. + if (annotations.isBuilderGenerationTarget(beanElement)) { + throw new TypeHierarchyErroneousException(beanElement.asType()); } - - // The bean is annotated for builder generation but the builder does not exist yet in this - // processing round. Throwing defers mapper generation to the next round. - throw new TypeHierarchyErroneousException(beanElement.asType()); - } - - /** - * Checks whether {@code candidate} is annotated {@code @BuilderImplementation(forClass = - * beanElement)}. - */ - private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanElement) { - TypeMirror forClass = annotations.builderImplementationForClass(candidate); - return forClass != null - && typeUtils.isSameType( - typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType())); + return null; } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java new file mode 100644 index 00000000..10965e86 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java @@ -0,0 +1,96 @@ +/* + * 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 java.util.HashMap; +import java.util.Map; + +/** + * State {@code BuilderProcessor} shares with the MapStruct SPI adapters while they share the + * annotation processor path (and therefore the classloader): the resolved {@code + * simplebuilder.usingMapStructIntegration} switch — which SPI environments cannot see because + * MapStruct forwards only its own processor options — and the qualified names of the builders + * planned for generation, so lookups do not have to guess names or scan packages. + * + *

All state is compilation-scoped: {@link #initCompilation} resets it when the processor is + * initialized, which matters in long-lived JVMs (Gradle daemon, incremental builds) that run + * several compilations on the same classloader. + */ +public final class MapStructIntegration { + + private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; + + /** The processor-resolved switch; {@code null} leaves the system-property fallback active. */ + private static volatile Boolean enabled; + + /** Builders planned for the current compilation: bean qualified name → builder qualified name. */ + private static final Map GENERATED_BUILDERS = new HashMap<>(); + + /** Reverse of {@link #GENERATED_BUILDERS}: builder qualified name → bean qualified name. */ + private static final Map BEAN_BY_BUILDER = new HashMap<>(); + + private MapStructIntegration() {} + + /** + * Starts a new compilation: clears the builder registry and publishes the integration switch the + * processor resolved ({@code null} when the option is unset). + */ + public static void initCompilation(Boolean integrationEnabled) { + GENERATED_BUILDERS.clear(); + BEAN_BY_BUILDER.clear(); + enabled = integrationEnabled; + } + + /** Registers a builder planned for {@code beanQualifiedName} in the current compilation. */ + public static void registerBuilder(String beanQualifiedName, String builderQualifiedName) { + GENERATED_BUILDERS.put(beanQualifiedName, builderQualifiedName); + BEAN_BY_BUILDER.put(builderQualifiedName, beanQualifiedName); + } + + /** The qualified name of the builder planned for {@code beanQualifiedName}, or {@code null}. */ + public static String builderFor(String beanQualifiedName) { + return GENERATED_BUILDERS.get(beanQualifiedName); + } + + /** + * The qualified name of the bean {@code builderQualifiedName} was generated for, or {@code null}. + */ + public static String beanFor(String builderQualifiedName) { + return BEAN_BY_BUILDER.get(builderQualifiedName); + } + + /** + * Whether the integration is switched off: the value the processor published wins; without one + * the {@code simplebuilder.*} convention applies (JVM system property before the annotation + * processor option MapStruct does not forward anyway). + */ + static boolean isDisabled(Map processorOptions) { + Boolean published = enabled; + if (published != null) { + return !published; + } + String value = AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT); + return "false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value); + } +} 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/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java index 8cfae966..44c6f48d 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -160,21 +160,23 @@ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { @Test void mapStruct_disabledIntegration_shouldMapViaSetters() { - // MapStruct does not forward foreign -A options to SPI environments; the off-switch is the - // simplebuilder.* JVM system property (same precedence as in CompilerArgumentsReader) - System.setProperty("simplebuilder.usingMapStructIntegration", "false"); - try { - Compilation compilation = compiler().compile(MUTABLE_DTO, MUTABLE_DTO_MAPPER); - assertThat(compilation).succeeded(); - ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); - - 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"); - } finally { - System.clearProperty("simplebuilder.usingMapStructIntegration"); - } + // MapStruct does not forward foreign -A options to SPI environments, so BuilderProcessor + // publishes the resolved switch to them via MapStructIntegration + Compilation compilation = + Compiler.javac() + .withProcessors(new BuilderProcessor(), new MappingProcessor()) + .withOptions( + "-Amapstruct.suppressGeneratorTimestamp=true", + "-Amapstruct.suppressGeneratorVersionInfoComment=true", + "-Asimplebuilder.usingMapStructIntegration=DISABLED") + .compile(MUTABLE_DTO, MUTABLE_DTO_MAPPER); + assertThat(compilation).succeeded(); + ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); + + 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"); } } From 854a62a0f3d96d46b438a868f4e6a61c226c0a8e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:33:26 +0000 Subject: [PATCH 07/25] Claim only builders this generator emitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate the conventional-name lookup on @BuilderImplementation(forClass = ): the marker has CLASS retention, so it is readable on builders generated in earlier compilations, while foreign types that merely match the conventional name are never claimed. The naming strategy drops its marker-free name fallback for the same reason — a builder is attributed to this generator only through the registry or the marker on the type itself. Builders emitted with usingBuilderImplementationAnnotation=DISABLED keep working through the registry in the run that produces them. --- .../MapStructAccessorNamingStrategy.java | 40 ++++--------------- .../mapstruct/MapStructBuilderProvider.java | 19 ++++++++- 2 files changed, 25 insertions(+), 34 deletions(-) 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 index a442bad2..fb41e8c2 100644 --- 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 @@ -51,14 +51,16 @@ * ({@code (Supplier)}, {@code (String, Object...)}, {@code (Consumer)}) * compete with the direct setter. For methods declared on a builder generated by simple-builders * this strategy returns {@link MethodType#OTHER} for everything that is not the direct property - * setter ({@code } taking a single argument of the field type). Every other - * type keeps the {@link DefaultAccessorNamingStrategy} behaviour. + * setter ({@code } taking a single argument of the field type). A builder is + * attributed to this generator only through the registry {@code BuilderProcessor} publishes or + * {@code @BuilderImplementation} on the type itself, so builders emitted without the marker ({@code + * usingBuilderImplementationAnnotation=DISABLED}) and foreign types keep the {@link + * DefaultAccessorNamingStrategy} behaviour. */ @AutoService(AccessorNamingStrategy.class) public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy { private static final String OPTION_PREFIX = "simplebuilder."; - private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; private AnnotationSupport annotations; private Map processorOptions = Map.of(); @@ -122,8 +124,9 @@ private Optional> computeDirectSetters(TypeElement build /** * The bean a builder was generated for: the registry {@code BuilderProcessor} publishes, then - * {@code @BuilderImplementation(forClass = ...)} on the builder, else the conventional name - * {@code } in the builder's package. + * {@code @BuilderImplementation(forClass = ...)} on the builder — the marker every emitted + * builder gets, kept at CLASS retention so it is readable on builders generated in earlier + * compilations. */ private TypeElement resolveBean(TypeElement builderType) { String registered = MapStructIntegration.beanFor(builderType.getQualifiedName().toString()); @@ -135,36 +138,9 @@ private TypeElement resolveBean(TypeElement builderType) { && declaredType.asElement() instanceof TypeElement beanElement) { return beanElement; } - String simpleName = builderType.getSimpleName().toString(); - String packageName = elementUtils.getPackageOf(builderType).getQualifiedName().toString(); - for (String suffix : builderSuffixCandidates()) { - if (simpleName.endsWith(suffix) && simpleName.length() > suffix.length()) { - TypeElement candidate = - elementUtils.getTypeElement( - packageName.isEmpty() - ? simpleName.substring(0, simpleName.length() - suffix.length()) - : packageName - + "." - + simpleName.substring(0, simpleName.length() - suffix.length())); - // Only a marked bean may claim the builder — otherwise a bean literally named - // Builder would hide its own non-field methods whenever a bean exists. - if (candidate != null && annotations.isBuilderGenerationTarget(candidate)) { - return candidate; - } - } - } return null; } - /** Candidate builder name suffixes: the globally configured one plus the default. */ - private String[] builderSuffixCandidates() { - String configured = - AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "builderSuffix"); - return configured != null && !configured.isEmpty() - ? new String[] {configured, DEFAULT_BUILDER_SUFFIX} - : new String[] {DEFAULT_BUILDER_SUFFIX}; - } - /** The {@code setterSuffix} configured for {@code beanElement} ({@code ""} by default). */ private String resolveSetterSuffix(TypeElement beanElement) { for (var optionsMirror : annotations.builderOptionsMirrors(beanElement)) { 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 index 537d35b4..146d7872 100644 --- 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 @@ -144,14 +144,17 @@ private TypeElement findBuilderElement(TypeElement beanElement) { BuilderNaming naming = builderNaming(beanElement); - // Conventional name first: a constant-time getTypeElement per candidate package and suffix, - // covering builders generated in earlier runs or with the integration's registration absent. + // Conventional name for builders generated in earlier runs (committed sources, other + // modules): a constant-time getTypeElement per candidate package and suffix. The candidate + // must carry @BuilderImplementation(forClass = ) so only classes this generator + // emitted are claimed — a foreign type that merely matches the name is ignored. for (String packageName : naming.packageNames()) { for (String suffix : naming.suffixes()) { TypeElement candidate = elementUtils.getTypeElement( qualifiedName(packageName, beanElement.getSimpleName() + suffix)); if (candidate != null + && isGeneratedBuilderFor(candidate, beanElement) && findCreationMethod(candidate) != null && !findBuildMethods(candidate, beanElement.asType()).isEmpty()) { return candidate; @@ -168,6 +171,18 @@ && findCreationMethod(candidate) != null return null; } + /** + * Whether {@code candidate} carries {@code @BuilderImplementation(forClass = beanElement)} — the + * marker every emitted builder gets, kept at CLASS retention so it is readable on builders + * generated in earlier compilations. + */ + private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanElement) { + TypeMirror forClass = annotations.builderImplementationForClass(candidate); + return forClass != null + && typeUtils.isSameType( + typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType())); + } + /** * Candidate packages and builder name suffixes for {@code beanElement}, collected from its * builder options in a single annotation pass. From 01c11fb7754bf026f413e287f8c4b69cb0848377 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:37:21 +0000 Subject: [PATCH 08/25] Claim builders exclusively through the published registry No extra detection at all: a type is attributed to this generator only when BuilderProcessor published it for the current compilation, so foreign builders and builders from earlier runs can never be claimed or shadow other solutions. The naming strategy's bean resolution and the provider's lookup both become pure registry reads; the now-unused annotation-based detection helpers are removed. --- .../mapstruct/AnnotationSupport.java | 54 -------- .../MapStructAccessorNamingStrategy.java | 21 +--- .../mapstruct/MapStructBuilderProvider.java | 116 +++--------------- 3 files changed, 21 insertions(+), 170 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java index 8e5f66cd..1e74b277 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java @@ -31,7 +31,6 @@ 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.Elements; /** @@ -47,10 +46,6 @@ final class AnnotationSupport { "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; - static final String BUILDER_IMPLEMENTATION_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.BuilderImplementation"; - static final String IGNORE_4_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; private final Elements elementUtils; @@ -108,55 +103,6 @@ String stringOption(AnnotationMirror optionsMirror, String name) { return null; } - /** - * The {@code forClass} type of {@code @BuilderImplementation} on {@code builderType}, or {@code - * null} when the annotation is absent. - */ - TypeMirror builderImplementationForClass(TypeElement builderType) { - for (AnnotationMirror mirror : builderType.getAnnotationMirrors()) { - if (!qualifiedNameOf(mirror).equals(BUILDER_IMPLEMENTATION_ANNOTATION)) { - continue; - } - for (Map.Entry entry : - mirror.getElementValues().entrySet()) { - if (entry.getKey().getSimpleName().contentEquals("forClass") - && entry.getValue().getValue() instanceof TypeMirror forClass) { - return forClass; - } - } - return null; - } - return null; - } - - /** - * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a - * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). - */ - boolean isBuilderGenerationTarget(TypeElement beanElement) { - boolean marked = false; - for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { - String annotationName = qualifiedNameOf(mirror); - if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { - return false; - } - if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { - marked = true; - continue; - } - // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined - // one): its type is meta-annotated with @SimpleBuilder.Template. - for (AnnotationMirror metaMirror : - mirror.getAnnotationType().asElement().getAnnotationMirrors()) { - if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - marked = true; - break; - } - } - } - return marked; - } - /** * Reads a {@code simplebuilder.*} option following the project convention: the JVM system * property wins over the annotation processor option (which MapStruct does not forward to SPI 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 index fb41e8c2..b797fe65 100644 --- 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 @@ -52,9 +52,8 @@ * compete with the direct setter. For methods declared on a builder generated by simple-builders * this strategy returns {@link MethodType#OTHER} for everything that is not the direct property * setter ({@code } taking a single argument of the field type). A builder is - * attributed to this generator only through the registry {@code BuilderProcessor} publishes or - * {@code @BuilderImplementation} on the type itself, so builders emitted without the marker ({@code - * usingBuilderImplementationAnnotation=DISABLED}) and foreign types keep the {@link + * attributed to this generator only through the registry {@code BuilderProcessor} publishes for the + * builders it plans, so builders produced by earlier compilations and foreign types keep the {@link * DefaultAccessorNamingStrategy} behaviour. */ @AutoService(AccessorNamingStrategy.class) @@ -123,22 +122,12 @@ private Optional> computeDirectSetters(TypeElement build } /** - * The bean a builder was generated for: the registry {@code BuilderProcessor} publishes, then - * {@code @BuilderImplementation(forClass = ...)} on the builder — the marker every emitted - * builder gets, kept at CLASS retention so it is readable on builders generated in earlier - * compilations. + * The bean a builder was generated for, through the registry {@code BuilderProcessor} publishes. + * Types not on the list are not attributed to this generator. */ private TypeElement resolveBean(TypeElement builderType) { String registered = MapStructIntegration.beanFor(builderType.getQualifiedName().toString()); - if (registered != null) { - return elementUtils.getTypeElement(registered); - } - TypeMirror forClass = annotations.builderImplementationForClass(builderType); - if (forClass instanceof DeclaredType declaredType - && declaredType.asElement() instanceof TypeElement beanElement) { - return beanElement; - } - return null; + return registered == null ? null : elementUtils.getTypeElement(registered); } /** The {@code setterSuffix} configured for {@code beanElement} ({@code ""} by default). */ 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 index 146d7872..ae8aa7fe 100644 --- 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 @@ -29,7 +29,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -50,15 +49,14 @@ * *

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 builders are located in this order: the registry - * {@code BuilderProcessor} publishes for the builders it plans (exact qualified names, incl. custom - * packages and {@code @SimpleBuilderFor} targets), then the conventional name {@code - * } in the candidate packages for builders generated in earlier runs — both - * constant-time lookups. + * class ({@code PersonDtoBuilder.create()}), so beans are paired with builders exclusively through + * the registry {@code BuilderProcessor} publishes for every builder it plans — a constant-time + * lookup with exact qualified names, 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. * - *

When a marked bean's builder is not visible yet, {@link TypeHierarchyErroneousException} - * defers the mapper to the next processing round so a builder generated in the same round can still - * be found. + *

When a planned builder is not visible yet, {@link TypeHierarchyErroneousException} defers the + * mapper to the next processing round so a builder generated in the same round can still be found. * *

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 @@ -69,12 +67,8 @@ @AutoService(BuilderProvider.class) public class MapStructBuilderProvider implements BuilderProvider { - private static final String DEFAULT_BUILDER_SUFFIX = "Builder"; - private static final String OPTION_PREFIX = "simplebuilder."; - private Elements elementUtils; private Types typeUtils; - private AnnotationSupport annotations; private Map processorOptions = Map.of(); /** Resolved builder infos by bean qualified name; only positive results are cached. */ @@ -84,7 +78,6 @@ public class MapStructBuilderProvider implements BuilderProvider { public void init(MapStructProcessingEnvironment processingEnvironment) { this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); - this.annotations = new AnnotationSupport(elementUtils); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; } @@ -126,95 +119,23 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanTy } /** - * Locates the generated builder for {@code beanElement}: the registry {@code BuilderProcessor} - * publishes first (it knows every builder it plans, incl. custom packages), then the conventional - * name for builders generated in earlier runs. A marked bean whose builder is not visible yet - * defers the mapper to the next processing round. + * Locates the generated builder for {@code beanElement} through the registry {@code + * BuilderProcessor} publishes. A bean not on the list gets no builder mapping. A planned builder + * that is not emitted yet defers the mapper to the next processing round. */ private TypeElement findBuilderElement(TypeElement beanElement) { String registered = MapStructIntegration.builderFor(beanElement.getQualifiedName().toString()); - if (registered != null) { - TypeElement registeredElement = elementUtils.getTypeElement(registered); - if (registeredElement == null) { - // Planned but not yet emitted in this round — defer. - throw new TypeHierarchyErroneousException(beanElement.asType()); - } - return registeredElement; - } - - BuilderNaming naming = builderNaming(beanElement); - - // Conventional name for builders generated in earlier runs (committed sources, other - // modules): a constant-time getTypeElement per candidate package and suffix. The candidate - // must carry @BuilderImplementation(forClass = ) so only classes this generator - // emitted are claimed — a foreign type that merely matches the name is ignored. - for (String packageName : naming.packageNames()) { - for (String suffix : naming.suffixes()) { - TypeElement candidate = - elementUtils.getTypeElement( - qualifiedName(packageName, beanElement.getSimpleName() + suffix)); - if (candidate != null - && isGeneratedBuilderFor(candidate, beanElement) - && findCreationMethod(candidate) != null - && !findBuildMethods(candidate, beanElement.asType()).isEmpty()) { - return candidate; - } - } + if (registered == null) { + return null; } - - // The bean is annotated for builder generation but neither the registry nor the naming - // convention resolved a builder — it is not emitted yet in this processing round. Throwing - // defers mapper generation to the next round. - if (annotations.isBuilderGenerationTarget(beanElement)) { + TypeElement registeredElement = elementUtils.getTypeElement(registered); + if (registeredElement == null) { + // Planned but not yet emitted in this round — defer. throw new TypeHierarchyErroneousException(beanElement.asType()); } - return null; - } - - /** - * Whether {@code candidate} carries {@code @BuilderImplementation(forClass = beanElement)} — the - * marker every emitted builder gets, kept at CLASS retention so it is readable on builders - * generated in earlier compilations. - */ - private boolean isGeneratedBuilderFor(TypeElement candidate, TypeElement beanElement) { - TypeMirror forClass = annotations.builderImplementationForClass(candidate); - return forClass != null - && typeUtils.isSameType( - typeUtils.erasure(forClass), typeUtils.erasure(beanElement.asType())); - } - - /** - * Candidate packages and builder name suffixes for {@code beanElement}, collected from its - * builder options in a single annotation pass. - */ - private BuilderNaming builderNaming(TypeElement beanElement) { - List packageNames = new ArrayList<>(); - List suffixes = new ArrayList<>(); - packageNames.add(elementUtils.getPackageOf(beanElement).getQualifiedName().toString()); - for (AnnotationMirror optionsMirror : annotations.builderOptionsMirrors(beanElement)) { - String packageName = annotations.stringOption(optionsMirror, "packageName"); - if (packageName != null && !packageName.isEmpty() && !packageNames.contains(packageName)) { - packageNames.add(packageName); - } - String suffix = annotations.stringOption(optionsMirror, "builderSuffix"); - if (suffix != null && !suffix.isEmpty() && !suffixes.contains(suffix)) { - suffixes.add(suffix); - } - } - String globalSuffix = - AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "builderSuffix"); - if (globalSuffix != null && !globalSuffix.isEmpty() && !suffixes.contains(globalSuffix)) { - suffixes.add(globalSuffix); - } - if (!suffixes.contains(DEFAULT_BUILDER_SUFFIX)) { - suffixes.add(DEFAULT_BUILDER_SUFFIX); - } - return new BuilderNaming(packageNames, suffixes); + return registeredElement; } - /** Naming candidates for locating a bean's builder. */ - private record BuilderNaming(List packageNames, List suffixes) {} - /** * A {@code public static} parameterless method on the builder returning the builder type, e.g. * {@code create()}. @@ -271,9 +192,4 @@ private boolean isBuilderType(TypeMirror type, TypeElement builderElement) { return typeUtils.isSameType( typeUtils.erasure(type), typeUtils.erasure(builderElement.asType())); } - - /** Qualified name of {@code simpleName} inside {@code packageName}. */ - private static String qualifiedName(String packageName, CharSequence simpleName) { - return packageName.isEmpty() ? simpleName.toString() : packageName + "." + simpleName; - } } From 4da00501c0d4e8318d996cdd1d1aab7f31407de9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:51:51 +0000 Subject: [PATCH 09/25] Guard shared state with a per-compilation lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit javac initializes each processor lazily in its turn, so MapStruct's SPIs may run before BuilderProcessor.init() of the same compilation — and see leftover state of a previous compile in a reused JVM (daemon/incremental builds). MapStructIntegration now carries INIT/PROCESSING/FINISHED: the published switch and registry count only once our processor initialized, a stale FINISHED observed at SPI init is reset to INIT for the new compile, and marked beans defer while the registry may still fill — a missing marked bean after FINISHED falls back gracefully instead of erroring in the last round. A regression test covers reversed processor ordering. --- .../builders/processor/BuilderProcessor.java | 1 + .../mapstruct/AnnotationSupport.java | 30 +++++++++ .../MapStructAccessorNamingStrategy.java | 1 + .../mapstruct/MapStructBuilderProvider.java | 16 ++++- .../mapstruct/MapStructIntegration.java | 63 ++++++++++++++++--- .../MapStructSpiIntegrationTest.java | 22 +++++++ 6 files changed, 122 insertions(+), 11 deletions(-) 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 ebae0db3..11d60317 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 @@ -158,6 +158,7 @@ public boolean process(Set annotations, RoundEnvironment // Generate Jackson Module if processing is over and feature is enabled if (roundEnv.processingOver()) { + MapStructIntegration.finishCompilation(); generateJacksonModules(context.getPerformanceTracker()); return false; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java index 1e74b277..d9253bdc 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java @@ -46,6 +46,8 @@ final class AnnotationSupport { "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; + static final String IGNORE_4_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; private final Elements elementUtils; @@ -103,6 +105,34 @@ String stringOption(AnnotationMirror optionsMirror, String name) { return null; } + /** + * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a + * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). + */ + boolean isBuilderGenerationTarget(TypeElement beanElement) { + boolean marked = false; + for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { + String annotationName = qualifiedNameOf(mirror); + if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { + return false; + } + if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { + marked = true; + continue; + } + // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined + // one): its type is meta-annotated with @SimpleBuilder.Template. + for (AnnotationMirror metaMirror : + mirror.getAnnotationType().asElement().getAnnotationMirrors()) { + if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + marked = true; + break; + } + } + } + return marked; + } + /** * Reads a {@code simplebuilder.*} option following the project convention: the JVM system * property wins over the annotation processor option (which MapStruct does not forward to SPI 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 index b797fe65..c9f261c0 100644 --- 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 @@ -68,6 +68,7 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrate @Override public void init(MapStructProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); + MapStructIntegration.spiInitialized(); annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; 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 index ae8aa7fe..e1c6e8f6 100644 --- 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 @@ -69,6 +69,7 @@ public class MapStructBuilderProvider implements BuilderProvider { private Elements elementUtils; private Types typeUtils; + private AnnotationSupport annotations; private Map processorOptions = Map.of(); /** Resolved builder infos by bean qualified name; only positive results are cached. */ @@ -76,8 +77,10 @@ public class MapStructBuilderProvider implements BuilderProvider { @Override public void init(MapStructProcessingEnvironment processingEnvironment) { + MapStructIntegration.spiInitialized(); this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); + this.annotations = new AnnotationSupport(elementUtils); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; } @@ -120,12 +123,21 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanTy /** * Locates the generated builder for {@code beanElement} through the registry {@code - * BuilderProcessor} publishes. A bean not on the list gets no builder mapping. A planned builder - * that is not emitted yet defers the mapper to the next processing round. + * BuilderProcessor} publishes — the list decides alone which type is claimed. A planned builder + * that is not emitted yet defers the mapper to the next processing round. While the compilation + * is not {@link MapStructIntegration.State#FINISHED} the registry may still grow (MapStruct may + * run ahead of this processor's first round), so a bean marked for generation gets the same + * deferral instead of a premature miss. */ private TypeElement findBuilderElement(TypeElement beanElement) { String registered = MapStructIntegration.builderFor(beanElement.getQualifiedName().toString()); if (registered == null) { + if (MapStructIntegration.state() != MapStructIntegration.State.FINISHED + && annotations.isBuilderGenerationTarget(beanElement)) { + // Marked for generation but not published yet — our processor has not had its round; + // defer so the registry can fill in before the mapper is generated. + throw new TypeHierarchyErroneousException(beanElement.asType()); + } return null; } TypeElement registeredElement = elementUtils.getTypeElement(registered); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java index 10965e86..9f3740bd 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java @@ -33,14 +33,31 @@ * MapStruct forwards only its own processor options — and the qualified names of the builders * planned for generation, so lookups do not have to guess names or scan packages. * - *

All state is compilation-scoped: {@link #initCompilation} resets it when the processor is - * initialized, which matters in long-lived JVMs (Gradle daemon, incremental builds) that run - * several compilations on the same classloader. + *

All state is compilation-scoped and guarded by {@link State}: javac initializes each processor + * lazily when its turn in a round comes, so before {@code 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). Only while {@link State#PROCESSING} or {@link + * State#FINISHED} are the published switch and registry the current compilation's truth. */ public final class MapStructIntegration { + /** Lifecycle of 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: the registry may still grow this round. */ + PROCESSING, + /** The last round ran: the registry is final for this compilation. */ + FINISHED + } + private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; + private static volatile State state = State.INIT; + /** The processor-resolved switch; {@code null} leaves the system-property fallback active. */ private static volatile Boolean enabled; @@ -60,6 +77,30 @@ public static void initCompilation(Boolean integrationEnabled) { GENERATED_BUILDERS.clear(); BEAN_BY_BUILDER.clear(); enabled = integrationEnabled; + state = State.PROCESSING; + } + + /** Marks the compilation as finished: the registry will not grow any further. */ + public static void finishCompilation() { + state = State.FINISHED; + } + + /** + * Called by each SPI adapter on {@code init}. javac initializes every processor lazily in its + * turn, so MapStruct's SPI init may run before {@code BuilderProcessor#init} of the same + * compilation; a {@link State#FINISHED} observed here can only be the previous compilation's + * leftover — a live {@link State#FINISHED} implies the last round already ran and no new SPI init + * would follow — and is reset to {@link State#INIT}. + */ + public static void spiInitialized() { + if (state == State.FINISHED) { + state = State.INIT; + } + } + + /** The lifecycle state the SPI adapters observe for the current compilation. */ + public static State state() { + return state; } /** Registers a builder planned for {@code beanQualifiedName} in the current compilation. */ @@ -81,14 +122,18 @@ public static String beanFor(String builderQualifiedName) { } /** - * Whether the integration is switched off: the value the processor published wins; without one - * the {@code simplebuilder.*} convention applies (JVM system property before the annotation - * processor option MapStruct does not forward anyway). + * Whether the integration is switched off: while the processor has published this compilation's + * resolution ({@link State#PROCESSING} or {@link State#FINISHED}) it wins; without one — incl. + * the stale leftovers of a previous run in {@link State#INIT} — the {@code simplebuilder.*} + * convention applies (JVM system property before the annotation processor option MapStruct does + * not forward anyway). */ static boolean isDisabled(Map processorOptions) { - Boolean published = enabled; - if (published != null) { - return !published; + if (state != State.INIT) { + Boolean published = enabled; + if (published != null) { + return !published; + } } String value = AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT); return "false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value); 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 index 44c6f48d..14da2947 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -141,6 +141,28 @@ void mapStruct_shouldUseGeneratedBuilder() { assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); } + @Test + void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() { + // MapStruct processing the mapper before BuilderProcessor's first round must not lose the + // builder: the marked bean defers via TypeHierarchyErroneousException until the registry + // holds the planned builder + Compilation compilation = + Compiler.javac() + .withProcessors(new MappingProcessor(), new BuilderProcessor()) + .withOptions( + "-Amapstruct.suppressGeneratorTimestamp=true", + "-Amapstruct.suppressGeneratorVersionInfoComment=true") + .compile(PERSON_DTO, PERSON_DTO_MAPPER); + assertThat(compilation).succeeded(); + ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); + + String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); + assertTrue( + mapperImpl.contains("PersonDtoBuilder.create()"), + "Reversed processor order must still bind the generated builder, got:\n" + mapperImpl); + assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); + } + @Test void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { Compilation compilation = compiler().compile(PERSON_DTO, PERSON_DTO_MAPPER); From 8a28134dab964c84c320c43994fd7aacd604178d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:08:09 +0000 Subject: [PATCH 10/25] Rename MapStructIntegration to SpiIntegration in processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge the SPI adapters share with the processor — compilation lifecycle, published integration switch and the bean-to-builder registry — is usable by future SPI integrations beyond MapStruct, so it moves out of the mapstruct package next to the option machinery. --- .../builders/processor/BuilderProcessor.java | 8 ++--- .../MapStructAccessorNamingStrategy.java | 7 +++-- .../mapstruct/MapStructBuilderProvider.java | 15 +++++----- .../SpiIntegration.java} | 30 ++++++++++--------- .../MapStructSpiIntegrationTest.java | 2 +- 5 files changed, 33 insertions(+), 29 deletions(-) rename processor/src/main/java/org/javahelpers/simple/builders/processor/{mapstruct/MapStructIntegration.java => processing/SpiIntegration.java} (82%) 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 11d60317..463e907d 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 @@ -66,7 +66,6 @@ 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; -import org.javahelpers.simple.builders.processor.mapstruct.MapStructIntegration; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.BuilderToGenerationTypeMapper; @@ -81,6 +80,7 @@ import org.javahelpers.simple.builders.processor.processing.OptionValueParsers; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; +import org.javahelpers.simple.builders.processor.processing.SpiIntegration; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -115,7 +115,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { // Publish the resolved integration switch to the MapStruct SPIs sharing this classloader; // their environment never sees foreign annotation processor options String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); - MapStructIntegration.initCompilation( + SpiIntegration.initCompilation( mapStructOption == null ? null : OptionValueParsers.parseOptionState(mapStructOption, logger) != OptionState.DISABLED); @@ -158,7 +158,7 @@ public boolean process(Set annotations, RoundEnvironment // Generate Jackson Module if processing is over and feature is enabled if (roundEnv.processingOver()) { - MapStructIntegration.finishCompilation(); + SpiIntegration.finishCompilation(); generateJacksonModules(context.getPerformanceTracker()); return false; } @@ -543,7 +543,7 @@ private void registerGeneratedTypes(List elementsToGenerate) effectiveBuilderPackage( elementToGenerate.reportingElement(), elementToGenerate.config()), elementToGenerate.config())); - MapStructIntegration.registerBuilder( + SpiIntegration.registerBuilder( targetType.getQualifiedName().toString(), builderTypeName( targetType, 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 index c9f261c0..c4ff9173 100644 --- 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 @@ -36,6 +36,7 @@ import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; +import org.javahelpers.simple.builders.processor.processing.SpiIntegration; import org.mapstruct.ap.spi.AccessorNamingStrategy; import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -68,7 +69,7 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrate @Override public void init(MapStructProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); - MapStructIntegration.spiInitialized(); + SpiIntegration.spiInitialized(); annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; @@ -77,7 +78,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (MapStructIntegration.isDisabled(processorOptions)) { + if (SpiIntegration.isDisabled(processorOptions)) { return methodType; } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { @@ -127,7 +128,7 @@ private Optional> computeDirectSetters(TypeElement build * Types not on the list are not attributed to this generator. */ private TypeElement resolveBean(TypeElement builderType) { - String registered = MapStructIntegration.beanFor(builderType.getQualifiedName().toString()); + String registered = SpiIntegration.beanFor(builderType.getQualifiedName().toString()); return registered == null ? null : elementUtils.getTypeElement(registered); } 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 index e1c6e8f6..a523a7df 100644 --- 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 @@ -38,6 +38,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.processing.SpiIntegration; import org.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -77,7 +78,7 @@ public class MapStructBuilderProvider implements BuilderProvider { @Override public void init(MapStructProcessingEnvironment processingEnvironment) { - MapStructIntegration.spiInitialized(); + SpiIntegration.spiInitialized(); this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); this.annotations = new AnnotationSupport(elementUtils); @@ -87,7 +88,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (MapStructIntegration.isDisabled(processorOptions)) { + if (SpiIntegration.isDisabled(processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -125,14 +126,14 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanTy * Locates the generated builder for {@code beanElement} through the registry {@code * BuilderProcessor} publishes — the list decides alone which type is claimed. A planned builder * that is not emitted yet defers the mapper to the next processing round. While the compilation - * is not {@link MapStructIntegration.State#FINISHED} the registry may still grow (MapStruct may - * run ahead of this processor's first round), so a bean marked for generation gets the same - * deferral instead of a premature miss. + * is not {@link SpiIntegration.State#FINISHED} the registry may still grow (MapStruct may run + * ahead of this processor's first round), so a bean marked for generation gets the same deferral + * instead of a premature miss. */ private TypeElement findBuilderElement(TypeElement beanElement) { - String registered = MapStructIntegration.builderFor(beanElement.getQualifiedName().toString()); + String registered = SpiIntegration.builderFor(beanElement.getQualifiedName().toString()); if (registered == null) { - if (MapStructIntegration.state() != MapStructIntegration.State.FINISHED + if (SpiIntegration.state() != SpiIntegration.State.FINISHED && annotations.isBuilderGenerationTarget(beanElement)) { // Marked for generation but not published yet — our processor has not had its round; // defer so the registry can fill in before the mapper is generated. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java similarity index 82% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java index 9f3740bd..f4decc1b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java @@ -21,17 +21,18 @@ * SOFTWARE. */ -package org.javahelpers.simple.builders.processor.mapstruct; +package org.javahelpers.simple.builders.processor.processing; import java.util.HashMap; import java.util.Map; /** - * State {@code BuilderProcessor} shares with the MapStruct SPI adapters while they share the - * annotation processor path (and therefore the classloader): the resolved {@code - * simplebuilder.usingMapStructIntegration} switch — which SPI environments cannot see because - * MapStruct forwards only its own processor options — and the qualified names of the builders - * planned for generation, so lookups do not have to guess names or scan packages. + * State {@code BuilderProcessor} shares with SPI adapters of other frameworks while they share the + * annotation processor path (and therefore the classloader): the resolved integration switch — + * which SPI environments cannot see because the hosting framework forwards only its own processor + * options (e.g. MapStruct's {@code simplebuilder.usingMapStructIntegration}) — and the qualified + * names of the builders planned for generation, so lookups do not have to guess names or scan + * packages. * *

All state is compilation-scoped and guarded by {@link State}: javac initializes each processor * lazily when its turn in a round comes, so before {@code BuilderProcessor} has run its {@code @@ -39,7 +40,7 @@ * long-lived JVM (Gradle daemon, incremental builds). Only while {@link State#PROCESSING} or {@link * State#FINISHED} are the published switch and registry the current compilation's truth. */ -public final class MapStructIntegration { +public final class SpiIntegration { /** Lifecycle of the current compilation as the SPI adapters observe it. */ public enum State { @@ -67,7 +68,7 @@ public enum State { /** Reverse of {@link #GENERATED_BUILDERS}: builder qualified name → bean qualified name. */ private static final Map BEAN_BY_BUILDER = new HashMap<>(); - private MapStructIntegration() {} + private SpiIntegration() {} /** * Starts a new compilation: clears the builder registry and publishes the integration switch the @@ -87,10 +88,10 @@ public static void finishCompilation() { /** * Called by each SPI adapter on {@code init}. javac initializes every processor lazily in its - * turn, so MapStruct's SPI init may run before {@code BuilderProcessor#init} of the same - * compilation; a {@link State#FINISHED} observed here can only be the previous compilation's - * leftover — a live {@link State#FINISHED} implies the last round already ran and no new SPI init - * would follow — and is reset to {@link State#INIT}. + * turn, so an SPI init may run before {@code BuilderProcessor#init} of the same compilation; a + * {@link State#FINISHED} observed here can only be the previous compilation's leftover — a live + * {@link State#FINISHED} implies the last round already ran and no new SPI init would follow — + * and is reset to {@link State#INIT}. */ public static void spiInitialized() { if (state == State.FINISHED) { @@ -128,14 +129,15 @@ public static String beanFor(String builderQualifiedName) { * convention applies (JVM system property before the annotation processor option MapStruct does * not forward anyway). */ - static boolean isDisabled(Map processorOptions) { + public static boolean isDisabled(Map processorOptions) { if (state != State.INIT) { Boolean published = enabled; if (published != null) { return !published; } } - String value = AnnotationSupport.systemOption(processorOptions, OPTION_USING_MAPSTRUCT); + String value = + System.getProperty(OPTION_USING_MAPSTRUCT, processorOptions.get(OPTION_USING_MAPSTRUCT)); return "false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value); } } 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 index 14da2947..4a87ad5d 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -183,7 +183,7 @@ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { @Test void mapStruct_disabledIntegration_shouldMapViaSetters() { // MapStruct does not forward foreign -A options to SPI environments, so BuilderProcessor - // publishes the resolved switch to them via MapStructIntegration + // publishes the resolved switch to them via SpiIntegration Compilation compilation = Compiler.javac() .withProcessors(new BuilderProcessor(), new MappingProcessor()) From c278bbf9e8fb6bfe2d0e686d896b680b021c6992 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:29:43 +0000 Subject: [PATCH 11/25] Rename SpiIntegration to SimpleBuildersSpiIntegration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status reports where simple-builders' own builder generation stands and the registry only lists builders BuilderProcessor plans to emit — make both scopes visible in the name. Javadoc states the lifecycle is transitioned by BuilderProcessor alone; SPI adapters only observe, with spiInitialized merely aging out a stale FINISHED. --- .../builders/processor/BuilderProcessor.java | 8 ++-- .../MapStructAccessorNamingStrategy.java | 9 +++-- .../mapstruct/MapStructBuilderProvider.java | 17 +++++---- ...java => SimpleBuildersSpiIntegration.java} | 37 +++++++++++-------- .../MapStructSpiIntegrationTest.java | 2 +- 5 files changed, 41 insertions(+), 32 deletions(-) rename processor/src/main/java/org/javahelpers/simple/builders/processor/processing/{SpiIntegration.java => SimpleBuildersSpiIntegration.java} (75%) 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 463e907d..df0a05ab 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 @@ -80,7 +80,7 @@ import org.javahelpers.simple.builders.processor.processing.OptionValueParsers; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; -import org.javahelpers.simple.builders.processor.processing.SpiIntegration; +import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -115,7 +115,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { // Publish the resolved integration switch to the MapStruct SPIs sharing this classloader; // their environment never sees foreign annotation processor options String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); - SpiIntegration.initCompilation( + SimpleBuildersSpiIntegration.initCompilation( mapStructOption == null ? null : OptionValueParsers.parseOptionState(mapStructOption, logger) != OptionState.DISABLED); @@ -158,7 +158,7 @@ public boolean process(Set annotations, RoundEnvironment // Generate Jackson Module if processing is over and feature is enabled if (roundEnv.processingOver()) { - SpiIntegration.finishCompilation(); + SimpleBuildersSpiIntegration.finishCompilation(); generateJacksonModules(context.getPerformanceTracker()); return false; } @@ -543,7 +543,7 @@ private void registerGeneratedTypes(List elementsToGenerate) effectiveBuilderPackage( elementToGenerate.reportingElement(), elementToGenerate.config()), elementToGenerate.config())); - SpiIntegration.registerBuilder( + SimpleBuildersSpiIntegration.registerBuilder( targetType.getQualifiedName().toString(), builderTypeName( targetType, 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 index c4ff9173..83cfb5a4 100644 --- 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 @@ -36,7 +36,7 @@ import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; -import org.javahelpers.simple.builders.processor.processing.SpiIntegration; +import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; import org.mapstruct.ap.spi.AccessorNamingStrategy; import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -69,7 +69,7 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrate @Override public void init(MapStructProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); - SpiIntegration.spiInitialized(); + SimpleBuildersSpiIntegration.spiInitialized(); annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; @@ -78,7 +78,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (SpiIntegration.isDisabled(processorOptions)) { + if (SimpleBuildersSpiIntegration.isDisabled(processorOptions)) { return methodType; } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { @@ -128,7 +128,8 @@ private Optional> computeDirectSetters(TypeElement build * Types not on the list are not attributed to this generator. */ private TypeElement resolveBean(TypeElement builderType) { - String registered = SpiIntegration.beanFor(builderType.getQualifiedName().toString()); + String registered = + SimpleBuildersSpiIntegration.beanFor(builderType.getQualifiedName().toString()); return registered == null ? null : elementUtils.getTypeElement(registered); } 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 index a523a7df..6ef0cf3d 100644 --- 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 @@ -38,7 +38,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.processing.SpiIntegration; +import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; import org.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -78,7 +78,7 @@ public class MapStructBuilderProvider implements BuilderProvider { @Override public void init(MapStructProcessingEnvironment processingEnvironment) { - SpiIntegration.spiInitialized(); + SimpleBuildersSpiIntegration.spiInitialized(); this.elementUtils = processingEnvironment.getElementUtils(); this.typeUtils = processingEnvironment.getTypeUtils(); this.annotations = new AnnotationSupport(elementUtils); @@ -88,7 +88,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (SpiIntegration.isDisabled(processorOptions)) { + if (SimpleBuildersSpiIntegration.isDisabled(processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -126,14 +126,15 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanTy * Locates the generated builder for {@code beanElement} through the registry {@code * BuilderProcessor} publishes — the list decides alone which type is claimed. A planned builder * that is not emitted yet defers the mapper to the next processing round. While the compilation - * is not {@link SpiIntegration.State#FINISHED} the registry may still grow (MapStruct may run - * ahead of this processor's first round), so a bean marked for generation gets the same deferral - * instead of a premature miss. + * is not {@link SimpleBuildersSpiIntegration.State#FINISHED} the registry may still grow + * (MapStruct may run ahead of this processor's first round), so a bean marked for generation gets + * the same deferral instead of a premature miss. */ private TypeElement findBuilderElement(TypeElement beanElement) { - String registered = SpiIntegration.builderFor(beanElement.getQualifiedName().toString()); + String registered = + SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()); if (registered == null) { - if (SpiIntegration.state() != SpiIntegration.State.FINISHED + if (SimpleBuildersSpiIntegration.state() != SimpleBuildersSpiIntegration.State.FINISHED && annotations.isBuilderGenerationTarget(beanElement)) { // Marked for generation but not published yet — our processor has not had its round; // defer so the registry can fill in before the mapper is generated. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java similarity index 75% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java index f4decc1b..1a2035c6 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java @@ -31,18 +31,23 @@ * annotation processor path (and therefore the classloader): the resolved integration switch — * which SPI environments cannot see because the hosting framework forwards only its own processor * options (e.g. MapStruct's {@code simplebuilder.usingMapStructIntegration}) — and the qualified - * names of the builders planned for generation, so lookups do not have to guess names or scan - * packages. + * names of the builders {@code BuilderProcessor} plans to generate, so lookups do not have to guess + * names or scan packages. * - *

All state is compilation-scoped and guarded by {@link State}: javac initializes each processor - * lazily when its turn in a round comes, so before {@code 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). Only while {@link State#PROCESSING} or {@link - * State#FINISHED} are the published switch and registry the current compilation's truth. + *

The lifecycle reports where {@code BuilderProcessor}'s own builder generation stands and is + * transitioned by that processor alone — other processors or SPI adapters never write it. All state + * is compilation-scoped: javac initializes each processor lazily when its turn in a round comes, so + * before {@code 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). Only while {@link State#PROCESSING} or {@link State#FINISHED} are the published switch + * and registry the current compilation's truth. */ -public final class SpiIntegration { +public final class SimpleBuildersSpiIntegration { - /** Lifecycle of the current compilation as the SPI adapters observe it. */ + /** + * Where {@code 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, @@ -68,7 +73,7 @@ public enum State { /** Reverse of {@link #GENERATED_BUILDERS}: builder qualified name → bean qualified name. */ private static final Map BEAN_BY_BUILDER = new HashMap<>(); - private SpiIntegration() {} + private SimpleBuildersSpiIntegration() {} /** * Starts a new compilation: clears the builder registry and publishes the integration switch the @@ -87,11 +92,13 @@ public static void finishCompilation() { } /** - * Called by each SPI adapter on {@code init}. javac initializes every processor lazily in its - * turn, so an SPI init may run before {@code BuilderProcessor#init} of the same compilation; a - * {@link State#FINISHED} observed here can only be the previous compilation's leftover — a live - * {@link State#FINISHED} implies the last round already ran and no new SPI init would follow — - * and is reset to {@link State#INIT}. + * Called by each SPI adapter on {@code init} to age out a stale {@link State#FINISHED} left by a + * previous compilation: javac initializes every processor lazily in its turn, so an SPI init may + * run before {@code BuilderProcessor#init} of the same compilation, and a {@link State#FINISHED} + * observed here can only be leftover — a live {@link State#FINISHED} implies the last round + * already ran and no new SPI init would follow — and is reset to {@link State#INIT}. This + * corrects the observation; it does not declare generation state, which {@code BuilderProcessor} + * alone transitions. */ public static void spiInitialized() { if (state == State.FINISHED) { 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 index 4a87ad5d..a44c2d05 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -183,7 +183,7 @@ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { @Test void mapStruct_disabledIntegration_shouldMapViaSetters() { // MapStruct does not forward foreign -A options to SPI environments, so BuilderProcessor - // publishes the resolved switch to them via SpiIntegration + // publishes the resolved switch to them via SimpleBuildersSpiIntegration Compilation compilation = Compiler.javac() .withProcessors(new BuilderProcessor(), new MappingProcessor()) From 588e01342695857a4a7882f52dbe7efb689c9afa Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:02:41 +0000 Subject: [PATCH 12/25] Publish resolved builders so SPIs read the registry, not elements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the registry now carries PublishedBuilder descriptors (ResolvedBuilder + configured setterSuffix) resolved by BuilderProcessor at registration time. The provider resolves create/build by the descriptor's named contract instead of scanning candidates; the naming strategy gets bean and setterSuffix from the descriptor and stops reading bean annotation mirrors — AnnotationSupport shrinks away, its only remaining use being the deferral marker inlined in the provider. SimpleBuildersSpiIntegration moves to the processor package with package-private mutators (only BuilderProcessor transitions state), Optional lookups keyed both directions, and the switch renamed to integrationEnabled. Registered-but-unresolved types defer only while the compilation is not FINISHED. Adds a unit test for the lifecycle, registry and switch precedence. --- .../builders/processor/BuilderProcessor.java | 23 ++- .../SimpleBuildersSpiIntegration.java | 84 +++++---- .../mapstruct/AnnotationSupport.java | 144 -------------- .../MapStructAccessorNamingStrategy.java | 79 +++----- .../mapstruct/MapStructBuilderProvider.java | 178 ++++++++++-------- .../SimpleBuildersSpiIntegrationTest.java | 133 +++++++++++++ 6 files changed, 324 insertions(+), 317 deletions(-) rename processor/src/main/java/org/javahelpers/simple/builders/processor/{processing => }/SimpleBuildersSpiIntegration.java (57%) delete mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java 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 df0a05ab..786b67d3 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 @@ -70,6 +70,8 @@ import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.BuilderToGenerationTypeMapper; import org.javahelpers.simple.builders.processor.model.core.GenerationTargetClassDto; +import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameList; import org.javahelpers.simple.builders.processor.model.type.TypeNameMap; @@ -80,7 +82,6 @@ import org.javahelpers.simple.builders.processor.processing.OptionValueParsers; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; -import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger; @@ -544,13 +545,19 @@ private void registerGeneratedTypes(List elementsToGenerate) elementToGenerate.reportingElement(), elementToGenerate.config()), elementToGenerate.config())); SimpleBuildersSpiIntegration.registerBuilder( - targetType.getQualifiedName().toString(), - builderTypeName( - targetType, - effectiveBuilderPackage( - elementToGenerate.reportingElement(), elementToGenerate.config()), - elementToGenerate.config()) - .getFullQualifiedName()); + new SimpleBuildersSpiIntegration.PublishedBuilder( + new TypeName( + context.getPackageName(targetType), targetType.getSimpleName().toString()), + new ResolvedBuilder( + builderTypeName( + targetType, + effectiveBuilderPackage( + elementToGenerate.reportingElement(), elementToGenerate.config()), + elementToGenerate.config()), + new StaticFactoryCall("create"), + Optional.empty(), + "build"), + elementToGenerate.config().getSetterSuffix())); } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java similarity index 57% rename from processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java rename to processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java index 1a2035c6..8ed736ce 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -21,23 +21,24 @@ * SOFTWARE. */ -package org.javahelpers.simple.builders.processor.processing; +package org.javahelpers.simple.builders.processor; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; +import org.javahelpers.simple.builders.processor.model.type.TypeName; /** - * State {@code BuilderProcessor} shares with SPI adapters of other frameworks while they share the - * annotation processor path (and therefore the classloader): the resolved integration switch — - * which SPI environments cannot see because the hosting framework forwards only its own processor - * options (e.g. MapStruct's {@code simplebuilder.usingMapStructIntegration}) — and the qualified - * names of the builders {@code BuilderProcessor} plans to generate, so lookups do not have to guess - * names or scan packages. + * 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 + * fully resolved builder — type name, creation and build method — plus the bean's configured {@code + * setterSuffix}, so adapters do not scan elements or read annotations themselves. * - *

The lifecycle reports where {@code BuilderProcessor}'s own builder generation stands and is + *

The lifecycle reports where {@link BuilderProcessor}'s builder generation stands and is * transitioned by that processor alone — other processors or SPI adapters never write it. All state * is compilation-scoped: javac initializes each processor lazily when its turn in a round comes, so - * before {@code BuilderProcessor} has run its {@code init()} nothing static can be trusted — values + * 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). Only while {@link State#PROCESSING} or {@link State#FINISHED} are the published switch * and registry the current compilation's truth. @@ -45,7 +46,7 @@ public final class SimpleBuildersSpiIntegration { /** - * Where {@code BuilderProcessor}'s builder generation stands in the current compilation, as the + * Where {@link BuilderProcessor}'s builder generation stands in the current compilation, as the * SPI adapters observe it. */ public enum State { @@ -60,44 +61,50 @@ public enum State { FINISHED } + /** + * One bean-to-builder pair resolved at registration time: the {@link ResolvedBuilder} this + * processor emits for {@code beanType} plus the bean's {@code setterSuffix} naming option. + */ + public record PublishedBuilder(TypeName beanType, ResolvedBuilder builder, String setterSuffix) {} + private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; private static volatile State state = State.INIT; - /** The processor-resolved switch; {@code null} leaves the system-property fallback active. */ - private static volatile Boolean enabled; + /** The processor-resolved integration switch; {@code null} leaves the fallbacks active. */ + private static volatile Boolean integrationEnabled; - /** Builders planned for the current compilation: bean qualified name → builder qualified name. */ - private static final Map GENERATED_BUILDERS = new HashMap<>(); + /** Builders published for the current compilation, keyed by bean qualified name. */ + private static final Map BY_BEAN = new HashMap<>(); - /** Reverse of {@link #GENERATED_BUILDERS}: builder qualified name → bean qualified name. */ - private static final Map BEAN_BY_BUILDER = 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 builder registry and publishes the integration switch the + * Starts a new compilation: clears the registry and publishes the integration switch the * processor resolved ({@code null} when the option is unset). */ - public static void initCompilation(Boolean integrationEnabled) { - GENERATED_BUILDERS.clear(); - BEAN_BY_BUILDER.clear(); - enabled = integrationEnabled; + static void initCompilation(Boolean enabled) { + BY_BEAN.clear(); + BY_BUILDER.clear(); + integrationEnabled = enabled; state = State.PROCESSING; } /** Marks the compilation as finished: the registry will not grow any further. */ - public static void finishCompilation() { + static void finishCompilation() { state = State.FINISHED; } /** * Called by each SPI adapter on {@code init} to age out a stale {@link State#FINISHED} left by a * previous compilation: javac initializes every processor lazily in its turn, so an SPI init may - * run before {@code BuilderProcessor#init} of the same compilation, and a {@link State#FINISHED} + * run before {@link BuilderProcessor#init} of the same compilation, and a {@link State#FINISHED} * observed here can only be leftover — a live {@link State#FINISHED} implies the last round * already ran and no new SPI init would follow — and is reset to {@link State#INIT}. This - * corrects the observation; it does not declare generation state, which {@code BuilderProcessor} + * corrects the observation; it does not declare generation state, which {@link BuilderProcessor} * alone transitions. */ public static void spiInitialized() { @@ -111,34 +118,35 @@ public static State state() { return state; } - /** Registers a builder planned for {@code beanQualifiedName} in the current compilation. */ - public static void registerBuilder(String beanQualifiedName, String builderQualifiedName) { - GENERATED_BUILDERS.put(beanQualifiedName, builderQualifiedName); - BEAN_BY_BUILDER.put(builderQualifiedName, beanQualifiedName); + /** Publishes a builder planned for {@code publishedBuilder.beanType} in this compilation. */ + static void registerBuilder(PublishedBuilder publishedBuilder) { + BY_BEAN.put(publishedBuilder.beanType().getFullQualifiedName(), publishedBuilder); + BY_BUILDER.put(publishedBuilder.builder().typeName().getFullQualifiedName(), publishedBuilder); } - /** The qualified name of the builder planned for {@code beanQualifiedName}, or {@code null}. */ - public static String builderFor(String beanQualifiedName) { - return GENERATED_BUILDERS.get(beanQualifiedName); + /** The builder published for {@code beanType}'s qualified name, if this compilation plans one. */ + public static Optional builderFor(String beanQualifiedName) { + return Optional.ofNullable(BY_BEAN.get(beanQualifiedName)); } /** - * The qualified name of the bean {@code builderQualifiedName} was generated for, or {@code null}. + * The published builder with the given qualified type name, if {@link BuilderProcessor} plans it + * in this compilation. */ - public static String beanFor(String builderQualifiedName) { - return BEAN_BY_BUILDER.get(builderQualifiedName); + public static Optional builderByName(String builderQualifiedName) { + return Optional.ofNullable(BY_BUILDER.get(builderQualifiedName)); } /** * Whether the integration is switched off: while the processor has published this compilation's * resolution ({@link State#PROCESSING} or {@link State#FINISHED}) it wins; without one — incl. * the stale leftovers of a previous run in {@link State#INIT} — the {@code simplebuilder.*} - * convention applies (JVM system property before the annotation processor option MapStruct does - * not forward anyway). + * convention applies (JVM system property before the annotation processor option the hosting + * framework does not forward anyway). */ - public static boolean isDisabled(Map processorOptions) { + public static boolean isIntegrationDisabled(Map processorOptions) { if (state != State.INIT) { - Boolean published = enabled; + Boolean published = integrationEnabled; if (published != null) { return !published; } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java deleted file mode 100644 index d9253bdc..00000000 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/AnnotationSupport.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * 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 java.util.ArrayList; -import java.util.List; -import java.util.Map; -import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.AnnotationValue; -import javax.lang.model.element.Element; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.TypeElement; -import javax.lang.model.util.Elements; - -/** - * Shared annotation-mirror helpers for the MapStruct SPI implementations: locating the - * simple-builders annotations and reading {@code @SimpleBuilder}/{@code @SimpleBuilder.Template} - * options without a {@code ProcessingContext}. Only explicitly set annotation values are read - * ({@link AnnotationMirror#getElementValues()}) — defaults materialize to the same outcome the - * callers already apply for absent values. - */ -final class AnnotationSupport { - - static final String SIMPLE_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; - static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; - static final String IGNORE_4_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; - - private final Elements elementUtils; - - AnnotationSupport(Elements elementUtils) { - this.elementUtils = elementUtils; - } - - String qualifiedNameOf(AnnotationMirror mirror) { - return ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); - } - - /** - * Options mirrors relevant for builder generation on {@code beanElement}: {@code options()} of - * {@code @SimpleBuilder} and of {@code @SimpleBuilder.Template} on builder template annotations. - */ - List builderOptionsMirrors(TypeElement beanElement) { - List optionsMirrors = new ArrayList<>(); - for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { - String annotationName = qualifiedNameOf(mirror); - if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { - addOptionsMirror(mirror, optionsMirrors); - } else { - // A custom builder template: read options of its @SimpleBuilder.Template meta-annotation. - Element annotationType = mirror.getAnnotationType().asElement(); - for (AnnotationMirror metaMirror : annotationType.getAnnotationMirrors()) { - if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - addOptionsMirror(metaMirror, optionsMirrors); - } - } - } - } - return optionsMirrors; - } - - private void addOptionsMirror(AnnotationMirror mirror, List optionsMirrors) { - for (Map.Entry entry : - mirror.getElementValues().entrySet()) { - if (entry.getKey().getSimpleName().contentEquals("options") - && entry.getValue().getValue() instanceof AnnotationMirror optionsMirror) { - optionsMirrors.add(optionsMirror); - } - } - } - - String stringOption(AnnotationMirror optionsMirror, String name) { - for (Map.Entry entry : - optionsMirror.getElementValues().entrySet()) { - if (entry.getKey().getSimpleName().contentEquals(name)) { - Object value = entry.getValue().getValue(); - if (value instanceof String stringValue) { - return stringValue; - } - } - } - return null; - } - - /** - * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a - * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). - */ - boolean isBuilderGenerationTarget(TypeElement beanElement) { - boolean marked = false; - for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { - String annotationName = qualifiedNameOf(mirror); - if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { - return false; - } - if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { - marked = true; - continue; - } - // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined - // one): its type is meta-annotated with @SimpleBuilder.Template. - for (AnnotationMirror metaMirror : - mirror.getAnnotationType().asElement().getAnnotationMirrors()) { - if (qualifiedNameOf(metaMirror).equals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - marked = true; - break; - } - } - } - return marked; - } - - /** - * Reads a {@code simplebuilder.*} option following the project convention: the JVM system - * property wins over the annotation processor option (which MapStruct does not forward to SPI - * environments). - */ - static String systemOption(Map processorOptions, String key) { - return System.getProperty(key, processorOptions.get(key)); - } -} 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 index 83cfb5a4..5de9a447 100644 --- 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 @@ -36,7 +36,8 @@ import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; -import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; import org.mapstruct.ap.spi.AccessorNamingStrategy; import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -50,27 +51,26 @@ * 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 generated by simple-builders - * this strategy returns {@link MethodType#OTHER} for everything that is not the direct property - * setter ({@code } taking a single argument of the field type). A builder is - * attributed to this generator only through the registry {@code BuilderProcessor} publishes for the - * builders it plans, so builders produced by earlier compilations and foreign types keep the {@link - * DefaultAccessorNamingStrategy} behaviour. + * 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 static final String OPTION_PREFIX = "simplebuilder."; - - private AnnotationSupport annotations; private Map processorOptions = Map.of(); - private final Map>> directSettersCache = new HashMap<>(); + + /** 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); + // Ages out stale lifecycle state left by a previous compilation on a reused JVM. SimpleBuildersSpiIntegration.spiInitialized(); - annotations = new AnnotationSupport(processingEnvironment.getElementUtils()); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; } @@ -78,20 +78,21 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (SimpleBuildersSpiIntegration.isDisabled(processorOptions)) { + if (SimpleBuildersSpiIntegration.isIntegrationDisabled(processorOptions)) { 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; } - Optional> directSetters = + Map directSetters = directSettersCache.computeIfAbsent( - builderType.getQualifiedName().toString(), - ignored -> computeDirectSetters(builderType)); - if (directSetters.isEmpty() || isDirectSetter(method, directSetters.get())) { + builderType.getQualifiedName().toString(), ignored -> directSettersOf(builderType)); + if (directSetters.isEmpty() || isDirectSetter(method, directSetters)) { return methodType; } return MethodType.OTHER; @@ -109,41 +110,23 @@ private boolean isDirectSetter(ExecutableElement method, Map } /** - * The direct property setters ({@code setter name -> field type}) of the bean {@code builderType} - * was generated for, or empty when {@code builderType} is not a simple-builders builder. + * 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. */ - private Optional> computeDirectSetters(TypeElement builderType) { - TypeElement beanElement = resolveBean(builderType); + private Map directSettersOf(TypeElement builderType) { + Optional published = + SimpleBuildersSpiIntegration.builderByName(builderType.getQualifiedName().toString()); + if (published.isEmpty()) { + return Map.of(); + } + TypeElement beanElement = + elementUtils.getTypeElement(published.get().beanType().getFullQualifiedName()); if (beanElement == null) { - return Optional.empty(); + return Map.of(); } - String setterSuffix = resolveSetterSuffix(beanElement); Map directSetters = new HashMap<>(); - collectFields(beanElement, setterSuffix, directSetters); - return Optional.of(directSetters); - } - - /** - * The bean a builder was generated for, through the registry {@code BuilderProcessor} publishes. - * Types not on the list are not attributed to this generator. - */ - private TypeElement resolveBean(TypeElement builderType) { - String registered = - SimpleBuildersSpiIntegration.beanFor(builderType.getQualifiedName().toString()); - return registered == null ? null : elementUtils.getTypeElement(registered); - } - - /** The {@code setterSuffix} configured for {@code beanElement} ({@code ""} by default). */ - private String resolveSetterSuffix(TypeElement beanElement) { - for (var optionsMirror : annotations.builderOptionsMirrors(beanElement)) { - String setterSuffix = annotations.stringOption(optionsMirror, "setterSuffix"); - if (setterSuffix != null) { - return setterSuffix; - } - } - String global = - AnnotationSupport.systemOption(processorOptions, OPTION_PREFIX + "setterSuffix"); - return global != null ? global : ""; + collectFields(beanElement, published.get().setterSuffix(), directSetters); + return directSetters; } /** Collects the bean's non-static fields incl. inherited ones into {@code directSetters}. */ 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 index 6ef0cf3d..bbffd542 100644 --- 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 @@ -24,11 +24,11 @@ package org.javahelpers.simple.builders.processor.mapstruct; import com.google.auto.service.AutoService; -import java.util.ArrayList; -import java.util.Comparator; 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.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; @@ -37,8 +37,9 @@ import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; -import javax.lang.model.util.Types; -import org.javahelpers.simple.builders.processor.processing.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration; +import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; +import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; import org.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -51,13 +52,17 @@ *

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 for every builder it plans — a constant-time - * lookup with exact qualified names, 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. + * 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. * - *

When a planned builder is not visible yet, {@link TypeHierarchyErroneousException} defers the - * mapper to the next processing round so a builder generated in the same round can still be found. + *

While the compilation is not {@link SimpleBuildersSpiIntegration.State#FINISHED} the registry + * may still grow, so a published builder that is not visible yet — or a bean marked for generation + * whose entry may still arrive — defers the mapper via {@link TypeHierarchyErroneousException} to + * the next processing round. After {@link SimpleBuildersSpiIntegration.State#FINISHED} a registry + * miss is definitive and the lookup gracefully returns {@code null}. * *

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 @@ -68,9 +73,14 @@ @AutoService(BuilderProvider.class) public class MapStructBuilderProvider implements BuilderProvider { + private static final String SIMPLE_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; + private static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; + private static final String IGNORE_4_BUILDER_ANNOTATION = + "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; + private Elements elementUtils; - private Types typeUtils; - private AnnotationSupport annotations; private Map processorOptions = Map.of(); /** Resolved builder infos by bean qualified name; only positive results are cached. */ @@ -78,17 +88,16 @@ public class MapStructBuilderProvider implements BuilderProvider { @Override public void init(MapStructProcessingEnvironment processingEnvironment) { + // Ages out stale lifecycle state left by a previous compilation on a reused JVM. SimpleBuildersSpiIntegration.spiInitialized(); this.elementUtils = processingEnvironment.getElementUtils(); - this.typeUtils = processingEnvironment.getTypeUtils(); - this.annotations = new AnnotationSupport(elementUtils); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; } @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (SimpleBuildersSpiIntegration.isDisabled(processorOptions)) { + if (SimpleBuildersSpiIntegration.isIntegrationDisabled(processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -99,111 +108,122 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { if (cached != null) { return cached; } - BuilderInfo builderInfo = createBuilderInfo(beanElement, type); + BuilderInfo builderInfo = createBuilderInfo(beanElement); if (builderInfo != null) { builderInfoCache.put(beanElement.getQualifiedName().toString(), builderInfo); } return builderInfo; } - private BuilderInfo createBuilderInfo(TypeElement beanElement, TypeMirror beanType) { - TypeElement builderElement = findBuilderElement(beanElement); + private BuilderInfo createBuilderInfo(TypeElement beanElement) { + Optional published = + SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()); + TypeElement builderElement = findBuilderElement(beanElement, published); if (builderElement == null) { return null; } - ExecutableElement creationMethod = findCreationMethod(builderElement); - List buildMethods = findBuildMethods(builderElement, beanType); - if (creationMethod == null || buildMethods.isEmpty()) { + ExecutableElement creationMethod = creationMethod(builderElement, published.orElseThrow()); + List buildMethod = buildMethod(builderElement, published.orElseThrow()); + if (creationMethod == null || buildMethod.isEmpty()) { return null; } return new BuilderInfo.Builder() .builderCreationMethod(creationMethod) - .buildMethod(buildMethods) + .buildMethod(buildMethod) .build(); } /** * Locates the generated builder for {@code beanElement} through the registry {@code - * BuilderProcessor} publishes — the list decides alone which type is claimed. A planned builder - * that is not emitted yet defers the mapper to the next processing round. While the compilation - * is not {@link SimpleBuildersSpiIntegration.State#FINISHED} the registry may still grow - * (MapStruct may run ahead of this processor's first round), so a bean marked for generation gets - * the same deferral instead of a premature miss. + * BuilderProcessor} publishes — the list decides alone which type is claimed. */ - private TypeElement findBuilderElement(TypeElement beanElement) { - String registered = - SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()); - if (registered == null) { + private TypeElement findBuilderElement( + TypeElement beanElement, Optional published) { + if (published.isEmpty()) { if (SimpleBuildersSpiIntegration.state() != SimpleBuildersSpiIntegration.State.FINISHED - && annotations.isBuilderGenerationTarget(beanElement)) { - // Marked for generation but not published yet — our processor has not had its round; - // defer so the registry can fill in before the mapper is generated. + && isBuilderGenerationTarget(beanElement)) { + // Marked for generation but not published yet — MapStruct may run ahead of this + // processor's round; defer so the registry can fill in before the mapper is generated. throw new TypeHierarchyErroneousException(beanElement.asType()); } return null; } - TypeElement registeredElement = elementUtils.getTypeElement(registered); - if (registeredElement == null) { - // Planned but not yet emitted in this round — defer. + TypeElement builderElement = + elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); + if (builderElement == null + && SimpleBuildersSpiIntegration.state() != SimpleBuildersSpiIntegration.State.FINISHED) { + // Published but not emitted yet — defer so the mapper retries once the type exists. throw new TypeHierarchyErroneousException(beanElement.asType()); } - return registeredElement; + return builderElement; } /** - * A {@code public static} parameterless method on the builder returning the builder type, e.g. - * {@code create()}. + * 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 findCreationMethod(TypeElement builderElement) { - List candidates = new ArrayList<>(); - for (Element member : builderElement.getEnclosedElements()) { - if (member.getKind() == ElementKind.METHOD - && member instanceof ExecutableElement method - && method.getParameters().isEmpty() - && method.getModifiers().contains(Modifier.PUBLIC) - && method.getModifiers().contains(Modifier.STATIC) - && isBuilderType(method.getReturnType(), builderElement)) { - candidates.add(method); - } - } - // Prefer the canonical factory names, then fall back alphabetically — deterministic for - // builders declaring several matching factories. - for (String preferred : List.of("create", "of")) { - for (ExecutableElement candidate : candidates) { - if (candidate.getSimpleName().contentEquals(preferred)) { - return candidate; - } - } + private ExecutableElement creationMethod(TypeElement builderElement, PublishedBuilder published) { + if (!(published.builder().funcForEmptyBuilder() instanceof StaticFactoryCall factory)) { + return null; } - return candidates.stream() - .min(Comparator.comparing(method -> method.getSimpleName().toString())) - .orElse(null); + return findMethod(builderElement, factory.methodName(), Modifier.PUBLIC, Modifier.STATIC); } /** - * {@code public} parameterless instance methods on the builder returning the bean type, e.g. - * {@code build()}. + * The published build method on the resolved builder — the {@code public} parameterless instance + * method named by the descriptor ({@code build} for generated builders). */ - private List findBuildMethods( - TypeElement builderElement, TypeMirror beanType) { - List buildMethods = new ArrayList<>(); - for (Element member : builderElement.getEnclosedElements()) { + private List buildMethod( + TypeElement builderElement, PublishedBuilder published) { + ExecutableElement method = + findMethod(builderElement, published.builder().buildMethodName(), Modifier.PUBLIC); + return method == null ? List.of() : List.of(method); + } + + /** The parameterless method with {@code name} and all {@code requiredModifiers} on the type. */ + private ExecutableElement findMethod( + TypeElement typeElement, String name, Modifier... requiredModifiers) { + for (Element member : typeElement.getEnclosedElements()) { if (member.getKind() == ElementKind.METHOD && member instanceof ExecutableElement method + && method.getSimpleName().contentEquals(name) && method.getParameters().isEmpty() - && method.getModifiers().contains(Modifier.PUBLIC) - && !method.getModifiers().contains(Modifier.STATIC) - && typeUtils.isSameType( - typeUtils.erasure(method.getReturnType()), typeUtils.erasure(beanType))) { - buildMethods.add(method); + && method.getModifiers().containsAll(List.of(requiredModifiers))) { + return method; } } - return buildMethods; + return null; } - /** Whether {@code type} is the builder's own type. */ - private boolean isBuilderType(TypeMirror type, TypeElement builderElement) { - return typeUtils.isSameType( - typeUtils.erasure(type), typeUtils.erasure(builderElement.asType())); + /** + * 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 deferral — it never decides which type is claimed. + */ + private boolean isBuilderGenerationTarget(TypeElement beanElement) { + boolean marked = false; + for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { + String annotationName = + ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); + if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { + return false; + } + if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { + marked = true; + continue; + } + // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined + // one): its type is meta-annotated with @SimpleBuilder.Template. + for (AnnotationMirror metaMirror : + mirror.getAnnotationType().asElement().getAnnotationMirrors()) { + if (((TypeElement) metaMirror.getAnnotationType().asElement()) + .getQualifiedName() + .contentEquals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { + marked = true; + break; + } + } + } + return marked; } } 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..2cd09e33 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -0,0 +1,133 @@ +/* + * 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.util.Map; +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, the registry exposes exactly the published descriptors, and the integration switch + * honors the {@code -D} > processor-published precedence. + */ +class SimpleBuildersSpiIntegrationTest { + + private static final String OPTION = "simplebuilder.usingMapStructIntegration"; + + private static final PublishedBuilder PERSON = + new PublishedBuilder( + new TypeName("test", "PersonDto"), + new ResolvedBuilder( + new TypeName("test", "PersonDtoBuilder"), + new StaticFactoryCall("create"), + java.util.Optional.empty(), + "build"), + ""); + + @AfterEach + void reset() { + System.clearProperty(OPTION); + SimpleBuildersSpiIntegration.initCompilation(null); + } + + @Test + void lifecycle_transitionsAndStaleReset() { + SimpleBuildersSpiIntegration.initCompilation(null); + assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); + + SimpleBuildersSpiIntegration.finishCompilation(); + assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); + + // A new compilation's SPI init must age the leftover FINISHED out. + SimpleBuildersSpiIntegration.spiInitialized(); + assertEquals(State.INIT, SimpleBuildersSpiIntegration.state()); + } + + @Test + void spiInitialized_processingStateSurvives() { + SimpleBuildersSpiIntegration.initCompilation(Boolean.TRUE); + SimpleBuildersSpiIntegration.spiInitialized(); + assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); + } + + @Test + void registry_publishesBothDirections() { + SimpleBuildersSpiIntegration.initCompilation(null); + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); + assertTrue(SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").isEmpty()); + + SimpleBuildersSpiIntegration.registerBuilder(PERSON); + + assertEquals(PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto").orElseThrow()); + assertEquals( + PERSON, SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").orElseThrow()); + assertEquals("test.PersonDtoBuilder", PERSON.builder().typeName().getFullQualifiedName()); + assertEquals("", PERSON.setterSuffix()); + } + + @Test + void registry_clearedOnNextCompilation() { + SimpleBuildersSpiIntegration.initCompilation(null); + SimpleBuildersSpiIntegration.registerBuilder(PERSON); + + SimpleBuildersSpiIntegration.initCompilation(null); + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); + } + + @Test + void isIntegrationDisabled_publishedSwitchWinsOverProperty() { + System.setProperty(OPTION, "DISABLED"); + SimpleBuildersSpiIntegration.initCompilation(Boolean.TRUE); + assertFalse(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + + SimpleBuildersSpiIntegration.initCompilation(Boolean.FALSE); + assertTrue(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + } + + @Test + void isIntegrationDisabled_fallsBackToPropertyOnlyInInit() { + // Stale published values must not leak: the previous compilation's DISABLED stays ignored + // once a new compilation resets to INIT, so the property decides until our init publishes. + SimpleBuildersSpiIntegration.initCompilation(Boolean.FALSE); + SimpleBuildersSpiIntegration.finishCompilation(); + SimpleBuildersSpiIntegration.spiInitialized(); + assertEquals(State.INIT, SimpleBuildersSpiIntegration.state()); + + System.setProperty(OPTION, "ENABLED"); + assertFalse(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + + System.setProperty(OPTION, "DISABLED"); + assertTrue(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + } +} From b6fb24acc5dd6a5428d11f88ae0d8e171d5d07f2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:48:13 +0000 Subject: [PATCH 13/25] Resolve MapStruct SPI review round 2 and add in-javac probe test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerBuilder takes (beanType, builderType, setterSuffix) and builds the ResolvedBuilder internally under the standard generated-builder contract; BuilderProcessor reuses its computed builderTypeName and maps the bean via JavaLangMapper.mapToTypeName - initCompilation parameter renamed to integrationEnabled - New MapStructSpiProbeTest: a probe processor ahead of BuilderProcessor drives both SPIs inside javac — deferral for marked unregistered and template-annotated beans, null for foreign/ignored beans, BuilderInfo + same-instance cache hit once emitted, noType lookup, and naming classification of generated builder methods (SETTER vs OTHER) --- .../builders/processor/BuilderProcessor.java | 24 +- .../SimpleBuildersSpiIntegration.java | 18 +- .../processor/MapStructSpiProbeTest.java | 293 ++++++++++++++++++ .../SimpleBuildersSpiIntegrationTest.java | 6 +- 4 files changed, 317 insertions(+), 24 deletions(-) create mode 100644 processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java 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 786b67d3..ac5a327c 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 @@ -63,6 +63,7 @@ import org.javahelpers.simple.builders.core.enums.OptionState; 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; @@ -70,8 +71,6 @@ import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.BuilderToGenerationTypeMapper; import org.javahelpers.simple.builders.processor.model.core.GenerationTargetClassDto; -import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; -import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameList; import org.javahelpers.simple.builders.processor.model.type.TypeNameMap; @@ -537,27 +536,16 @@ 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); SimpleBuildersSpiIntegration.registerBuilder( - new SimpleBuildersSpiIntegration.PublishedBuilder( - new TypeName( - context.getPackageName(targetType), targetType.getSimpleName().toString()), - new ResolvedBuilder( - builderTypeName( - targetType, - effectiveBuilderPackage( - elementToGenerate.reportingElement(), elementToGenerate.config()), - elementToGenerate.config()), - new StaticFactoryCall("create"), - Optional.empty(), - "build"), - elementToGenerate.config().getSetterSuffix())); + beanType, builderType, 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 index 8ed736ce..21ba0770 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; +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; @@ -86,10 +87,10 @@ private SimpleBuildersSpiIntegration() {} * Starts a new compilation: clears the registry and publishes the integration switch the * processor resolved ({@code null} when the option is unset). */ - static void initCompilation(Boolean enabled) { + static void initCompilation(Boolean integrationEnabled) { BY_BEAN.clear(); BY_BUILDER.clear(); - integrationEnabled = enabled; + SimpleBuildersSpiIntegration.integrationEnabled = integrationEnabled; state = State.PROCESSING; } @@ -118,8 +119,17 @@ public static State state() { return state; } - /** Publishes a builder planned for {@code publishedBuilder.beanType} in this compilation. */ - static void registerBuilder(PublishedBuilder publishedBuilder) { + /** + * Publishes the builder planned for {@code beanType} in this compilation under the standard + * contract every generated builder satisfies: {@code create()} obtains an empty builder instance + * and {@code build()} returns the finished bean. + */ + static void registerBuilder(TypeName beanType, TypeName builderType, String setterSuffix) { + PublishedBuilder publishedBuilder = + new PublishedBuilder( + beanType, + new ResolvedBuilder(builderType, new StaticFactoryCall("create"), null), + setterSuffix); BY_BEAN.put(publishedBuilder.beanType().getFullQualifiedName(), publishedBuilder); BY_BUILDER.put(publishedBuilder.builder().typeName().getFullQualifiedName(), publishedBuilder); } 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..bd6ad58f --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -0,0 +1,293 @@ +/* + * 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 { + + private static final JavaFileObject PERSON_DTO = + 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 final JavaFileObject FOREIGN_DTO = + ProcessorTestUtils.forSource( + """ + package test; + + public class ForeignDto { + private String name; + } + """); + + private static final JavaFileObject IGNORED_DTO = + 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 final JavaFileObject TEMPLATE_DTO = + ProcessorTestUtils.forSource( + """ + package test; + + import org.javahelpers.simple.builders.core.annotations.SimpleMinimalBuilder; + + @SimpleMinimalBuilder + public class MinimalDto { + private String name; + } + """); + + @Test + void probe_shouldDriveSpiAgainstEmittedElements() { + Compilation compilation = + Compiler.javac() + .withProcessors(new SpiProbeProcessor(), new BuilderProcessor()) + .compile(PERSON_DTO, FOREIGN_DTO, IGNORED_DTO, TEMPLATE_DTO); + assertThat(compilation).succeeded(); + + ProbeResults results = ProbeResults.instance; + + // Marked beans defer while the registry can still grow: unregistered yet or emitted later. + assertInstanceOf(TypeHierarchyErroneousException.class, results.unregisteredDeferral); + assertInstanceOf(TypeHierarchyErroneousException.class, results.templateDeferral); + + // Foreign and opted-out beans are never claimed, whichever lifecycle state applies. + assertNull(results.foreignWhileProcessing); + assertNull(results.ignoredWhileProcessing); + 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")); + } + + /** 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; + BuilderInfo foreignWhileProcessing; + BuilderInfo ignoredWhileProcessing; + BuilderInfo foreignWhenFinished; + BuilderInfo noType; + BuilderInfo builderInfo; + BuilderInfo cachedBuilderInfo; + + void reset() { + methodTypes.clear(); + unregisteredDeferral = null; + templateDeferral = null; + foreignWhileProcessing = null; + ignoredWhileProcessing = 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 a marked bean is probed before registration. + */ + @SupportedAnnotationTypes("*") + public static final class SpiProbeProcessor extends AbstractProcessor { + + private final MapStructBuilderProvider provider = new MapStructBuilderProvider(); + private final MapStructAccessorNamingStrategy naming = new MapStructAccessorNamingStrategy(); + private boolean namingProbed; + + @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()) { + results.foreignWhenFinished = lookup(provider, "test.ForeignDto"); + return false; + } + + TypeElement bean = processingEnv.getElementUtils().getTypeElement("test.PersonDto"); + if (bean == null) { + 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.foreignWhileProcessing = lookup(provider, "test.ForeignDto"); + results.ignoredWhileProcessing = lookup(provider, "test.IgnoredDto"); + TypeElement minimal = processingEnv.getElementUtils().getTypeElement("test.MinimalDto"); + results.templateDeferral = lookupExpectingDeferral(minimal); + 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.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 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); + } + } +} 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 index 2cd09e33..6e8d8e0f 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -87,7 +87,8 @@ void registry_publishesBothDirections() { assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); assertTrue(SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").isEmpty()); - SimpleBuildersSpiIntegration.registerBuilder(PERSON); + SimpleBuildersSpiIntegration.registerBuilder( + PERSON.beanType(), PERSON.builder().typeName(), PERSON.setterSuffix()); assertEquals(PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto").orElseThrow()); assertEquals( @@ -99,7 +100,8 @@ void registry_publishesBothDirections() { @Test void registry_clearedOnNextCompilation() { SimpleBuildersSpiIntegration.initCompilation(null); - SimpleBuildersSpiIntegration.registerBuilder(PERSON); + SimpleBuildersSpiIntegration.registerBuilder( + PERSON.beanType(), PERSON.builder().typeName(), PERSON.setterSuffix()); SimpleBuildersSpiIntegration.initCompilation(null); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); From 4fd72465798750499adebe03e093adc137772b8e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:13:40 +0000 Subject: [PATCH 14/25] Bound SPI deferral by lifecycle state instead of marker alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New fourth state TARGETS_REGISTERED marks the end of BuilderProcessor's first processing round. A registry miss before it defers the mapper for any bean — this covers @SimpleBuilderFor targets, which carry no marker of their own, with no annotation scanning. Afterwards only beans marked for generation defer while their entry may still arrive in a later round; foreign and opted-out beans resolve to null immediately. Probe and lifecycle tests cover both windows. --- .../builders/processor/BuilderProcessor.java | 2 + .../SimpleBuildersSpiIntegration.java | 24 ++++++++++-- .../mapstruct/MapStructBuilderProvider.java | 25 +++++++------ .../processor/MapStructSpiProbeTest.java | 37 +++++++++++++------ .../SimpleBuildersSpiIntegrationTest.java | 7 ++++ 5 files changed, 69 insertions(+), 26 deletions(-) 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 ac5a327c..0211f043 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 @@ -207,6 +207,8 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); + // The round's targets are published; only transitions on the first round. + SimpleBuildersSpiIntegration.targetsRegistered(); // Returning false leaves the annotations unclaimed so other processors on the // processor path (e.g. MapStruct, AutoService) still see them. return false; 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 index 21ba0770..6b8c5c74 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -41,8 +41,8 @@ * 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). Only while {@link State#PROCESSING} or {@link State#FINISHED} are the published switch - * and registry the current compilation's truth. + * builds). Only while {@link State#PROCESSING}, {@link State#TARGETS_REGISTERED} or {@link + * State#FINISHED} are the published switch and registry the current compilation's truth. */ public final class SimpleBuildersSpiIntegration { @@ -56,8 +56,16 @@ public enum State { * and the registry must be treated as possibly still filling. */ INIT, - /** {@code BuilderProcessor} initialized: the registry may still grow this round. */ + /** + * {@code BuilderProcessor} initialized: even its initial targets may be unpublished — the + * registry must be treated as possibly still filling. + */ PROCESSING, + /** + * {@code BuilderProcessor}'s first processing round ran: the registry holds all targets + * discoverable so far and may still grow in later rounds. + */ + TARGETS_REGISTERED, /** The last round ran: the registry is final for this compilation. */ FINISHED } @@ -94,6 +102,16 @@ static void initCompilation(Boolean integrationEnabled) { state = State.PROCESSING; } + /** + * Marks the end of {@link BuilderProcessor}'s first processing round: the initial targets are + * published; the registry may still grow in later rounds. + */ + static void targetsRegistered() { + if (state == State.PROCESSING) { + state = State.TARGETS_REGISTERED; + } + } + /** Marks the compilation as finished: the registry will not grow any further. */ static void finishCompilation() { state = State.FINISHED; 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 index bbffd542..eebf3e08 100644 --- 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 @@ -39,6 +39,7 @@ import javax.lang.model.util.Elements; import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration; 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.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; @@ -58,11 +59,11 @@ * compilations are not discovered: only the beans the processor plans in the current run get * builder mapping. * - *

While the compilation is not {@link SimpleBuildersSpiIntegration.State#FINISHED} the registry - * may still grow, so a published builder that is not visible yet — or a bean marked for generation - * whose entry may still arrive — defers the mapper via {@link TypeHierarchyErroneousException} to - * the next processing round. After {@link SimpleBuildersSpiIntegration.State#FINISHED} a registry - * miss is definitive and the lookup gracefully returns {@code null}. + *

While the compilation is not {@link State#FINISHED} the registry may still grow: before {@link + * State#TARGETS_REGISTERED} even the initial targets may be unpublished, so a lookup miss defers + * the mapper via {@link TypeHierarchyErroneousException} for any bean; afterwards only a bean + * marked for generation may still be registered in a later round and defers then. After {@link + * State#FINISHED} a registry miss is definitive and the lookup gracefully returns {@code null}. * *

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 @@ -140,18 +141,20 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement) { private TypeElement findBuilderElement( TypeElement beanElement, Optional published) { if (published.isEmpty()) { - if (SimpleBuildersSpiIntegration.state() != SimpleBuildersSpiIntegration.State.FINISHED - && isBuilderGenerationTarget(beanElement)) { - // Marked for generation but not published yet — MapStruct may run ahead of this - // processor's round; defer so the registry can fill in before the mapper is generated. + State state = SimpleBuildersSpiIntegration.state(); + // While the initial target registration may still be missing, any bean may still get an + // entry; afterwards only a bean marked for generation may still be registered in a later + // round. Deferring retries the mapper once the registry could have filled. + if (state == State.INIT + || state == State.PROCESSING + || (state == State.TARGETS_REGISTERED && isBuilderGenerationTarget(beanElement))) { throw new TypeHierarchyErroneousException(beanElement.asType()); } return null; } TypeElement builderElement = elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); - if (builderElement == null - && SimpleBuildersSpiIntegration.state() != SimpleBuildersSpiIntegration.State.FINISHED) { + if (builderElement == null && SimpleBuildersSpiIntegration.state() != State.FINISHED) { // Published but not emitted yet — defer so the mapper retries once the type exists. throw new TypeHierarchyErroneousException(beanElement.asType()); } 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 index bd6ad58f..ea87b14c 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -133,13 +133,17 @@ void probe_shouldDriveSpiAgainstEmittedElements() { ProbeResults results = ProbeResults.instance; - // Marked beans defer while the registry can still grow: unregistered yet or emitted later. + // While the initial targets may be unpublished every miss defers — marked beans, + // template-annotated beans, foreign and opted-out beans alike. assertInstanceOf(TypeHierarchyErroneousException.class, results.unregisteredDeferral); assertInstanceOf(TypeHierarchyErroneousException.class, results.templateDeferral); + assertInstanceOf(TypeHierarchyErroneousException.class, results.foreignUnpublished); + assertInstanceOf(TypeHierarchyErroneousException.class, results.ignoredUnpublished); - // Foreign and opted-out beans are never claimed, whichever lifecycle state applies. - assertNull(results.foreignWhileProcessing); - assertNull(results.ignoredWhileProcessing); + // Once targets are registered only marked beans may still arrive — foreign and + // opted-out beans resolve to no builder immediately and never get claimed. + assertNull(results.foreignRegistered); + assertNull(results.ignoredRegistered); assertNull(results.foreignWhenFinished); assertNull(results.noType); @@ -166,8 +170,10 @@ private static final class ProbeResults { final Map methodTypes = new LinkedHashMap<>(); Throwable unregisteredDeferral; Throwable templateDeferral; - BuilderInfo foreignWhileProcessing; - BuilderInfo ignoredWhileProcessing; + Throwable foreignUnpublished; + Throwable ignoredUnpublished; + BuilderInfo foreignRegistered; + BuilderInfo ignoredRegistered; BuilderInfo foreignWhenFinished; BuilderInfo noType; BuilderInfo builderInfo; @@ -177,8 +183,10 @@ void reset() { methodTypes.clear(); unregisteredDeferral = null; templateDeferral = null; - foreignWhileProcessing = null; - ignoredWhileProcessing = null; + foreignUnpublished = null; + ignoredUnpublished = null; + foreignRegistered = null; + ignoredRegistered = null; foreignWhenFinished = null; noType = null; builderInfo = null; @@ -243,10 +251,9 @@ public boolean process(Set annotations, RoundEnvironment // 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.foreignWhileProcessing = lookup(provider, "test.ForeignDto"); - results.ignoredWhileProcessing = lookup(provider, "test.IgnoredDto"); - TypeElement minimal = processingEnv.getElementUtils().getTypeElement("test.MinimalDto"); - results.templateDeferral = lookupExpectingDeferral(minimal); + results.foreignUnpublished = lookupExpectingDeferral(element("test.ForeignDto")); + results.ignoredUnpublished = lookupExpectingDeferral(element("test.IgnoredDto")); + results.templateDeferral = lookupExpectingDeferral(element("test.MinimalDto")); return false; } @@ -262,6 +269,8 @@ public boolean process(Set annotations, RoundEnvironment } 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)); @@ -275,6 +284,10 @@ public boolean process(Set annotations, RoundEnvironment return false; } + private TypeElement element(String qualifiedName) { + return processingEnv.getElementUtils().getTypeElement(qualifiedName); + } + private Throwable lookupExpectingDeferral(TypeElement bean) { try { provider.findBuilderInfo(bean.asType()); 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 index 6e8d8e0f..49296760 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -66,6 +66,13 @@ void lifecycle_transitionsAndStaleReset() { SimpleBuildersSpiIntegration.initCompilation(null); assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); + SimpleBuildersSpiIntegration.targetsRegistered(); + assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); + + // Later rounds keep the state; only the first transition counts. + SimpleBuildersSpiIntegration.targetsRegistered(); + assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); + SimpleBuildersSpiIntegration.finishCompilation(); assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); From 9b3e44995b1acded1fd499b7d9e1583f154b4316 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:19:42 +0000 Subject: [PATCH 15/25] MapStruct SPI: marked-only deferral, analyser helpers, test cleanup Restrict TypeHierarchyErroneousException deferral to beans marked for generation while INIT/PROCESSING holds; unmarked beans return null in every state so foreign types are never claimed or delayed. At FINISHED a marked bean without a registered builder reports a stderr warning (SPI environments expose no Messager). Published-but-unemitted builder types keep deferring until FINISHED. Move the parameterless method scan into JavaLangAnalyser as findMethodWithoutParameters and use its findAnnotation helpers for the marker and template checks. Reword the integration-switch comment to not resemble code and suppress java:S3516 on process(), which must always return false to leave annotations unclaimed. Restructure the SPI tests to the repo conventions: test sources as private static helper methods, a createCompiler(Processor...) overload in ProcessorTestUtils preserving invocation order, an assertNoWarningContaining helper, and a scoped-out marked bean covering the TARGETS_REGISTERED/FINISHED miss paths. --- .../builders/processor/BuilderProcessor.java | 4 +- .../processor/analysis/JavaLangAnalyser.java | 25 ++ .../mapstruct/MapStructBuilderProvider.java | 131 +++++------ .../MapStructSpiIntegrationTest.java | 221 +++++++++--------- .../processor/MapStructSpiProbeTest.java | 178 +++++++++----- .../processor/testing/ProcessorTestUtils.java | 35 ++- 6 files changed, 350 insertions(+), 244 deletions(-) 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 0211f043..6127077d 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 @@ -112,8 +112,8 @@ public synchronized void init(ProcessingEnvironment processingEnv) { BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger); logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); - // Publish the resolved integration switch to the MapStruct SPIs sharing this classloader; - // their environment never sees foreign annotation processor options + // The SPI environments never receive foreign annotation processor options — publish the + // resolved switch through the shared classloader instead String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); SimpleBuildersSpiIntegration.initCompilation( mapStructOption == null 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/MapStructBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java index eebf3e08..f3bb2984 100644 --- 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 @@ -29,17 +29,18 @@ import java.util.Map; import java.util.Optional; import javax.lang.model.element.AnnotationMirror; -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.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.SimpleBuildersSpiIntegration.State; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; import org.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; @@ -59,11 +60,13 @@ * compilations are not discovered: only the beans the processor plans in the current run get * builder mapping. * - *

While the compilation is not {@link State#FINISHED} the registry may still grow: before {@link - * State#TARGETS_REGISTERED} even the initial targets may be unpublished, so a lookup miss defers - * the mapper via {@link TypeHierarchyErroneousException} for any bean; afterwards only a bean - * marked for generation may still be registered in a later round and defers then. After {@link - * State#FINISHED} a registry miss is definitive and the lookup gracefully returns {@code null}. + *

A registry miss for a bean marked for generation ({@code @SimpleBuilder} or a template + * annotation) defers the mapper via {@link TypeHierarchyErroneousException} while {@link + * State#INIT} or {@link State#PROCESSING} hold — MapStruct may run ahead of this processor's first + * round. From {@link State#TARGETS_REGISTERED} on, an unregistered bean was skipped by planning or + * is foreign and resolves to no builder; at {@link State#FINISHED} a marked bean that was never + * registered is reported as a warning. A published builder whose type is not emitted yet defers + * until {@link State#FINISHED}. * *

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 @@ -74,13 +77,6 @@ @AutoService(BuilderProvider.class) public class MapStructBuilderProvider implements BuilderProvider { - private static final String SIMPLE_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder"; - private static final String SIMPLE_BUILDER_TEMPLATE_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template"; - private static final String IGNORE_4_BUILDER_ANNOTATION = - "org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration"; - private Elements elementUtils; private Map processorOptions = Map.of(); @@ -116,6 +112,10 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { return builderInfo; } + /** + * 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 = SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()); @@ -124,33 +124,41 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement) { return null; } ExecutableElement creationMethod = creationMethod(builderElement, published.orElseThrow()); - List buildMethod = buildMethod(builderElement, published.orElseThrow()); + Optional buildMethod = buildMethod(builderElement, published.orElseThrow()); if (creationMethod == null || buildMethod.isEmpty()) { return null; } return new BuilderInfo.Builder() .builderCreationMethod(creationMethod) - .buildMethod(buildMethod) + .buildMethod(List.of(buildMethod.orElseThrow())) .build(); } /** - * Locates the generated builder for {@code beanElement} through the registry {@code - * BuilderProcessor} publishes — the list decides alone which type is claimed. + * 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) { + boolean markedForGeneration = isBuilderGenerationTarget(beanElement); if (published.isEmpty()) { - State state = SimpleBuildersSpiIntegration.state(); - // While the initial target registration may still be missing, any bean may still get an - // entry; afterwards only a bean marked for generation may still be registered in a later - // round. Deferring retries the mapper once the registry could have filled. - if (state == State.INIT - || state == State.PROCESSING - || (state == State.TARGETS_REGISTERED && isBuilderGenerationTarget(beanElement))) { - throw new TypeHierarchyErroneousException(beanElement.asType()); + if (!markedForGeneration) { + // Foreign bean — never claimed, never deferred. + return null; } - return null; + return switch (SimpleBuildersSpiIntegration.state()) { + // Marked but not published yet — MapStruct may run ahead of this processor's first + // round; defer so the registry can fill in before the mapper is generated. + case INIT, PROCESSING -> throw new TypeHierarchyErroneousException(beanElement.asType()); + // Initial targets are registered — a marked bean without an entry was skipped by + // planning; it maps without a builder. + case TARGETS_REGISTERED -> null; + case FINISHED -> { + warnMarkedBeanWithoutRegisteredBuilder(beanElement); + yield null; + } + }; } TypeElement builderElement = elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); @@ -161,6 +169,15 @@ private TypeElement findBuilderElement( return builderElement; } + /** Warns that a bean marked for generation got no registered builder in this compilation. */ + private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { + // SPI environments expose no Messager — stderr is the only channel a build shows. + System.err.println( + "simple-builders: WARNING: 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). @@ -169,64 +186,42 @@ private ExecutableElement creationMethod(TypeElement builderElement, PublishedBu if (!(published.builder().funcForEmptyBuilder() instanceof StaticFactoryCall factory)) { return null; } - return findMethod(builderElement, factory.methodName(), Modifier.PUBLIC, Modifier.STATIC); + return JavaLangAnalyser.findMethodWithoutParameters( + builderElement, factory.methodName(), 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 List buildMethod( + private Optional buildMethod( TypeElement builderElement, PublishedBuilder published) { - ExecutableElement method = - findMethod(builderElement, published.builder().buildMethodName(), Modifier.PUBLIC); - return method == null ? List.of() : List.of(method); - } - - /** The parameterless method with {@code name} and all {@code requiredModifiers} on the type. */ - private ExecutableElement findMethod( - TypeElement typeElement, String name, Modifier... requiredModifiers) { - for (Element member : typeElement.getEnclosedElements()) { - if (member.getKind() == ElementKind.METHOD - && member instanceof ExecutableElement method - && method.getSimpleName().contentEquals(name) - && method.getParameters().isEmpty() - && method.getModifiers().containsAll(List.of(requiredModifiers))) { - return method; - } - } - return null; + return JavaLangAnalyser.findMethodWithoutParameters( + builderElement, published.builder().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 deferral — it never decides which type is claimed. + * only decides deferral and the finished-state warning — it never decides which type is claimed. */ private boolean isBuilderGenerationTarget(TypeElement beanElement) { - boolean marked = false; - for (AnnotationMirror mirror : elementUtils.getAllAnnotationMirrors(beanElement)) { - String annotationName = - ((TypeElement) mirror.getAnnotationType().asElement()).getQualifiedName().toString(); - if (annotationName.equals(IGNORE_4_BUILDER_ANNOTATION)) { - return false; - } - if (annotationName.equals(SIMPLE_BUILDER_ANNOTATION)) { - marked = true; - continue; - } + 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. - for (AnnotationMirror metaMirror : - mirror.getAnnotationType().asElement().getAnnotationMirrors()) { - if (((TypeElement) metaMirror.getAnnotationType().asElement()) - .getQualifiedName() - .contentEquals(SIMPLE_BUILDER_TEMPLATE_ANNOTATION)) { - marked = true; - break; - } + if (JavaLangAnalyser.findAnnotation( + mirror.getAnnotationType().asElement(), SimpleBuilder.Template.class) + .isPresent()) { + return true; } } - return marked; + return false; } } 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 index a44c2d05..5d793476 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -24,12 +24,15 @@ 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; @@ -42,97 +45,12 @@ */ class MapStructSpiIntegrationTest { - private static final JavaFileObject PERSON_DTO = - 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 final JavaFileObject PERSON_DTO_MAPPER = - ProcessorTestUtils.forSource( - """ - package test; - - import org.mapstruct.Mapper; - - @Mapper - public interface PersonDtoMapper { - - PersonDto copy(PersonDto source); - } - """); - - private static final JavaFileObject MUTABLE_DTO = - 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 final JavaFileObject MUTABLE_DTO_MAPPER = - ProcessorTestUtils.forSource( - """ - package test; - - import org.mapstruct.Mapper; - - @Mapper - public interface MutableDtoMapper { - - MutableDto copy(MutableDto source); - } - """); - - private static Compiler compiler() { - return Compiler.javac() - .withProcessors(new BuilderProcessor(), new MappingProcessor()) - .withOptions( - "-Amapstruct.suppressGeneratorTimestamp=true", - "-Amapstruct.suppressGeneratorVersionInfoComment=true"); - } - @Test void mapStruct_shouldUseGeneratedBuilder() { - Compilation compilation = compiler().compile(PERSON_DTO, PERSON_DTO_MAPPER); + Compilation compilation = + mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) + .compile(personDto(), personDtoMapper()); assertThat(compilation).succeeded(); - ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); assertTrue( @@ -147,37 +65,25 @@ void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() { // builder: the marked bean defers via TypeHierarchyErroneousException until the registry // holds the planned builder Compilation compilation = - Compiler.javac() - .withProcessors(new MappingProcessor(), new BuilderProcessor()) - .withOptions( - "-Amapstruct.suppressGeneratorTimestamp=true", - "-Amapstruct.suppressGeneratorVersionInfoComment=true") - .compile(PERSON_DTO, PERSON_DTO_MAPPER); + mapStructCompiler(new MappingProcessor(), new BuilderProcessor()) + .compile(personDto(), personDtoMapper()); assertThat(compilation).succeeded(); - ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); assertTrue( mapperImpl.contains("PersonDtoBuilder.create()"), - "Reversed processor order must still bind the generated builder, got:\n" + mapperImpl); + "Reversed processor order must still bind the generated builder"); assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()"); } @Test void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { - Compilation compilation = compiler().compile(PERSON_DTO, PERSON_DTO_MAPPER); + Compilation compilation = + mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) + .compile(personDto(), personDtoMapper()); assertThat(compilation).succeeded(); - ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); - String warnings = - compilation.warnings().stream() - .map(diagnostic -> diagnostic.getMessage(null)) - .reduce("", (left, right) -> left + "\n" + right); - assertFalse( - warnings.toLowerCase().contains("unmapped target property"), - "Generated helper methods (add2*, *Update, Supplier/Consumer overloads) must not " - + "surface as unmapped target properties, got: " - + warnings); + assertNoWarningContaining(compilation, "unmapped target property"); } @Test @@ -185,15 +91,10 @@ void mapStruct_disabledIntegration_shouldMapViaSetters() { // MapStruct does not forward foreign -A options to SPI environments, so BuilderProcessor // publishes the resolved switch to them via SimpleBuildersSpiIntegration Compilation compilation = - Compiler.javac() - .withProcessors(new BuilderProcessor(), new MappingProcessor()) - .withOptions( - "-Amapstruct.suppressGeneratorTimestamp=true", - "-Amapstruct.suppressGeneratorVersionInfoComment=true", - "-Asimplebuilder.usingMapStructIntegration=DISABLED") - .compile(MUTABLE_DTO, MUTABLE_DTO_MAPPER); + mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) + .withOptions("-Asimplebuilder.usingMapStructIntegration=DISABLED") + .compile(mutableDto(), mutableDtoMapper()); assertThat(compilation).succeeded(); - ProcessorTestUtils.printDiagnosticsOnVerbose(compilation); String mapperImpl = loadGeneratedSource(compilation, "MutableDtoMapperImpl"); assertFalse( @@ -201,4 +102,94 @@ void mapStruct_disabledIntegration_shouldMapViaSetters() { "Disabled integration must leave the generated builder unused"); assertTrue(mapperImpl.contains(".setName("), "MapStruct should fall back to setter mapping"); } + + /** 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 index ea87b14c..64fbed37 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -64,84 +64,100 @@ */ class MapStructSpiProbeTest { - private static final JavaFileObject PERSON_DTO = - ProcessorTestUtils.forSource( - """ - package test; + private static JavaFileObject personDto() { + return ProcessorTestUtils.forSource( + """ + package test; - import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; - @SimpleBuilder - public class PersonDto { - private String name; + @SimpleBuilder + public class PersonDto { + private String name; - public String getName() { - return name; - } + public String getName() { + return name; + } - public void setName(String name) { - this.name = name; - } + public void setName(String name) { + this.name = name; } - """); + } + """); + } - private static final JavaFileObject FOREIGN_DTO = - ProcessorTestUtils.forSource( - """ - package test; + private static JavaFileObject foreignDto() { + return ProcessorTestUtils.forSource( + """ + package test; - public class ForeignDto { - private String name; - } - """); + public class ForeignDto { + private String name; + } + """); + } - private static final JavaFileObject IGNORED_DTO = - ProcessorTestUtils.forSource( - """ - package test; + 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; + import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; - @SimpleBuilder - @Ignore4BuilderGeneration - public class IgnoredDto { - private String name; - } - """); + @SimpleBuilder + @Ignore4BuilderGeneration + public class IgnoredDto { + private String name; + } + """); + } - private static final JavaFileObject TEMPLATE_DTO = - ProcessorTestUtils.forSource( - """ - package test; + private static JavaFileObject scopedDto() { + return ProcessorTestUtils.forSource( + """ + package scoped; - import org.javahelpers.simple.builders.core.annotations.SimpleMinimalBuilder; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; - @SimpleMinimalBuilder - public class MinimalDto { - private String name; - } - """); + @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; + } + """); + } @Test void probe_shouldDriveSpiAgainstEmittedElements() { Compilation compilation = Compiler.javac() .withProcessors(new SpiProbeProcessor(), new BuilderProcessor()) - .compile(PERSON_DTO, FOREIGN_DTO, IGNORED_DTO, TEMPLATE_DTO); + .compile(personDto(), foreignDto(), ignoredDto(), templateDto()); assertThat(compilation).succeeded(); ProbeResults results = ProbeResults.instance; - // While the initial targets may be unpublished every miss defers — marked beans, - // template-annotated beans, foreign and opted-out beans alike. + // Marked beans defer while unpublished — marked or template-annotated alike. assertInstanceOf(TypeHierarchyErroneousException.class, results.unregisteredDeferral); assertInstanceOf(TypeHierarchyErroneousException.class, results.templateDeferral); - assertInstanceOf(TypeHierarchyErroneousException.class, results.foreignUnpublished); - assertInstanceOf(TypeHierarchyErroneousException.class, results.ignoredUnpublished); - // Once targets are registered only marked beans may still arrive — foreign and - // opted-out beans resolve to no builder immediately and never get claimed. + // Foreign and opted-out beans are never claimed and never wait, whichever state applies. + assertNull(results.foreignUnpublished); + assertNull(results.ignoredUnpublished); assertNull(results.foreignRegistered); assertNull(results.ignoredRegistered); assertNull(results.foreignWhenFinished); @@ -161,6 +177,20 @@ void probe_shouldDriveSpiAgainstEmittedElements() { 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 + // unpublished, then resolves to no builder — reported as a warning only once finished. + // 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. */ @@ -170,8 +200,11 @@ private static final class ProbeResults { final Map methodTypes = new LinkedHashMap<>(); Throwable unregisteredDeferral; Throwable templateDeferral; - Throwable foreignUnpublished; - Throwable ignoredUnpublished; + Throwable skippedBeanDeferred; + BuilderInfo foreignUnpublished; + BuilderInfo ignoredUnpublished; + BuilderInfo skippedBeanRegistered; + BuilderInfo skippedBeanFinished; BuilderInfo foreignRegistered; BuilderInfo ignoredRegistered; BuilderInfo foreignWhenFinished; @@ -183,8 +216,11 @@ void reset() { methodTypes.clear(); unregisteredDeferral = null; templateDeferral = null; + skippedBeanDeferred = null; foreignUnpublished = null; ignoredUnpublished = null; + skippedBeanRegistered = null; + skippedBeanFinished = null; foreignRegistered = null; ignoredRegistered = null; foreignWhenFinished = null; @@ -198,15 +234,26 @@ void reset() { * 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 a marked bean is probed before registration. + * {@link BuilderProcessor} in the first round so a marked bean is probed before registration. In + * {@code skippedMode} it probes the marked bean that the scoped {@link BuilderProcessor} never + * plans, covering the post-registration and finished outcomes. */ @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); @@ -238,7 +285,11 @@ public Map getOptions() { public boolean process(Set annotations, RoundEnvironment roundEnv) { ProbeResults results = ProbeResults.instance; if (roundEnv.processingOver()) { - results.foreignWhenFinished = lookup(provider, "test.ForeignDto"); + if (skippedMode) { + results.skippedBeanFinished = lookup(provider, "test.PersonDto"); + } else { + results.foreignWhenFinished = lookup(provider, "test.ForeignDto"); + } return false; } @@ -247,12 +298,23 @@ public boolean process(Set annotations, RoundEnvironment return false; } + if (skippedMode) { + // The bean is marked but out of the generation scope: it defers while unpublished, + // then resolves to no builder from TARGETS_REGISTERED on. + 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.foreignUnpublished = lookupExpectingDeferral(element("test.ForeignDto")); - results.ignoredUnpublished = lookupExpectingDeferral(element("test.IgnoredDto")); + results.foreignUnpublished = lookup(provider, "test.ForeignDto"); + results.ignoredUnpublished = lookup(provider, "test.IgnoredDto"); results.templateDeferral = lookupExpectingDeferral(element("test.MinimalDto")); return false; } 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. * From 6feebfee5d363b370bd80fd0a1c3d7decd51a7df Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:34:24 +0000 Subject: [PATCH 16/25] Pin SPI state to its compilation via Elements identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPI adapters now only read the holder: staleness detection moves to isCurrentCompilation(Elements) — javac hands all processors of one compilation the same Elements instance, so a mismatch means the holder still describes a previous run. This replaces spiInitialized(). - targetsRegistered() transitions unconditionally - registerBuilder takes the ResolvedBuilder (real instance from resolveGeneratedBuilder, new API extracted in BuilderScopeResolver which now deduplicates the in-round contract construction) - isMapstructGenerationEnabled(elementUtils, options) replaces isIntegrationDisabled; stale observers fall back to -D/-A - isSimpleBuildersFinishedForIntegration() helper replaces state comparisons at deferral points - Naming strategy defers via TypeHierarchyErroneousException when a published bean type is not emitted yet - Bean field scanning moved into JavaLangAnalyser.findFields (non-static incl. inherited) --- .../builders/processor/BuilderProcessor.java | 9 +- .../SimpleBuildersSpiIntegration.java | 79 ++++++++-------- .../analysis/BuilderScopeResolver.java | 34 +++++-- .../processor/analysis/JavaLangAnalyser.java | 28 ++++++ .../MapStructAccessorNamingStrategy.java | 43 ++++----- .../mapstruct/MapStructBuilderProvider.java | 33 +++++-- .../SimpleBuildersSpiIntegrationTest.java | 91 ++++++++++++------- 7 files changed, 198 insertions(+), 119 deletions(-) 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 6127077d..e22377d7 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 @@ -116,6 +116,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { // resolved switch through the shared classloader instead String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); SimpleBuildersSpiIntegration.initCompilation( + processingEnv.getElementUtils(), mapStructOption == null ? null : OptionValueParsers.parseOptionState(mapStructOption, logger) != OptionState.DISABLED); @@ -546,8 +547,12 @@ private void registerGeneratedTypes(List elementsToGenerate) elementToGenerate.reportingElement(), elementToGenerate.config()), elementToGenerate.config()); scopeResolver.registerGeneratedBuilder(beanType, builderType); - SimpleBuildersSpiIntegration.registerBuilder( - beanType, builderType, elementToGenerate.config().getSetterSuffix()); + 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 index 6b8c5c74..9efeac0f 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -26,7 +26,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; +import javax.lang.model.util.Elements; import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; @@ -37,12 +37,13 @@ * setterSuffix}, so adapters do not scan elements or read annotations themselves. * *

The lifecycle reports where {@link BuilderProcessor}'s builder generation stands and is - * transitioned by that processor alone — other processors or SPI adapters never write 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). Only while {@link State#PROCESSING}, {@link State#TARGETS_REGISTERED} or {@link - * State#FINISHED} are the published switch and registry the current compilation's truth. + * 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} to + * {@link #isCurrentCompilation}: 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 { @@ -80,6 +81,9 @@ public record PublishedBuilder(TypeName beanType, ResolvedBuilder builder, Strin private static volatile State state = State.INIT; + /** The {@link Elements} of the compilation this holder's content describes. */ + private static volatile Elements compilationElements; + /** The processor-resolved integration switch; {@code null} leaves the fallbacks active. */ private static volatile Boolean integrationEnabled; @@ -95,9 +99,10 @@ private SimpleBuildersSpiIntegration() {} * Starts a new compilation: clears the registry and publishes the integration switch the * processor resolved ({@code null} when the option is unset). */ - static void initCompilation(Boolean integrationEnabled) { + static void initCompilation(Elements elements, Boolean integrationEnabled) { BY_BEAN.clear(); BY_BUILDER.clear(); + compilationElements = elements; SimpleBuildersSpiIntegration.integrationEnabled = integrationEnabled; state = State.PROCESSING; } @@ -107,9 +112,7 @@ static void initCompilation(Boolean integrationEnabled) { * published; the registry may still grow in later rounds. */ static void targetsRegistered() { - if (state == State.PROCESSING) { - state = State.TARGETS_REGISTERED; - } + state = State.TARGETS_REGISTERED; } /** Marks the compilation as finished: the registry will not grow any further. */ @@ -118,18 +121,13 @@ static void finishCompilation() { } /** - * Called by each SPI adapter on {@code init} to age out a stale {@link State#FINISHED} left by a - * previous compilation: javac initializes every processor lazily in its turn, so an SPI init may - * run before {@link BuilderProcessor#init} of the same compilation, and a {@link State#FINISHED} - * observed here can only be leftover — a live {@link State#FINISHED} implies the last round - * already ran and no new SPI init would follow — and is reset to {@link State#INIT}. This - * corrects the observation; it does not declare generation state, which {@link BuilderProcessor} - * alone transitions. + * 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. */ - public static void spiInitialized() { - if (state == State.FINISHED) { - state = State.INIT; - } + public static boolean isCurrentCompilation(Elements observed) { + return observed != null && observed == compilationElements; } /** The lifecycle state the SPI adapters observe for the current compilation. */ @@ -137,17 +135,17 @@ public static State state() { return state; } + /** Whether {@link BuilderProcessor} finished generating builders for this compilation. */ + public static boolean isSimpleBuildersFinishedForIntegration() { + return state == State.FINISHED; + } + /** - * Publishes the builder planned for {@code beanType} in this compilation under the standard - * contract every generated builder satisfies: {@code create()} obtains an empty builder instance - * and {@code build()} returns the finished bean. + * Publishes the builder {@link BuilderProcessor} resolved for {@code beanType} in this + * compilation. */ - static void registerBuilder(TypeName beanType, TypeName builderType, String setterSuffix) { - PublishedBuilder publishedBuilder = - new PublishedBuilder( - beanType, - new ResolvedBuilder(builderType, new StaticFactoryCall("create"), null), - setterSuffix); + static void registerBuilder(TypeName beanType, ResolvedBuilder builder, String setterSuffix) { + PublishedBuilder publishedBuilder = new PublishedBuilder(beanType, builder, setterSuffix); BY_BEAN.put(publishedBuilder.beanType().getFullQualifiedName(), publishedBuilder); BY_BUILDER.put(publishedBuilder.builder().typeName().getFullQualifiedName(), publishedBuilder); } @@ -166,21 +164,22 @@ public static Optional builderByName(String builderQualifiedNa } /** - * Whether the integration is switched off: while the processor has published this compilation's - * resolution ({@link State#PROCESSING} or {@link State#FINISHED}) it wins; without one — incl. - * the stale leftovers of a previous run in {@link State#INIT} — the {@code simplebuilder.*} - * convention applies (JVM system property before the annotation processor option the hosting - * framework does not forward anyway). + * Whether the MapStruct integration is switched on: while the processor has published this + * compilation's resolution (and {@code observed} proves it current) that value wins; without one + * — incl. the stale leftovers of a previous run — the {@code simplebuilder.*} convention applies + * (JVM system property before the annotation processor option the hosting framework does not + * forward anyway). Anything but {@code false}/{@code disabled} keeps it on. */ - public static boolean isIntegrationDisabled(Map processorOptions) { - if (state != State.INIT) { + public static boolean isMapstructGenerationEnabled( + Elements observed, Map processorOptions) { + if (isCurrentCompilation(observed) && state != State.INIT) { Boolean published = integrationEnabled; if (published != null) { - return !published; + return published; } } String value = System.getProperty(OPTION_USING_MAPSTRUCT, processorOptions.get(OPTION_USING_MAPSTRUCT)); - return "false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value); + return !("false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value)); } } 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..e8d2345d 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. + * + *

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. @@ -214,16 +237,9 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - Optional generatedBuilder = generatedBuilders.findBuilder(referencedTypeName); + Optional generatedBuilder = resolveGeneratedBuilder(referencedType); if (generatedBuilder.isPresent()) { - // Our generators always emit a static create() and no create(T) - the empty path uses - // the factory, the copy path the constructor - return generatedBuilder.map( - builder -> - new ResolvedBuilder( - builder, - new BuilderInstantiation.StaticFactoryCall("create"), - new BuilderInstantiation.ConstructorCall())); + return generatedBuilder; } // Reusing builders not generated in this round - nested or anchored inside the referenced 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 40836913..f091d9a3 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 @@ -44,6 +44,8 @@ import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import org.apache.commons.collections4.CollectionUtils; @@ -797,6 +799,32 @@ public static Optional findFieldElement( .findFirst(); } + /** + * Finds all non-static field elements declared by the given type or inherited from its + * superclasses (the walk stops at {@code java.lang.Object}). + * + * @param type the type element to search + * @return list of field elements, declared fields first, then superclass fields + */ + public static List findFields(TypeElement type) { + List fields = new ArrayList<>(); + TypeElement current = type; + while (current != null && !current.getQualifiedName().contentEquals("java.lang.Object")) { + for (VariableElement field : ElementFilter.fieldsIn(current.getEnclosedElements())) { + if (!field.getModifiers().contains(Modifier.STATIC)) { + fields.add(field); + } + } + TypeMirror superclass = current.getSuperclass(); + current = + superclass.getKind() == TypeKind.DECLARED + && ((DeclaredType) superclass).asElement() instanceof TypeElement parent + ? parent + : null; + } + return fields; + } + /** * Finds a record component by name in the given type element. * 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 index 5de9a447..fa415561 100644 --- 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 @@ -27,21 +27,18 @@ import java.util.HashMap; import java.util.Map; import java.util.Optional; -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.TypeElement; import javax.lang.model.element.VariableElement; -import javax.lang.model.type.DeclaredType; -import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; 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.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 @@ -69,8 +66,6 @@ public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrate @Override public void init(MapStructProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); - // Ages out stale lifecycle state left by a previous compilation on a reused JVM. - SimpleBuildersSpiIntegration.spiInitialized(); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; } @@ -78,7 +73,11 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (SimpleBuildersSpiIntegration.isIntegrationDisabled(processorOptions)) { + if (!SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) + || !SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( + elementUtils, processorOptions)) { + // The holder describes another compilation (or is disabled): simple-builders is not + // ready here, so everything keeps the default classification. return methodType; } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { @@ -122,28 +121,18 @@ private Map directSettersOf(TypeElement builderType) { 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()) { + throw new TypeHierarchyErroneousException(builderType.asType()); + } return Map.of(); } Map directSetters = new HashMap<>(); - collectFields(beanElement, published.get().setterSuffix(), directSetters); - return directSetters; - } - - /** Collects the bean's non-static fields incl. inherited ones into {@code directSetters}. */ - private void collectFields( - TypeElement beanElement, String setterSuffix, Map directSetters) { - for (Element member : beanElement.getEnclosedElements()) { - if (member.getKind() == ElementKind.FIELD - && member instanceof VariableElement field - && !field.getModifiers().contains(Modifier.STATIC)) { - directSetters.put(field.getSimpleName().toString() + setterSuffix, field.asType()); - } - } - TypeMirror superclass = beanElement.getSuperclass(); - if (superclass.getKind() == TypeKind.DECLARED - && ((DeclaredType) superclass).asElement() instanceof TypeElement parent - && !parent.getQualifiedName().contentEquals("java.lang.Object")) { - collectFields(parent, setterSuffix, directSetters); + for (VariableElement field : JavaLangAnalyser.findFields(beanElement)) { + 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/MapStructBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java index f3bb2984..4050b67c 100644 --- 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 @@ -85,8 +85,6 @@ public class MapStructBuilderProvider implements BuilderProvider { @Override public void init(MapStructProcessingEnvironment processingEnvironment) { - // Ages out stale lifecycle state left by a previous compilation on a reused JVM. - SimpleBuildersSpiIntegration.spiInitialized(); this.elementUtils = processingEnvironment.getElementUtils(); Map options = processingEnvironment.getOptions(); processorOptions = options == null ? Map.of() : options; @@ -94,7 +92,8 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (SimpleBuildersSpiIntegration.isIntegrationDisabled(processorOptions)) { + if (!SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( + elementUtils, processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -117,8 +116,7 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { * BuilderProcessor} publishes — the list decides alone which type is claimed. */ private BuilderInfo createBuilderInfo(TypeElement beanElement) { - Optional published = - SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()); + Optional published = publishedFor(beanElement); TypeElement builderElement = findBuilderElement(beanElement, published); if (builderElement == null) { return null; @@ -147,7 +145,7 @@ private TypeElement findBuilderElement( // Foreign bean — never claimed, never deferred. return null; } - return switch (SimpleBuildersSpiIntegration.state()) { + return switch (state()) { // Marked but not published yet — MapStruct may run ahead of this processor's first // round; defer so the registry can fill in before the mapper is generated. case INIT, PROCESSING -> throw new TypeHierarchyErroneousException(beanElement.asType()); @@ -162,13 +160,34 @@ private TypeElement findBuilderElement( } TypeElement builderElement = elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); - if (builderElement == null && SimpleBuildersSpiIntegration.state() != State.FINISHED) { + if (builderElement == null + && !SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()) { // Published but not emitted yet — defer so the mapper retries once the type exists. throw new TypeHierarchyErroneousException(beanElement.asType()); } return builderElement; } + /** + * The state of the compilation this provider's {@code elementUtils} belongs to — {@link + * State#INIT} while the holder still describes a previous run on a reused JVM. + */ + private State state() { + return SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) + ? SimpleBuildersSpiIntegration.state() + : State.INIT; + } + + /** + * The builder published for {@code beanElement} — empty while the holder still describes a + * previous run, so stale entries can never claim a bean. + */ + private Optional publishedFor(TypeElement beanElement) { + return SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) + ? SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()) + : Optional.empty(); + } + /** Warns that a bean marked for generation got no registered builder in this compilation. */ private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { // SPI environments expose no Messager — stderr is the only channel a build shows. 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 index 49296760..a52fd364 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -27,7 +27,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Proxy; import java.util.Map; +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; @@ -37,14 +39,16 @@ import org.junit.jupiter.api.Test; /** - * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current - * compilation, the registry exposes exactly the published descriptors, and the integration switch - * honors the {@code -D} > processor-published precedence. + * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current compilation + * (identified by its {@link Elements}), the registry exposes exactly the published descriptors, and + * the integration switch honors the {@code -D} > processor-published precedence. */ class SimpleBuildersSpiIntegrationTest { private static final String OPTION = "simplebuilder.usingMapStructIntegration"; + private static final Elements ELEMENTS = fakeElements(); + private static final PublishedBuilder PERSON = new PublishedBuilder( new TypeName("test", "PersonDto"), @@ -55,47 +59,57 @@ class SimpleBuildersSpiIntegrationTest { "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() { System.clearProperty(OPTION); - SimpleBuildersSpiIntegration.initCompilation(null); + SimpleBuildersSpiIntegration.initCompilation(null, null); } @Test - void lifecycle_transitionsAndStaleReset() { - SimpleBuildersSpiIntegration.initCompilation(null); + void lifecycle_transitions() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); SimpleBuildersSpiIntegration.targetsRegistered(); assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); - // Later rounds keep the state; only the first transition counts. SimpleBuildersSpiIntegration.targetsRegistered(); assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); SimpleBuildersSpiIntegration.finishCompilation(); assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); - - // A new compilation's SPI init must age the leftover FINISHED out. - SimpleBuildersSpiIntegration.spiInitialized(); - assertEquals(State.INIT, SimpleBuildersSpiIntegration.state()); + assertTrue(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()); } @Test - void spiInitialized_processingStateSurvives() { - SimpleBuildersSpiIntegration.initCompilation(Boolean.TRUE); - SimpleBuildersSpiIntegration.spiInitialized(); - assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); + void staleness_onlyOwnCompilationIsCurrent() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); + 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(null); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); assertTrue(SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").isEmpty()); SimpleBuildersSpiIntegration.registerBuilder( - PERSON.beanType(), PERSON.builder().typeName(), PERSON.setterSuffix()); + PERSON.beanType(), PERSON.builder(), PERSON.setterSuffix()); assertEquals(PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto").orElseThrow()); assertEquals( @@ -106,37 +120,46 @@ void registry_publishesBothDirections() { @Test void registry_clearedOnNextCompilation() { - SimpleBuildersSpiIntegration.initCompilation(null); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); SimpleBuildersSpiIntegration.registerBuilder( - PERSON.beanType(), PERSON.builder().typeName(), PERSON.setterSuffix()); + PERSON.beanType(), PERSON.builder(), PERSON.setterSuffix()); - SimpleBuildersSpiIntegration.initCompilation(null); + SimpleBuildersSpiIntegration.initCompilation(fakeElements(), null); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); } @Test - void isIntegrationDisabled_publishedSwitchWinsOverProperty() { + void isMapstructGenerationEnabled_publishedSwitchWinsOverProperty() { System.setProperty(OPTION, "DISABLED"); - SimpleBuildersSpiIntegration.initCompilation(Boolean.TRUE); - assertFalse(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.TRUE); + assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); - SimpleBuildersSpiIntegration.initCompilation(Boolean.FALSE); - assertTrue(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.FALSE); + assertFalse(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); } @Test - void isIntegrationDisabled_fallsBackToPropertyOnlyInInit() { - // Stale published values must not leak: the previous compilation's DISABLED stays ignored - // once a new compilation resets to INIT, so the property decides until our init publishes. - SimpleBuildersSpiIntegration.initCompilation(Boolean.FALSE); - SimpleBuildersSpiIntegration.finishCompilation(); - SimpleBuildersSpiIntegration.spiInitialized(); - assertEquals(State.INIT, SimpleBuildersSpiIntegration.state()); + void isMapstructGenerationEnabled_staleObserverFallsBackToProperty() { + // The previous compilation's DISABLED must not leak: an SPI observing with another + // compilation's Elements gets the property fallback until our init publishes. + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.FALSE); + Elements otherCompilation = fakeElements(); System.setProperty(OPTION, "ENABLED"); - assertFalse(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + assertTrue( + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(otherCompilation, Map.of())); + + System.setProperty(OPTION, "DISABLED"); + assertFalse( + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(otherCompilation, Map.of())); + } + + @Test + void isMapstructGenerationEnabled_noPublishedValueFallsBackToProperty() { + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); + assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); System.setProperty(OPTION, "DISABLED"); - assertTrue(SimpleBuildersSpiIntegration.isIntegrationDisabled(Map.of())); + assertFalse(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); } } From 38de9b9e44840276d5ac19abe808ca7af2a4408b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:15:47 +0000 Subject: [PATCH 17/25] Fix Sonar findings: AtomicReference, logger, NPE guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - compilationElements held in AtomicReference (java:S3077 — volatile on a non-thread-safe type is not enough) - finished-state warning goes through System.Logger (java:S106) — SPI environments still expose no Messager - null-guard annotation type element before meta-annotation lookup (javabugs:S2259) --- .../SimpleBuildersSpiIntegration.java | 7 ++++--- .../mapstruct/MapStructBuilderProvider.java | 18 ++++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) 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 index 9efeac0f..51bb26e4 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -26,6 +26,7 @@ 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.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; @@ -82,7 +83,7 @@ public record PublishedBuilder(TypeName beanType, ResolvedBuilder builder, Strin private static volatile State state = State.INIT; /** The {@link Elements} of the compilation this holder's content describes. */ - private static volatile Elements compilationElements; + private static final AtomicReference compilationElements = new AtomicReference<>(); /** The processor-resolved integration switch; {@code null} leaves the fallbacks active. */ private static volatile Boolean integrationEnabled; @@ -102,7 +103,7 @@ private SimpleBuildersSpiIntegration() {} static void initCompilation(Elements elements, Boolean integrationEnabled) { BY_BEAN.clear(); BY_BUILDER.clear(); - compilationElements = elements; + compilationElements.set(elements); SimpleBuildersSpiIntegration.integrationEnabled = integrationEnabled; state = State.PROCESSING; } @@ -127,7 +128,7 @@ static void finishCompilation() { * staleness check that never requires the SPI adapters to write anything. */ public static boolean isCurrentCompilation(Elements observed) { - return observed != null && observed == compilationElements; + return observed != null && observed == compilationElements.get(); } /** The lifecycle state the SPI adapters observe for the current compilation. */ 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 index 4050b67c..1f3a6f89 100644 --- 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 @@ -190,11 +190,13 @@ private Optional publishedFor(TypeElement beanElement) { /** Warns that a bean marked for generation got no registered builder in this compilation. */ private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { - // SPI environments expose no Messager — stderr is the only channel a build shows. - System.err.println( - "simple-builders: WARNING: no generated builder was registered for the marked bean '" - + beanElement.getQualifiedName() - + "'; MapStruct maps it without a builder."); + // 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 '{0}';" + + " MapStruct maps it without a builder.", + beanElement.getQualifiedName()); } /** @@ -235,9 +237,9 @@ private boolean isBuilderGenerationTarget(TypeElement beanElement) { 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 (JavaLangAnalyser.findAnnotation( - mirror.getAnnotationType().asElement(), SimpleBuilder.Template.class) - .isPresent()) { + if (mirror.getAnnotationType().asElement() instanceof TypeElement annotationType + && JavaLangAnalyser.findAnnotation(annotationType, SimpleBuilder.Template.class) + .isPresent()) { return true; } } From 0845b879d716237e7bbf2329dd5e58f88169e604 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:42:32 +0000 Subject: [PATCH 18/25] Defer marked beans in every non-final state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A registry miss for a marked bean now defers via\nTypeHierarchyErroneousException throughout the non-final phase (the\nTARGETS_REGISTERED fallback is dropped — a bean may still be registered\nin a later round or with an element another processor emits); once\nFINISHED the miss is definitive and reported as a warning. The skipped\nprobe compile runs behind BuilderProcessor so FINISHED is observable and\nits warning exercised end-to-end. --- .../mapstruct/MapStructBuilderProvider.java | 38 +++++++++---------- .../processor/MapStructSpiProbeTest.java | 27 +++++++------ 2 files changed, 32 insertions(+), 33 deletions(-) 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 index 1f3a6f89..73353f05 100644 --- 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 @@ -61,12 +61,12 @@ * builder mapping. * *

A registry miss for a bean marked for generation ({@code @SimpleBuilder} or a template - * annotation) defers the mapper via {@link TypeHierarchyErroneousException} while {@link - * State#INIT} or {@link State#PROCESSING} hold — MapStruct may run ahead of this processor's first - * round. From {@link State#TARGETS_REGISTERED} on, an unregistered bean was skipped by planning or - * is foreign and resolves to no builder; at {@link State#FINISHED} a marked bean that was never - * registered is reported as a warning. A published builder whose type is not emitted yet defers - * until {@link State#FINISHED}. + * annotation) defers the mapper via {@link TypeHierarchyErroneousException} in every state before + * {@link State#FINISHED} — it may still be registered in a later round or with an element another + * processor emits. Once finished, a marked bean that was never registered is reported as a warning + * and resolves to no builder. Unmarked beans return {@code null} in every state: nothing foreign is + * ever claimed or delayed. A published builder whose type is not emitted yet defers until {@link + * State#FINISHED}. * *

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 @@ -145,18 +145,14 @@ private TypeElement findBuilderElement( // Foreign bean — never claimed, never deferred. return null; } - return switch (state()) { - // Marked but not published yet — MapStruct may run ahead of this processor's first - // round; defer so the registry can fill in before the mapper is generated. - case INIT, PROCESSING -> throw new TypeHierarchyErroneousException(beanElement.asType()); - // Initial targets are registered — a marked bean without an entry was skipped by - // planning; it maps without a builder. - case TARGETS_REGISTERED -> null; - case FINISHED -> { - warnMarkedBeanWithoutRegisteredBuilder(beanElement); - yield null; - } - }; + // Marked but not published — the bean may still be registered while the compilation is + // not in its final phase; a marked bean missing once generation finished was skipped by + // planning. + if (state() != State.FINISHED) { + throw new TypeHierarchyErroneousException(beanElement.asType()); + } + warnMarkedBeanWithoutRegisteredBuilder(beanElement); + return null; } TypeElement builderElement = elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); @@ -194,9 +190,9 @@ private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { System.getLogger(MapStructBuilderProvider.class.getName()) .log( System.Logger.Level.WARNING, - "simple-builders: no generated builder was registered for the marked bean '{0}';" - + " MapStruct maps it without a builder.", - beanElement.getQualifiedName()); + "simple-builders: no generated builder was registered for the marked bean '" + + beanElement.getQualifiedName() + + "'; MapStruct maps it without a builder."); } /** 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 index 64fbed37..e42c4fae 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -178,18 +178,19 @@ void probe_shouldDriveSpiAgainstEmittedElements() { 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 - // unpublished, then resolves to no builder — reported as a warning only once finished. - // The in-scope bean forces a second round so the post-registration lookup runs. + // A marked bean outside the generation scope is skipped by planning: it defers in every + // non-final state while unpublished and resolves to no builder — reported as a warning — + // only once finished. The in-scope bean forces a second round so the post-registration + // lookup runs. Compilation skippedCompile = Compiler.javac() - .withProcessors(new SpiProbeProcessor(true), new BuilderProcessor()) + .withProcessors(new BuilderProcessor(), new SpiProbeProcessor(true)) .withOptions("-Asimplebuilder.builderGenerationPackages=scoped") .compile(personDto(), scopedDto()); assertThat(skippedCompile).succeeded(); ProbeResults skipped = ProbeResults.instance; assertInstanceOf(TypeHierarchyErroneousException.class, skipped.skippedBeanDeferred); - assertNull(skipped.skippedBeanRegistered); + assertInstanceOf(TypeHierarchyErroneousException.class, skipped.skippedBeanRegisteredDeferral); assertNull(skipped.skippedBeanFinished); } @@ -201,9 +202,9 @@ private static final class ProbeResults { Throwable unregisteredDeferral; Throwable templateDeferral; Throwable skippedBeanDeferred; + Throwable skippedBeanRegisteredDeferral; BuilderInfo foreignUnpublished; BuilderInfo ignoredUnpublished; - BuilderInfo skippedBeanRegistered; BuilderInfo skippedBeanFinished; BuilderInfo foreignRegistered; BuilderInfo ignoredRegistered; @@ -217,9 +218,9 @@ void reset() { unregisteredDeferral = null; templateDeferral = null; skippedBeanDeferred = null; + skippedBeanRegisteredDeferral = null; foreignUnpublished = null; ignoredUnpublished = null; - skippedBeanRegistered = null; skippedBeanFinished = null; foreignRegistered = null; ignoredRegistered = null; @@ -235,8 +236,9 @@ void reset() { * 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 a marked bean is probed before registration. In - * {@code skippedMode} it probes the marked bean that the scoped {@link BuilderProcessor} never - * plans, covering the post-registration and finished outcomes. + * {@code skippedMode} it runs behind {@link BuilderProcessor} and probes the marked bean that the + * scoped processor never plans — deferral through the registered states plus the finished + * outcome. */ @SupportedAnnotationTypes("*") public static final class SpiProbeProcessor extends AbstractProcessor { @@ -299,12 +301,13 @@ public boolean process(Set annotations, RoundEnvironment } if (skippedMode) { - // The bean is marked but out of the generation scope: it defers while unpublished, - // then resolves to no builder from TARGETS_REGISTERED on. + // The bean is marked but out of the generation scope: it defers in every non-final + // state while unpublished — the probe runs behind the processor, so these lookups + // already observe TARGETS_REGISTERED. if (results.skippedBeanDeferred == null) { results.skippedBeanDeferred = lookupExpectingDeferral(bean); } else { - results.skippedBeanRegistered = lookup(provider, "test.PersonDto"); + results.skippedBeanRegisteredDeferral = lookupExpectingDeferral(bean); } return false; } From 50c3d0323217646372e703b1e202cec266f3268c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:45:38 +0000 Subject: [PATCH 19/25] =?UTF-8?q?Drop=20TARGETS=5FREGISTERED=20=E2=80=94?= =?UTF-8?q?=20keep=20INIT/PROCESSING/FINISHED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TARGETS_REGISTERED behaved identically to PROCESSING in every check\nsince marked beans defer through all non-final states; the lifecycle\nsimplifies to INIT -> PROCESSING -> FINISHED. FINISHED stays: it is the\nonly point where 'never registered' is provable — later rounds still\nregister targets, and never-planned marked beans need warn+fallback\ninstead of a last-round error. --- .../builders/processor/BuilderProcessor.java | 2 -- .../processor/SimpleBuildersSpiIntegration.java | 17 ++--------------- .../processor/MapStructSpiProbeTest.java | 2 +- .../SimpleBuildersSpiIntegrationTest.java | 7 +------ 4 files changed, 4 insertions(+), 24 deletions(-) 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 e22377d7..8c359864 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 @@ -208,8 +208,6 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); - // The round's targets are published; only transitions on the first round. - SimpleBuildersSpiIntegration.targetsRegistered(); // Returning false leaves the annotations unclaimed so other processors on the // processor path (e.g. MapStruct, AutoService) still see them. return false; 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 index 51bb26e4..ec55a6de 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -59,15 +59,10 @@ public enum State { */ INIT, /** - * {@code BuilderProcessor} initialized: even its initial targets may be unpublished — the - * registry must be treated as possibly still filling. + * {@code BuilderProcessor} initialized: the registry may still fill in this and later rounds — + * elements emitted by other processors can add targets until the last round. */ PROCESSING, - /** - * {@code BuilderProcessor}'s first processing round ran: the registry holds all targets - * discoverable so far and may still grow in later rounds. - */ - TARGETS_REGISTERED, /** The last round ran: the registry is final for this compilation. */ FINISHED } @@ -108,14 +103,6 @@ static void initCompilation(Elements elements, Boolean integrationEnabled) { state = State.PROCESSING; } - /** - * Marks the end of {@link BuilderProcessor}'s first processing round: the initial targets are - * published; the registry may still grow in later rounds. - */ - static void targetsRegistered() { - state = State.TARGETS_REGISTERED; - } - /** Marks the compilation as finished: the registry will not grow any further. */ static void finishCompilation() { state = State.FINISHED; 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 index e42c4fae..b1820be3 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -303,7 +303,7 @@ public boolean process(Set annotations, RoundEnvironment if (skippedMode) { // The bean is marked but out of the generation scope: it defers in every non-final // state while unpublished — the probe runs behind the processor, so these lookups - // already observe TARGETS_REGISTERED. + // already observe the post-registration state. if (results.skippedBeanDeferred == null) { results.skippedBeanDeferred = lookupExpectingDeferral(bean); } else { 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 index a52fd364..9642f71e 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -80,12 +80,7 @@ void reset() { void lifecycle_transitions() { SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); - - SimpleBuildersSpiIntegration.targetsRegistered(); - assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); - - SimpleBuildersSpiIntegration.targetsRegistered(); - assertEquals(State.TARGETS_REGISTERED, SimpleBuildersSpiIntegration.state()); + assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()); SimpleBuildersSpiIntegration.finishCompilation(); assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); From 111c006220e617614c3360398faaecd35841d242 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:56:56 +0000 Subject: [PATCH 20/25] Generate builders in exactly one round and defer all misses until the registry is final BuilderProcessor now generates in the first round carrying its annotations and marks the registry final when that round ends, so the SPI lookup can treat every miss before it as "maybe later": any bean defers via TypeHierarchyErroneousException, which also covers SimpleBuilderFor targets that carry no bean-side marker. The marker check remains only to warn for marked beans missing once the registry is final. Elements first appearing in later rounds get no builder; documented in CONFIGURATION. --- docs/CONFIGURATION.md | 2 + .../builders/processor/BuilderProcessor.java | 25 +++++++--- .../SimpleBuildersSpiIntegration.java | 15 ++++-- .../mapstruct/MapStructBuilderProvider.java | 37 +++++++------- .../processor/MapStructSpiProbeTest.java | 49 +++++++++---------- 5 files changed, 73 insertions(+), 55 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index bada6336..340b5104 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1058,6 +1058,8 @@ Unlike the other options this switch is consumed inside MapStruct's SPI environm **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 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 8c359864..8887c21e 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 @@ -101,6 +101,9 @@ public class BuilderProcessor extends AbstractProcessor { private JacksonModuleGenerator jacksonModuleGenerator; private boolean supportedJdk = true; + /** Whether this processor's single generating round already ran in this compilation. */ + private boolean generated; + @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); @@ -143,11 +146,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") @@ -163,7 +169,6 @@ public boolean process(Set annotations, RoundEnvironment generateJacksonModules(context.getPerformanceTracker()); return false; } - PerformanceTracker tracker = context.getPerformanceTracker(); context.info("simple-builders: PROCESSING ROUND START"); @@ -195,6 +200,11 @@ 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 (generated || (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); @@ -208,6 +218,9 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); + generated = true; + // The generating round is done — the registry is final for SPI adapters from here on. + SimpleBuildersSpiIntegration.finishCompilation(); // Returning false leaves the annotations unclaimed so other processors on the // processor path (e.g. MapStruct, AutoService) still see them. return false; 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 index ec55a6de..3f3e3997 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -59,11 +59,15 @@ public enum State { */ INIT, /** - * {@code BuilderProcessor} initialized: the registry may still fill in this and later rounds — - * elements emitted by other processors can add targets until the last round. + * {@code BuilderProcessor} initialized: its single generating round may not have run yet — the + * registry must be treated as possibly still filling. */ PROCESSING, - /** The last round ran: the registry is final for this compilation. */ + /** + * 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 } @@ -103,7 +107,10 @@ static void initCompilation(Elements elements, Boolean integrationEnabled) { state = State.PROCESSING; } - /** Marks the compilation as finished: the registry will not grow any further. */ + /** + * 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; } 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 index 73353f05..e8ba9ea6 100644 --- 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 @@ -60,13 +60,13 @@ * compilations are not discovered: only the beans the processor plans in the current run get * builder mapping. * - *

A registry miss for a bean marked for generation ({@code @SimpleBuilder} or a template - * annotation) defers the mapper via {@link TypeHierarchyErroneousException} in every state before - * {@link State#FINISHED} — it may still be registered in a later round or with an element another - * processor emits. Once finished, a marked bean that was never registered is reported as a warning - * and resolves to no builder. Unmarked beans return {@code null} in every state: nothing foreign is - * ever claimed or delayed. A published builder whose type is not emitted yet defers until {@link - * State#FINISHED}. + *

{@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 @@ -139,26 +139,23 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement) { */ private TypeElement findBuilderElement( TypeElement beanElement, Optional published) { - boolean markedForGeneration = isBuilderGenerationTarget(beanElement); if (published.isEmpty()) { - if (!markedForGeneration) { - // Foreign bean — never claimed, never deferred. - return null; - } - // Marked but not published — the bean may still be registered while the compilation is - // not in its final phase; a marked bean missing once generation finished was skipped by - // planning. if (state() != State.FINISHED) { + // 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()); } - warnMarkedBeanWithoutRegisteredBuilder(beanElement); + // 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().builder().typeName().getFullQualifiedName()); - if (builderElement == null - && !SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()) { - // Published but not emitted yet — defer so the mapper retries once the type exists. + 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; @@ -221,7 +218,7 @@ private Optional buildMethod( /** * 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 deferral and the finished-state warning — it never decides which type is claimed. + * 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()) { 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 index b1820be3..ca1d9350 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -151,13 +151,14 @@ void probe_shouldDriveSpiAgainstEmittedElements() { ProbeResults results = ProbeResults.instance; - // Marked beans defer while unpublished — marked or template-annotated alike. + // 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); - // Foreign and opted-out beans are never claimed and never wait, whichever state applies. - assertNull(results.foreignUnpublished); - assertNull(results.ignoredUnpublished); + // Once the registry is final, foreign and opted-out beans are never claimed. assertNull(results.foreignRegistered); assertNull(results.ignoredRegistered); assertNull(results.foreignWhenFinished); @@ -178,19 +179,19 @@ void probe_shouldDriveSpiAgainstEmittedElements() { 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 in every - // non-final state while unpublished and resolves to no builder — reported as a warning — - // only once finished. The in-scope bean forces a second round so the post-registration + // 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 BuilderProcessor(), new SpiProbeProcessor(true)) + .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); - assertInstanceOf(TypeHierarchyErroneousException.class, skipped.skippedBeanRegisteredDeferral); + assertNull(skipped.skippedBeanRegistered); assertNull(skipped.skippedBeanFinished); } @@ -202,9 +203,9 @@ private static final class ProbeResults { Throwable unregisteredDeferral; Throwable templateDeferral; Throwable skippedBeanDeferred; - Throwable skippedBeanRegisteredDeferral; - BuilderInfo foreignUnpublished; - BuilderInfo ignoredUnpublished; + Throwable foreignUnpublishedDeferral; + Throwable ignoredUnpublishedDeferral; + BuilderInfo skippedBeanRegistered; BuilderInfo skippedBeanFinished; BuilderInfo foreignRegistered; BuilderInfo ignoredRegistered; @@ -218,9 +219,9 @@ void reset() { unregisteredDeferral = null; templateDeferral = null; skippedBeanDeferred = null; - skippedBeanRegisteredDeferral = null; - foreignUnpublished = null; - ignoredUnpublished = null; + foreignUnpublishedDeferral = null; + ignoredUnpublishedDeferral = null; + skippedBeanRegistered = null; skippedBeanFinished = null; foreignRegistered = null; ignoredRegistered = null; @@ -235,10 +236,9 @@ void reset() { * 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 a marked bean is probed before registration. In - * {@code skippedMode} it runs behind {@link BuilderProcessor} and probes the marked bean that the - * scoped processor never plans — deferral through the registered states plus the finished - * outcome. + * {@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 { @@ -301,13 +301,12 @@ public boolean process(Set annotations, RoundEnvironment } if (skippedMode) { - // The bean is marked but out of the generation scope: it defers in every non-final - // state while unpublished — the probe runs behind the processor, so these lookups - // already observe the post-registration state. + // 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.skippedBeanRegisteredDeferral = lookupExpectingDeferral(bean); + results.skippedBeanRegistered = lookup(provider, "test.PersonDto"); } return false; } @@ -316,8 +315,8 @@ public boolean process(Set annotations, RoundEnvironment // 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.foreignUnpublished = lookup(provider, "test.ForeignDto"); - results.ignoredUnpublished = lookup(provider, "test.IgnoredDto"); + results.foreignUnpublishedDeferral = lookupExpectingDeferral(element("test.ForeignDto")); + results.ignoredUnpublishedDeferral = lookupExpectingDeferral(element("test.IgnoredDto")); results.templateDeferral = lookupExpectingDeferral(element("test.MinimalDto")); return false; } From 071e71ebd7398b22e4a98adf703a0bb9ea660850 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 21:40:28 +0000 Subject: [PATCH 21/25] Gate the generating round on the finished state and document the never-claim contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extra 'generated' member duplicated what the SPI state already knows: FINISHED means the generating round ran. process() also gains javadoc explaining why it must always return false — claiming is all-or-nothing over the wildcard supported set required for template discovery. --- .../builders/processor/BuilderProcessor.java | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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 8887c21e..b4834d62 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 @@ -101,9 +101,6 @@ public class BuilderProcessor extends AbstractProcessor { private JacksonModuleGenerator jacksonModuleGenerator; private boolean supportedJdk = true; - /** Whether this processor's single generating round already ran in this compilation. */ - private boolean generated; - @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); @@ -149,11 +146,11 @@ public synchronized void init(ProcessingEnvironment processingEnv) { * 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. + *

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") @@ -200,7 +197,8 @@ 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 (generated || (sortedElements.isEmpty() && sortedHolders.isEmpty())) { + if (SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration() + || (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; @@ -218,7 +216,6 @@ public boolean process(Set annotations, RoundEnvironment // Reset indentation level at the end of each processing round to prevent cascading errors context.resetIndentation(); - generated = true; // The generating round is done — the registry is final for SPI adapters from here on. SimpleBuildersSpiIntegration.finishCompilation(); // Returning false leaves the annotations unclaimed so other processors on the From 252d607d1302c170d65e897cab0c29f5c314808f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:49:01 +0000 Subject: [PATCH 22/25] Read the integration switch from the SPI options, flatten PublishedBuilder, tidy tests MapStruct forwards only declared options into SPI environments: the new MapStructAdditionalSupportedOptionsProvider declares both option spellings so -Asimplebuilder.usingMapStructIntegration reaches the SPIs without any processor-published state. isMapstructGenerationEnabled now reads -D then -A (prefixed, then bare) like CompilerArgumentsReader and needs no compilation check. PublishedBuilder flattens to bean/builder type plus creation and build method names, so SPI adapters no longer touch ResolvedBuilder; resolveGeneratedBuilder's only outside caller is the SPI publish in BuilderProcessor and resolve() goes back to inlining it. Test cleanups: default processor pair inside the compile helper, source factories moved to the bottom of the probe test. --- .../builders/processor/BuilderProcessor.java | 11 +- .../SimpleBuildersSpiIntegration.java | 75 +++++---- .../analysis/BuilderScopeResolver.java | 13 +- .../MapStructAccessorNamingStrategy.java | 3 +- ...uctAdditionalSupportedOptionsProvider.java | 47 ++++++ .../mapstruct/MapStructBuilderProvider.java | 18 +- .../MapStructSpiIntegrationTest.java | 19 ++- .../processor/MapStructSpiProbeTest.java | 154 +++++++++--------- .../SimpleBuildersSpiIntegrationTest.java | 82 +++++----- 9 files changed, 236 insertions(+), 186 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java 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 b4834d62..2987bfff 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 @@ -60,7 +60,6 @@ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder.Template; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFor; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors; -import org.javahelpers.simple.builders.core.enums.OptionState; import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper; @@ -78,7 +77,6 @@ import org.javahelpers.simple.builders.processor.processing.BuilderConfigurationReader; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; -import org.javahelpers.simple.builders.processor.processing.OptionValueParsers; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; import org.javahelpers.simple.builders.processor.processing.logging.PerformanceTracker; @@ -112,14 +110,7 @@ public synchronized void init(ProcessingEnvironment processingEnv) { BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger); logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig); - // The SPI environments never receive foreign annotation processor options — publish the - // resolved switch through the shared classloader instead - String mapStructOption = reader.readValue(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION); - SimpleBuildersSpiIntegration.initCompilation( - processingEnv.getElementUtils(), - mapStructOption == null - ? null - : OptionValueParsers.parseOptionState(mapStructOption, logger) != OptionState.DISABLED); + SimpleBuildersSpiIntegration.initCompilation(processingEnv.getElementUtils()); this.context = new ProcessingContext(logger, globalConfig, processingEnv); this.codeGenerator = new RoasterCodeGenerator(context, processingEnv); 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 index 3f3e3997..c9da98aa 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -28,13 +28,15 @@ 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; +import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; /** * 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 - * fully resolved builder — type name, creation and build method — plus the bean's configured {@code + * resolved builder's type, creation and build method names plus the bean's configured {@code * setterSuffix}, so adapters do not scan elements or read annotations themselves. * *

The lifecycle reports where {@link BuilderProcessor}'s builder generation stands and is @@ -72,21 +74,22 @@ public enum State { } /** - * One bean-to-builder pair resolved at registration time: the {@link ResolvedBuilder} this - * processor emits for {@code beanType} plus the bean's {@code setterSuffix} naming option. + * 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, ResolvedBuilder builder, String setterSuffix) {} - - private static final String OPTION_USING_MAPSTRUCT = "simplebuilder.usingMapStructIntegration"; + 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<>(); - /** The processor-resolved integration switch; {@code null} leaves the fallbacks active. */ - private static volatile Boolean integrationEnabled; - /** Builders published for the current compilation, keyed by bean qualified name. */ private static final Map BY_BEAN = new HashMap<>(); @@ -95,15 +98,11 @@ public record PublishedBuilder(TypeName beanType, ResolvedBuilder builder, Strin private SimpleBuildersSpiIntegration() {} - /** - * Starts a new compilation: clears the registry and publishes the integration switch the - * processor resolved ({@code null} when the option is unset). - */ - static void initCompilation(Elements elements, Boolean integrationEnabled) { + /** 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); - SimpleBuildersSpiIntegration.integrationEnabled = integrationEnabled; state = State.PROCESSING; } @@ -137,12 +136,21 @@ public static boolean isSimpleBuildersFinishedForIntegration() { /** * Publishes the builder {@link BuilderProcessor} resolved for {@code beanType} in this - * compilation. + * compilation — generated builders always create via a static factory. */ static void registerBuilder(TypeName beanType, ResolvedBuilder builder, String setterSuffix) { - PublishedBuilder publishedBuilder = new PublishedBuilder(beanType, builder, 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.builder().typeName().getFullQualifiedName(), publishedBuilder); + BY_BUILDER.put(publishedBuilder.builderType().getFullQualifiedName(), publishedBuilder); } /** The builder published for {@code beanType}'s qualified name, if this compilation plans one. */ @@ -159,22 +167,25 @@ public static Optional builderByName(String builderQualifiedNa } /** - * Whether the MapStruct integration is switched on: while the processor has published this - * compilation's resolution (and {@code observed} proves it current) that value wins; without one - * — incl. the stale leftovers of a previous run — the {@code simplebuilder.*} convention applies - * (JVM system property before the annotation processor option the hosting framework does not - * forward anyway). Anything but {@code false}/{@code disabled} keeps it on. + * Whether the MapStruct integration is switched on, resolved like {@code + * CompilerArgumentsReader#readValue} — {@code -Dsimplebuilder.usingMapStructIntegration} first, + * then the same {@code -A} argument, then the bare {@code -AusingMapStructIntegration}. {@code + * processorOptions} is the full javac options map the SPI environment exposes, so foreign {@code + * -A} arguments reach it without any processor involvement. Anything but {@code false}/{@code + * disabled} keeps it on. */ - public static boolean isMapstructGenerationEnabled( - Elements observed, Map processorOptions) { - if (isCurrentCompilation(observed) && state != State.INIT) { - Boolean published = integrationEnabled; - if (published != null) { - return published; - } - } + public static boolean isMapstructGenerationEnabled(Map processorOptions) { String value = - System.getProperty(OPTION_USING_MAPSTRUCT, processorOptions.get(OPTION_USING_MAPSTRUCT)); + System.getProperty(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument()); + if (value == null) { + value = + processorOptions.get( + CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument()); + } + if (value == null) { + value = + processorOptions.get(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getOptionName()); + } return !("false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value)); } } 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 e8d2345d..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 @@ -139,7 +139,7 @@ public Optional resolveUsableBuilderType(TypeElement referenced /** * 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. + * 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. @@ -237,9 +237,16 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - Optional generatedBuilder = resolveGeneratedBuilder(referencedType); + Optional generatedBuilder = generatedBuilders.findBuilder(referencedTypeName); if (generatedBuilder.isPresent()) { - return generatedBuilder; + // Our generators always emit a static create() and no create(T) - the empty path uses + // the factory, the copy path the constructor + return generatedBuilder.map( + builder -> + new ResolvedBuilder( + builder, + new BuilderInstantiation.StaticFactoryCall("create"), + new BuilderInstantiation.ConstructorCall())); } // Reusing builders not generated in this round - nested or anchored inside the referenced 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 index fa415561..57a1e4d4 100644 --- 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 @@ -74,8 +74,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); if (!SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) - || !SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( - elementUtils, processorOptions)) { + || !SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(processorOptions)) { // The holder describes another compilation (or is disabled): simple-builders is not // ready here, so everything keeps the default classification. return methodType; 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 index e8ba9ea6..ce7c362d 100644 --- 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 @@ -41,7 +41,6 @@ import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.State; import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; -import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall; import org.mapstruct.ap.spi.BuilderInfo; import org.mapstruct.ap.spi.BuilderProvider; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -71,8 +70,9 @@ *

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} (resolved by {@code BuilderProcessor}, which - * shares the classloader, or via the {@code -D} JVM system property). + * -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 { @@ -92,8 +92,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (!SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( - elementUtils, processorOptions)) { + if (!SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(processorOptions)) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -152,7 +151,7 @@ private TypeElement findBuilderElement( return null; } TypeElement builderElement = - elementUtils.getTypeElement(published.get().builder().typeName().getFullQualifiedName()); + 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. @@ -197,11 +196,8 @@ private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) { * factory whose name the descriptor carries ({@code create} for generated builders). */ private ExecutableElement creationMethod(TypeElement builderElement, PublishedBuilder published) { - if (!(published.builder().funcForEmptyBuilder() instanceof StaticFactoryCall factory)) { - return null; - } return JavaLangAnalyser.findMethodWithoutParameters( - builderElement, factory.methodName(), Modifier.PUBLIC, Modifier.STATIC) + builderElement, published.creationMethodName(), Modifier.PUBLIC, Modifier.STATIC) .orElse(null); } @@ -212,7 +208,7 @@ private ExecutableElement creationMethod(TypeElement builderElement, PublishedBu private Optional buildMethod( TypeElement builderElement, PublishedBuilder published) { return JavaLangAnalyser.findMethodWithoutParameters( - builderElement, published.builder().buildMethodName(), Modifier.PUBLIC); + builderElement, published.buildMethodName(), Modifier.PUBLIC); } /** 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 index 5d793476..0fccd760 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -47,9 +47,7 @@ class MapStructSpiIntegrationTest { @Test void mapStruct_shouldUseGeneratedBuilder() { - Compilation compilation = - mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) - .compile(personDto(), personDtoMapper()); + Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper()); assertThat(compilation).succeeded(); String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl"); @@ -78,9 +76,7 @@ void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() { @Test void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { - Compilation compilation = - mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) - .compile(personDto(), personDtoMapper()); + Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper()); assertThat(compilation).succeeded(); assertNoWarningContaining(compilation, "unmapped target property"); @@ -88,10 +84,10 @@ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() { @Test void mapStruct_disabledIntegration_shouldMapViaSetters() { - // MapStruct does not forward foreign -A options to SPI environments, so BuilderProcessor - // publishes the resolved switch to them via SimpleBuildersSpiIntegration + // Our AdditionalSupportedOptionsProvider declares the option, so MapStruct forwards the -A + // value into the SPI environment's options Compilation compilation = - mapStructCompiler(new BuilderProcessor(), new MappingProcessor()) + mapStructCompiler() .withOptions("-Asimplebuilder.usingMapStructIntegration=DISABLED") .compile(mutableDto(), mutableDtoMapper()); assertThat(compilation).succeeded(); @@ -103,6 +99,11 @@ void mapStruct_disabledIntegration_shouldMapViaSetters() { 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) 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 index ca1d9350..b15ee1a0 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java @@ -64,83 +64,6 @@ */ class MapStructSpiProbeTest { - 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; - } - """); - } - @Test void probe_shouldDriveSpiAgainstEmittedElements() { Compilation compilation = @@ -367,4 +290,81 @@ private BuilderInfo lookup(MapStructBuilderProvider provider, String qualifiedNa 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 index 9642f71e..680e7135 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -29,6 +29,7 @@ import java.lang.reflect.Proxy; import java.util.Map; +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; @@ -41,23 +42,27 @@ /** * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current compilation * (identified by its {@link Elements}), the registry exposes exactly the published descriptors, and - * the integration switch honors the {@code -D} > processor-published precedence. + * the integration switch honors the {@code -D} > {@code -A} > bare-option precedence. */ class SimpleBuildersSpiIntegrationTest { private static final String OPTION = "simplebuilder.usingMapStructIntegration"; + private static final String BARE_OPTION = "usingMapStructIntegration"; + 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( - new TypeName("test", "PersonDto"), - new ResolvedBuilder( - new TypeName("test", "PersonDtoBuilder"), - new StaticFactoryCall("create"), - java.util.Optional.empty(), - "build"), - ""); + 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() { @@ -73,12 +78,12 @@ private static Elements fakeElements() { @AfterEach void reset() { System.clearProperty(OPTION); - SimpleBuildersSpiIntegration.initCompilation(null, null); + SimpleBuildersSpiIntegration.initCompilation(null); } @Test void lifecycle_transitions() { - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()); @@ -89,7 +94,7 @@ void lifecycle_transitions() { @Test void staleness_onlyOwnCompilationIsCurrent() { - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); + 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. @@ -99,62 +104,55 @@ void staleness_onlyOwnCompilationIsCurrent() { @Test void registry_publishesBothDirections() { - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); assertTrue(SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").isEmpty()); - SimpleBuildersSpiIntegration.registerBuilder( - PERSON.beanType(), PERSON.builder(), PERSON.setterSuffix()); + SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, ""); assertEquals(PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto").orElseThrow()); assertEquals( PERSON, SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").orElseThrow()); - assertEquals("test.PersonDtoBuilder", PERSON.builder().typeName().getFullQualifiedName()); + assertEquals("test.PersonDtoBuilder", PERSON.builderType().getFullQualifiedName()); + assertEquals("create", PERSON.creationMethodName()); + assertEquals("build", PERSON.buildMethodName()); assertEquals("", PERSON.setterSuffix()); } @Test void registry_clearedOnNextCompilation() { - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); - SimpleBuildersSpiIntegration.registerBuilder( - PERSON.beanType(), PERSON.builder(), PERSON.setterSuffix()); + SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); + SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, ""); - SimpleBuildersSpiIntegration.initCompilation(fakeElements(), null); + SimpleBuildersSpiIntegration.initCompilation(fakeElements()); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); } @Test - void isMapstructGenerationEnabled_publishedSwitchWinsOverProperty() { - System.setProperty(OPTION, "DISABLED"); - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.TRUE); - assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); - - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.FALSE); - assertFalse(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); + void isMapstructGenerationEnabled_defaultsToEnabled() { + assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(Map.of())); } @Test - void isMapstructGenerationEnabled_staleObserverFallsBackToProperty() { - // The previous compilation's DISABLED must not leak: an SPI observing with another - // compilation's Elements gets the property fallback until our init publishes. - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, Boolean.FALSE); - Elements otherCompilation = fakeElements(); + void isMapstructGenerationEnabled_systemPropertyWins() { + System.setProperty(OPTION, "DISABLED"); + assertFalse( + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( + Map.of(OPTION, "ENABLED", BARE_OPTION, "ENABLED"))); System.setProperty(OPTION, "ENABLED"); assertTrue( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(otherCompilation, Map.of())); - - System.setProperty(OPTION, "DISABLED"); - assertFalse( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(otherCompilation, Map.of())); + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( + Map.of(OPTION, "DISABLED", BARE_OPTION, "DISABLED"))); } @Test - void isMapstructGenerationEnabled_noPublishedValueFallsBackToProperty() { - SimpleBuildersSpiIntegration.initCompilation(ELEMENTS, null); - assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); + void isMapstructGenerationEnabled_compilerArgumentBeatsBareOption() { + assertFalse( + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( + Map.of(OPTION, "DISABLED", BARE_OPTION, "ENABLED"))); - System.setProperty(OPTION, "DISABLED"); - assertFalse(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(ELEMENTS, Map.of())); + assertFalse( + SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(Map.of(BARE_OPTION, "DISABLED"))); } } From e01ec6621aef0d2be4ae9d5daab843fa9d25687e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:32:15 +0000 Subject: [PATCH 23/25] Read the integration switch on the SPI side and bake staleness into the finished check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MapStruct SPIs resolve simplebuilder.usingMapStructIntegration from the options map MapStruct hands them — through static CompilerArgumentsReader overloads taking an explicit options map, sharing the -D > -A > bare precedence with the processor. SimpleBuildersSpiIntegration is back to pure registry plus lifecycle state. isSimpleBuildersFinishedForIntegration(elements) now answers FINISHED only for the compilation the given Elements belongs to, so no caller can observe a previous run's final state. --- .../builders/processor/BuilderProcessor.java | 3 +- .../SimpleBuildersSpiIntegration.java | 33 +++--------- .../MapStructAccessorNamingStrategy.java | 16 +++++- .../mapstruct/MapStructBuilderProvider.java | 27 ++++++---- .../processing/CompilerArgumentsReader.java | 45 ++++++++++++++-- .../CompilerArgumentsReaderTest.java | 53 +++++++++++++++++++ .../SimpleBuildersSpiIntegrationTest.java | 45 +++------------- 7 files changed, 141 insertions(+), 81 deletions(-) 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 2987bfff..2be766fc 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 @@ -188,7 +188,8 @@ 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() + 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. 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 index c9da98aa..52ab1406 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -31,7 +31,6 @@ 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.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; /** * The builders {@link BuilderProcessor} generates, published to SPI adapters of other frameworks @@ -129,9 +128,12 @@ public static State state() { return state; } - /** Whether {@link BuilderProcessor} finished generating builders for this compilation. */ - public static boolean isSimpleBuildersFinishedForIntegration() { - return state == State.FINISHED; + /** + * Whether {@link BuilderProcessor} finished generating builders for the compilation {@code + * observed} belongs to — {@code false} while this holder still describes another run. + */ + public static boolean isSimpleBuildersFinishedForIntegration(Elements observed) { + return isCurrentCompilation(observed) && state == State.FINISHED; } /** @@ -165,27 +167,4 @@ public static Optional builderFor(String beanQualifiedName) { public static Optional builderByName(String builderQualifiedName) { return Optional.ofNullable(BY_BUILDER.get(builderQualifiedName)); } - - /** - * Whether the MapStruct integration is switched on, resolved like {@code - * CompilerArgumentsReader#readValue} — {@code -Dsimplebuilder.usingMapStructIntegration} first, - * then the same {@code -A} argument, then the bare {@code -AusingMapStructIntegration}. {@code - * processorOptions} is the full javac options map the SPI environment exposes, so foreign {@code - * -A} arguments reach it without any processor involvement. Anything but {@code false}/{@code - * disabled} keeps it on. - */ - public static boolean isMapstructGenerationEnabled(Map processorOptions) { - String value = - System.getProperty(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument()); - if (value == null) { - value = - processorOptions.get( - CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument()); - } - if (value == null) { - value = - processorOptions.get(CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getOptionName()); - } - return !("false".equalsIgnoreCase(value) || "disabled".equalsIgnoreCase(value)); - } } 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 index 57a1e4d4..2374ed79 100644 --- 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 @@ -34,6 +34,8 @@ 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.AccessorNamingStrategy; import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy; import org.mapstruct.ap.spi.MapStructProcessingEnvironment; @@ -74,7 +76,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); if (!SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) - || !SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(processorOptions)) { + || !isIntegrationEnabled()) { // The holder describes another compilation (or is disabled): simple-builders is not // ready here, so everything keeps the default classification. return methodType; @@ -96,6 +98,16 @@ public MethodType getMethodType(ExecutableElement method) { 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. @@ -122,7 +134,7 @@ private Map directSettersOf(TypeElement builderType) { 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()) { + if (!SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils)) { throw new TypeHierarchyErroneousException(builderType.asType()); } return Map.of(); 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 index ce7c362d..2d605773 100644 --- 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 @@ -39,8 +39,9 @@ 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.SimpleBuildersSpiIntegration.State; 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; @@ -92,7 +93,7 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public BuilderInfo findBuilderInfo(TypeMirror type) { - if (!SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(processorOptions)) { + if (!isIntegrationEnabled()) { return null; } if (!(type instanceof DeclaredType declaredType) @@ -110,6 +111,16 @@ public BuilderInfo findBuilderInfo(TypeMirror type) { 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. @@ -139,7 +150,7 @@ private BuilderInfo createBuilderInfo(TypeElement beanElement) { private TypeElement findBuilderElement( TypeElement beanElement, Optional published) { if (published.isEmpty()) { - if (state() != State.FINISHED) { + 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()); @@ -161,13 +172,11 @@ private TypeElement findBuilderElement( } /** - * The state of the compilation this provider's {@code elementUtils} belongs to — {@link - * State#INIT} while the holder still describes a previous run on a reused JVM. + * 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 State state() { - return SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) - ? SimpleBuildersSpiIntegration.state() - : State.INIT; + private boolean isFinished() { + return SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils); } /** 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/SimpleBuildersSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java index 680e7135..a54bcd51 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -28,7 +28,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Proxy; -import java.util.Map; import java.util.Optional; import javax.lang.model.util.Elements; import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder; @@ -41,15 +40,10 @@ /** * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current compilation - * (identified by its {@link Elements}), the registry exposes exactly the published descriptors, and - * the integration switch honors the {@code -D} > {@code -A} > bare-option precedence. + * (identified by its {@link Elements}), and the registry exposes exactly the published descriptors. */ class SimpleBuildersSpiIntegrationTest { - private static final String OPTION = "simplebuilder.usingMapStructIntegration"; - - private static final String BARE_OPTION = "usingMapStructIntegration"; - private static final Elements ELEMENTS = fakeElements(); private static final TypeName PERSON_BEAN = new TypeName("test", "PersonDto"); @@ -77,7 +71,6 @@ private static Elements fakeElements() { @AfterEach void reset() { - System.clearProperty(OPTION); SimpleBuildersSpiIntegration.initCompilation(null); } @@ -85,11 +78,15 @@ void reset() { void lifecycle_transitions() { SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state()); - assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()); + assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(ELEMENTS)); SimpleBuildersSpiIntegration.finishCompilation(); assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state()); - assertTrue(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration()); + 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 @@ -127,32 +124,4 @@ void registry_clearedOnNextCompilation() { SimpleBuildersSpiIntegration.initCompilation(fakeElements()); assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); } - - @Test - void isMapstructGenerationEnabled_defaultsToEnabled() { - assertTrue(SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(Map.of())); - } - - @Test - void isMapstructGenerationEnabled_systemPropertyWins() { - System.setProperty(OPTION, "DISABLED"); - assertFalse( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( - Map.of(OPTION, "ENABLED", BARE_OPTION, "ENABLED"))); - - System.setProperty(OPTION, "ENABLED"); - assertTrue( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( - Map.of(OPTION, "DISABLED", BARE_OPTION, "DISABLED"))); - } - - @Test - void isMapstructGenerationEnabled_compilerArgumentBeatsBareOption() { - assertFalse( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled( - Map.of(OPTION, "DISABLED", BARE_OPTION, "ENABLED"))); - - assertFalse( - SimpleBuildersSpiIntegration.isMapstructGenerationEnabled(Map.of(BARE_OPTION, "DISABLED"))); - } } From 3cc01146c23f7eccef88d755d678258fd33b4777 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:56:06 +0000 Subject: [PATCH 24/25] Scope every SPI lookup to the caller's compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit builderFor, builderByName and isSimpleBuildersFinishedForIntegration now take the caller's Elements and answer empty/not-finished for foreign compilations — the staleness guard lives inside the API instead of at each call site. isCurrentCompilation and state() are internal again (package-private), the strategy's early compilation check drops because an empty registry already yields default classification, and the finished check's javadoc documents why the guard exists: the classloader outlives the compilation, so a previous run's FINISHED plus registry must never be trusted before this run's init. --- .../SimpleBuildersSpiIntegration.java | 40 ++++++++++++++----- .../MapStructAccessorNamingStrategy.java | 13 +++--- .../mapstruct/MapStructBuilderProvider.java | 9 ++--- .../SimpleBuildersSpiIntegrationTest.java | 24 ++++++++--- 4 files changed, 59 insertions(+), 27 deletions(-) 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 index 52ab1406..1c593731 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -119,18 +119,29 @@ static void finishCompilation() { * 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. */ - public static boolean isCurrentCompilation(Elements observed) { + static boolean isCurrentCompilation(Elements observed) { return observed != null && observed == compilationElements.get(); } - /** The lifecycle state the SPI adapters observe for the current compilation. */ - public static State state() { + /** 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; @@ -155,16 +166,25 @@ static void registerBuilder(TypeName beanType, ResolvedBuilder builder, String s BY_BUILDER.put(publishedBuilder.builderType().getFullQualifiedName(), publishedBuilder); } - /** The builder published for {@code beanType}'s qualified name, if this compilation plans one. */ - public static Optional builderFor(String beanQualifiedName) { - return Optional.ofNullable(BY_BEAN.get(beanQualifiedName)); + /** + * 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, if {@link BuilderProcessor} plans it - * in this compilation. + * 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) { - return Optional.ofNullable(BY_BUILDER.get(builderQualifiedName)); + 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/mapstruct/MapStructAccessorNamingStrategy.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java index 2374ed79..b7a3b0a1 100644 --- 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 @@ -75,10 +75,8 @@ public void init(MapStructProcessingEnvironment processingEnvironment) { @Override public MethodType getMethodType(ExecutableElement method) { MethodType methodType = super.getMethodType(method); - if (!SimpleBuildersSpiIntegration.isCurrentCompilation(elementUtils) - || !isIntegrationEnabled()) { - // The holder describes another compilation (or is disabled): simple-builders is not - // ready here, so everything keeps the default classification. + if (!isIntegrationEnabled()) { + // The integration is switched off — everything keeps the default classification. return methodType; } if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) { @@ -121,11 +119,14 @@ private boolean isDirectSetter(ExecutableElement method, Map /** * 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. + * 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()); + SimpleBuildersSpiIntegration.builderByName( + builderType.getQualifiedName().toString(), elementUtils); if (published.isEmpty()) { return Map.of(); } 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 index 2d605773..914a5fa8 100644 --- 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 @@ -180,13 +180,12 @@ private boolean isFinished() { } /** - * The builder published for {@code beanElement} — empty while the holder still describes a - * previous run, so stale entries can never claim a bean. + * 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.isCurrentCompilation(elementUtils) - ? SimpleBuildersSpiIntegration.builderFor(beanElement.getQualifiedName().toString()) - : Optional.empty(); + return SimpleBuildersSpiIntegration.builderFor( + beanElement.getQualifiedName().toString(), elementUtils); } /** Warns that a bean marked for generation got no registered builder in this compilation. */ 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 index a54bcd51..ec15d772 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java @@ -102,14 +102,23 @@ void staleness_onlyOwnCompilationIsCurrent() { @Test void registry_publishesBothDirections() { SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); - assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); - assertTrue(SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").isEmpty()); + 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").orElseThrow()); assertEquals( - PERSON, SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder").orElseThrow()); + 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()); @@ -121,7 +130,10 @@ void registry_clearedOnNextCompilation() { SimpleBuildersSpiIntegration.initCompilation(ELEMENTS); SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, ""); - SimpleBuildersSpiIntegration.initCompilation(fakeElements()); - assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto").isEmpty()); + Elements nextCompilation = fakeElements(); + SimpleBuildersSpiIntegration.initCompilation(nextCompilation); + assertTrue( + SimpleBuildersSpiIntegration.builderFor("test.PersonDto", nextCompilation).isEmpty()); + assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).isEmpty()); } } From a5a401431f9ef61ab1b38d5c72c1e0b6a313f770 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:19:44 +0000 Subject: [PATCH 25/25] Review cleanup: drop redundant findFields, stale docs and javadoc - JavaLangAnalyser.findFields removed: Elements.getAllMembers already covers inherited fields; the naming strategy uses ElementFilter on it with a static-field filter directly - BuilderProcessor: duplicate never-claim comment removed (method javadoc carries the contract) - SimpleBuildersSpiIntegration: stale reference to package-private isCurrentCompilation in the class javadoc fixed - docs/CONFIGURATION.md: usingMapStructIntegration section rewritten for the SPI-side option read via AdditionalSupportedOptionsProvider --- docs/CONFIGURATION.md | 2 +- .../builders/processor/BuilderProcessor.java | 2 -- .../SimpleBuildersSpiIntegration.java | 8 +++--- .../processor/analysis/JavaLangAnalyser.java | 28 ------------------- .../MapStructAccessorNamingStrategy.java | 11 +++++--- .../MapStructSpiIntegrationTest.java | 6 ++-- 6 files changed, 15 insertions(+), 42 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 340b5104..e89ae3bb 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1054,7 +1054,7 @@ This is highly recommended to ensure deterministic output location and avoid spl 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. -Unlike the other options this switch is consumed inside MapStruct's SPI environment, which does not see `simplebuilder.*` annotation processor options. `BuilderProcessor` therefore resolves the option itself (same `-D` > `-A` precedence as every option) and publishes it to the SPIs sharing the classloader; `-D` also reaches them directly for setups where only the SPI jar is on the path. +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. 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 2be766fc..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 @@ -210,8 +210,6 @@ public boolean process(Set annotations, RoundEnvironment context.resetIndentation(); // The generating round is done — the registry is final for SPI adapters from here on. SimpleBuildersSpiIntegration.finishCompilation(); - // Returning false leaves the annotations unclaimed so other processors on the - // processor path (e.g. MapStruct, AutoService) still see them. return false; } 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 index 1c593731..e27ca206 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java @@ -36,16 +36,16 @@ * 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 elements or read annotations themselves. + * 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} to - * {@link #isCurrentCompilation}: javac hands every processor of one compilation the same {@code - * Elements} instance, so a mismatch means this holder still describes an older run. + * 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 { 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 f091d9a3..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 @@ -44,8 +44,6 @@ import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; -import javax.lang.model.type.DeclaredType; -import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import org.apache.commons.collections4.CollectionUtils; @@ -799,32 +797,6 @@ public static Optional findFieldElement( .findFirst(); } - /** - * Finds all non-static field elements declared by the given type or inherited from its - * superclasses (the walk stops at {@code java.lang.Object}). - * - * @param type the type element to search - * @return list of field elements, declared fields first, then superclass fields - */ - public static List findFields(TypeElement type) { - List fields = new ArrayList<>(); - TypeElement current = type; - while (current != null && !current.getQualifiedName().contentEquals("java.lang.Object")) { - for (VariableElement field : ElementFilter.fieldsIn(current.getEnclosedElements())) { - if (!field.getModifiers().contains(Modifier.STATIC)) { - fields.add(field); - } - } - TypeMirror superclass = current.getSuperclass(); - current = - superclass.getKind() == TypeKind.DECLARED - && ((DeclaredType) superclass).asElement() instanceof TypeElement parent - ? parent - : null; - } - return fields; - } - /** * Finds a record component by name in the given type element. * 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 index b7a3b0a1..d7790f8c 100644 --- 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 @@ -28,12 +28,13 @@ 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.analysis.JavaLangAnalyser; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum; import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader; import org.mapstruct.ap.spi.AccessorNamingStrategy; @@ -141,9 +142,11 @@ private Map directSettersOf(TypeElement builderType) { return Map.of(); } Map directSetters = new HashMap<>(); - for (VariableElement field : JavaLangAnalyser.findFields(beanElement)) { - directSetters.put( - field.getSimpleName().toString() + published.get().setterSuffix(), field.asType()); + 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/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java index 0fccd760..8d46d70c 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java @@ -59,9 +59,9 @@ void mapStruct_shouldUseGeneratedBuilder() { @Test void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() { - // MapStruct processing the mapper before BuilderProcessor's first round must not lose the - // builder: the marked bean defers via TypeHierarchyErroneousException until the registry - // holds the planned builder + // 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());