diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 6b671ac0..7ff9b14e 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -770,6 +770,21 @@ */ String builderUsagePackages() default ""; + /** + * Reuse builders that were not generated by this processor: classpath builders discovered + * through {@link #builderUsagePackages()} or builders anchored inside the referenced type + * itself (a nested {@code Person.Builder} or a {@code Person.builder()} factory method).
+ * When disabled, only builders generated in the current processing round qualify and every + * other referenced type falls back to a plain setter - opting out of false-positive detections + * on types that coincidentally look like builders. + * + *

Default: ENABLED
+ * Compiler option: -Asimplebuilder.usingExistingBuilders + * + * @return the option state for reusing existing builders + */ + OptionState usingExistingBuilders() default OptionState.UNSET; + // === Naming === /** * Suffix to append to the DTO name to generate the builder class name.
diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java index c6df4cf7..54d4777a 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java @@ -94,6 +94,7 @@ generateJavaDoc = OptionState.DISABLED, copyTypeAnnotations = OptionState.DISABLED, implementsBuilderBase = OptionState.DISABLED, + usingExistingBuilders = OptionState.DISABLED, builderSuffix = "Builder", setterSuffix = "", formattingMode = "lightweight")) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 03d59e0f..2c595c04 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -743,6 +743,16 @@ Controls which packages may provide builders as nested builder helpers. Packages comma-separated, each listed package includes all of its subpackages, and matching ignores case. +Before constructing a candidate name, the processor checks the referenced type +itself for an anchored builder — the type's own declaration wins over the +same-package candidate below. Accessible nested types satisfying the contract +are checked first (e.g. `Person.PersonBuilder` or `Person.Builder`), then a +static parameterless factory on the type — `builder()` preferred — whose return +type must be accessible and declare a no-arg `build()` method (Lombok/Immutables +style). The factory itself is the empty-instantiation path; the seeded path is +the builder's own contract path, a static `builder(T)`-style method, or an +instance method on the value like `toBuilder()`. + The processor constructs the candidate builder name using `builderUsageSuffix` (or `builderSuffix` if not configured) and verifies the builder contract: a way to create an empty builder (a no-arg constructor or a static parameterless @@ -769,6 +779,19 @@ The `example` module contains a runnable demo in package It demonstrates a generation-scope builder consumer, a usage-scope missing-builder fallback, and an out-of-scope plain setter in [`ScopedOwnerDtoBuilder.java`](../example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java). +#### `usingExistingBuilders` + +**Default**: `ENABLED` | **Compiler Option**: `-Asimplebuilder.usingExistingBuilders=DISABLED` + +Controls whether builders that were not generated by this processor may be +reused — both the classpath lookup described above (usage scope, in-type +anchors like `Person.builder()` or a nested `Person.Builder`) and any other +existing builder satisfying the contract. When disabled, only builders +generated in the current processing round qualify and every other referenced +type falls back to a plain setter, opting out of false-positive detections on +types that coincidentally look like builders. `@SimpleMinimalBuilder` disables +this option. + ### Component Filtering #### `deactivateGenerationComponents` diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 82fe828c..16aa198b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java @@ -29,7 +29,11 @@ import java.util.Map; import java.util.Optional; import javax.lang.model.element.Element; +import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; +import javax.lang.model.type.DeclaredType; +import javax.lang.model.type.TypeKind; +import javax.lang.model.util.ElementFilter; import org.javahelpers.simple.builders.core.annotations.Ignore4BuilderGeneration; import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.core.PackageScopes; @@ -96,6 +100,12 @@ public BuilderScopeResolver(ProcessingContext context) { *

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