com.fasterxml.jackson.core
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
index 779c2fe2..d0117fb8 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/BuilderProcessor.java
@@ -62,6 +62,7 @@
import org.javahelpers.simple.builders.core.annotations.SimpleBuilderFors;
import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver;
import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser;
+import org.javahelpers.simple.builders.processor.analysis.JavaLangMapper;
import org.javahelpers.simple.builders.processor.classgen.roaster.RoasterCodeGenerator;
import org.javahelpers.simple.builders.processor.exceptions.BuilderException;
import org.javahelpers.simple.builders.processor.generators.integration.JacksonModuleGenerator;
@@ -109,6 +110,8 @@ public synchronized void init(ProcessingEnvironment processingEnv) {
BuilderConfiguration globalConfig = reader.readBuilderConfiguration(logger);
logger.debug("Loaded global configuration from compiler arguments: %s", globalConfig);
+ SimpleBuildersSpiIntegration.initCompilation(processingEnv.getElementUtils());
+
this.context = new ProcessingContext(logger, globalConfig, processingEnv);
this.codeGenerator = new RoasterCodeGenerator(context, processingEnv);
this.jacksonModuleGenerator = new JacksonModuleGenerator(processingEnv, logger, globalConfig);
@@ -131,11 +134,14 @@ public synchronized void init(ProcessingEnvironment processingEnv) {
}
/**
- * Always returns {@code false}, so this method intentionally never claims annotations (hence the
- * {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and {@link
- * SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template annotations;
- * claiming would therefore hide every annotation in the round — including foreign ones like
- * MapStruct's {@code @Mapper} — from later processors.
+ * Generates all builders in exactly one round — the first round carrying simple-builders
+ * annotations — and marks the SPI registry final when that round ends.
+ *
+ * Always returns {@code false}, so this method intentionally never claims annotations (hence
+ * the {@code java:S3516} suppression). Claiming is all-or-nothing over the supported set and
+ * {@link SupportedAnnotationTypes} must stay {@code "*"} to discover user-defined template
+ * annotations; claiming would therefore hide every annotation in the round — including foreign
+ * ones like MapStruct's {@code @Mapper} — from later processors.
*/
@Override
@SuppressWarnings("java:S3516")
@@ -147,10 +153,10 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
// Generate Jackson Module if processing is over and feature is enabled
if (roundEnv.processingOver()) {
+ SimpleBuildersSpiIntegration.finishCompilation();
generateJacksonModules(context.getPerformanceTracker());
return false;
}
-
PerformanceTracker tracker = context.getPerformanceTracker();
context.info("simple-builders: PROCESSING ROUND START");
@@ -182,6 +188,13 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
context.debug(
"simple-builders: %d of %d annotated element(s) are inside the builderGenerationPackages scope.",
elementsToGenerate.size(), sortedElements.size());
+ if (SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(
+ processingEnv.getElementUtils())
+ || (sortedElements.isEmpty() && sortedHolders.isEmpty())) {
+ // simple-builders generates in exactly one round — the first round carrying its
+ // annotations; elements first appearing in later rounds get no builder.
+ return false;
+ }
registerGeneratedTypes(elementsToGenerate);
int successfulGenerations = generateBuilders(elementsToGenerate, tracker);
@@ -195,6 +208,8 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
// Reset indentation level at the end of each processing round to prevent cascading errors
context.resetIndentation();
+ // The generating round is done — the registry is final for SPI adapters from here on.
+ SimpleBuildersSpiIntegration.finishCompilation();
return false;
}
@@ -522,13 +537,20 @@ private void registerGeneratedTypes(List elementsToGenerate)
if (!(elementToGenerate.element() instanceof TypeElement targetType)) {
continue;
}
- scopeResolver.registerGeneratedBuilder(
- new TypeName(context.getPackageName(targetType), targetType.getSimpleName().toString()),
+ TypeName beanType = JavaLangMapper.mapToTypeName(targetType, context);
+ TypeName builderType =
builderTypeName(
targetType,
effectiveBuilderPackage(
elementToGenerate.reportingElement(), elementToGenerate.config()),
- elementToGenerate.config()));
+ elementToGenerate.config());
+ scopeResolver.registerGeneratedBuilder(beanType, builderType);
+ scopeResolver
+ .resolveGeneratedBuilder(targetType)
+ .ifPresent(
+ resolvedBuilder ->
+ SimpleBuildersSpiIntegration.registerBuilder(
+ beanType, resolvedBuilder, elementToGenerate.config().getSetterSuffix()));
}
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java
new file mode 100644
index 00000000..e27ca206
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegration.java
@@ -0,0 +1,190 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.lang.model.util.Elements;
+import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall;
+import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder;
+import org.javahelpers.simple.builders.processor.model.type.TypeName;
+
+/**
+ * The builders {@link BuilderProcessor} generates, published to SPI adapters of other frameworks
+ * sharing the annotation processor path (and therefore the classloader). Each entry carries the
+ * resolved builder's type, creation and build method names plus the bean's configured {@code
+ * setterSuffix}, so adapters do not scan candidates or read annotation configuration themselves.
+ *
+ * The lifecycle reports where {@link BuilderProcessor}'s builder generation stands and is
+ * transitioned by that processor alone — SPI adapters only ever read this holder, they never
+ * initialize or mutate it. All state is compilation-scoped: javac initializes each processor lazily
+ * when its turn in a round comes, so before {@link BuilderProcessor} has run its {@code init()}
+ * nothing static can be trusted — values may be leftovers of a previous compilation in a long-lived
+ * JVM (Gradle daemon, incremental builds). Adapters therefore pass their own {@link Elements}
+ * instance to every read: javac hands every processor of one compilation the same {@code Elements}
+ * instance, so a mismatch means this holder still describes an older run.
+ */
+public final class SimpleBuildersSpiIntegration {
+
+ /**
+ * Where {@link BuilderProcessor}'s builder generation stands in the current compilation, as the
+ * SPI adapters observe it.
+ */
+ public enum State {
+ /**
+ * No processor init happened in this compilation yet — static content may be a previous run's,
+ * and the registry must be treated as possibly still filling.
+ */
+ INIT,
+ /**
+ * {@code BuilderProcessor} initialized: its single generating round may not have run yet — the
+ * registry must be treated as possibly still filling.
+ */
+ PROCESSING,
+ /**
+ * The generating round ran: the registry is final for this compilation. {@code
+ * BuilderProcessor} generates in exactly one round — the first round carrying its annotations —
+ * so elements first appearing in later rounds get no builder.
+ */
+ FINISHED
+ }
+
+ /**
+ * One bean-to-builder pair resolved at registration time: the type this processor emits for
+ * {@code beanType} plus its creation/build method names and the bean's {@code setterSuffix}
+ * naming option — flattened to names so SPI adapters never touch the resolution model.
+ */
+ public record PublishedBuilder(
+ TypeName beanType,
+ TypeName builderType,
+ String creationMethodName,
+ String buildMethodName,
+ String setterSuffix) {}
+
+ private static volatile State state = State.INIT;
+
+ /** The {@link Elements} of the compilation this holder's content describes. */
+ private static final AtomicReference compilationElements = new AtomicReference<>();
+
+ /** Builders published for the current compilation, keyed by bean qualified name. */
+ private static final Map BY_BEAN = new HashMap<>();
+
+ /** The same entries keyed by builder qualified name. */
+ private static final Map BY_BUILDER = new HashMap<>();
+
+ private SimpleBuildersSpiIntegration() {}
+
+ /** Starts a new compilation: clears the registry and marks generation as running. */
+ static void initCompilation(Elements elements) {
+ BY_BEAN.clear();
+ BY_BUILDER.clear();
+ compilationElements.set(elements);
+ state = State.PROCESSING;
+ }
+
+ /**
+ * Marks the registry as final: {@link BuilderProcessor}'s generating round ran (or the
+ * compilation ended without one) — no more builders will be published.
+ */
+ static void finishCompilation() {
+ state = State.FINISHED;
+ }
+
+ /**
+ * Whether {@code observed} belongs to the compilation this holder currently describes — {@code
+ * false} while it still carries a previous run's leftovers. javac creates one {@link Elements}
+ * per compilation and shares it between all processors, so reference identity is a reliable
+ * staleness check that never requires the SPI adapters to write anything.
+ */
+ static boolean isCurrentCompilation(Elements observed) {
+ return observed != null && observed == compilationElements.get();
+ }
+
+ /** The lifecycle state of the current compilation. */
+ static State state() {
+ return state;
+ }
+
+ /**
+ * Whether {@link BuilderProcessor} finished generating builders for the compilation {@code
+ * observed} belongs to — {@code false} while this holder still describes another run.
+ *
+ * The guard is not optional: this holder's statics live in the processor-path classloader,
+ * which build tools reuse across compilations (Gradle daemon, in-process javac, IDE builds,
+ * repeated compiles in one JVM). In a new compilation's first round — before {@link
+ * BuilderProcessor}'s {@code init()} had its turn — {@code state} can still be the previous run's
+ * {@link State#FINISHED} and the registry still holds its entries. Trusting them would claim
+ * beans by qualified name they were never planned with here and answer misses as final instead of
+ * deferring. javac hands every processor of one compilation the same {@link Elements} instance
+ * and a different one per compilation, so identity is the read-only, order-independent boundary
+ * marker — an SPI-side reset cannot work, because SPI init order against {@link
+ * BuilderProcessor}'s init is path-order dependent.
+ */
+ public static boolean isSimpleBuildersFinishedForIntegration(Elements observed) {
+ return isCurrentCompilation(observed) && state == State.FINISHED;
+ }
+
+ /**
+ * Publishes the builder {@link BuilderProcessor} resolved for {@code beanType} in this
+ * compilation — generated builders always create via a static factory.
+ */
+ static void registerBuilder(TypeName beanType, ResolvedBuilder builder, String setterSuffix) {
+ if (!(builder.funcForEmptyBuilder() instanceof StaticFactoryCall factory)) {
+ return;
+ }
+ PublishedBuilder publishedBuilder =
+ new PublishedBuilder(
+ beanType,
+ builder.typeName(),
+ factory.methodName(),
+ builder.buildMethodName(),
+ setterSuffix);
+ BY_BEAN.put(publishedBuilder.beanType().getFullQualifiedName(), publishedBuilder);
+ BY_BUILDER.put(publishedBuilder.builderType().getFullQualifiedName(), publishedBuilder);
+ }
+
+ /**
+ * The builder published for {@code beanType}'s qualified name in the compilation {@code observed}
+ * belongs to — empty while this holder still describes another run, so stale entries can never
+ * claim a bean.
+ */
+ public static Optional builderFor(String beanQualifiedName, Elements observed) {
+ return isCurrentCompilation(observed)
+ ? Optional.ofNullable(BY_BEAN.get(beanQualifiedName))
+ : Optional.empty();
+ }
+
+ /**
+ * The published builder with the given qualified type name in the compilation {@code observed}
+ * belongs to — empty while this holder still describes another run.
+ */
+ public static Optional builderByName(
+ String builderQualifiedName, Elements observed) {
+ return isCurrentCompilation(observed)
+ ? Optional.ofNullable(BY_BUILDER.get(builderQualifiedName))
+ : Optional.empty();
+ }
+}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java
index 31373962..0c0dd081 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java
@@ -136,6 +136,29 @@ public Optional resolveUsableBuilderType(TypeElement referenced
referencedType.getQualifiedName().toString(), fqn -> resolve(referencedType));
}
+ /**
+ * The builder contract of a type whose builder this processor registered for the current round —
+ * always the generated {@code create()} factory for the empty path and the constructor for the
+ * copy path. Only the SPI integrations consume this, to publish the emitted contract.
+ *
+ * Unlike {@link #resolveUsableBuilderType(TypeElement)} this does not read the per-element
+ * configuration and is safe to call during generation-plan registration.
+ *
+ * @param referencedType the type element being referenced
+ * @return the resolved builder, or empty if no generated builder is registered for the type
+ */
+ public Optional resolveGeneratedBuilder(TypeElement referencedType) {
+ TypeName referencedTypeName = JavaLangMapper.mapToTypeName(referencedType, context);
+ return generatedBuilders
+ .findBuilder(referencedTypeName)
+ .map(
+ builder ->
+ new ResolvedBuilder(
+ builder,
+ new BuilderInstantiation.StaticFactoryCall("create"),
+ new BuilderInstantiation.ConstructorCall()));
+ }
+
/**
* Checks whether a builder may be generated for the given element under the generation scope of
* the resolved configuration.
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
index 8ccc5a9a..40836913 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java
@@ -40,6 +40,7 @@
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
import javax.lang.model.element.RecordComponentElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
@@ -398,6 +399,30 @@ private static boolean hasNoParameters(ExecutableElement method) {
return method.getParameters().isEmpty();
}
+ /**
+ * Finds a parameterless method with the given name directly declared on the type that carries all
+ * {@code requiredModifiers}.
+ *
+ * @param type the type element to inspect
+ * @param name the simple method name
+ * @param requiredModifiers modifiers the method must declare (e.g. {@code PUBLIC}, {@code
+ * STATIC})
+ * @return the matching method, or empty if none is declared
+ */
+ public static Optional findMethodWithoutParameters(
+ TypeElement type, String name, Modifier... requiredModifiers) {
+ for (Element member : type.getEnclosedElements()) {
+ if (member.getKind() == ElementKind.METHOD
+ && member instanceof ExecutableElement method
+ && method.getSimpleName().contentEquals(name)
+ && method.getParameters().isEmpty()
+ && method.getModifiers().containsAll(List.of(requiredModifiers))) {
+ return Optional.of(method);
+ }
+ }
+ return Optional.empty();
+ }
+
private static boolean hasParameters(
ExecutableElement method, List expectedParameterTypes) {
List extends VariableElement> parameters = method.getParameters();
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java
new file mode 100644
index 00000000..d7790f8c
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAccessorNamingStrategy.java
@@ -0,0 +1,153 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor.mapstruct;
+
+import com.google.auto.service.AutoService;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.element.VariableElement;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.ElementFilter;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader;
+import org.mapstruct.ap.spi.AccessorNamingStrategy;
+import org.mapstruct.ap.spi.DefaultAccessorNamingStrategy;
+import org.mapstruct.ap.spi.MapStructProcessingEnvironment;
+import org.mapstruct.ap.spi.MethodType;
+import org.mapstruct.ap.spi.TypeHierarchyErroneousException;
+
+/**
+ * MapStruct {@link AccessorNamingStrategy} that hides the generated helper methods of
+ * simple-builders builders from bean mapping.
+ *
+ * Generated builders carry convenience methods MapStruct must not treat as property setters:
+ * differently-named helpers ({@code add2}, {@code Update}, {@code conditional(...)})
+ * would surface as phantom "unmapped target property" warnings, and same-named helper overloads
+ * ({@code (Supplier)}, {@code (String, Object...)}, {@code (Consumer)})
+ * compete with the direct setter. For methods declared on a builder published by {@code
+ * BuilderProcessor} this strategy returns {@link MethodType#OTHER} for everything that is not the
+ * direct property setter ({@code } taking a single argument of the field
+ * type). The setter suffix and the bean of each builder come from the published {@link
+ * PublishedBuilder} descriptors, so builders produced by earlier compilations and foreign types
+ * keep the {@link DefaultAccessorNamingStrategy} behaviour.
+ */
+@AutoService(AccessorNamingStrategy.class)
+public class MapStructAccessorNamingStrategy extends DefaultAccessorNamingStrategy {
+
+ private Map processorOptions = Map.of();
+
+ /** Direct setters (setter name → field type) by builder qualified name. */
+ private final Map> directSettersCache = new HashMap<>();
+
+ @Override
+ public void init(MapStructProcessingEnvironment processingEnvironment) {
+ super.init(processingEnvironment);
+ Map options = processingEnvironment.getOptions();
+ processorOptions = options == null ? Map.of() : options;
+ }
+
+ @Override
+ public MethodType getMethodType(ExecutableElement method) {
+ MethodType methodType = super.getMethodType(method);
+ if (!isIntegrationEnabled()) {
+ // The integration is switched off — everything keeps the default classification.
+ return methodType;
+ }
+ if (methodType != MethodType.SETTER && methodType != MethodType.ADDER) {
+ // Only write accessors can conflict with generated helpers — everything else keeps the
+ // default classification untouched.
+ return methodType;
+ }
+ if (!(method.getEnclosingElement() instanceof TypeElement builderType)) {
+ return methodType;
+ }
+ Map directSetters =
+ directSettersCache.computeIfAbsent(
+ builderType.getQualifiedName().toString(), ignored -> directSettersOf(builderType));
+ if (directSetters.isEmpty() || isDirectSetter(method, directSetters)) {
+ return methodType;
+ }
+ return MethodType.OTHER;
+ }
+
+ /**
+ * Whether the integration is switched on for this compilation — read from the options map
+ * MapStruct hands the SPI ({@code -A} arguments reach it because {@link
+ * MapStructAdditionalSupportedOptionsProvider} declares them), defaulting to enabled.
+ */
+ private boolean isIntegrationEnabled() {
+ return CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, processorOptions, true);
+ }
+
+ /**
+ * Whether {@code method} is the direct property setter: it carries the configured setter name of
+ * a bean field and takes exactly one argument assignable to that field's declared type.
+ */
+ private boolean isDirectSetter(ExecutableElement method, Map directSetters) {
+ TypeMirror fieldType = directSetters.get(method.getSimpleName().toString());
+ return fieldType != null
+ && method.getParameters().size() == 1
+ && typeUtils.isSameType(method.getParameters().get(0).asType(), fieldType);
+ }
+
+ /**
+ * The direct property setters (setter name → field type) of the bean {@code builderType} was
+ * published for — empty when {@code builderType} is not a simple-builders builder of this
+ * compilation (the lookup is empty while the holder still describes a previous run, so stale
+ * entries can never match here either).
+ */
+ private Map directSettersOf(TypeElement builderType) {
+ Optional published =
+ SimpleBuildersSpiIntegration.builderByName(
+ builderType.getQualifiedName().toString(), elementUtils);
+ if (published.isEmpty()) {
+ return Map.of();
+ }
+ TypeElement beanElement =
+ elementUtils.getTypeElement(published.get().beanType().getFullQualifiedName());
+ if (beanElement == null) {
+ // The published bean is not emitted yet — another processor may produce it in a later
+ // round, so the classification retries once the type exists.
+ if (!SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils)) {
+ throw new TypeHierarchyErroneousException(builderType.asType());
+ }
+ return Map.of();
+ }
+ Map directSetters = new HashMap<>();
+ for (VariableElement field : ElementFilter.fieldsIn(elementUtils.getAllMembers(beanElement))) {
+ if (!field.getModifiers().contains(Modifier.STATIC)) {
+ directSetters.put(
+ field.getSimpleName().toString() + published.get().setterSuffix(), field.asType());
+ }
+ }
+ return directSetters;
+ }
+}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java
new file mode 100644
index 00000000..f8385aee
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructAdditionalSupportedOptionsProvider.java
@@ -0,0 +1,47 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor.mapstruct;
+
+import com.google.auto.service.AutoService;
+import java.util.Set;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum;
+import org.mapstruct.ap.spi.AdditionalSupportedOptionsProvider;
+
+/**
+ * Declares the simple-builders options the MapStruct SPIs read. MapStruct filters the options map
+ * it hands to SPI environments down to the names {@link AdditionalSupportedOptionsProvider}s
+ * declare — without this provider {@code -Asimplebuilder.usingMapStructIntegration} would never
+ * reach them.
+ */
+@AutoService(AdditionalSupportedOptionsProvider.class)
+public class MapStructAdditionalSupportedOptionsProvider
+ implements AdditionalSupportedOptionsProvider {
+
+ @Override
+ public Set getAdditionalSupportedOptions() {
+ return Set.of(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getCompilerArgument(),
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION.getOptionName());
+ }
+}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java
new file mode 100644
index 00000000..914a5fa8
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/MapStructBuilderProvider.java
@@ -0,0 +1,245 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor.mapstruct;
+
+import com.google.auto.service.AutoService;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import javax.lang.model.element.AnnotationMirror;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.Modifier;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.Elements;
+import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
+import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder;
+import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsEnum;
+import org.javahelpers.simple.builders.processor.processing.CompilerArgumentsReader;
+import org.mapstruct.ap.spi.BuilderInfo;
+import org.mapstruct.ap.spi.BuilderProvider;
+import org.mapstruct.ap.spi.MapStructProcessingEnvironment;
+import org.mapstruct.ap.spi.TypeHierarchyErroneousException;
+
+/**
+ * MapStruct {@link BuilderProvider} SPI that makes MapStruct use builders generated by
+ * simple-builders.
+ *
+ * MapStruct's default provider only considers {@code public static} methods on the bean type
+ * itself as builder-creation candidates. simple-builders keeps the factory on the generated builder
+ * class ({@code PersonDtoBuilder.create()}), so beans are paired with builders exclusively through
+ * the registry {@code BuilderProcessor} publishes — a constant-time lookup of already-resolved
+ * {@link PublishedBuilder} descriptors (builder type, creation and build method, setter suffix),
+ * covering custom packages and {@code @SimpleBuilderFor} targets. Builders produced by earlier
+ * compilations are not discovered: only the beans the processor plans in the current run get
+ * builder mapping.
+ *
+ *
{@code BuilderProcessor} generates in exactly one round — the first round carrying its
+ * annotations — and marks the registry final when it ends. A lookup miss before that point defers
+ * the mapper via {@link TypeHierarchyErroneousException}, MapStruct's own retry mechanism, for
+ * every bean: no bean-side marker is needed, so {@code @SimpleBuilderFor} targets resolve too. Once
+ * final, a miss is definitive: a bean marked for generation is reported as a warning, a foreign
+ * bean just maps without a builder. A published builder whose type is not emitted yet defers until
+ * the registry is final.
+ *
+ *
The provider is registered via {@code META-INF/services} and is only loaded when
+ * simple-builders-processor and mapstruct-processor share the annotation processor path. The
+ * integration can be switched off entirely with {@code
+ * -Asimplebuilder.usingMapStructIntegration=DISABLED} or the {@code -D} JVM system property — the
+ * {@code -A} argument reaches SPI environments because {@link
+ * MapStructAdditionalSupportedOptionsProvider} declares it.
+ */
+@AutoService(BuilderProvider.class)
+public class MapStructBuilderProvider implements BuilderProvider {
+
+ private Elements elementUtils;
+ private Map processorOptions = Map.of();
+
+ /** Resolved builder infos by bean qualified name; only positive results are cached. */
+ private final Map builderInfoCache = new HashMap<>();
+
+ @Override
+ public void init(MapStructProcessingEnvironment processingEnvironment) {
+ this.elementUtils = processingEnvironment.getElementUtils();
+ Map options = processingEnvironment.getOptions();
+ processorOptions = options == null ? Map.of() : options;
+ }
+
+ @Override
+ public BuilderInfo findBuilderInfo(TypeMirror type) {
+ if (!isIntegrationEnabled()) {
+ return null;
+ }
+ if (!(type instanceof DeclaredType declaredType)
+ || !(declaredType.asElement() instanceof TypeElement beanElement)) {
+ return null;
+ }
+ BuilderInfo cached = builderInfoCache.get(beanElement.getQualifiedName().toString());
+ if (cached != null) {
+ return cached;
+ }
+ BuilderInfo builderInfo = createBuilderInfo(beanElement);
+ if (builderInfo != null) {
+ builderInfoCache.put(beanElement.getQualifiedName().toString(), builderInfo);
+ }
+ return builderInfo;
+ }
+
+ /**
+ * Whether the integration is switched on for this compilation — read from the options map
+ * MapStruct hands the SPI ({@code -A} arguments reach it because {@link
+ * MapStructAdditionalSupportedOptionsProvider} declares them), defaulting to enabled.
+ */
+ private boolean isIntegrationEnabled() {
+ return CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, processorOptions, true);
+ }
+
+ /**
+ * Resolves the generated builder for {@code beanElement} through the registry {@code
+ * BuilderProcessor} publishes — the list decides alone which type is claimed.
+ */
+ private BuilderInfo createBuilderInfo(TypeElement beanElement) {
+ Optional published = publishedFor(beanElement);
+ TypeElement builderElement = findBuilderElement(beanElement, published);
+ if (builderElement == null) {
+ return null;
+ }
+ ExecutableElement creationMethod = creationMethod(builderElement, published.orElseThrow());
+ Optional buildMethod = buildMethod(builderElement, published.orElseThrow());
+ if (creationMethod == null || buildMethod.isEmpty()) {
+ return null;
+ }
+ return new BuilderInfo.Builder()
+ .builderCreationMethod(creationMethod)
+ .buildMethod(List.of(buildMethod.orElseThrow()))
+ .build();
+ }
+
+ /**
+ * The element of the builder published for {@code beanElement}, waiting for a pending
+ * registration or emission via {@link TypeHierarchyErroneousException} — MapStruct's own deferral
+ * mechanism. {@code null} for foreign beans and for misses that are definitive.
+ */
+ private TypeElement findBuilderElement(
+ TypeElement beanElement, Optional published) {
+ if (published.isEmpty()) {
+ if (!isFinished()) {
+ // The generating round may not have run yet — any bean may still be registered, so
+ // defer the mapper for a retry once the registry is final.
+ throw new TypeHierarchyErroneousException(beanElement.asType());
+ }
+ // The registry is final: a marked bean without an entry was skipped by planning.
+ if (isBuilderGenerationTarget(beanElement)) {
+ warnMarkedBeanWithoutRegisteredBuilder(beanElement);
+ }
+ return null;
+ }
+ TypeElement builderElement =
+ elementUtils.getTypeElement(published.get().builderType().getFullQualifiedName());
+ if (builderElement == null) {
+ // The type is written with the generating round's sources and materializes in the next
+ // round — defer until it exists.
+ throw new TypeHierarchyErroneousException(beanElement.asType());
+ }
+ return builderElement;
+ }
+
+ /**
+ * Whether builder generation is final for the compilation this provider's {@code elementUtils}
+ * belongs to — {@code false} while the holder still describes a previous run on a reused JVM.
+ */
+ private boolean isFinished() {
+ return SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(elementUtils);
+ }
+
+ /**
+ * The builder published for {@code beanElement} in this provider's compilation — empty while the
+ * holder still describes a previous run, so stale entries can never claim a bean.
+ */
+ private Optional publishedFor(TypeElement beanElement) {
+ return SimpleBuildersSpiIntegration.builderFor(
+ beanElement.getQualifiedName().toString(), elementUtils);
+ }
+
+ /** Warns that a bean marked for generation got no registered builder in this compilation. */
+ private void warnMarkedBeanWithoutRegisteredBuilder(TypeElement beanElement) {
+ // SPI environments expose no Messager — a logger is the only channel a build shows.
+ System.getLogger(MapStructBuilderProvider.class.getName())
+ .log(
+ System.Logger.Level.WARNING,
+ "simple-builders: no generated builder was registered for the marked bean '"
+ + beanElement.getQualifiedName()
+ + "'; MapStruct maps it without a builder.");
+ }
+
+ /**
+ * The published creation method on the resolved builder — a {@code public static} parameterless
+ * factory whose name the descriptor carries ({@code create} for generated builders).
+ */
+ private ExecutableElement creationMethod(TypeElement builderElement, PublishedBuilder published) {
+ return JavaLangAnalyser.findMethodWithoutParameters(
+ builderElement, published.creationMethodName(), Modifier.PUBLIC, Modifier.STATIC)
+ .orElse(null);
+ }
+
+ /**
+ * The published build method on the resolved builder — the {@code public} parameterless instance
+ * method named by the descriptor ({@code build} for generated builders).
+ */
+ private Optional buildMethod(
+ TypeElement builderElement, PublishedBuilder published) {
+ return JavaLangAnalyser.findMethodWithoutParameters(
+ builderElement, published.buildMethodName(), Modifier.PUBLIC);
+ }
+
+ /**
+ * Whether {@code beanElement} is marked for builder generation ({@code @SimpleBuilder} or a
+ * builder template annotation, not opted out via {@code @Ignore4BuilderGeneration}). The marker
+ * only decides the finished-state warning — deferral and claiming never depend on it.
+ */
+ private boolean isBuilderGenerationTarget(TypeElement beanElement) {
+ if (JavaLangAnalyser.findAnnotation(beanElement, Ignore4BuilderGeneration.class).isPresent()) {
+ return false;
+ }
+ if (JavaLangAnalyser.findAnnotation(beanElement, SimpleBuilder.class).isPresent()) {
+ return true;
+ }
+ for (AnnotationMirror mirror : beanElement.getAnnotationMirrors()) {
+ // A custom builder template annotation (e.g. @SimpleMinimalBuilder or a project-defined
+ // one): its type is meta-annotated with @SimpleBuilder.Template.
+ if (mirror.getAnnotationType().asElement() instanceof TypeElement annotationType
+ && JavaLangAnalyser.findAnnotation(annotationType, SimpleBuilder.Template.class)
+ .isPresent()) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java
new file mode 100644
index 00000000..2fbb3c30
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/mapstruct/package-info.java
@@ -0,0 +1,28 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+/**
+ * Integration of simple-builders with MapStruct: makes generated builders discoverable by
+ * MapStruct's builder detection via the {@code org.mapstruct.ap.spi.BuilderProvider} SPI.
+ */
+package org.javahelpers.simple.builders.processor.mapstruct;
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
index 6fa81777..8dfa6118 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java
@@ -138,6 +138,13 @@ public enum CompilerArgumentsEnum {
/** Option for Jackson Module generation. */
GENERATE_JACKSON_MODULE("generateJacksonModule", optionState(Builder::generateJacksonModule)),
+ /**
+ * Option for the MapStruct SPI integration. Processor-level: it switches the SPI adapters
+ * discovered by MapStruct itself, so it is read directly via {@link CompilerArgumentsReader}
+ * rather than applied to a {@link BuilderConfiguration.Builder}.
+ */
+ USING_MAPSTRUCT_INTEGRATION("usingMapStructIntegration"),
+
/** Option for Javadoc generation on the generated builder. */
GENERATE_JAVADOC("generateJavaDoc", optionState(Builder::generateJavaDoc)),
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
index 42ed27ab..178b0a4c 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsReader.java
@@ -24,6 +24,7 @@
package org.javahelpers.simple.builders.processor.processing;
+import java.util.Map;
import java.util.stream.Stream;
import javax.annotation.processing.ProcessingEnvironment;
import org.apache.commons.lang3.Strings;
@@ -62,17 +63,29 @@ public CompilerArgumentsReader(ProcessingEnvironment processingEnv) {
* @return the value of the compiler argument, or null if not set
*/
public String readValue(CompilerArgumentsEnum argument) {
+ return readValue(argument, processingEnv.getOptions());
+ }
+
+ /**
+ * Reads the value of a compiler argument from an explicit options map — the same lookup SPI
+ * adapters can use on the options map their host framework hands them.
+ *
+ * @param argument the compiler argument enum to read
+ * @param options the compiler options map to search
+ * @return the value of the compiler argument, or null if not set
+ */
+ public static String readValue(CompilerArgumentsEnum argument, Map options) {
// Try the -D JVM system property first (e.g., -Dsimplebuilder.verbose)
String value = System.getProperty(argument.getCompilerArgument());
// Then the -A compiler argument (e.g., -Asimplebuilder.verbose)
if (value == null) {
- value = processingEnv.getOptions().get(argument.getCompilerArgument());
+ value = options.get(argument.getCompilerArgument());
}
// Finally the bare option name for backward compatibility (e.g., -Averbose)
if (value == null) {
- value = processingEnv.getOptions().get(argument.getOptionName());
+ value = options.get(argument.getOptionName());
}
return value;
@@ -87,8 +100,32 @@ public String readValue(CompilerArgumentsEnum argument) {
* @return true if the value is "true" (case-insensitive), false otherwise
*/
public boolean readBooleanValue(CompilerArgumentsEnum argument) {
- String value = readValue(argument);
- return Strings.CI.equalsAny(value, "true", "enabled");
+ return readBooleanValue(argument, processingEnv.getOptions(), false);
+ }
+
+ /**
+ * Reads the value of a compiler argument as a boolean with an explicit default — the same
+ * resolution SPI adapters can use on the options map their host framework hands them.
+ *
+ * @param argument the compiler argument enum to read
+ * @param options the compiler options map to search
+ * @param defaultValue the result for an unset or unrecognizable value
+ * @return "true"/"enabled" resolves to true, "false"/"disabled" to false, anything else to {@code
+ * defaultValue}
+ */
+ public static boolean readBooleanValue(
+ CompilerArgumentsEnum argument, Map options, boolean defaultValue) {
+ String value = readValue(argument, options);
+ if (value == null || value.isEmpty()) {
+ return defaultValue;
+ }
+ if (Strings.CI.equalsAny(value, "true", "enabled")) {
+ return true;
+ }
+ if (Strings.CI.equalsAny(value, "false", "disabled")) {
+ return false;
+ }
+ return defaultValue;
}
/**
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
index 212e7fce..c1928de6 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/CompilerArgumentsReaderTest.java
@@ -197,6 +197,59 @@ void readBooleanValue_InvalidValues_ReturnsFalse(String value) {
"Should return false for: " + value);
}
+ /** Test: the static readBooleanValue resolves an options map without an environment. */
+ @Test
+ void readBooleanValue_ExplicitOptionsMap_ResolvesLikeEnvironment() {
+ assertTrue(
+ CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION, Map.of(), true));
+
+ assertFalse(
+ CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION,
+ Map.of("usingMapStructIntegration", "DISABLED"),
+ true));
+
+ assertTrue(
+ CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION,
+ Map.of("simplebuilder.usingMapStructIntegration", "invalid"),
+ true));
+ }
+
+ /** Test: the static readBooleanValue honors the -D > -A > bare-option precedence. */
+ @Test
+ void readBooleanValue_ExplicitOptionsMap_SystemPropertyWins() {
+ System.setProperty("simplebuilder.usingMapStructIntegration", "DISABLED");
+ try {
+ assertFalse(
+ CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION,
+ Map.of(
+ "simplebuilder.usingMapStructIntegration",
+ "ENABLED",
+ "usingMapStructIntegration",
+ "ENABLED"),
+ true));
+ } finally {
+ System.clearProperty("simplebuilder.usingMapStructIntegration");
+ }
+ }
+
+ /** Test: the prefixed compiler argument beats the bare option name. */
+ @Test
+ void readBooleanValue_ExplicitOptionsMap_CompilerArgumentBeatsBareOption() {
+ assertFalse(
+ CompilerArgumentsReader.readBooleanValue(
+ CompilerArgumentsEnum.USING_MAPSTRUCT_INTEGRATION,
+ Map.of(
+ "simplebuilder.usingMapStructIntegration",
+ "DISABLED",
+ "usingMapStructIntegration",
+ "ENABLED"),
+ true));
+ }
+
/** Test: readBuilderConfiguration with no arguments returns all UNSET/DEFAULT values. */
@Test
void readBuilderConfiguration_NoArguments_ReturnsDefaults() {
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java
new file mode 100644
index 00000000..8d46d70c
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiIntegrationTest.java
@@ -0,0 +1,196 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor;
+
+import static com.google.testing.compile.CompilationSubject.assertThat;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.assertNoWarningContaining;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.testing.compile.Compilation;
+import com.google.testing.compile.Compiler;
+import javax.annotation.processing.Processor;
+import javax.tools.JavaFileObject;
+import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.ap.MappingProcessor;
+
+/**
+ * Integration test for the MapStruct SPI implementations: {@code MapStructBuilderProvider} supplies
+ * the builder, {@code MapStructAccessorNamingStrategy} hides the generated helper methods so
+ * MapStruct neither binds them nor reports them as unmapped target properties.
+ */
+class MapStructSpiIntegrationTest {
+
+ @Test
+ void mapStruct_shouldUseGeneratedBuilder() {
+ Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper());
+ assertThat(compilation).succeeded();
+
+ String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl");
+ assertTrue(
+ mapperImpl.contains("PersonDtoBuilder.create()"),
+ "MapStruct should instantiate the generated builder");
+ assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()");
+ }
+
+ @Test
+ void mapStruct_processorOrderReversed_shouldStillUseGeneratedBuilder() {
+ // MapStruct processing the mapper before BuilderProcessor's generating round must not lose
+ // the builder: the bean defers via TypeHierarchyErroneousException until the registry is
+ // final and holds the planned builder
+ Compilation compilation =
+ mapStructCompiler(new MappingProcessor(), new BuilderProcessor())
+ .compile(personDto(), personDtoMapper());
+ assertThat(compilation).succeeded();
+
+ String mapperImpl = loadGeneratedSource(compilation, "PersonDtoMapperImpl");
+ assertTrue(
+ mapperImpl.contains("PersonDtoBuilder.create()"),
+ "Reversed processor order must still bind the generated builder");
+ assertTrue(mapperImpl.contains(".build()"), "MapStruct should finish via build()");
+ }
+
+ @Test
+ void mapStruct_shouldNotReportHelpersAsUnmappedTargetProperties() {
+ Compilation compilation = mapStructCompiler().compile(personDto(), personDtoMapper());
+ assertThat(compilation).succeeded();
+
+ assertNoWarningContaining(compilation, "unmapped target property");
+ }
+
+ @Test
+ void mapStruct_disabledIntegration_shouldMapViaSetters() {
+ // Our AdditionalSupportedOptionsProvider declares the option, so MapStruct forwards the -A
+ // value into the SPI environment's options
+ Compilation compilation =
+ mapStructCompiler()
+ .withOptions("-Asimplebuilder.usingMapStructIntegration=DISABLED")
+ .compile(mutableDto(), mutableDtoMapper());
+ assertThat(compilation).succeeded();
+
+ String mapperImpl = loadGeneratedSource(compilation, "MutableDtoMapperImpl");
+ assertFalse(
+ mapperImpl.contains("MutableDtoBuilder"),
+ "Disabled integration must leave the generated builder unused");
+ assertTrue(mapperImpl.contains(".setName("), "MapStruct should fall back to setter mapping");
+ }
+
+ /** A javac compiler with BuilderProcessor ahead of MapStruct and stable mapper output. */
+ private static Compiler mapStructCompiler() {
+ return mapStructCompiler(new BuilderProcessor(), new MappingProcessor());
+ }
+
+ /** A javac compiler with the given processors in invocation order and stable mapper output. */
+ private static Compiler mapStructCompiler(Processor... processors) {
+ return createCompiler(processors)
+ .withOptions(
+ "-Amapstruct.suppressGeneratorTimestamp=true",
+ "-Amapstruct.suppressGeneratorVersionInfoComment=true");
+ }
+
+ private static JavaFileObject personDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import java.util.List;
+ import java.util.Optional;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class PersonDto {
+ private String name;
+ private List nicknames;
+ private Optional email;
+
+ public String getName() {
+ return name;
+ }
+
+ public List getNicknames() {
+ return nicknames;
+ }
+
+ public Optional getEmail() {
+ return email;
+ }
+ }
+ """);
+ }
+
+ private static JavaFileObject personDtoMapper() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.mapstruct.Mapper;
+
+ @Mapper
+ public interface PersonDtoMapper {
+
+ PersonDto copy(PersonDto source);
+ }
+ """);
+ }
+
+ private static JavaFileObject mutableDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class MutableDto {
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+ """);
+ }
+
+ private static JavaFileObject mutableDtoMapper() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.mapstruct.Mapper;
+
+ @Mapper
+ public interface MutableDtoMapper {
+
+ MutableDto copy(MutableDto source);
+ }
+ """);
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java
new file mode 100644
index 00000000..b15ee1a0
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/MapStructSpiProbeTest.java
@@ -0,0 +1,370 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor;
+
+import static com.google.testing.compile.CompilationSubject.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import com.google.testing.compile.Compilation;
+import com.google.testing.compile.Compiler;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
+import javax.lang.model.element.TypeElement;
+import javax.lang.model.type.TypeMirror;
+import javax.lang.model.util.ElementFilter;
+import javax.lang.model.util.Elements;
+import javax.lang.model.util.Types;
+import javax.tools.JavaFileObject;
+import org.javahelpers.simple.builders.processor.mapstruct.MapStructAccessorNamingStrategy;
+import org.javahelpers.simple.builders.processor.mapstruct.MapStructBuilderProvider;
+import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils;
+import org.junit.jupiter.api.Test;
+import org.mapstruct.ap.spi.BuilderInfo;
+import org.mapstruct.ap.spi.MapStructProcessingEnvironment;
+import org.mapstruct.ap.spi.MethodType;
+import org.mapstruct.ap.spi.TypeHierarchyErroneousException;
+
+/**
+ * Exercises both MapStruct SPIs from inside a real {@code javac} run: a probe processor drives them
+ * against the elements the same compilation emits — covering paths MapStruct's own call sites never
+ * reach in the integration tests (method classification on the builder type, deferral while the
+ * published builder is not emitted yet, marker scans of foreign, ignored and template beans, cache
+ * hits, and lookups after the registry finalized).
+ */
+class MapStructSpiProbeTest {
+
+ @Test
+ void probe_shouldDriveSpiAgainstEmittedElements() {
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new SpiProbeProcessor(), new BuilderProcessor())
+ .compile(personDto(), foreignDto(), ignoredDto(), templateDto());
+ assertThat(compilation).succeeded();
+
+ ProbeResults results = ProbeResults.instance;
+
+ // While the registry is unpublished every lookup defers — no marker is needed, so marked,
+ // template, foreign and opted-out beans alike wait for the generating round.
+ assertInstanceOf(TypeHierarchyErroneousException.class, results.unregisteredDeferral);
+ assertInstanceOf(TypeHierarchyErroneousException.class, results.templateDeferral);
+ assertInstanceOf(TypeHierarchyErroneousException.class, results.foreignUnpublishedDeferral);
+ assertInstanceOf(TypeHierarchyErroneousException.class, results.ignoredUnpublishedDeferral);
+
+ // Once the registry is final, foreign and opted-out beans are never claimed.
+ assertNull(results.foreignRegistered);
+ assertNull(results.ignoredRegistered);
+ assertNull(results.foreignWhenFinished);
+ assertNull(results.noType);
+
+ // Resolution once the builder exists resolves the published contract methods and caches.
+ assertNotNull(results.builderInfo);
+ assertEquals(
+ "create", results.builderInfo.getBuilderCreationMethod().getSimpleName().toString());
+ assertEquals(
+ "build",
+ results.builderInfo.getBuildMethods().iterator().next().getSimpleName().toString());
+ assertEquals(results.builderInfo, results.cachedBuilderInfo);
+
+ // The naming strategy keeps only direct property setters visible as write accessors.
+ assertEquals(MethodType.SETTER, results.methodTypes.get("name"));
+ assertEquals(MethodType.OTHER, results.methodTypes.get("nameUpdate"));
+ assertEquals(MethodType.OTHER, results.methodTypes.get("build"));
+ assertEquals(MethodType.OTHER, results.methodTypes.get("create"));
+
+ // A marked bean outside the generation scope is skipped by planning: it defers while the
+ // registry may still fill and resolves to no builder — reported as a warning — once the
+ // generating round is done. The in-scope bean forces a second round so the post-registration
+ // lookup runs.
+ Compilation skippedCompile =
+ Compiler.javac()
+ .withProcessors(new SpiProbeProcessor(true), new BuilderProcessor())
+ .withOptions("-Asimplebuilder.builderGenerationPackages=scoped")
+ .compile(personDto(), scopedDto());
+ assertThat(skippedCompile).succeeded();
+ ProbeResults skipped = ProbeResults.instance;
+ assertInstanceOf(TypeHierarchyErroneousException.class, skipped.skippedBeanDeferred);
+ assertNull(skipped.skippedBeanRegistered);
+ assertNull(skipped.skippedBeanFinished);
+ }
+
+ /** Records the SPI outcomes of one probe compilation for assertions after it. */
+ private static final class ProbeResults {
+ static final ProbeResults instance = new ProbeResults();
+
+ final Map methodTypes = new LinkedHashMap<>();
+ Throwable unregisteredDeferral;
+ Throwable templateDeferral;
+ Throwable skippedBeanDeferred;
+ Throwable foreignUnpublishedDeferral;
+ Throwable ignoredUnpublishedDeferral;
+ BuilderInfo skippedBeanRegistered;
+ BuilderInfo skippedBeanFinished;
+ BuilderInfo foreignRegistered;
+ BuilderInfo ignoredRegistered;
+ BuilderInfo foreignWhenFinished;
+ BuilderInfo noType;
+ BuilderInfo builderInfo;
+ BuilderInfo cachedBuilderInfo;
+
+ void reset() {
+ methodTypes.clear();
+ unregisteredDeferral = null;
+ templateDeferral = null;
+ skippedBeanDeferred = null;
+ foreignUnpublishedDeferral = null;
+ ignoredUnpublishedDeferral = null;
+ skippedBeanRegistered = null;
+ skippedBeanFinished = null;
+ foreignRegistered = null;
+ ignoredRegistered = null;
+ foreignWhenFinished = null;
+ noType = null;
+ builderInfo = null;
+ cachedBuilderInfo = null;
+ }
+ }
+
+ /**
+ * Drives {@link MapStructBuilderProvider} and {@link MapStructAccessorNamingStrategy} like
+ * MapStruct would — one SPI instance per compilation, lookups on the mapped bean while its
+ * builder is pending, then method classification once the builder type exists. Runs ahead of
+ * {@link BuilderProcessor} in the first round so beans are probed before registration. In {@code
+ * skippedMode} it probes the marked bean that the scoped {@link BuilderProcessor} never plans —
+ * deferral while unpublished plus the finished outcome.
+ */
+ @SupportedAnnotationTypes("*")
+ public static final class SpiProbeProcessor extends AbstractProcessor {
+
+ private final MapStructBuilderProvider provider = new MapStructBuilderProvider();
+ private final MapStructAccessorNamingStrategy naming = new MapStructAccessorNamingStrategy();
+ private final boolean skippedMode;
+ private boolean namingProbed;
+
+ SpiProbeProcessor() {
+ this(false);
+ }
+
+ SpiProbeProcessor(boolean skippedMode) {
+ this.skippedMode = skippedMode;
+ }
+
+ @Override
+ public synchronized void init(javax.annotation.processing.ProcessingEnvironment env) {
+ super.init(env);
+ ProbeResults.instance.reset();
+ provider.init(spiEnvironment());
+ naming.init(spiEnvironment());
+ }
+
+ private MapStructProcessingEnvironment spiEnvironment() {
+ return new MapStructProcessingEnvironment() {
+ @Override
+ public Elements getElementUtils() {
+ return processingEnv.getElementUtils();
+ }
+
+ @Override
+ public Types getTypeUtils() {
+ return processingEnv.getTypeUtils();
+ }
+
+ @Override
+ public Map getOptions() {
+ return Map.of();
+ }
+ };
+ }
+
+ @Override
+ public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) {
+ ProbeResults results = ProbeResults.instance;
+ if (roundEnv.processingOver()) {
+ if (skippedMode) {
+ results.skippedBeanFinished = lookup(provider, "test.PersonDto");
+ } else {
+ results.foreignWhenFinished = lookup(provider, "test.ForeignDto");
+ }
+ return false;
+ }
+
+ TypeElement bean = processingEnv.getElementUtils().getTypeElement("test.PersonDto");
+ if (bean == null) {
+ return false;
+ }
+
+ if (skippedMode) {
+ // The bean is marked but out of the generation scope: it defers while the registry
+ // may still fill, then resolves to no builder once the generating round is done.
+ if (results.skippedBeanDeferred == null) {
+ results.skippedBeanDeferred = lookupExpectingDeferral(bean);
+ } else {
+ results.skippedBeanRegistered = lookup(provider, "test.PersonDto");
+ }
+ return false;
+ }
+
+ if (results.unregisteredDeferral == null) {
+ // Probed before BuilderProcessor planned the bean — a marked bean without a registry
+ // entry must defer like a mapper running ahead of the generating round.
+ results.unregisteredDeferral = lookupExpectingDeferral(bean);
+ results.foreignUnpublishedDeferral = lookupExpectingDeferral(element("test.ForeignDto"));
+ results.ignoredUnpublishedDeferral = lookupExpectingDeferral(element("test.IgnoredDto"));
+ results.templateDeferral = lookupExpectingDeferral(element("test.MinimalDto"));
+ return false;
+ }
+
+ TypeElement builder = processingEnv.getElementUtils().getTypeElement("test.PersonDtoBuilder");
+ if (builder == null) {
+ // Published but not emitted yet — the lookup must defer until the type exists.
+ lookupExpectingDeferral(bean);
+ return false;
+ }
+
+ if (results.builderInfo != null) {
+ return false;
+ }
+ results.builderInfo = provider.findBuilderInfo(bean.asType());
+ results.cachedBuilderInfo = provider.findBuilderInfo(bean.asType());
+ results.foreignRegistered = lookup(provider, "test.ForeignDto");
+ results.ignoredRegistered = lookup(provider, "test.IgnoredDto");
+ results.noType =
+ provider.findBuilderInfo(
+ processingEnv.getTypeUtils().getNoType(javax.lang.model.type.TypeKind.NONE));
+
+ if (!namingProbed) {
+ namingProbed = true;
+ for (ExecutableElement method : ElementFilter.methodsIn(builder.getEnclosedElements())) {
+ results.methodTypes.put(method.getSimpleName().toString(), naming.getMethodType(method));
+ }
+ }
+ return false;
+ }
+
+ private TypeElement element(String qualifiedName) {
+ return processingEnv.getElementUtils().getTypeElement(qualifiedName);
+ }
+
+ private Throwable lookupExpectingDeferral(TypeElement bean) {
+ try {
+ provider.findBuilderInfo(bean.asType());
+ return null;
+ } catch (RuntimeException deferred) {
+ return deferred;
+ }
+ }
+
+ private BuilderInfo lookup(MapStructBuilderProvider provider, String qualifiedName) {
+ Element element = processingEnv.getElementUtils().getTypeElement(qualifiedName);
+ TypeMirror type = element == null ? null : element.asType();
+ return type == null ? null : provider.findBuilderInfo(type);
+ }
+ }
+
+ private static JavaFileObject personDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class PersonDto {
+ private String name;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+ """);
+ }
+
+ private static JavaFileObject foreignDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ public class ForeignDto {
+ private String name;
+ }
+ """);
+ }
+
+ private static JavaFileObject ignoredDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ @Ignore4BuilderGeneration
+ public class IgnoredDto {
+ private String name;
+ }
+ """);
+ }
+
+ private static JavaFileObject scopedDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package scoped;
+
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class ScopedDto {
+ private String name;
+ }
+ """);
+ }
+
+ private static JavaFileObject templateDto() {
+ return ProcessorTestUtils.forSource(
+ """
+ package test;
+
+ import org.javahelpers.simple.builders.core.annotations.SimpleMinimalBuilder;
+
+ @SimpleMinimalBuilder
+ public class MinimalDto {
+ private String name;
+ }
+ """);
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java
new file mode 100644
index 00000000..ec15d772
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/SimpleBuildersSpiIntegrationTest.java
@@ -0,0 +1,139 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2026 Andreas Igel
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons with the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package org.javahelpers.simple.builders.processor;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Proxy;
+import java.util.Optional;
+import javax.lang.model.util.Elements;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.PublishedBuilder;
+import org.javahelpers.simple.builders.processor.SimpleBuildersSpiIntegration.State;
+import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation.StaticFactoryCall;
+import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder;
+import org.javahelpers.simple.builders.processor.model.type.TypeName;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit test for the shared SPI bridge: lifecycle transitions stay pinned to the current compilation
+ * (identified by its {@link Elements}), and the registry exposes exactly the published descriptors.
+ */
+class SimpleBuildersSpiIntegrationTest {
+
+ private static final Elements ELEMENTS = fakeElements();
+
+ private static final TypeName PERSON_BEAN = new TypeName("test", "PersonDto");
+
+ private static final ResolvedBuilder PERSON_RESOLVED =
+ new ResolvedBuilder(
+ new TypeName("test", "PersonDtoBuilder"),
+ new StaticFactoryCall("create"),
+ Optional.empty(),
+ "build");
+
+ private static final PublishedBuilder PERSON =
+ new PublishedBuilder(PERSON_BEAN, PERSON_RESOLVED.typeName(), "create", "build", "");
+
+ /** A stand-in {@link Elements}; javac identity is what matters, never its methods. */
+ private static Elements fakeElements() {
+ return (Elements)
+ Proxy.newProxyInstance(
+ SimpleBuildersSpiIntegrationTest.class.getClassLoader(),
+ new Class>[] {Elements.class},
+ (proxy, method, args) -> {
+ throw new UnsupportedOperationException();
+ });
+ }
+
+ @AfterEach
+ void reset() {
+ SimpleBuildersSpiIntegration.initCompilation(null);
+ }
+
+ @Test
+ void lifecycle_transitions() {
+ SimpleBuildersSpiIntegration.initCompilation(ELEMENTS);
+ assertEquals(State.PROCESSING, SimpleBuildersSpiIntegration.state());
+ assertFalse(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(ELEMENTS));
+
+ SimpleBuildersSpiIntegration.finishCompilation();
+ assertEquals(State.FINISHED, SimpleBuildersSpiIntegration.state());
+ assertTrue(SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(ELEMENTS));
+ // The finished answer is scoped to the compilation it was asked about — a foreign
+ // compilation sees the holder as not finished even in State.FINISHED.
+ assertFalse(
+ SimpleBuildersSpiIntegration.isSimpleBuildersFinishedForIntegration(fakeElements()));
+ }
+
+ @Test
+ void staleness_onlyOwnCompilationIsCurrent() {
+ SimpleBuildersSpiIntegration.initCompilation(ELEMENTS);
+ assertTrue(SimpleBuildersSpiIntegration.isCurrentCompilation(ELEMENTS));
+ // A different Elements instance belongs to another compilation — the holder must answer
+ // as not current until its initCompilation ran with that instance.
+ assertFalse(SimpleBuildersSpiIntegration.isCurrentCompilation(fakeElements()));
+ assertFalse(SimpleBuildersSpiIntegration.isCurrentCompilation(null));
+ }
+
+ @Test
+ void registry_publishesBothDirections() {
+ SimpleBuildersSpiIntegration.initCompilation(ELEMENTS);
+ assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).isEmpty());
+ assertTrue(
+ SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", ELEMENTS).isEmpty());
+
+ SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, "");
+
+ assertEquals(
+ PERSON, SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).orElseThrow());
+ assertEquals(
+ PERSON,
+ SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", ELEMENTS)
+ .orElseThrow());
+ // Lookups are scoped to the compilation asked about — a foreign compilation sees nothing.
+ assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", fakeElements()).isEmpty());
+ assertTrue(
+ SimpleBuildersSpiIntegration.builderByName("test.PersonDtoBuilder", fakeElements())
+ .isEmpty());
+ assertEquals("test.PersonDtoBuilder", PERSON.builderType().getFullQualifiedName());
+ assertEquals("create", PERSON.creationMethodName());
+ assertEquals("build", PERSON.buildMethodName());
+ assertEquals("", PERSON.setterSuffix());
+ }
+
+ @Test
+ void registry_clearedOnNextCompilation() {
+ SimpleBuildersSpiIntegration.initCompilation(ELEMENTS);
+ SimpleBuildersSpiIntegration.registerBuilder(PERSON_BEAN, PERSON_RESOLVED, "");
+
+ Elements nextCompilation = fakeElements();
+ SimpleBuildersSpiIntegration.initCompilation(nextCompilation);
+ assertTrue(
+ SimpleBuildersSpiIntegration.builderFor("test.PersonDto", nextCompilation).isEmpty());
+ assertTrue(SimpleBuildersSpiIntegration.builderFor("test.PersonDto", ELEMENTS).isEmpty());
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
index 7b67fa84..e0bbf582 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
@@ -6,9 +6,12 @@
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import javax.annotation.processing.Processor;
import javax.tools.JavaFileObject;
import org.apache.commons.lang3.Strings;
import org.javahelpers.simple.builders.processor.BuilderProcessor;
+import org.junit.jupiter.api.Assertions;
/**
* Utilities to simplify annotation-processor tests by reducing boilerplate for building sources,
@@ -37,7 +40,21 @@ private ProcessorTestUtils() {}
* @return a Compiler instance configured with BuilderProcessor and optional verbose output
*/
public static Compiler createCompiler() {
- Compiler compiler = Compiler.javac().withProcessors(new BuilderProcessor());
+ return createCompiler(new BuilderProcessor());
+ }
+
+ /**
+ * Creates a configured {@link Compiler} instance with the given processors in invocation order.
+ *
+ * Use this overload when a test compiles with several annotation processors whose relative
+ * order matters (e.g. {@code BuilderProcessor} alongside the MapStruct {@code MappingProcessor}).
+ * Verbose handling matches {@link #createCompiler()}.
+ *
+ * @param processors the processors to register, in the order javac invokes them
+ * @return a Compiler instance configured with the given processors and optional verbose output
+ */
+ public static Compiler createCompiler(Processor... processors) {
+ Compiler compiler = Compiler.javac().withProcessors(processors);
// Check for verbose flag from Maven property
if (isVerboseEnabled()) {
@@ -47,6 +64,22 @@ public static Compiler createCompiler() {
return compiler;
}
+ /**
+ * Asserts that none of the compilation's warnings contains the given text (case-insensitive).
+ *
+ * @param compilation the compilation result to check
+ * @param text the text no warning message may contain
+ */
+ public static void assertNoWarningContaining(Compilation compilation, String text) {
+ String warnings =
+ compilation.warnings().stream()
+ .map(diagnostic -> diagnostic.getMessage(null))
+ .collect(Collectors.joining("\n"));
+ Assertions.assertFalse(
+ warnings.toLowerCase().contains(text.toLowerCase()),
+ "No warning containing '" + text + "' expected, got: " + warnings);
+ }
+
/**
* Checks if verbose mode is enabled via system properties.
*