From a6d5bdb878ebd4239918917a88afaacdd83a06eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:27:47 +0000 Subject: [PATCH 01/12] Resolve builders anchored inside the referenced type (#317) --- docs/CONFIGURATION.md | 5 + .../analysis/BuilderScopeResolver.java | 47 ++++++++ .../processor/analysis/JavaLangAnalyser.java | 34 +++++- .../processor/BuilderScopeResolverTest.java | 109 ++++++++++++++++++ 4 files changed, 190 insertions(+), 5 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 03d59e0f..fdf40c18 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -743,6 +743,11 @@ 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 accessible static parameterless method returning a builder type +— a method named `builder()` is preferred — whose return type must satisfy the +same contract. This in-type anchor wins over the same-package candidate below. + 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 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..92db22b0 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,10 @@ 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 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 +99,9 @@ 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 static parameterless method on the referenced type itself returning a + * contract-satisfying type (e.g. {@code builder()}) anchors the builder — the type's own + * declaration wins over the same-package candidate. *
  • 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 +216,15 @@ private Optional resolve(TypeElement referencedType) { new BuilderInstantiation.ConstructorCall())); } + // 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. @@ -260,6 +275,38 @@ private Optional resolveByBuilderContract( * @param builderTypeElement the candidate builder type to inspect * @return the instantiation to emit, or empty when the builder offers neither */ + /** + * Resolves a builder anchored inside the referenced type itself: an accessible static + * parameterless method on the type returning a type that satisfies the builder contract, like the + * Lombok/Immutables-style {@code Person.builder()} MapStruct also detects. 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, passed to the contract check + * @return the resolved builder, or empty when the type anchors no contract-satisfying builder + */ + private Optional resolveInTypeBuilder( + 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 candidate = (TypeElement) ((DeclaredType) factory.getReturnType()).asElement(); + Optional resolved = + resolveByBuilderContract( + JavaLangMapper.mapToTypeName(candidate, context), referencedTypeName); + if (resolved.isPresent()) { + return resolved; + } + } + return Optional.empty(); + } + private Optional resolveFuncForEmptyBuilder( TypeElement builderTypeElement) { Optional func = findStaticFactoryCall(builderTypeElement, List.of()); 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..236b6519 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 @@ -294,21 +294,45 @@ public static List findMethodsStatic( .toList(); } + /** + * 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(); + } + private 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/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 0839110d..110a8d7a 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 @@ -347,6 +347,115 @@ public LibHelperBuilder(LibHelper value) {} ResolverProbeProcessor.usageDefaultSuffix.get().typeName().getFullQualifiedName()); } + @Test + void resolverUsageScope_ResolvesBuilderAnchoredInsideType() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static builders.LibHelperBuilder builder() { + return new builders.LibHelperBuilder(); + } + } + """), + ProcessorTestUtils.forSource( + """ + package builders; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(lib.LibHelper value) {} + public lib.LibHelper build() { return new lib.LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // The type's own builder() declaration anchors the builder, even in another package + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + assertEquals("builders.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); + assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); + assertInstanceOf( + BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); + } + + @Test + void resolverUsageScope_IgnoresInTypeAnchorWithoutContract() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static builders.NoContract builder() { + return new builders.NoContract(); + } + } + """), + ProcessorTestUtils.forSource( + """ + package builders; + public class NoContract { + public NoContract() {} + } + """)); + + assertThat(compilation).succeeded(); + // The anchored type does not satisfy the contract, so resolution falls through + assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); + } + + @Test + void resolverUsageScope_PrefersBuilderNamedInTypeFactory() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static builders.OtherBuilder other() { + return new builders.OtherBuilder(); + } + public static builders.LibHelperBuilder builder() { + return new builders.LibHelperBuilder(); + } + } + """), + ProcessorTestUtils.forSource( + """ + 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 { + public LibHelperBuilder() {} + public LibHelperBuilder(lib.LibHelper value) {} + public lib.LibHelper build() { return new lib.LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // Both candidates satisfy the contract; the conventional builder() name wins + assertEquals( + "builders.LibHelperBuilder", + ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName()); + } + private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; From 4efc3cfeb00c03e2f99159b9a21383b3b463ccf9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:42:22 +0000 Subject: [PATCH 02/12] Return ExecutableElement from all findMethodsStatic overloads --- .../processor/analysis/BuilderScopeResolver.java | 10 +++++++--- .../builders/processor/analysis/JavaLangAnalyser.java | 7 +++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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 92db22b0..d2ca63b7 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 @@ -352,9 +352,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 236b6519..f9c76d07 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,16 +281,15 @@ 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(); } From 78e79e37300c0d7f48e2490cefbad4d427a0c768 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:52:54 +0000 Subject: [PATCH 03/12] Attach resolveInTypeBuilder javadoc to its method --- .../analysis/BuilderScopeResolver.java | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) 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 d2ca63b7..cd0f309a 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 @@ -275,6 +275,35 @@ private Optional resolveByBuilderContract( * @param builderTypeElement the candidate builder type to inspect * @return the instantiation to emit, or empty when the builder offers neither */ + private Optional resolveFuncForEmptyBuilder( + TypeElement builderTypeElement) { + Optional func = findStaticFactoryCall(builderTypeElement, List.of()); + if (func.isEmpty() && JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { + func = Optional.of(new BuilderInstantiation.ConstructorCall()); + } + return func; + } + + /** + * Resolves the instantiation path for a builder instance seeded with a value of the referenced + * type: a static factory accepting the type when the builder offers one, the constructor + * accepting the type otherwise. + * + * @param builderTypeElement the candidate builder type to inspect + * @param expectedType the referenced type to seed the builder with + * @return the instantiation to emit, or empty when the builder offers neither + */ + private Optional resolveFuncForPrefilledBuilder( + TypeElement builderTypeElement, TypeName expectedType) { + Optional func = + findStaticFactoryCall(builderTypeElement, List.of(expectedType)); + if (func.isEmpty() + && JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { + func = Optional.of(new BuilderInstantiation.ConstructorCall()); + } + return func; + } + /** * Resolves a builder anchored inside the referenced type itself: an accessible static * parameterless method on the type returning a type that satisfies the builder contract, like the @@ -307,35 +336,6 @@ private Optional resolveInTypeBuilder( return Optional.empty(); } - private Optional resolveFuncForEmptyBuilder( - TypeElement builderTypeElement) { - Optional func = findStaticFactoryCall(builderTypeElement, List.of()); - if (func.isEmpty() && JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { - func = Optional.of(new BuilderInstantiation.ConstructorCall()); - } - return func; - } - - /** - * Resolves the instantiation path for a builder instance seeded with a value of the referenced - * type: a static factory accepting the type when the builder offers one, the constructor - * accepting the type otherwise. - * - * @param builderTypeElement the candidate builder type to inspect - * @param expectedType the referenced type to seed the builder with - * @return the instantiation to emit, or empty when the builder offers neither - */ - private Optional resolveFuncForPrefilledBuilder( - TypeElement builderTypeElement, TypeName expectedType) { - Optional func = - findStaticFactoryCall(builderTypeElement, List.of(expectedType)); - if (func.isEmpty() - && JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { - func = Optional.of(new BuilderInstantiation.ConstructorCall()); - } - return func; - } - /** * 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} From 3ec584c8ce4e13679fadb9fea95a0f8003487c92 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:28:16 +0000 Subject: [PATCH 04/12] Anchor in-type builders via nested types, static factories, and seeded paths --- docs/CONFIGURATION.md | 11 +- .../analysis/BuilderScopeResolver.java | 144 ++++++++++++++++-- .../processor/analysis/JavaLangAnalyser.java | 26 +++- .../processor/analysis/JavaLangMapper.java | 7 +- .../model/type/BuilderInstantiation.java | 38 +++++ .../processor/BuilderScopeResolverTest.java | 68 ++++++++- 6 files changed, 271 insertions(+), 23 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index fdf40c18..31bc9194 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -744,9 +744,14 @@ comma-separated, each listed package includes all of its subpackages, and matchi case. Before constructing a candidate name, the processor checks the referenced type -itself for an accessible static parameterless method returning a builder type -— a method named `builder()` is preferred — whose return type must satisfy the -same contract. This in-type anchor wins over the same-package candidate below. +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 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 cd0f309a..4f79d3c8 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 @@ -33,6 +33,7 @@ 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; @@ -99,9 +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 static parameterless method on the referenced type itself returning a - * contract-satisfying type (e.g. {@code builder()}) anchors the builder — the type's own - * declaration wins over the same-package candidate. + *
  • 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 @@ -305,18 +309,73 @@ private Optional resolveFuncForPrefilledBuilder( } /** - * Resolves a builder anchored inside the referenced type itself: an accessible static - * parameterless method on the type returning a type that satisfies the builder contract, like the - * Lombok/Immutables-style {@code Person.builder()} MapStruct also detects. The conventional - * {@code builder} method name wins over other candidate names; declaration order decides between - * equals. + * 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 satisfying the full builder contract are checked 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 contract-satisfying builder + * @return the resolved builder, or empty when the type anchors no usable builder */ private Optional resolveInTypeBuilder( TypeElement referencedType, TypeName referencedTypeName) { + Optional nestedBuilder = + resolveNestedBuilder(referencedType, referencedTypeName); + if (nestedBuilder.isPresent()) { + return nestedBuilder; + } + return resolveAnchoredBuilder(referencedType, referencedTypeName); + } + + /** + * Resolves a nested type of the referenced type satisfying the builder contract, 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 + * @param referencedTypeName the referenced type, passed to the contract check + * @return the resolved builder, or empty when no nested type satisfies the contract + */ + private Optional resolveNestedBuilder( + TypeElement referencedType, TypeName referencedTypeName) { + String builderSuffix = context.getConfiguration().getBuilderSuffix(); + String preferredName = referencedType.getSimpleName() + builderSuffix; + List nestedTypes = + ElementFilter.typesIn(context.getAllMembers(referencedType)).stream() + .filter(context::isMemberAccessibleFromBuilderPackage) + .sorted( + Comparator.comparingInt( + nested -> + preferredName.contentEquals(nested.getSimpleName()) + ? 0 + : builderSuffix.contentEquals(nested.getSimpleName()) ? 1 : 2)) + .toList(); + for (TypeElement nestedType : nestedTypes) { + Optional resolved = + resolveByBuilderContract( + JavaLangMapper.mapToTypeName(nestedType, context), referencedTypeName); + if (resolved.isPresent()) { + return resolved; + } + } + return Optional.empty(); + } + + /** + * 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) @@ -325,17 +384,72 @@ private Optional resolveInTypeBuilder( method -> "builder".contentEquals(method.getSimpleName()) ? 0 : 1)) .toList(); for (ExecutableElement factory : factories) { - TypeElement candidate = (TypeElement) ((DeclaredType) factory.getReturnType()).asElement(); - Optional resolved = - resolveByBuilderContract( - JavaLangMapper.mapToTypeName(candidate, context), referencedTypeName); - if (resolved.isPresent()) { - return resolved; + 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} 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 f9c76d07..df4a686c 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 @@ -309,7 +309,31 @@ public static List findMethodsStatic( .toList(); } - private static List findMethods( + /** + * Finds accessible methods on the given type taking the expected parameter types, regardless of + * their return type and whether they are static. + * + * @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 findMethods( + TypeElement type, List expectedParameterTypes, ProcessingContext context) { + return findMethods(type, expectedParameterTypes, mirror -> true, context); + } + + /** + * 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, 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..7bfcec9c 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 @@ -140,8 +140,11 @@ 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 = + packageName.isEmpty() ? qualifiedName : qualifiedName.substring(packageName.length() + 1); + return new TypeName(packageName, className); } /** 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/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeResolverTest.java index 110a8d7a..c3b37ec8 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 @@ -374,10 +374,15 @@ public LibHelperBuilder(lib.LibHelper value) {} """)); assertThat(compilation).succeeded(); - // The type's own builder() declaration anchors the builder, even in another package + // 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()); - assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); + 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()); } @@ -456,6 +461,65 @@ public LibHelperBuilder(lib.LibHelper value) {} ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName()); } + @Test + void resolverUsageScope_ResolvesNestedTypeBuilder() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper build() { return new LibHelper(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // The nested type satisfies the full contract and wins over any anchored method + 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_ResolvesToBuilderAsSeededPath() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static LibHelperBuilder builder() { return new LibHelperBuilder(); } + public LibHelperBuilder toBuilder() { return new LibHelperBuilder(); } + public static class LibHelperBuilder { + private LibHelperBuilder() {} + public LibHelper build() { return new LibHelper(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // Lombok-style shape: builder() anchors the empty path, toBuilder() the seeded one + 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()); + } + private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; From c4159dc54255f2954794ad7f8227b16303154505 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:57:15 +0000 Subject: [PATCH 05/12] Feed nested builder candidates through the shared contract check --- .../analysis/BuilderScopeResolver.java | 58 ++++++++----------- 1 file changed, 24 insertions(+), 34 deletions(-) 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 4f79d3c8..7ee0b99e 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 @@ -311,8 +311,9 @@ private Optional resolveFuncForPrefilledBuilder( /** * 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 satisfying the full builder contract are checked first; then - * static parameterless factory methods on the type anchor the empty-instantiation path directly. + * 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 @@ -321,46 +322,35 @@ private Optional resolveFuncForPrefilledBuilder( private Optional resolveInTypeBuilder( TypeElement referencedType, TypeName referencedTypeName) { Optional nestedBuilder = - resolveNestedBuilder(referencedType, referencedTypeName); - if (nestedBuilder.isPresent()) { - return nestedBuilder; - } - return resolveAnchoredBuilder(referencedType, referencedTypeName); + nestedBuilderCandidates(referencedType).stream() + .map(candidate -> resolveByBuilderContract(candidate, referencedTypeName)) + .flatMap(Optional::stream) + .findFirst(); + return nestedBuilder.or(() -> resolveAnchoredBuilder(referencedType, referencedTypeName)); } /** - * Resolves a nested type of the referenced type satisfying the builder contract, 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. + * 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 - * @param referencedTypeName the referenced type, passed to the contract check - * @return the resolved builder, or empty when no nested type satisfies the contract + * @return the nested type candidates in preference order */ - private Optional resolveNestedBuilder( - TypeElement referencedType, TypeName referencedTypeName) { + private List nestedBuilderCandidates(TypeElement referencedType) { String builderSuffix = context.getConfiguration().getBuilderSuffix(); String preferredName = referencedType.getSimpleName() + builderSuffix; - List nestedTypes = - ElementFilter.typesIn(context.getAllMembers(referencedType)).stream() - .filter(context::isMemberAccessibleFromBuilderPackage) - .sorted( - Comparator.comparingInt( - nested -> - preferredName.contentEquals(nested.getSimpleName()) - ? 0 - : builderSuffix.contentEquals(nested.getSimpleName()) ? 1 : 2)) - .toList(); - for (TypeElement nestedType : nestedTypes) { - Optional resolved = - resolveByBuilderContract( - JavaLangMapper.mapToTypeName(nestedType, context), referencedTypeName); - if (resolved.isPresent()) { - return resolved; - } - } - return Optional.empty(); + return ElementFilter.typesIn(context.getAllMembers(referencedType)).stream() + .filter(context::isMemberAccessibleFromBuilderPackage) + .sorted( + Comparator.comparingInt( + nested -> + preferredName.contentEquals(nested.getSimpleName()) + ? 0 + : builderSuffix.contentEquals(nested.getSimpleName()) ? 1 : 2)) + .map(nested -> JavaLangMapper.mapToTypeName(nested, context)) + .toList(); } /** From 9fcdc6f0c8ed6e7da6a58288c219eaeb847e1dd8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:02:01 +0000 Subject: [PATCH 06/12] Add framework-shaped fixture tests for in-type builder lookup --- .../processor/BuilderScopeResolverTest.java | 158 +++++++++++++++++- 1 file changed, 155 insertions(+), 3 deletions(-) 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 c3b37ec8..287d1eae 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 @@ -490,7 +490,7 @@ public LibHelperBuilder(LibHelper value) {} } @Test - void resolverUsageScope_ResolvesToBuilderAsSeededPath() { + void resolverUsageScope_ResolvesLombokBuilderShape() { ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() @@ -503,14 +503,15 @@ public class LibHelper { public static LibHelperBuilder builder() { return new LibHelperBuilder(); } public LibHelperBuilder toBuilder() { return new LibHelperBuilder(); } public static class LibHelperBuilder { - private LibHelperBuilder() {} + LibHelperBuilder() {} public LibHelper build() { return new LibHelper(); } } } """)); assertThat(compilation).succeeded(); - // Lombok-style shape: builder() anchors the empty path, toBuilder() the seeded one + // 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()); @@ -520,6 +521,157 @@ private LibHelperBuilder() {} assertEquals("toBuilder", prefilled.methodName()); } + @Test + void resolverUsageScope_LombokBuilderShapeWithoutToBuilderFallsBack() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static LibHelperBuilder builder() { return new LibHelperBuilder(); } + public static class LibHelperBuilder { + LibHelperBuilder() {} + public LibHelper build() { return new LibHelper(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // Delombok output of @Builder without toBuilder: builder() anchors the empty path, but + // no seeded path exists, so the anchored candidate is rejected + assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); + } + + @Test + void resolverUsageScope_FreeBuilderShapeFallsBack() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static class Builder { + public Builder() {} + public Builder mergeFrom(LibHelper value) { return this; } + public LibHelper build() { return new LibHelper(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // 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 + // and resolution falls back to the classpath candidate (absent here) + assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); + } + + @Test + void resolverUsageScope_AutoValueShapeFallsBack() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public static AutoValue_LibHelper.Builder create() { + return new AutoValue_LibHelper.Builder(); + } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class AutoValue_LibHelper { + public static class Builder { + Builder() {} + public LibHelper build() { return new LibHelper(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // 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) + assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); + } + + @Test + void resolverUsageScope_ImmutablesShapeFallsBack() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public LibHelper() {} + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class 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(); } + } + } + """)); + + assertThat(compilation).succeeded(); + // org.immutables generated shape: builder() and toBuilder() live on the generated + // sibling ImmutableLibHelper, unreachable from the referenced type itself + assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); + } + + @Test + void resolverUsageScope_ResolvesRecordBuilderShape() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public LibHelper() {} + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper build() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // 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()); + } + private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; From be6bbf6f5b6c12d482ab813736953592c73bbbeb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:11:09 +0000 Subject: [PATCH 07/12] Add usingExistingBuilders option gating reuse of non-generated builders --- .../core/annotations/SimpleBuilder.java | 15 +++++ .../annotations/SimpleMinimalBuilder.java | 1 + docs/CONFIGURATION.md | 13 +++++ .../analysis/BuilderScopeResolver.java | 7 +++ .../model/core/BuilderConfiguration.java | 23 ++++++++ .../processing/CompilerArgumentsEnum.java | 3 + .../processor/BuilderScopeResolverTest.java | 56 +++++++++++++++++++ 7 files changed, 118 insertions(+) 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 31bc9194..2c595c04 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -779,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 7ee0b99e..723795b5 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 @@ -220,6 +220,13 @@ 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. 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/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 287d1eae..5ec79704 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 @@ -38,6 +38,7 @@ import javax.annotation.processing.RoundEnvironment; import javax.lang.model.SourceVersion; import javax.lang.model.element.TypeElement; +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; @@ -672,6 +673,41 @@ public LibHelperBuilder(LibHelper value) {} BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); } + @Test + void resolverUsageScope_UsingExistingBuildersDisabled() { + ResolverProbeProcessor.reset(); + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public LibHelper() {} + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelperBuilder(LibHelper value) {} + public LibHelper build() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // 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 class ResolverProbeProcessor extends AbstractProcessor { private static Optional first; private static Optional second; @@ -684,6 +720,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; @@ -699,6 +737,8 @@ static void reset() { usageWithSuffix = null; usageDefaultSuffix = null; usagePackagePrivate = null; + usageWithExistingDisabled = null; + usageGeneratedWithExistingDisabled = null; } @Override @@ -761,6 +801,14 @@ public boolean process(Set 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; } @@ -778,6 +826,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( From 72a2a7ba6e17914a9a606465016ebba7837da9ee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:29:04 +0000 Subject: [PATCH 08/12] Use StringUtils.removeStart in mapToTypeName; move probe reset to @BeforeEach --- .../processor/analysis/JavaLangMapper.java | 4 +-- .../processor/BuilderScopeResolverTest.java | 27 +++++-------------- 2 files changed, 8 insertions(+), 23 deletions(-) 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 7bfcec9c..db6d9b24 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.StringUtils; 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; @@ -142,8 +143,7 @@ public static TypeName mapToTypeName(TypeElement typeElement, ProcessingContext } String packageName = context.getPackageName(typeElement); String qualifiedName = typeElement.getQualifiedName().toString(); - String className = - packageName.isEmpty() ? qualifiedName : qualifiedName.substring(packageName.length() + 1); + String className = StringUtils.removeStart(qualifiedName, packageName + "."); return new TypeName(packageName, className); } 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 5ec79704..ed70aa48 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 @@ -48,6 +48,7 @@ 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; /** @@ -58,9 +59,13 @@ */ class BuilderScopeResolverTest { + @BeforeEach + void resetProbe() { + ResolverProbeProcessor.reset(); + } + @Test void resolverReturnsEmptyAfterConfigurationChangesToExcludePackage() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -87,7 +92,6 @@ public class LibHelper { public LibHelper() {} } @Test void resolverCachesResolvedOptionalInstancePerReferencedType() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -107,7 +111,6 @@ public class LibHelper { public LibHelper() {} } @Test void resolverClearsCacheOnRegistrationAndResolvesUsageScope() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -135,7 +138,6 @@ public class LibHelper { public LibHelper() {} } @Test void resolverUsageScope_ResolvesBuilderWithoutSimpleBuilderAnnotation() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -164,7 +166,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_RejectsBuilderWithoutNoArgConstructor() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -191,7 +192,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_RejectsBuilderWithPrivateConstructor() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -219,7 +219,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_AcceptsBuilderWithStaticFactories() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -258,7 +257,6 @@ private LibHelperBuilder() {} @Test void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -290,7 +288,6 @@ public class LibHelperBuilder { @Test void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -319,7 +316,6 @@ public LibHelperFactory(LibHelper value) {} @Test void resolverUsageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotSet() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -350,7 +346,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_ResolvesBuilderAnchoredInsideType() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -390,7 +385,6 @@ public LibHelperBuilder(lib.LibHelper value) {} @Test void resolverUsageScope_IgnoresInTypeAnchorWithoutContract() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -419,7 +413,6 @@ public NoContract() {} @Test void resolverUsageScope_PrefersBuilderNamedInTypeFactory() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -464,7 +457,6 @@ public LibHelperBuilder(lib.LibHelper value) {} @Test void resolverUsageScope_ResolvesNestedTypeBuilder() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -492,7 +484,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_ResolvesLombokBuilderShape() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -524,7 +515,6 @@ public static class LibHelperBuilder { @Test void resolverUsageScope_LombokBuilderShapeWithoutToBuilderFallsBack() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -549,7 +539,6 @@ public static class LibHelperBuilder { @Test void resolverUsageScope_FreeBuilderShapeFallsBack() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -575,7 +564,6 @@ public Builder() {} @Test void resolverUsageScope_AutoValueShapeFallsBack() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -608,7 +596,6 @@ public static class Builder { @Test void resolverUsageScope_ImmutablesShapeFallsBack() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -641,7 +628,6 @@ public Builder() {} @Test void resolverUsageScope_ResolvesRecordBuilderShape() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -675,7 +661,6 @@ public LibHelperBuilder(LibHelper value) {} @Test void resolverUsageScope_UsingExistingBuildersDisabled() { - ResolverProbeProcessor.reset(); Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) From 7efcdb3dc17195e8d49d1abbcaff9c166026c9e7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:39:58 +0000 Subject: [PATCH 09/12] Address Sonar: extract candidate ranking, parameterize contract-variant tests --- .../analysis/BuilderScopeResolver.java | 22 +- .../processor/BuilderScopeResolverTest.java | 285 +++++++++--------- 2 files changed, 167 insertions(+), 140 deletions(-) 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 723795b5..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 @@ -352,14 +352,28 @@ private List nestedBuilderCandidates(TypeElement referencedType) { .filter(context::isMemberAccessibleFromBuilderPackage) .sorted( Comparator.comparingInt( - nested -> - preferredName.contentEquals(nested.getSimpleName()) - ? 0 - : builderSuffix.contentEquals(nested.getSimpleName()) ? 1 : 2)) + 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; 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 ed70aa48..032baf95 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,6 +34,8 @@ 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; @@ -50,6 +52,9 @@ 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 @@ -136,154 +141,162 @@ public class LibHelper { public LibHelper() {} } ResolverProbeProcessor.usageAfterRegistration.get().typeName().getFullQualifiedName()); } - @Test - void resolverUsageScope_ResolvesBuilderWithoutSimpleBuilderAnnotation() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - public LibHelperBuilder() {} - public LibHelperBuilder(LibHelper value) {} - public LibHelper build() { return new LibHelper(); } - } - """)); + private static final String LIB_HELPER = + """ + package lib; + public class LibHelper { public LibHelper() {} } + """; + + @ParameterizedTest(name = "{0}") + @MethodSource("resolvedContractVariants") + void resolverUsageScope_ResolvesContractVariants( + String name, + String builderSource, + Supplier> probeResult, + String expectedBuilder, + Class expectedEmptyPath, + String expectedEmptyMethod, + Class expectedPrefilledPath, + String expectedPrefilledMethod) { + Compilation compilation = compileWithBuilder(builderSource); assertThat(compilation).succeeded(); - // Usage scope without @SimpleBuilder annotation — builder resolved by contract check - assertEquals( - "lib.LibHelperBuilder", - ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName()); + 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); } - @Test - void resolverUsageScope_RejectsBuilderWithoutNoArgConstructor() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - public LibHelperBuilder(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); + static Stream resolvedContractVariants() { + return Stream.of( + // Usage scope without @SimpleBuilder annotation — builder resolved by contract check + Arguments.of( + "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 + Arguments.of( + "StaticFactories", + """ + package lib; + 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(); } + } + """, + 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.of( + "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)); } - @Test - void resolverUsageScope_RejectsBuilderWithPrivateConstructor() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - private LibHelperBuilder() {} - public LibHelperBuilder(LibHelper value) {} - public LibHelper build() { return new 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); + @ParameterizedTest(name = "{0}") + @MethodSource("rejectedContractVariants") + void resolverUsageScope_RejectsContractVariants( + String name, String builderSource, Supplier> probeResult) { + assertThat(compileWithBuilder(builderSource)).succeeded(); + assertEquals(Optional.empty(), probeResult.get()); } - @Test - void resolverUsageScope_AcceptsBuilderWithStaticFactories() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - 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(); } - } - """)); + 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.of( + "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.of( + "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.of( + "PackagePrivateMembers_OtherPackage", + """ + package lib; + public class LibHelperBuilder { + LibHelperBuilder() {} + LibHelperBuilder(LibHelper value) {} + LibHelper build() { return new LibHelper(); } + } + """, + probe(() -> ResolverProbeProcessor.usageWithoutAnnotation))); + } - 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()); - assertEquals( - "create", - assertInstanceOf( - BuilderInstantiation.StaticFactoryCall.class, resolved.funcForEmptyBuilder()) - .methodName()); - assertEquals( - "of", - assertInstanceOf( - BuilderInstantiation.StaticFactoryCall.class, resolved.funcForPrefilledBuilder()) - .methodName()); + private static Compilation compileWithBuilder(String builderSource) { + return Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource(LIB_HELPER), ProcessorTestUtils.forSource(builderSource)); } - @Test - void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { public LibHelper() {} } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelperBuilder { - LibHelperBuilder() {} - LibHelperBuilder(LibHelper value) {} - LibHelper build() { return new LibHelper(); } - } - """)); + private static Supplier> probe( + Supplier> field) { + return field; + } - 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); + private static void assertFactoryMethod( + String expectedMethod, BuilderInstantiation instantiation) { + if (expectedMethod != null) { + assertEquals( + expectedMethod, + assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, instantiation) + .methodName()); + } } @Test From e75acf58e9f56ff40b5e65e85e903320b9b76c19 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:45:52 +0000 Subject: [PATCH 10/12] Cover seeded-path ranking branches; drop unused findMethods overload --- .../processor/analysis/JavaLangAnalyser.java | 14 ---- .../processor/BuilderScopeResolverTest.java | 76 ++++++++++++++++++- 2 files changed, 74 insertions(+), 16 deletions(-) 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 df4a686c..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 @@ -309,20 +309,6 @@ public static List findMethodsStatic( .toList(); } - /** - * Finds accessible methods on the given type taking the expected parameter types, regardless of - * their return type and whether they are static. - * - * @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 findMethods( - TypeElement type, List expectedParameterTypes, ProcessingContext context) { - return findMethods(type, expectedParameterTypes, mirror -> true, context); - } - /** * 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. 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 032baf95..165e3fdc 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 @@ -190,7 +190,8 @@ public LibHelperBuilder(LibHelper value) {} null, BuilderInstantiation.ConstructorCall.class, null), - // No accessible constructors: both instantiation paths come from the static factories + // No accessible constructors: both instantiation paths come from the static + // factories — the second empty factory exercises the preferred-name ranking Arguments.of( "StaticFactories", """ @@ -198,6 +199,7 @@ public LibHelperBuilder(LibHelper value) {} 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(); } } @@ -468,6 +470,72 @@ public LibHelperBuilder(lib.LibHelper value) {} 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.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.ValueFactoryCall.class, resolved.funcForPrefilledBuilder()); + assertEquals("toBuilder", prefilled.methodName()); + } + @Test void resolverUsageScope_ResolvesNestedTypeBuilder() { Compilation compilation = @@ -483,11 +551,15 @@ public LibHelperBuilder() {} public LibHelperBuilder(LibHelper value) {} public LibHelper build() { return new LibHelper(); } } + public static class Other { + public Other() {} + } } """)); assertThat(compilation).succeeded(); - // The nested type satisfies the full contract and wins over any anchored method + // 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()); From 4e3442155c0a7f13062be1d64be540e6a3aec1f4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:57:33 +0000 Subject: [PATCH 11/12] Strings.CS.removeStart; argumentSet display names; merge fall-back tests --- .../processor/analysis/JavaLangMapper.java | 4 +- .../processor/BuilderScopeResolverTest.java | 281 ++++++++---------- 2 files changed, 131 insertions(+), 154 deletions(-) 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 db6d9b24..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,7 +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.StringUtils; +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; @@ -143,7 +143,7 @@ public static TypeName mapToTypeName(TypeElement typeElement, ProcessingContext } String packageName = context.getPackageName(typeElement); String qualifiedName = typeElement.getQualifiedName().toString(); - String className = StringUtils.removeStart(qualifiedName, packageName + "."); + String className = Strings.CS.removeStart(qualifiedName, packageName + "."); return new TypeName(packageName, className); } 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 165e3fdc..c3011b16 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 @@ -40,6 +40,7 @@ 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; @@ -147,10 +148,9 @@ public class LibHelper { public LibHelper() {} } public class LibHelper { public LibHelper() {} } """; - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "{argumentSetName}") @MethodSource("resolvedContractVariants") void resolverUsageScope_ResolvesContractVariants( - String name, String builderSource, Supplier> probeResult, String expectedBuilder, @@ -174,7 +174,7 @@ void resolverUsageScope_ResolvesContractVariants( static Stream resolvedContractVariants() { return Stream.of( // Usage scope without @SimpleBuilder annotation — builder resolved by contract check - Arguments.of( + Arguments.argumentSet( "BuilderWithoutSimpleBuilderAnnotation", """ package lib; @@ -192,7 +192,7 @@ public LibHelperBuilder(LibHelper value) {} null), // No accessible constructors: both instantiation paths come from the static // factories — the second empty factory exercises the preferred-name ranking - Arguments.of( + Arguments.argumentSet( "StaticFactories", """ package lib; @@ -212,7 +212,7 @@ private LibHelperBuilder() {} "of"), // Package-private contract members are accessible when the generated builder is in // the same package (builderPackage "lib") - Arguments.of( + Arguments.argumentSet( "PackagePrivateMembers_SamePackage", """ package lib; @@ -230,10 +230,10 @@ public class LibHelperBuilder { null)); } - @ParameterizedTest(name = "{0}") + @ParameterizedTest(name = "{argumentSetName}") @MethodSource("rejectedContractVariants") void resolverUsageScope_RejectsContractVariants( - String name, String builderSource, Supplier> probeResult) { + String builderSource, Supplier> probeResult) { assertThat(compileWithBuilder(builderSource)).succeeded(); assertEquals(Optional.empty(), probeResult.get()); } @@ -242,7 +242,7 @@ 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.of( + Arguments.argumentSet( "BuilderWithoutNoArgConstructor", """ package lib; @@ -253,7 +253,7 @@ public LibHelperBuilder(LibHelper value) {} """, probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)), // The no-arg ctor exists but is private: generated code could not call it - Arguments.of( + Arguments.argumentSet( "BuilderWithPrivateConstructor", """ package lib; @@ -266,7 +266,7 @@ public LibHelperBuilder(LibHelper value) {} probe(() -> ResolverProbeProcessor.usageWithoutAnnotation)), // Package-private contract members are not accessible from a different package // (builderPackage unset) - Arguments.of( + Arguments.argumentSet( "PackagePrivateMembers_OtherPackage", """ package lib; @@ -280,10 +280,14 @@ public class LibHelperBuilder { } private static Compilation compileWithBuilder(String builderSource) { + return compileWithSources(LIB_HELPER, builderSource); + } + + private static Compilation compileWithSources(String... sources) { return Compiler.javac() .withProcessors(new ResolverProbeProcessor()) .compile( - ProcessorTestUtils.forSource(LIB_HELPER), ProcessorTestUtils.forSource(builderSource)); + Stream.of(sources).map(ProcessorTestUtils::forSource).toArray(JavaFileObject[]::new)); } private static Supplier> probe( @@ -398,34 +402,6 @@ public LibHelperBuilder(lib.LibHelper value) {} BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); } - @Test - void resolverUsageScope_IgnoresInTypeAnchorWithoutContract() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { - public static builders.NoContract builder() { - return new builders.NoContract(); - } - } - """), - ProcessorTestUtils.forSource( - """ - package builders; - public class NoContract { - public NoContract() {} - } - """)); - - assertThat(compilation).succeeded(); - // The anchored type does not satisfy the contract, so resolution falls through - assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); - } - @Test void resolverUsageScope_PrefersBuilderNamedInTypeFactory() { Compilation compilation = @@ -536,6 +512,120 @@ public static class LibHelperBuilder { 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); + } + + 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(); } + } + } + """ + })); + } + @Test void resolverUsageScope_ResolvesNestedTypeBuilder() { Compilation compilation = @@ -598,119 +688,6 @@ public static class LibHelperBuilder { assertEquals("toBuilder", prefilled.methodName()); } - @Test - void resolverUsageScope_LombokBuilderShapeWithoutToBuilderFallsBack() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { - public static LibHelperBuilder builder() { return new LibHelperBuilder(); } - public static class LibHelperBuilder { - LibHelperBuilder() {} - public LibHelper build() { return new LibHelper(); } - } - } - """)); - - assertThat(compilation).succeeded(); - // Delombok output of @Builder without toBuilder: builder() anchors the empty path, but - // no seeded path exists, so the anchored candidate is rejected - assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); - } - - @Test - void resolverUsageScope_FreeBuilderShapeFallsBack() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { - public static class Builder { - public Builder() {} - public Builder mergeFrom(LibHelper value) { return this; } - public LibHelper build() { return new LibHelper(); } - } - } - """)); - - assertThat(compilation).succeeded(); - // 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 - // and resolution falls back to the classpath candidate (absent here) - assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); - } - - @Test - void resolverUsageScope_AutoValueShapeFallsBack() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { - public static AutoValue_LibHelper.Builder create() { - return new AutoValue_LibHelper.Builder(); - } - } - """), - ProcessorTestUtils.forSource( - """ - package lib; - public class AutoValue_LibHelper { - public static class Builder { - Builder() {} - public LibHelper build() { return new LibHelper(); } - } - } - """)); - - assertThat(compilation).succeeded(); - // 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) - assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); - } - - @Test - void resolverUsageScope_ImmutablesShapeFallsBack() { - Compilation compilation = - Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - ProcessorTestUtils.forSource( - """ - package lib; - public class LibHelper { - public LibHelper() {} - } - """), - ProcessorTestUtils.forSource( - """ - 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(); } - } - } - """)); - - assertThat(compilation).succeeded(); - // org.immutables generated shape: builder() and toBuilder() live on the generated - // sibling ImmutableLibHelper, unreachable from the referenced type itself - assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); - } - @Test void resolverUsageScope_ResolvesRecordBuilderShape() { Compilation compilation = From a4008bbee7b043c9300aaa217f728d7b58ce7db9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:40 +0000 Subject: [PATCH 12/12] Collect test helpers at the bottom of BuilderScopeResolverTest --- .../processor/BuilderScopeResolverTest.java | 477 +++++++++--------- 1 file changed, 239 insertions(+), 238 deletions(-) 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 c3011b16..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 @@ -142,12 +142,6 @@ public class LibHelper { public LibHelper() {} } ResolverProbeProcessor.usageAfterRegistration.get().typeName().getFullQualifiedName()); } - private static final String LIB_HELPER = - """ - package lib; - public class LibHelper { public LibHelper() {} } - """; - @ParameterizedTest(name = "{argumentSetName}") @MethodSource("resolvedContractVariants") void resolverUsageScope_ResolvesContractVariants( @@ -171,65 +165,6 @@ void resolverUsageScope_ResolvesContractVariants( assertFactoryMethod(expectedPrefilledMethod, prefilled); } - 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)); - } - @ParameterizedTest(name = "{argumentSetName}") @MethodSource("rejectedContractVariants") void resolverUsageScope_RejectsContractVariants( @@ -238,73 +173,6 @@ void resolverUsageScope_RejectsContractVariants( assertEquals(Optional.empty(), probeResult.get()); } - 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))); - } - - private static Compilation compileWithBuilder(String builderSource) { - return compileWithSources(LIB_HELPER, builderSource); - } - - private static Compilation compileWithSources(String... sources) { - return Compiler.javac() - .withProcessors(new ResolverProbeProcessor()) - .compile( - Stream.of(sources).map(ProcessorTestUtils::forSource).toArray(JavaFileObject[]::new)); - } - - 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()); - } - } - @Test void resolverUsageScope_UsesBuilderUsageSuffixWhenConfigured() { Compilation compilation = @@ -520,112 +388,6 @@ void resolverUsageScope_FallsBackToClasspathCandidate(String[] sources) { assertEquals(Optional.empty(), 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(); } - } - } - """ - })); - } - @Test void resolverUsageScope_ResolvesNestedTypeBuilder() { Compilation compilation = @@ -755,6 +517,245 @@ public LibHelperBuilder(LibHelper value) {} 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 { private static Optional first; private static Optional second;