diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java
index 6b671ac0..7ff9b14e 100644
--- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java
@@ -770,6 +770,21 @@
*/
String builderUsagePackages() default "";
+ /**
+ * Reuse builders that were not generated by this processor: classpath builders discovered
+ * through {@link #builderUsagePackages()} or builders anchored inside the referenced type
+ * itself (a nested {@code Person.Builder} or a {@code Person.builder()} factory method).
+ * When disabled, only builders generated in the current processing round qualify and every
+ * other referenced type falls back to a plain setter - opting out of false-positive detections
+ * on types that coincidentally look like builders.
+ *
+ *
Default: ENABLED
+ * Compiler option: -Asimplebuilder.usingExistingBuilders
+ *
+ * @return the option state for reusing existing builders
+ */
+ OptionState usingExistingBuilders() default OptionState.UNSET;
+
// === Naming ===
/**
* Suffix to append to the DTO name to generate the builder class name.
diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java
index c6df4cf7..54d4777a 100644
--- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java
+++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java
@@ -94,6 +94,7 @@
generateJavaDoc = OptionState.DISABLED,
copyTypeAnnotations = OptionState.DISABLED,
implementsBuilderBase = OptionState.DISABLED,
+ usingExistingBuilders = OptionState.DISABLED,
builderSuffix = "Builder",
setterSuffix = "",
formattingMode = "lightweight"))
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 03d59e0f..2c595c04 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -743,6 +743,16 @@ Controls which packages may provide builders as nested builder helpers. Packages
comma-separated, each listed package includes all of its subpackages, and matching ignores
case.
+Before constructing a candidate name, the processor checks the referenced type
+itself for an anchored builder — the type's own declaration wins over the
+same-package candidate below. Accessible nested types satisfying the contract
+are checked first (e.g. `Person.PersonBuilder` or `Person.Builder`), then a
+static parameterless factory on the type — `builder()` preferred — whose return
+type must be accessible and declare a no-arg `build()` method (Lombok/Immutables
+style). The factory itself is the empty-instantiation path; the seeded path is
+the builder's own contract path, a static `builder(T)`-style method, or an
+instance method on the value like `toBuilder()`.
+
The processor constructs the candidate builder name using `builderUsageSuffix`
(or `builderSuffix` if not configured) and verifies the builder contract: a way
to create an empty builder (a no-arg constructor or a static parameterless
@@ -769,6 +779,19 @@ The `example` module contains a runnable demo in package
It demonstrates a generation-scope builder consumer, a usage-scope missing-builder fallback, and
an out-of-scope plain setter in [`ScopedOwnerDtoBuilder.java`](../example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java).
+#### `usingExistingBuilders`
+
+**Default**: `ENABLED` | **Compiler Option**: `-Asimplebuilder.usingExistingBuilders=DISABLED`
+
+Controls whether builders that were not generated by this processor may be
+reused — both the classpath lookup described above (usage scope, in-type
+anchors like `Person.builder()` or a nested `Person.Builder`) and any other
+existing builder satisfying the contract. When disabled, only builders
+generated in the current processing round qualify and every other referenced
+type falls back to a plain setter, opting out of false-positive detections on
+types that coincidentally look like builders. `@SimpleMinimalBuilder` disables
+this option.
+
### Component Filtering
#### `deactivateGenerationComponents`
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 82fe828c..16aa198b 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
@@ -29,7 +29,11 @@
import java.util.Map;
import java.util.Optional;
import javax.lang.model.element.Element;
+import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
+import javax.lang.model.type.DeclaredType;
+import javax.lang.model.type.TypeKind;
+import javax.lang.model.util.ElementFilter;
import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.model.core.PackageScopes;
@@ -96,6 +100,12 @@ public BuilderScopeResolver(ProcessingContext context) {
*
If the referenced type's builder is generated in the current processing round (registered
* via {@link #registerGeneratedBuilder}), the registered builder name is returned
* immediately — trusted without a classpath lookup or contract check.
+ * Otherwise, a builder anchored inside the referenced type itself wins over the
+ * same-package candidate: first accessible nested types satisfying the builder contract
+ * (e.g. {@code Person.PersonBuilder}), then static parameterless factory methods on the
+ * type returning a builder type with a no-arg {@code build()} (e.g. {@code
+ * Person.builder()}), with a seeded path on the type ({@code builder(T)}/{@code
+ * toBuilder()}) or on the builder.
* Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix}
* (falling back to {@code builderSuffix} if not configured). The candidate is looked up on
* the classpath and returned if it satisfies the builder contract: an instantiation path
@@ -210,6 +220,22 @@ private Optional resolve(TypeElement referencedType) {
new BuilderInstantiation.ConstructorCall()));
}
+ // Reusing builders not generated in this round - nested or anchored inside the referenced
+ // type, or looked up on the classpath - is opt-out via usingExistingBuilders, so a type
+ // coincidentally looking like a builder cannot be picked up unwillingly.
+ if (!context.getConfiguration().shouldUseExistingBuilders()) {
+ return Optional.empty();
+ }
+
+ // A type may anchor its builder inside itself: an accessible static parameterless method
+ // returning a contract-satisfying type (MapStruct-style `Person.builder()`). The type's own
+ // declaration wins over the same-package candidate below.
+ Optional inTypeBuilder =
+ resolveInTypeBuilder(referencedType, referencedTypeName);
+ if (inTypeBuilder.isPresent()) {
+ return inTypeBuilder;
+ }
+
// For types not generated in this round, look up the candidate on the classpath using
// builderUsageSuffix (which falls back to builderSuffix if not configured) and verify
// the builder contract.
@@ -289,6 +315,152 @@ private Optional resolveFuncForPrefilledBuilder(
return func;
}
+ /**
+ * Resolves a builder anchored inside the referenced type itself, like the nested {@code
+ * Person.PersonBuilder} or the static {@code Person.builder()} factory that Lombok, Immutables or
+ * FreeBuilder produce. Nested types are ordinary contract candidates checked through {@link
+ * #resolveByBuilderContract} first; then static parameterless factory methods on the type anchor
+ * the empty-instantiation path directly.
+ *
+ * @param referencedType the type element being referenced
+ * @param referencedTypeName the referenced type, passed to the contract check
+ * @return the resolved builder, or empty when the type anchors no usable builder
+ */
+ private Optional resolveInTypeBuilder(
+ TypeElement referencedType, TypeName referencedTypeName) {
+ Optional nestedBuilder =
+ nestedBuilderCandidates(referencedType).stream()
+ .map(candidate -> resolveByBuilderContract(candidate, referencedTypeName))
+ .flatMap(Optional::stream)
+ .findFirst();
+ return nestedBuilder.or(() -> resolveAnchoredBuilder(referencedType, referencedTypeName));
+ }
+
+ /**
+ * Collects the accessible nested types of the referenced type as builder candidates, like the
+ * {@code Person.PersonBuilder} or {@code Person.Builder} inner classes Lombok and FreeBuilder
+ * generate. Names built from the referenced type's simple name and the builder suffix rank first,
+ * the plain builder suffix second; declaration order decides between equals.
+ *
+ * @param referencedType the type element being referenced
+ * @return the nested type candidates in preference order
+ */
+ private List nestedBuilderCandidates(TypeElement referencedType) {
+ String builderSuffix = context.getConfiguration().getBuilderSuffix();
+ String preferredName = referencedType.getSimpleName() + builderSuffix;
+ return ElementFilter.typesIn(context.getAllMembers(referencedType)).stream()
+ .filter(context::isMemberAccessibleFromBuilderPackage)
+ .sorted(
+ Comparator.comparingInt(
+ nested -> nestedCandidateRank(nested, preferredName, builderSuffix)))
+ .map(nested -> JavaLangMapper.mapToTypeName(nested, context))
+ .toList();
+ }
+
+ /**
+ * Ranks a nested builder candidate by name: {@code } first, the plain
+ * builder suffix second, all other names last.
+ *
+ * @param nested the nested type to rank
+ * @param preferredName the preferred candidate name
+ * @param builderSuffix the builder suffix
+ * @return the rank, lower wins
+ */
+ private static int nestedCandidateRank(
+ Element nested, String preferredName, String builderSuffix) {
+ if (preferredName.contentEquals(nested.getSimpleName())) {
+ return 0;
+ }
+ return builderSuffix.contentEquals(nested.getSimpleName()) ? 1 : 2;
+ }
+
+ /**
+ * Resolves a builder obtained through a static parameterless factory method anchored on the
+ * referenced type itself (e.g. {@code Person.builder()}). The factory is the instantiation path;
+ * the returned type must be accessible and declare a no-arg {@code build()} method returning the
+ * referenced type. The conventional {@code builder} method name wins over other candidate names;
+ * declaration order decides between equals.
+ *
+ * @param referencedType the type element being referenced
+ * @param referencedTypeName the referenced type, matched against {@code build()}'s return type
+ * @return the resolved builder, or empty when no factory anchors a usable builder
+ */
+ private Optional resolveAnchoredBuilder(
+ TypeElement referencedType, TypeName referencedTypeName) {
+ List factories =
+ JavaLangAnalyser.findMethodsStatic(referencedType, List.of(), context).stream()
+ .filter(method -> method.getReturnType().getKind() == TypeKind.DECLARED)
+ .sorted(
+ Comparator.comparingInt(
+ method -> "builder".contentEquals(method.getSimpleName()) ? 0 : 1))
+ .toList();
+ for (ExecutableElement factory : factories) {
+ TypeElement builderElement =
+ (TypeElement) ((DeclaredType) factory.getReturnType()).asElement();
+ if (!context.isMemberAccessibleFromBuilderPackage(builderElement)
+ || !JavaLangAnalyser.hasBuildMethodReturning(
+ builderElement, referencedTypeName, context)) {
+ continue;
+ }
+ Optional funcForPrefilledBuilder =
+ resolveAnchoredPrefilledBuilder(referencedType, referencedTypeName, builderElement);
+ if (funcForPrefilledBuilder.isPresent()) {
+ return Optional.of(
+ new ResolvedBuilder(
+ JavaLangMapper.mapToTypeName(builderElement, context),
+ new BuilderInstantiation.AnchorFactoryCall(
+ referencedTypeName, factory.getSimpleName().toString()),
+ funcForPrefilledBuilder.get()));
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Resolves the seeded-instantiation path for an anchored builder: the builder's own contract path
+ * first, then a static factory on the referenced type accepting it (e.g. {@code
+ * Person.builder(person)}), then an instance method on the value (e.g. {@code
+ * person.toBuilder()}).
+ *
+ * @param referencedType the type element being referenced
+ * @param referencedTypeName the referenced type, passed to the contract check
+ * @param builderElement the anchored builder type
+ * @return the instantiation to emit, or empty when no seeded path exists
+ */
+ private Optional resolveAnchoredPrefilledBuilder(
+ TypeElement referencedType, TypeName referencedTypeName, TypeElement builderElement) {
+ Optional func =
+ resolveFuncForPrefilledBuilder(builderElement, referencedTypeName);
+ if (func.isPresent()) {
+ return func;
+ }
+ TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderElement, context);
+ func =
+ JavaLangAnalyser.findMethodsStatic(
+ referencedType, List.of(referencedTypeName), builderTypeName, context)
+ .stream()
+ .min(
+ Comparator.comparingInt(
+ method -> "builder".contentEquals(method.getSimpleName()) ? 0 : 1))
+ .map(
+ method ->
+ new BuilderInstantiation.AnchorFactoryCall(
+ referencedTypeName, method.getSimpleName().toString()));
+ if (func.isPresent()) {
+ return func;
+ }
+ return JavaLangAnalyser.findMethods(referencedType, List.of(), builderTypeName, context)
+ .stream()
+ .filter(JavaLangAnalyser::isNotStatic)
+ .min(
+ Comparator.comparingInt(
+ method -> "toBuilder".contentEquals(method.getSimpleName()) ? 0 : 1))
+ .map(
+ method ->
+ new BuilderInstantiation.ValueFactoryCall(
+ referencedTypeName, method.getSimpleName().toString()));
+ }
+
/**
* Finds an accessible static method on the builder type taking the expected parameter types and
* returning the builder type itself. When several candidates exist, {@code create} and {@code of}
@@ -305,9 +477,13 @@ private Optional findStaticFactoryCall(
builderTypeElement, expectedParameterTypes, builderTypeName, context)
.stream()
.min(
- Comparator.comparingInt(BuilderScopeResolver::preferredFactoryNameRank)
- .thenComparing(Comparator.naturalOrder()))
- .map(BuilderInstantiation.StaticFactoryCall::new);
+ Comparator.comparingInt(
+ (ExecutableElement method) ->
+ preferredFactoryNameRank(method.getSimpleName().toString()))
+ .thenComparing(method -> method.getSimpleName().toString()))
+ .map(
+ method ->
+ new BuilderInstantiation.StaticFactoryCall(method.getSimpleName().toString()));
}
private static int preferredFactoryNameRank(String name) {
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 fe232802..2d6d9fb8 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
@@ -265,7 +265,7 @@ public static boolean hasBuildMethodReturning(
* Single-parameter variant of {@link #findMethodsStatic(TypeElement, List, TypeName,
* ProcessingContext)}.
*/
- public static List findMethodsStatic(
+ public static List findMethodsStatic(
TypeElement type,
TypeName expectedParameterType,
TypeName expectedReturnType,
@@ -281,34 +281,67 @@ public static List findMethodsStatic(
* @param expectedParameterTypes the required parameter types
* @param expectedReturnType the required return type, or {@code null} for void-returning methods
* @param context the processing context, used to access all members
- * @return the names of all matching methods, in declaration order
+ * @return the matching methods, in declaration order
*/
- public static List findMethodsStatic(
+ public static List findMethodsStatic(
TypeElement type,
List expectedParameterTypes,
TypeName expectedReturnType,
ProcessingContext context) {
return findMethods(type, expectedParameterTypes, expectedReturnType, context).stream()
.filter(Predicate.not(JavaLangAnalyser::isNotStatic))
- .map(method -> method.getSimpleName().toString())
.toList();
}
- private static List findMethods(
+ /**
+ * Finds accessible static methods on the given type taking the expected parameter types,
+ * regardless of their return type.
+ *
+ * @param type the type element to inspect
+ * @param expectedParameterTypes the required parameter types
+ * @param context the processing context, used to access all members
+ * @return the matching methods, in declaration order
+ */
+ public static List findMethodsStatic(
+ TypeElement type, List expectedParameterTypes, ProcessingContext context) {
+ return findMethods(type, expectedParameterTypes, mirror -> true, context).stream()
+ .filter(Predicate.not(JavaLangAnalyser::isNotStatic))
+ .toList();
+ }
+
+ /**
+ * Finds accessible methods on the given type taking the expected parameter types and returning
+ * the expected return type, whether they are static or instance methods.
+ *
+ * @param type the type element to inspect
+ * @param expectedParameterTypes the required parameter types
+ * @param expectedReturnType the required return type, or {@code null} for void-returning methods
+ * @param context the processing context, used to access all members
+ * @return the matching methods, in declaration order
+ */
+ public static List findMethods(
TypeElement type,
List expectedParameterTypes,
TypeName expectedReturnType,
ProcessingContext context) {
+ Predicate returnTypeMatcher =
+ expectedReturnType == null
+ ? mirror -> mirror.getKind() == VOID
+ : mirror -> hasType(mirror, expectedReturnType);
+ return findMethods(type, expectedParameterTypes, returnTypeMatcher, context);
+ }
+
+ private static List findMethods(
+ TypeElement type,
+ List expectedParameterTypes,
+ Predicate returnTypeMatcher,
+ ProcessingContext context) {
if (type == null) {
return List.of();
}
return ElementFilter.methodsIn(context.getAllMembers(type)).stream()
.filter(context::isMemberAccessibleFromBuilderPackage)
- .filter(
- method ->
- expectedReturnType == null
- ? method.getReturnType().getKind() == VOID
- : hasType(method.getReturnType(), expectedReturnType))
+ .filter(method -> returnTypeMatcher.test(method.getReturnType()))
.filter(method -> hasParameters(method, expectedParameterTypes))
.toList();
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
index 702cf23b..5d4c53ff 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangMapper.java
@@ -39,6 +39,7 @@
import javax.lang.model.type.TypeMirror;
import javax.lang.model.type.TypeVariable;
import javax.lang.model.util.SimpleTypeVisitor14;
+import org.apache.commons.lang3.Strings;
import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto;
import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto;
import org.javahelpers.simple.builders.processor.model.type.GenericParameterDto;
@@ -140,8 +141,10 @@ public static TypeName mapToTypeName(TypeElement typeElement, ProcessingContext
if (typeElement == null) {
return null;
}
- return new TypeName(
- context.getPackageName(typeElement), typeElement.getSimpleName().toString());
+ String packageName = context.getPackageName(typeElement);
+ String qualifiedName = typeElement.getQualifiedName().toString();
+ String className = Strings.CS.removeStart(qualifiedName, packageName + ".");
+ return new TypeName(packageName, className);
}
/**
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
index 14a8f7b9..8d98f3dd 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java
@@ -71,6 +71,8 @@
* @param builderUsagePackages Additional packages whose builders may be used as helpers (unscoped =
* all packages if builderGenerationPackages is unscoped as well, otherwise only the generation
* scope is usable)
+ * @param usingExistingBuilders Reuse builders not generated by this processor (classpath lookup and
+ * in-type anchors); when disabled only same-round generated builders qualify
* @param builderSuffix Suffix for builder class name
* @param builderUsageSuffix Suffix for builder class name when referencing builders from the usage
* scope; null means fall back to {@code builderSuffix}
@@ -109,6 +111,7 @@ public record BuilderConfiguration(
String packageName,
PackageScopes builderGenerationPackages,
PackageScopes builderUsagePackages,
+ OptionState usingExistingBuilders,
String builderSuffix,
String builderUsageSuffix,
String setterSuffix,
@@ -152,6 +155,7 @@ public record BuilderConfiguration(
.jacksonModulePackage(null)
.builderGenerationPackages(PackageScopes.unscoped())
.builderUsagePackages(PackageScopes.unscoped())
+ .usingExistingBuilders(ENABLED)
.builderSuffix("Builder")
.setterSuffix("")
.formattingMode(FormattingMode.JDT.getOptionValue())
@@ -292,6 +296,10 @@ public Set getBuilderUsagePackagesSet() {
return builderUsagePackages.packages();
}
+ public boolean shouldUseExistingBuilders() {
+ return usingExistingBuilders == ENABLED;
+ }
+
public String getBuilderSuffix() {
return builderSuffix;
}
@@ -388,6 +396,8 @@ public BuilderConfiguration merge(BuilderConfiguration other) {
.builderGenerationPackages(
overrideScopes(other.builderGenerationPackages, this.builderGenerationPackages))
.builderUsagePackages(overrideScopes(other.builderUsagePackages, this.builderUsagePackages))
+ .usingExistingBuilders(
+ mergeOptionState(other.usingExistingBuilders, this.usingExistingBuilders))
.builderSuffix(mergeString(other.builderSuffix, this.builderSuffix))
.builderUsageSuffix(mergeString(other.builderUsageSuffix, this.builderUsageSuffix))
.setterSuffix(mergeString(other.setterSuffix, this.setterSuffix))
@@ -477,6 +487,7 @@ public String toString() {
.appendIfNotEmpty("packageName", packageName)
.appendIfNotEmpty("builderGenerationPackages", builderGenerationPackages.toString())
.appendIfNotEmpty("builderUsagePackages", builderUsagePackages.toString())
+ .appendValueIfSet("usingExistingBuilders", usingExistingBuilders)
.appendIfNotEmpty("builderSuffix", builderSuffix)
.appendIfNotEmpty("builderUsageSuffix", builderUsageSuffix)
.appendIfNotEmpty("setterSuffix", setterSuffix)
@@ -572,6 +583,7 @@ public static class Builder {
// === Builder Scoping ===
private PackageScopes builderGenerationPackages = null;
private PackageScopes builderUsagePackages = null;
+ private OptionState usingExistingBuilders = OptionState.UNSET;
// === Error Handling ===
private OptionState strict = OptionState.UNSET;
@@ -687,6 +699,16 @@ public Builder builderUsagePackages(PackageScopes value) {
return this;
}
+ public Builder usingExistingBuilders(OptionState value) {
+ this.usingExistingBuilders = value;
+ return this;
+ }
+
+ public Builder usingExistingBuilders(boolean value) {
+ this.usingExistingBuilders = value ? ENABLED : DISABLED;
+ return this;
+ }
+
public Builder generateVarArgsHelpers(OptionState value) {
this.generateVarArgsHelpers = value;
return this;
@@ -918,6 +940,7 @@ public BuilderConfiguration build() {
packageName,
builderGenerationPackages,
builderUsagePackages,
+ usingExistingBuilders,
builderSuffix,
builderUsageSuffix,
setterSuffix,
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java
index 295f0ecc..7fed91c4 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java
@@ -74,4 +74,42 @@ public String methodReference(String builderTypeExpression) {
return builderTypeExpression + "::" + methodName;
}
}
+
+ /**
+ * Instantiation through a static factory method anchored on the referenced type itself: {@code
+ * T.methodName(arguments)} (e.g. {@code Person.builder()}), as generated by Lombok or Immutables.
+ *
+ * @param anchor the type declaring the factory method
+ * @param methodName name of the static factory method (e.g. {@code builder})
+ */
+ record AnchorFactoryCall(TypeName anchor, String methodName) implements BuilderInstantiation {
+ @Override
+ public String instantiationCode(String builderTypeExpression, String arguments) {
+ return anchor.getFullQualifiedName() + "." + methodName + "(" + arguments + ")";
+ }
+
+ @Override
+ public String methodReference(String builderTypeExpression) {
+ return anchor.getFullQualifiedName() + "::" + methodName;
+ }
+ }
+
+ /**
+ * Instantiation through an instance factory method on the value being copied: {@code
+ * arguments.methodName()} (e.g. {@code person.toBuilder()}).
+ *
+ * @param anchor the type declaring the instance method
+ * @param methodName name of the instance factory method (e.g. {@code toBuilder})
+ */
+ record ValueFactoryCall(TypeName anchor, String methodName) implements BuilderInstantiation {
+ @Override
+ public String instantiationCode(String builderTypeExpression, String arguments) {
+ return arguments + "." + methodName + "()";
+ }
+
+ @Override
+ public String methodReference(String builderTypeExpression) {
+ return anchor.getFullQualifiedName() + "::" + methodName;
+ }
+ }
}
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 41cc7277..70bcc60d 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
@@ -158,6 +158,9 @@ public enum CompilerArgumentsEnum {
/** Option for builder usage packages. */
BUILDER_USAGE_PACKAGES("builderUsagePackages", string(Builder::builderUsagePackages)),
+ /** Option for reusing builders not generated by this processor. */
+ USING_EXISTING_BUILDERS("usingExistingBuilders", optionState(Builder::usingExistingBuilders)),
+
// === Naming ===
/** Option for builder class name suffix. */
BUILDER_SUFFIX("builderSuffix", string(Builder::builderSuffix)),
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java
index 0839110d..a6664bd9 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java
@@ -34,10 +34,14 @@
import com.google.testing.compile.Compiler;
import java.util.Optional;
import java.util.Set;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.TypeElement;
+import javax.tools.JavaFileObject;
+import org.javahelpers.simple.builders.core.enums.OptionState;
import org.javahelpers.simple.builders.processor.analysis.BuilderScopeResolver;
import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration;
import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation;
@@ -47,7 +51,11 @@
import org.javahelpers.simple.builders.processor.processing.ProcessingTarget;
import org.javahelpers.simple.builders.processor.processing.logging.ProcessingLogger;
import org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
/**
* Probe-based coverage of {@link BuilderScopeResolver} internals that generated-source assertions
@@ -57,9 +65,13 @@
*/
class BuilderScopeResolverTest {
+ @BeforeEach
+ void resetProbe() {
+ ResolverProbeProcessor.reset();
+ }
+
@Test
void resolverReturnsEmptyAfterConfigurationChangesToExcludePackage() {
- ResolverProbeProcessor.reset();
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -86,7 +98,6 @@ public class LibHelper { public LibHelper() {} }
@Test
void resolverCachesResolvedOptionalInstancePerReferencedType() {
- ResolverProbeProcessor.reset();
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -106,7 +117,6 @@ public class LibHelper { public LibHelper() {} }
@Test
void resolverClearsCacheOnRegistrationAndResolvesUsageScope() {
- ResolverProbeProcessor.reset();
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -132,9 +142,39 @@ public class LibHelper { public LibHelper() {} }
ResolverProbeProcessor.usageAfterRegistration.get().typeName().getFullQualifiedName());
}
+ @ParameterizedTest(name = "{argumentSetName}")
+ @MethodSource("resolvedContractVariants")
+ void resolverUsageScope_ResolvesContractVariants(
+ String builderSource,
+ Supplier> probeResult,
+ String expectedBuilder,
+ Class> expectedEmptyPath,
+ String expectedEmptyMethod,
+ Class> expectedPrefilledPath,
+ String expectedPrefilledMethod) {
+ Compilation compilation = compileWithBuilder(builderSource);
+
+ assertThat(compilation).succeeded();
+ ResolvedBuilder resolved = probeResult.get().get();
+ assertEquals(expectedBuilder, resolved.typeName().getFullQualifiedName());
+ BuilderInstantiation empty = resolved.funcForEmptyBuilder();
+ BuilderInstantiation prefilled = resolved.funcForPrefilledBuilder();
+ assertInstanceOf(expectedEmptyPath, empty);
+ assertInstanceOf(expectedPrefilledPath, prefilled);
+ assertFactoryMethod(expectedEmptyMethod, empty);
+ assertFactoryMethod(expectedPrefilledMethod, prefilled);
+ }
+
+ @ParameterizedTest(name = "{argumentSetName}")
+ @MethodSource("rejectedContractVariants")
+ void resolverUsageScope_RejectsContractVariants(
+ String builderSource, Supplier> probeResult) {
+ assertThat(compileWithBuilder(builderSource)).succeeded();
+ assertEquals(Optional.empty(), probeResult.get());
+ }
+
@Test
- void resolverUsageScope_ResolvesBuilderWithoutSimpleBuilderAnnotation() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -147,23 +187,22 @@ public class LibHelper { public LibHelper() {} }
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelperBuilder {
- public LibHelperBuilder() {}
- public LibHelperBuilder(LibHelper value) {}
+ public class LibHelperFactory {
+ public LibHelperFactory() {}
+ public LibHelperFactory(LibHelper value) {}
public LibHelper build() { return new LibHelper(); }
}
"""));
assertThat(compilation).succeeded();
- // Usage scope without @SimpleBuilder annotation — builder resolved by contract check
+ // With builderUsageSuffix="Factory", the candidate name uses "Factory"
assertEquals(
- "lib.LibHelperBuilder",
- ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName());
+ "lib.LibHelperFactory",
+ ResolverProbeProcessor.usageWithSuffix.get().typeName().getFullQualifiedName());
}
@Test
- void resolverUsageScope_RejectsBuilderWithoutNoArgConstructor() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotSet() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -171,26 +210,29 @@ void resolverUsageScope_RejectsBuilderWithoutNoArgConstructor() {
ProcessorTestUtils.forSource(
"""
package lib;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+ @SimpleBuilder
public class LibHelper { public LibHelper() {} }
"""),
ProcessorTestUtils.forSource(
"""
package lib;
public class LibHelperBuilder {
+ public LibHelperBuilder() {}
public LibHelperBuilder(LibHelper value) {}
public LibHelper build() { return new LibHelper(); }
}
"""));
assertThat(compilation).succeeded();
- // ctor(T) + build() but no no-arg ctor: generated consumer code calls `new
- // LibHelperBuilder()`, so the builder must not qualify
- assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation);
+ // Without builderUsageSuffix, the candidate name uses builderSuffix ("Builder")
+ assertEquals(
+ "lib.LibHelperBuilder",
+ ResolverProbeProcessor.usageDefaultSuffix.get().typeName().getFullQualifiedName());
}
@Test
- void resolverUsageScope_RejectsBuilderWithPrivateConstructor() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_ResolvesBuilderAnchoredInsideType() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -198,27 +240,38 @@ void resolverUsageScope_RejectsBuilderWithPrivateConstructor() {
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelper { public LibHelper() {} }
+ public class LibHelper {
+ public static builders.LibHelperBuilder builder() {
+ return new builders.LibHelperBuilder();
+ }
+ }
"""),
ProcessorTestUtils.forSource(
"""
- package lib;
+ package builders;
public class LibHelperBuilder {
- private LibHelperBuilder() {}
- public LibHelperBuilder(LibHelper value) {}
- public LibHelper build() { return new LibHelper(); }
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(lib.LibHelper value) {}
+ public lib.LibHelper build() { return new lib.LibHelper(); }
}
"""));
assertThat(compilation).succeeded();
- // The no-arg ctor exists but is private: generated code could not call it, so the builder
- // must not qualify
- assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation);
+ // The type's own builder() declaration anchors the builder, even in another package:
+ // the empty path calls LibHelper.builder(), the seeded path the builder's ctor
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("builders.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ BuilderInstantiation.AnchorFactoryCall empty =
+ assertInstanceOf(
+ BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder());
+ assertEquals("builder", empty.methodName());
+ assertEquals("lib.LibHelper", empty.anchor().getFullQualifiedName());
+ assertInstanceOf(
+ BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder());
}
@Test
- void resolverUsageScope_AcceptsBuilderWithStaticFactories() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_PrefersBuilderNamedInTypeFactory() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -226,38 +279,117 @@ void resolverUsageScope_AcceptsBuilderWithStaticFactories() {
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelper { public LibHelper() {} }
+ public class LibHelper {
+ public static builders.OtherBuilder other() {
+ return new builders.OtherBuilder();
+ }
+ public static builders.LibHelperBuilder builder() {
+ return new builders.LibHelperBuilder();
+ }
+ }
"""),
ProcessorTestUtils.forSource(
"""
- package lib;
+ package builders;
+ public class OtherBuilder {
+ public OtherBuilder() {}
+ public OtherBuilder(lib.LibHelper value) {}
+ public lib.LibHelper build() { return new lib.LibHelper(); }
+ }
+ """),
+ ProcessorTestUtils.forSource(
+ """
+ package builders;
public class LibHelperBuilder {
- private LibHelperBuilder() {}
- public static LibHelperBuilder create() { return new LibHelperBuilder(); }
- public static LibHelperBuilder of(LibHelper value) { return create(); }
- public LibHelper build() { return new LibHelper(); }
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(lib.LibHelper value) {}
+ public lib.LibHelper build() { return new lib.LibHelper(); }
}
"""));
assertThat(compilation).succeeded();
- // No accessible constructors: both instantiation paths come from the static factories
- ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
- assertEquals("lib.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ // Both candidates satisfy the contract; the conventional builder() name wins
assertEquals(
- "create",
+ "builders.LibHelperBuilder",
+ ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName());
+ }
+
+ @Test
+ void resolverUsageScope_ResolvesStaticSeededPath() {
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelper {
+ public static LibHelperBuilder builder() { return new LibHelperBuilder(); }
+ public static LibHelperBuilder builder(LibHelper value) {
+ return new LibHelperBuilder();
+ }
+ public static LibHelperBuilder builder2(LibHelper value) {
+ return new LibHelperBuilder();
+ }
+ public static class LibHelperBuilder {
+ private LibHelperBuilder() {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // No seeded path on the builder itself: the type's static builder(T) becomes the seeded
+ // path — two candidates match, the conventional name wins
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ assertInstanceOf(BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder());
+ BuilderInstantiation.AnchorFactoryCall prefilled =
assertInstanceOf(
- BuilderInstantiation.StaticFactoryCall.class, resolved.funcForEmptyBuilder())
- .methodName());
- assertEquals(
- "of",
+ BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForPrefilledBuilder());
+ assertEquals("builder", prefilled.methodName());
+ }
+
+ @Test
+ void resolverUsageScope_InstanceSeededPathPrefersToBuilder() {
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ ProcessorTestUtils.forSource(
+ """
+ package lib;
+ public class LibHelper {
+ public static LibHelperBuilder builder() { return new LibHelperBuilder(); }
+ public LibHelperBuilder copy() { return new LibHelperBuilder(); }
+ public LibHelperBuilder toBuilder() { return new LibHelperBuilder(); }
+ public static class LibHelperBuilder {
+ LibHelperBuilder() {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // Two instance methods return the builder type; the conventional toBuilder name wins
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ BuilderInstantiation.ValueFactoryCall prefilled =
assertInstanceOf(
- BuilderInstantiation.StaticFactoryCall.class, resolved.funcForPrefilledBuilder())
- .methodName());
+ BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder());
+ assertEquals("toBuilder", prefilled.methodName());
+ }
+
+ /** Fixtures where no contract-satisfying builder is reachable from the referenced type. */
+ @ParameterizedTest(name = "{argumentSetName}")
+ @MethodSource("fallBackVariants")
+ void resolverUsageScope_FallsBackToClasspathCandidate(String[] sources) {
+ assertThat(compileWithSources(sources)).succeeded();
+ assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation);
}
@Test
- void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_ResolvesNestedTypeBuilder() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -265,31 +397,61 @@ void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() {
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelper { public LibHelper() {} }
- """),
+ public class LibHelper {
+ public static class LibHelperBuilder {
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ public static class Other {
+ public Other() {}
+ }
+ }
+ """));
+
+ assertThat(compilation).succeeded();
+ // The preferred-name nested type satisfies the full contract and wins over any anchored
+ // method; the second nested type exercises the candidate ranking
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder());
+ assertInstanceOf(
+ BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder());
+ }
+
+ @Test
+ void resolverUsageScope_ResolvesLombokBuilderShape() {
+ Compilation compilation =
+ Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelperBuilder {
- LibHelperBuilder() {}
- LibHelperBuilder(LibHelper value) {}
- LibHelper build() { return new LibHelper(); }
+ public class LibHelper {
+ public static LibHelperBuilder builder() { return new LibHelperBuilder(); }
+ public LibHelperBuilder toBuilder() { return new LibHelperBuilder(); }
+ public static class LibHelperBuilder {
+ LibHelperBuilder() {}
+ public LibHelper build() { return new LibHelper(); }
+ }
}
"""));
assertThat(compilation).succeeded();
- // All contract members are package-private: accessible when the generated builder is in the
- // same package (builderPackage "lib")...
- assertEquals(
- "lib.LibHelperBuilder",
- ResolverProbeProcessor.usagePackagePrivate.get().typeName().getFullQualifiedName());
- // ...but not from a different one (builderPackage unset)
- assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation);
+ // Delombok output of @Builder(toBuilder = true): builder() anchors the empty path,
+ // toBuilder() the seeded one; the nested builder's package-private ctor is never called
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ assertInstanceOf(BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder());
+ BuilderInstantiation.ValueFactoryCall prefilled =
+ assertInstanceOf(
+ BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder());
+ assertEquals("toBuilder", prefilled.methodName());
}
@Test
- void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_ResolvesRecordBuilderShape() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -297,28 +459,32 @@ void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() {
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelper { public LibHelper() {} }
+ public class LibHelper {
+ public LibHelper() {}
+ }
"""),
ProcessorTestUtils.forSource(
"""
package lib;
- public class LibHelperFactory {
- public LibHelperFactory() {}
- public LibHelperFactory(LibHelper value) {}
+ public class LibHelperBuilder {
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
public LibHelper build() { return new LibHelper(); }
}
"""));
assertThat(compilation).succeeded();
- // With builderUsageSuffix="Factory", the candidate name uses "Factory"
- assertEquals(
- "lib.LibHelperFactory",
- ResolverProbeProcessor.usageWithSuffix.get().typeName().getFullQualifiedName());
+ // io.soabase.record-builder generated shape: a same-package Builder with public
+ // ctors resolves through the constructed-name candidate path
+ ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get();
+ assertEquals("lib.LibHelperBuilder", resolved.typeName().getFullQualifiedName());
+ assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder());
+ assertInstanceOf(
+ BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder());
}
@Test
- void resolverUsageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotSet() {
- ResolverProbeProcessor.reset();
+ void resolverUsageScope_UsingExistingBuildersDisabled() {
Compilation compilation =
Compiler.javac()
.withProcessors(new ResolverProbeProcessor())
@@ -326,9 +492,9 @@ void resolverUsageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotSet() {
ProcessorTestUtils.forSource(
"""
package lib;
- import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
- @SimpleBuilder
- public class LibHelper { public LibHelper() {} }
+ public class LibHelper {
+ public LibHelper() {}
+ }
"""),
ProcessorTestUtils.forSource(
"""
@@ -341,10 +507,253 @@ public LibHelperBuilder(LibHelper value) {}
"""));
assertThat(compilation).succeeded();
- // Without builderUsageSuffix, the candidate name uses builderSuffix ("Builder")
- assertEquals(
- "lib.LibHelperBuilder",
- ResolverProbeProcessor.usageDefaultSuffix.get().typeName().getFullQualifiedName());
+ // usingExistingBuilders=DISABLED: the contract-satisfying classpath builder is not reused
+ assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithExistingDisabled);
+ // ... but a builder generated in the current round is own generation and still resolves
+ ResolvedBuilder generated = ResolverProbeProcessor.usageGeneratedWithExistingDisabled.get();
+ assertEquals("lib.LibHelperBuilder", generated.typeName().getFullQualifiedName());
+ assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, generated.funcForEmptyBuilder());
+ assertInstanceOf(
+ BuilderInstantiation.ConstructorCall.class, generated.funcForPrefilledBuilder());
+ }
+
+ private static final String LIB_HELPER =
+ """
+ package lib;
+ public class LibHelper { public LibHelper() {} }
+ """;
+
+ static Stream resolvedContractVariants() {
+ return Stream.of(
+ // Usage scope without @SimpleBuilder annotation — builder resolved by contract check
+ Arguments.argumentSet(
+ "BuilderWithoutSimpleBuilderAnnotation",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ public LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usageWithoutAnnotation),
+ "lib.LibHelperBuilder",
+ BuilderInstantiation.ConstructorCall.class,
+ null,
+ BuilderInstantiation.ConstructorCall.class,
+ null),
+ // No accessible constructors: both instantiation paths come from the static
+ // factories — the second empty factory exercises the preferred-name ranking
+ Arguments.argumentSet(
+ "StaticFactories",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ private LibHelperBuilder() {}
+ public static LibHelperBuilder create() { return new LibHelperBuilder(); }
+ public static LibHelperBuilder make() { return create(); }
+ public static LibHelperBuilder of(LibHelper value) { return create(); }
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usageWithoutAnnotation),
+ "lib.LibHelperBuilder",
+ BuilderInstantiation.StaticFactoryCall.class,
+ "create",
+ BuilderInstantiation.StaticFactoryCall.class,
+ "of"),
+ // Package-private contract members are accessible when the generated builder is in
+ // the same package (builderPackage "lib")
+ Arguments.argumentSet(
+ "PackagePrivateMembers_SamePackage",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ LibHelperBuilder() {}
+ LibHelperBuilder(LibHelper value) {}
+ LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usagePackagePrivate),
+ "lib.LibHelperBuilder",
+ BuilderInstantiation.ConstructorCall.class,
+ null,
+ BuilderInstantiation.ConstructorCall.class,
+ null));
+ }
+
+ static Stream rejectedContractVariants() {
+ return Stream.of(
+ // ctor(T) + build() but no no-arg ctor: generated consumer code calls `new
+ // LibHelperBuilder()`, so the builder must not qualify
+ Arguments.argumentSet(
+ "BuilderWithoutNoArgConstructor",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)),
+ // The no-arg ctor exists but is private: generated code could not call it
+ Arguments.argumentSet(
+ "BuilderWithPrivateConstructor",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ private LibHelperBuilder() {}
+ public LibHelperBuilder(LibHelper value) {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)),
+ // Package-private contract members are not accessible from a different package
+ // (builderPackage unset)
+ Arguments.argumentSet(
+ "PackagePrivateMembers_OtherPackage",
+ """
+ package lib;
+ public class LibHelperBuilder {
+ LibHelperBuilder() {}
+ LibHelperBuilder(LibHelper value) {}
+ LibHelper build() { return new LibHelper(); }
+ }
+ """,
+ probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)));
+ }
+
+ static Stream fallBackVariants() {
+ return Stream.of(
+ // The anchored type does not satisfy the contract, so resolution falls through
+ Arguments.argumentSet(
+ "InTypeAnchorWithoutContract",
+ (Object)
+ new String[] {
+ """
+ package lib;
+ public class LibHelper {
+ public static builders.NoContract builder() {
+ return new builders.NoContract();
+ }
+ }
+ """,
+ """
+ package builders;
+ public class NoContract {
+ public NoContract() {}
+ }
+ """
+ }),
+ // Delombok output of @Builder without toBuilder: builder() anchors the empty path,
+ // but no seeded path exists, so the anchored candidate is rejected
+ Arguments.argumentSet(
+ "LombokBuilderShapeWithoutToBuilder",
+ (Object)
+ new String[] {
+ """
+ package lib;
+ public class LibHelper {
+ public static LibHelperBuilder builder() { return new LibHelperBuilder(); }
+ public static class LibHelperBuilder {
+ LibHelperBuilder() {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ }
+ """
+ }),
+ // org.inferred.freebuilder generated shape: nested Builder with a public ctor and
+ // mergeFrom(T) — mergeFrom is an instance method, so the contract's seeded path fails
+ Arguments.argumentSet(
+ "FreeBuilderShape",
+ (Object)
+ new String[] {
+ """
+ package lib;
+ public class LibHelper {
+ public static class Builder {
+ public Builder() {}
+ public Builder mergeFrom(LibHelper value) { return this; }
+ public LibHelper build() { return new LibHelper(); }
+ }
+ }
+ """
+ }),
+ // com.google.auto.value generated shape: create() anchors a builder on the generated
+ // sibling, but AutoValue offers no seeded path (no ctor(T), no toBuilder)
+ Arguments.argumentSet(
+ "AutoValueShape",
+ (Object)
+ new String[] {
+ """
+ package lib;
+ public class LibHelper {
+ public static AutoValue_LibHelper.Builder create() {
+ return new AutoValue_LibHelper.Builder();
+ }
+ }
+ """,
+ """
+ package lib;
+ public class AutoValue_LibHelper {
+ public static class Builder {
+ Builder() {}
+ public LibHelper build() { return new LibHelper(); }
+ }
+ }
+ """
+ }),
+ // org.immutables generated shape: builder() and toBuilder() live on the generated
+ // sibling ImmutableLibHelper, unreachable from the referenced type itself
+ Arguments.argumentSet(
+ "ImmutablesShape",
+ (Object)
+ new String[] {
+ """
+ package lib;
+ public class LibHelper {
+ public LibHelper() {}
+ }
+ """,
+ """
+ package lib;
+ public class ImmutableLibHelper {
+ public static Builder builder() { return new Builder(); }
+ public Builder toBuilder() { return new Builder(); }
+ public static class Builder {
+ public Builder() {}
+ public ImmutableLibHelper build() { return new ImmutableLibHelper(); }
+ }
+ }
+ """
+ }));
+ }
+
+ private static Compilation compileWithSources(String... sources) {
+ return Compiler.javac()
+ .withProcessors(new ResolverProbeProcessor())
+ .compile(
+ Stream.of(sources).map(ProcessorTestUtils::forSource).toArray(JavaFileObject[]::new));
+ }
+
+ private static Compilation compileWithBuilder(String builderSource) {
+ return compileWithSources(LIB_HELPER, builderSource);
+ }
+
+
+ private static Supplier> probe(
+ Supplier> field) {
+ return field;
+ }
+
+ private static void assertFactoryMethod(
+ String expectedMethod, BuilderInstantiation instantiation) {
+ if (expectedMethod != null) {
+ assertEquals(
+ expectedMethod,
+ assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, instantiation)
+ .methodName());
+ }
}
private static final class ResolverProbeProcessor extends AbstractProcessor {
@@ -359,6 +768,8 @@ private static final class ResolverProbeProcessor extends AbstractProcessor {
private static Optional usageWithSuffix;
private static Optional usageDefaultSuffix;
private static Optional usagePackagePrivate;
+ private static Optional usageWithExistingDisabled;
+ private static Optional usageGeneratedWithExistingDisabled;
private boolean captured;
@@ -374,6 +785,8 @@ static void reset() {
usageWithSuffix = null;
usageDefaultSuffix = null;
usagePackagePrivate = null;
+ usageWithExistingDisabled = null;
+ usageGeneratedWithExistingDisabled = null;
}
@Override
@@ -436,6 +849,14 @@ public boolean process(Set extends TypeElement> annotations, RoundEnvironment
// contract members are accessible
context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("lib"), "lib"));
usagePackagePrivate = resolver.resolveUsableBuilderType(helper);
+ // usingExistingBuilders=DISABLED: existing builders are not reused, but a builder
+ // generated in the current round is own generation and still resolves
+ context.initProcessingTarget(
+ new ProcessingTarget(existingBuildersDisabledConfiguration("lib"), ""));
+ usageWithExistingDisabled = resolver.resolveUsableBuilderType(helper);
+ resolver.registerGeneratedBuilder(
+ new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder"));
+ usageGeneratedWithExistingDisabled = resolver.resolveUsableBuilderType(helper);
captured = true;
return false;
}
@@ -453,6 +874,14 @@ private static BuilderConfiguration usageOnlyConfiguration(String packageName) {
BuilderConfiguration.builder().builderUsagePackages(packageName).build());
}
+ private static BuilderConfiguration existingBuildersDisabledConfiguration(String packageName) {
+ return BuilderConfiguration.DEFAULT.merge(
+ BuilderConfiguration.builder()
+ .builderUsagePackages(packageName)
+ .usingExistingBuilders(OptionState.DISABLED)
+ .build());
+ }
+
private static BuilderConfiguration usageWithSuffixConfiguration(
String packageName, String usageSuffix) {
return BuilderConfiguration.DEFAULT.merge(