diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 96cd2291..03d59e0f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -744,13 +744,16 @@ comma-separated, each listed package includes all of its subpackages, and matchi case. The processor constructs the candidate builder name using `builderUsageSuffix` -(or `builderSuffix` if not configured) and verifies the builder contract: a -constructor accepting the referenced type, a no-arg constructor, and a no-arg -`build()` method returning it — each accessible from the generated builder's -package. Any class with the expected name and a matching contract qualifies, allowing -references to builders generated with custom template annotations, external tools, -or different suffixes. If the candidate builder cannot be found, the field falls -back to a plain setter. +(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 +factory like `create()`), a way to create one seeded with the value (a +constructor accepting the referenced type or a static factory like `create(T)` +or `of(T)`), and a no-arg `build()` method returning it — each accessible from +the generated builder's package. Generated code calls a factory instead of +`new` when the builder offers one. Any class with the expected name and a +matching contract qualifies, allowing references to builders generated with +custom template annotations, external tools, or different suffixes. If the +candidate builder cannot be found, the field falls back to a plain setter. Packages listed in `builderGenerationPackages` are automatically included in the usage scope — their builders are generated in the same compilation and don't need to be listed @@ -1024,12 +1027,15 @@ If empty, `builderSuffix` is used instead. This allows referencing builders that with a different suffix (e.g. by another module using `"Factory"` as suffix) without changing the suffix used for own builder generation. -The candidate class must provide a constructor accepting the referenced type, a no-arg -constructor, and a no-arg `build()` method returning it — each accessible from the generated -builder's package. The contract check is annotation-agnostic, so builders -generated with custom template annotations or external tools are supported. If the -candidate class does not exist or does not satisfy this contract, the field falls back -to a plain setter. +The candidate class must provide a way to create an empty builder (a no-arg +constructor or a static parameterless factory like `create()`), a way to create +one seeded with the value (a constructor accepting the referenced type or a +static factory like `create(T)` or `of(T)`), and a no-arg `build()` method +returning it — each accessible from the generated builder's package. Generated +code calls a factory instead of `new` when the builder offers one. The contract +check is annotation-agnostic, so builders generated with custom template +annotations or external tools are supported. If the candidate class does not +exist or does not satisfy this contract, the field falls back to a plain setter. **Example**: ```java diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoBuilder.java index b46cbbab..b05bb3a5 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/PersonDtoBuilder.java @@ -237,7 +237,7 @@ public PersonDtoBuilder mannschaft(MannschaftDto mannschaft) { public PersonDtoBuilder mannschaft(Consumer mannschaftBuilderConsumer) { MannschaftDtoBuilder builder = this.mannschaft.isSet() ? new MannschaftDtoBuilder(this.mannschaft.value()) - : new MannschaftDtoBuilder(); + : MannschaftDtoBuilder.create(); mannschaftBuilderConsumer.accept(builder); this.mannschaft = changedValue(builder.build()); return this; diff --git a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java index d098f5fb..9c660869 100644 --- a/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java +++ b/example/generated-example-builder/org/javahelpers/simple/builders/example/scoping/ScopedOwnerDtoBuilder.java @@ -269,7 +269,7 @@ public ScopedOwnerDtoBuilder trusted(TrustedHelperDto trusted) { public ScopedOwnerDtoBuilder trusted(Consumer trustedBuilderConsumer) { TrustedHelperDtoBuilder builder = this.trusted.isSet() ? new TrustedHelperDtoBuilder(this.trusted.value()) - : new TrustedHelperDtoBuilder(); + : TrustedHelperDtoBuilder.create(); trustedBuilderConsumer.accept(builder); this.trusted = changedValue(builder.build()); return this; 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 e46bda11..82fe828c 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 @@ -23,7 +23,9 @@ */ package org.javahelpers.simple.builders.processor.analysis; +import java.util.Comparator; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import javax.lang.model.element.Element; @@ -31,6 +33,8 @@ 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; +import org.javahelpers.simple.builders.processor.model.type.BuilderInstantiation; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; @@ -55,10 +59,13 @@ public final class BuilderScopeResolver { */ private record ResolutionInputs(BuilderConfiguration configuration, String builderPackage) {} + // Factory method names preferred when a builder offers several candidates + private static final List PREFERRED_FACTORY_NAMES = List.of("create", "of"); + private final ProcessingContext context; private ResolutionInputs cachedResolutionInputs; private PackageScopes usagePackages = PackageScopes.unscoped(); - private final Map> resolvedBuilderTypes = new HashMap<>(); + private final Map> resolvedBuilderTypes = new HashMap<>(); private final GeneratedBuilders generatedBuilders = new GeneratedBuilders(); /** @@ -91,18 +98,20 @@ public BuilderScopeResolver(ProcessingContext context) { * immediately — trusted without a classpath lookup or contract check. *
  • 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: a constructor accepting - * the referenced type, a no-arg constructor, and a no-arg {@code build()} method returning - * it - each accessible from the generated builder's package. The contract check is - * annotation-agnostic, so builders generated with custom template annotations, external + * the classpath and returned if it satisfies the builder contract: an instantiation path + * for an empty builder (no-arg constructor or static factory like {@code create()}), an + * instantiation path seeded with the value (constructor accepting the referenced type or + * static factory like {@code create(T)}/{@code of(T)}), and a no-arg {@code build()} method + * returning it - each accessible from the generated builder's package. The contract check + * is annotation-agnostic, so builders generated with custom template annotations, external * tools, or different suffixes are supported. The referenced type must not be opted out * with {@code @Ignore4BuilderGeneration}. * * * @param referencedType the type element being referenced as a field or collection element - * @return the builder type to reference, or empty if no builder should be referenced + * @return the resolved builder to reference, or empty if no builder should be referenced */ - public Optional resolveUsableBuilderType(TypeElement referencedType) { + public Optional resolveUsableBuilderType(TypeElement referencedType) { if (referencedType == null) { return Optional.empty(); } @@ -171,13 +180,13 @@ public void resetGeneratedBuilders() { resolvedBuilderTypes.clear(); } - private Optional resolve(TypeElement referencedType) { + private Optional resolve(TypeElement referencedType) { if (referencedType == null || isIgnoredForBuilderGeneration(referencedType)) { return Optional.empty(); } - String referencedTypeFqn = referencedType.getQualifiedName().toString(); String packageName = context.getPackageName(referencedType); + TypeName referencedTypeName = JavaLangMapper.mapToTypeName(referencedType, context); // The usage scope determines whether a type is eligible to be referenced as a builder // helper. When empty, any package is allowed (backward compatibility). When set, only @@ -189,11 +198,16 @@ private Optional resolve(TypeElement referencedType) { // Types whose builders are generated in the current processing round are trusted // immediately — our own generators always produce the builder contract, so no // classpath lookup or contract check is needed. - Optional generatedBuilder = - generatedBuilders.findBuilder( - new TypeName(packageName, referencedType.getSimpleName().toString())); + Optional generatedBuilder = generatedBuilders.findBuilder(referencedTypeName); if (generatedBuilder.isPresent()) { - return generatedBuilder; + // Our generators always emit a static create() and no create(T) - the empty path uses + // the factory, the copy path the constructor + return generatedBuilder.map( + builder -> + new ResolvedBuilder( + builder, + new BuilderInstantiation.StaticFactoryCall("create"), + new BuilderInstantiation.ConstructorCall())); } // For types not generated in this round, look up the candidate on the classpath using @@ -201,38 +215,104 @@ private Optional resolve(TypeElement referencedType) { // the builder contract. String suffix = context.getConfiguration().getBuilderUsageSuffix(); TypeName candidate = JavaLangMapper.createBuilderTypeName(referencedType, context, suffix); - return resolveByBuilderContract(candidate, referencedTypeFqn); + return resolveByBuilderContract(candidate, referencedTypeName); } /** * Looks up the candidate builder type on the classpath and verifies it satisfies the builder - * contract: a constructor accepting the referenced type, a no-arg constructor, and a no-arg - * {@code build()} method returning it - each accessible from the generated builder's package, - * since the generated code calls them from there. The contract check is annotation-agnostic, so - * builders generated with custom template annotations or from external sources are supported as - * long as they follow the builder contract. It also avoids false positives like {@code String} → - * {@code StringBuilder}. + * contract: a way to create an empty instance (a no-arg constructor or a static parameterless + * factory like {@code create()}), a way to create an instance seeded with a value (a constructor + * accepting the referenced type or a static factory like {@code create(T)}/{@code of(T)}), and a + * no-arg {@code build()} method returning it - each accessible from the generated builder's + * package, since the generated code calls them from there. The contract check is + * annotation-agnostic, so builders generated with custom template annotations or from external + * sources are supported as long as they follow the builder contract. It also avoids false + * positives like {@code String} → {@code StringBuilder}. * * @param candidate the candidate builder type name to look up - * @param expectedType the qualified name of the referenced type the builder must accept and - * return - * @return the candidate if a matching builder class exists on the classpath, empty otherwise + * @param expectedType the referenced type the builder must accept and return + * @return the resolved builder with the instantiation paths to call, or empty if no matching + * builder class exists on the classpath */ - private Optional resolveByBuilderContract(TypeName candidate, String expectedType) { + private Optional resolveByBuilderContract( + TypeName candidate, TypeName expectedType) { TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName()); if (builderTypeElement == null) { return Optional.empty(); } - if (!JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { + Optional funcForEmptyBuilder = + resolveFuncForEmptyBuilder(builderTypeElement); + Optional funcForPrefilledBuilder = + resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); + if (funcForEmptyBuilder.isEmpty() + || funcForPrefilledBuilder.isEmpty() + || !JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { return Optional.empty(); } - if (!JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { - return Optional.empty(); + return Optional.of( + new ResolvedBuilder(candidate, funcForEmptyBuilder.get(), funcForPrefilledBuilder.get())); + } + + /** + * Resolves the instantiation path for an empty builder instance: a static parameterless factory + * when the builder offers one, the no-arg constructor otherwise. + * + * @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()); } - if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { - return Optional.empty(); + 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 Optional.of(candidate); + 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} + * are preferred in that order, then the alphabetically first name. + * + * @param builderTypeElement the builder type to inspect + * @param expectedParameterTypes the parameter types the factory must accept + * @return the instantiation calling the found factory, or empty when none exists + */ + private Optional findStaticFactoryCall( + TypeElement builderTypeElement, List expectedParameterTypes) { + TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderTypeElement, context); + return JavaLangAnalyser.findMethodsStatic( + builderTypeElement, expectedParameterTypes, builderTypeName, context) + .stream() + .min( + Comparator.comparingInt(BuilderScopeResolver::preferredFactoryNameRank) + .thenComparing(Comparator.naturalOrder())) + .map(BuilderInstantiation.StaticFactoryCall::new); + } + + private static int preferredFactoryNameRank(String name) { + int index = PREFERRED_FACTORY_NAMES.indexOf(name); + return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index; } private void refreshForConfigurationIfNeeded() { 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 fced6e0f..fe232802 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 @@ -32,6 +32,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Predicate; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.AnnotationValue; import javax.lang.model.element.Element; @@ -45,6 +46,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; import org.javahelpers.simple.builders.core.annotations.SimpleBuilderConstructor; +import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; /** Helperclass for extrating specific information from existing classes. */ @@ -191,11 +193,9 @@ public static boolean isSetterForField(ExecutableElement mth) { * @return {@code true}, if the element has an accessible empty constructor */ public static boolean hasEmptyConstructor(TypeElement typeElement, ProcessingContext context) { - List constructors = - ElementFilter.constructorsIn(context.getAllMembers(typeElement)); - return constructors.stream() + return ElementFilter.constructorsIn(context.getAllMembers(typeElement)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) - .anyMatch(c -> c.getParameters().isEmpty()); + .anyMatch(JavaLangAnalyser::hasNoParameters); } /** @@ -251,23 +251,88 @@ public static Optional findAnnotationAttribute( * {@code String}. * * @param builderType the candidate builder type element to check - * @param expectedReturnType the qualified name of the type that {@code build()} must return + * @param expectedReturnType the type that {@code build()} must return * @param context the processing context, used to access all members * @return {@code true} if the type declares or inherits a matching {@code build()} method */ public static boolean hasBuildMethodReturning( - TypeElement builderType, String expectedReturnType, ProcessingContext context) { - if (builderType == null) { - return false; + TypeElement builderType, TypeName expectedReturnType, ProcessingContext context) { + return findMethods(builderType, List.of(), expectedReturnType, context).stream() + .anyMatch(method -> method.getSimpleName().contentEquals("build")); + } + + /** + * Single-parameter variant of {@link #findMethodsStatic(TypeElement, List, TypeName, + * ProcessingContext)}. + */ + public static List findMethodsStatic( + TypeElement type, + TypeName expectedParameterType, + TypeName expectedReturnType, + ProcessingContext context) { + return findMethodsStatic(type, List.of(expectedParameterType), expectedReturnType, context); + } + + /** + * Finds accessible static methods on the given type taking the expected parameter types and + * returning the expected return type. + * + * @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 names of all matching methods, in declaration order + */ + 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( + TypeElement type, + List expectedParameterTypes, + TypeName expectedReturnType, + ProcessingContext context) { + if (type == null) { + return List.of(); } - return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream() + return ElementFilter.methodsIn(context.getAllMembers(type)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) - .anyMatch( + .filter( method -> - method.getSimpleName().contentEquals("build") - && method.getParameters().isEmpty() - && method.getReturnType().getKind() != VOID - && method.getReturnType().toString().equals(expectedReturnType)); + expectedReturnType == null + ? method.getReturnType().getKind() == VOID + : hasType(method.getReturnType(), expectedReturnType)) + .filter(method -> hasParameters(method, expectedParameterTypes)) + .toList(); + } + + private static boolean hasNoParameters(ExecutableElement method) { + return method.getParameters().isEmpty(); + } + + private static boolean hasParameters( + ExecutableElement method, List expectedParameterTypes) { + List parameters = method.getParameters(); + if (parameters.size() != expectedParameterTypes.size()) { + return false; + } + for (int i = 0; i < parameters.size(); i++) { + if (!hasType(parameters.get(i).asType(), expectedParameterTypes.get(i))) { + return false; + } + } + return true; + } + + private static boolean hasType(TypeMirror typeMirror, TypeName expectedType) { + return typeMirror.toString().equals(expectedType.getFullQualifiedName()); } /** @@ -275,21 +340,18 @@ public static boolean hasBuildMethodReturning( * the builder contract used when the field already has a value that is passed to the builder. * * @param builderType the candidate builder type element to check - * @param expectedType the qualified name of the type the constructor must accept + * @param expectedType the type the constructor must accept * @param context the processing context, used to access all members * @return {@code true} if the type declares or inherits a matching constructor */ public static boolean hasConstructorAccepting( - TypeElement builderType, String expectedType, ProcessingContext context) { + TypeElement builderType, TypeName expectedType, ProcessingContext context) { if (builderType == null) { return false; } return ElementFilter.constructorsIn(context.getAllMembers(builderType)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) - .anyMatch( - constructor -> - constructor.getParameters().size() == 1 - && constructor.getParameters().get(0).asType().toString().equals(expectedType)); + .anyMatch(constructor -> hasParameters(constructor, List.of(expectedType))); } /** 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 ab54646a..702cf23b 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 @@ -128,6 +128,22 @@ public static MethodParameterDto map2MethodParameter( return result; } + /** + * Maps a {@code TypeElement} to its {@code TypeName} holding package and simple name only, + * without resolving builder or constructor information. + * + * @param typeElement the TypeElement to map + * @param context the processing context + * @return TypeName of the type element, or null if the type element is null + */ + public static TypeName mapToTypeName(TypeElement typeElement, ProcessingContext context) { + if (typeElement == null) { + return null; + } + return new TypeName( + context.getPackageName(typeElement), typeElement.getSimpleName().toString()); + } + /** * Maps a {@code TypeElement} to a simple-builder {@code TypeName}. * @@ -177,7 +193,7 @@ private static void setBuilderTypeIfScopeMatches( context .getBuilderScopeResolver() .resolveUsableBuilderType(typeElement) - .ifPresent(typeName::setBuilderType); + .ifPresent(typeName::setResolvedBuilder); } /** @@ -228,7 +244,7 @@ private static void setElementBuilderTypeIfScopeMatches( context .getBuilderScopeResolver() .resolveUsableBuilderType(elementTypeElement) - .ifPresent(genericType::setElementBuilderType); + .ifPresent(genericType::setElementResolvedBuilder); } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/ListConsumerGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/ListConsumerGenerator.java index 45c1d24c..74752394 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/ListConsumerGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/ListConsumerGenerator.java @@ -39,6 +39,7 @@ import org.javahelpers.simple.builders.processor.generators.MethodGenerator; import org.javahelpers.simple.builders.processor.model.core.FieldDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameGeneric; import org.javahelpers.simple.builders.processor.model.type.TypeNameList; @@ -124,21 +125,21 @@ public List generateMethods( } TypeName elementType = fieldTypeGeneric.getElementType(); - Optional elementBuilderType = fieldTypeGeneric.getElementBuilderType(); + Optional elementBuilder = fieldTypeGeneric.getElementResolvedBuilder(); - if (elementBuilderType.isPresent() + if (elementBuilder.isPresent() && context.getConfiguration().shouldUseArrayListBuilderWithElementBuilders()) { TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(ArrayListBuilderWithElementBuilders.class), elementType, - elementBuilderType.get()); + elementBuilder.get().typeName()); BuilderMethodDto method = createFieldConsumerWithElementBuilders( - field, collectionBuilderType, elementBuilderType.get(), builderType, context); + field, collectionBuilderType, elementBuilder.get(), builderType, context); // Add method-level example for list consumer - addExampleToListConsumerWithBuilder(method, elementBuilderType.get()); + addExampleToListConsumerWithBuilder(method, elementBuilder.get().typeName()); return List.of(method); } else if (context.getConfiguration().shouldUseArrayListBuilder()) { @@ -147,7 +148,7 @@ public List generateMethods( BuilderMethodDto method = createFieldConsumerWithBuilder( field, - builderTypeGeneric, + new ResolvedBuilder(builderTypeGeneric), "this.$fieldName:N.value()", "", Map.of(), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/MapConsumerGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/MapConsumerGenerator.java index ac80efb4..c0319fff 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/MapConsumerGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/MapConsumerGenerator.java @@ -35,6 +35,7 @@ import org.javahelpers.simple.builders.processor.model.core.BuilderConfiguration; import org.javahelpers.simple.builders.processor.model.core.FieldDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameGeneric; import org.javahelpers.simple.builders.processor.model.type.TypeNameMap; @@ -146,7 +147,7 @@ public List generateMethods( BuilderMethodDto mapConsumerWithBuilder = createFieldConsumerWithBuilder( field, - builderTargetTypeName, + new ResolvedBuilder(builderTargetTypeName), "this.$fieldName:N.value()", "", Map.of(), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/NestedBuilderConsumerGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/NestedBuilderConsumerGenerator.java index 7c645ccb..ed7e45c9 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/NestedBuilderConsumerGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/NestedBuilderConsumerGenerator.java @@ -34,6 +34,7 @@ import org.javahelpers.simple.builders.processor.generators.MethodGenerator; import org.javahelpers.simple.builders.processor.model.core.FieldDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; @@ -98,24 +99,18 @@ public boolean appliesTo(FieldDto field, TypeName dtoType, ProcessingContext con @Override public List generateMethods( FieldDto field, TypeName builderType, ProcessingContext context) { - Optional fieldBuilderOpt = field.getFieldType().getBuilderType(); + Optional fieldBuilderOpt = field.getFieldType().getResolvedBuilder(); if (fieldBuilderOpt.isEmpty()) { return Collections.emptyList(); } - TypeName fieldBuilderType = fieldBuilderOpt.get(); + ResolvedBuilder fieldBuilder = fieldBuilderOpt.get(); BuilderMethodDto method = createFieldConsumerWithBuilder( - field, - fieldBuilderType, - "this.$fieldName:N.value()", - "", - Map.of(), - builderType, - context); + field, fieldBuilder, "this.$fieldName:N.value()", "", Map.of(), builderType, context); // Add example fragment showing the consumer lambda pattern - String builderVar = StringUtils.uncapitalize(fieldBuilderType.getClassName()); + String builderVar = StringUtils.uncapitalize(fieldBuilder.typeName().getClassName()); addExampleChainFragmentTemplate( method, "#{methodName}(" + builderVar + " -> " + builderVar + ")"); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/SetConsumerGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/SetConsumerGenerator.java index 6ccf30b5..7a79a035 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/SetConsumerGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/field/SetConsumerGenerator.java @@ -37,6 +37,7 @@ import org.javahelpers.simple.builders.processor.generators.MethodGenerator; import org.javahelpers.simple.builders.processor.model.core.FieldDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameGeneric; import org.javahelpers.simple.builders.processor.model.type.TypeNameSet; @@ -120,19 +121,19 @@ public List generateMethods( } TypeName elementType = fieldTypeGeneric.getElementType(); - Optional elementBuilderType = fieldTypeGeneric.getElementBuilderType(); + Optional elementBuilder = fieldTypeGeneric.getElementResolvedBuilder(); - if (elementBuilderType.isPresent() + if (elementBuilder.isPresent() && context.getConfiguration().shouldUseHashSetBuilderWithElementBuilders()) { TypeName collectionBuilderType = new TypeNameGeneric( map2TypeName(HashSetBuilderWithElementBuilders.class), elementType, - elementBuilderType.get()); + elementBuilder.get().typeName()); BuilderMethodDto method = createFieldConsumerWithElementBuilders( - field, collectionBuilderType, elementBuilderType.get(), builderType, context); - addExampleToSetConsumerWithBuilder(method, elementBuilderType.get()); + field, collectionBuilderType, elementBuilder.get(), builderType, context); + addExampleToSetConsumerWithBuilder(method, elementBuilder.get().typeName()); return List.of(method); } else if (context.getConfiguration().shouldUseHashSetBuilder()) { TypeName hashSetBuilderType = map2TypeName(HashSetBuilder.class); @@ -140,7 +141,7 @@ public List generateMethods( BuilderMethodDto method = createFieldConsumerWithBuilder( field, - builderTypeGeneric, + new ResolvedBuilder(builderTypeGeneric), "this.$fieldName:N.value()", "", Map.of(), diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java index 328ba5b2..6fb7a55f 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/util/MethodGeneratorUtil.java @@ -36,6 +36,7 @@ import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto; import org.javahelpers.simple.builders.processor.model.type.GenericParameterDto; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameGeneric; import org.javahelpers.simple.builders.processor.model.type.TypeNameList; @@ -205,7 +206,8 @@ public static BuilderMethodDto createBuilderMethodForFieldWithTransform( * Creates a field consumer method that accepts a builder for the field value. * * @param field the field DTO - * @param fieldBuilderType the builder type used to construct the field value + * @param fieldBuilder the resolved builder used to construct the field value, including the + * instantiation paths to emit * @param existingValueConstructorArgs constructor arguments when field already has a value * @param emptyConstructorArgs constructor arguments when field is not yet set * @param additionalTemplateArguments additional code template arguments for method generation @@ -215,12 +217,13 @@ public static BuilderMethodDto createBuilderMethodForFieldWithTransform( */ public static BuilderMethodDto createFieldConsumerWithBuilder( FieldDto field, - TypeName fieldBuilderType, + ResolvedBuilder fieldBuilder, String existingValueConstructorArgs, String emptyConstructorArgs, Map additionalTemplateArguments, TypeName parentBuilderType, ProcessingContext context) { + TypeName fieldBuilderType = fieldBuilder.typeName(); TypeNameGeneric consumerType = createConsumerType(fieldBuilderType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(field.getFieldNameInBuilder() + BUILDER_SUFFIX + SUFFIX_CONSUMER); @@ -234,13 +237,19 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( methodDto.setCode( """ $helperType:T builder = this.$fieldName:N.isSet() - ? new $helperType:T(%s) - : new $helperType:T(%s); + ? %s + : %s; $dtoMethodParam:N.accept(builder); this.$fieldName:N = $builderFieldWrapper:T.changedValue($buildExpression:N); return this; """ - .formatted(existingValueConstructorArgs, emptyConstructorArgs)); + .formatted( + fieldBuilder + .funcForPrefilledBuilder() + .instantiationCode("$helperType:T", existingValueConstructorArgs), + fieldBuilder + .funcForEmptyBuilder() + .instantiationCode("$helperType:T", emptyConstructorArgs))); methodDto.addArgument("fieldName", field.getFieldNameInBuilder()); methodDto.addArgument("dtoMethodParam", parameter.getParameterName()); methodDto.addArgument("helperType", fieldBuilderType); @@ -320,7 +329,8 @@ public static String wrapConcreteCollectionType(TypeName fieldType, String baseE * * @param field the field DTO * @param collectionBuilderType the collection builder type - * @param elementBuilderType the element builder type + * @param elementBuilder the resolved element builder; its empty-instantiation path is passed to + * the collection builder as a method reference * @param returnBuilderType the return builder type * @param context the processing context * @return the method DTO for the consumer @@ -328,15 +338,19 @@ public static String wrapConcreteCollectionType(TypeName fieldType, String baseE public static BuilderMethodDto createFieldConsumerWithElementBuilders( FieldDto field, TypeName collectionBuilderType, - TypeName elementBuilderType, + ResolvedBuilder elementBuilder, TypeName returnBuilderType, ProcessingContext context) { + // The collection builder receives a supplier for element builders: the resolved + // instantiation path as a method reference + String elementSupplier = + elementBuilder.funcForEmptyBuilder().methodReference("$elementBuilderType:T"); return createFieldConsumerWithBuilder( field, - collectionBuilderType, - "this.$fieldName:N.value(), $elementBuilderType:T::create", - "$elementBuilderType:T::create", - Map.of("elementBuilderType", elementBuilderType), + new ResolvedBuilder(collectionBuilderType), + "this.$fieldName:N.value(), " + elementSupplier, + elementSupplier, + Map.of("elementBuilderType", elementBuilder.typeName()), returnBuilderType, context); } 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 new file mode 100644 index 00000000..295f0ecc --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java @@ -0,0 +1,77 @@ +/* + * Copyright 2025 Andreas Igel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.javahelpers.simple.builders.processor.model.type; + +/** + * The instantiation path generated code uses to obtain an instance of a resolved builder. + * + *

    The resolver records the concrete decision made while checking the builder contract, so + * generation sites emit exactly the path that was resolved - constructor call or static factory + * call - instead of silently falling back between the two. + */ +public sealed interface BuilderInstantiation { + + /** + * Renders the code obtaining a builder instance of {@code builderTypeExpression} invoked with + * {@code arguments}. + * + * @param builderTypeExpression expression evaluating to the builder type, may be a template + * placeholder like {@code $helperType:T} + * @param arguments the arguments for the constructor or factory call + * @return the instantiation code + */ + String instantiationCode(String builderTypeExpression, String arguments); + + /** + * Renders a method reference obtaining a builder instance (e.g. {@code B::new} or {@code + * B::create}). + * + * @param builderTypeExpression expression evaluating to the builder type, may be a template + * placeholder like {@code $elementBuilderType:T} + * @return the method reference + */ + String methodReference(String builderTypeExpression); + + /** Instantiation through a constructor: {@code new B(arguments)}. */ + record ConstructorCall() implements BuilderInstantiation { + @Override + public String instantiationCode(String builderTypeExpression, String arguments) { + return "new " + builderTypeExpression + "(" + arguments + ")"; + } + + @Override + public String methodReference(String builderTypeExpression) { + return builderTypeExpression + "::new"; + } + } + + /** + * Instantiation through a static factory method on the builder: {@code B.methodName(arguments)}. + * + * @param methodName name of the static factory method (e.g. {@code create}, {@code of}) + */ + record StaticFactoryCall(String methodName) implements BuilderInstantiation { + @Override + public String instantiationCode(String builderTypeExpression, String arguments) { + return builderTypeExpression + "." + methodName + "(" + arguments + ")"; + } + + @Override + public String methodReference(String builderTypeExpression) { + return builderTypeExpression + "::" + methodName; + } + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java new file mode 100644 index 00000000..b96fdeda --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 Andreas Igel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.javahelpers.simple.builders.processor.model.type; + +/** + * A builder type resolved for a referenced field or element type, together with the instantiation + * paths generated code must use to call it. + * + *

    Each component records the concrete instantiation the contract check selected - constructor + * call or static factory call - so generation sites emit exactly what was resolved. + * + * @param typeName the builder type to reference + * @param funcForEmptyBuilder how to obtain an empty builder instance (e.g. {@code new B()} or + * {@code B.create()}) + * @param funcForPrefilledBuilder how to obtain a builder instance seeded with a value (e.g. {@code + * new B(value)} or {@code B.of(value)}) + */ +public record ResolvedBuilder( + TypeName typeName, + BuilderInstantiation funcForEmptyBuilder, + BuilderInstantiation funcForPrefilledBuilder) { + + /** + * A resolution instantiated exclusively through constructors. + * + * @param typeName the builder type to reference + */ + public ResolvedBuilder(TypeName typeName) { + this( + typeName, + new BuilderInstantiation.ConstructorCall(), + new BuilderInstantiation.ConstructorCall()); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java index d00b40c1..79e390a2 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeName.java @@ -46,8 +46,8 @@ public class TypeName { /** Annotations on this type (TYPE_USE). */ private final java.util.List annotations = new java.util.ArrayList<>(); - /** Builder type for this type if it has @SimpleBuilder annotation. */ - private TypeName builderType; + /** Resolved builder for this type, including how generated code instantiates it. */ + private ResolvedBuilder resolvedBuilder; /** Whether this type has an empty constructor. */ private boolean hasEmptyConstructor = false; @@ -126,21 +126,31 @@ public void addAnnotation(AnnotationDto annotation) { } /** - * Returns the builder type for this type if it has @SimpleBuilder annotation. + * Returns the builder type for this type if a usable builder was resolved. * * @return optional builder type */ public Optional getBuilderType() { - return Optional.ofNullable(builderType); + return getResolvedBuilder().map(ResolvedBuilder::typeName); } /** - * Sets the builder type for this type. + * Returns the resolved builder for this type, including the instantiation paths generated code + * must use. * - * @param builderType the builder type + * @return optional resolved builder */ - public void setBuilderType(TypeName builderType) { - this.builderType = builderType; + public Optional getResolvedBuilder() { + return Optional.ofNullable(resolvedBuilder); + } + + /** + * Sets the resolved builder for this type. + * + * @param resolvedBuilder the resolved builder + */ + public void setResolvedBuilder(ResolvedBuilder resolvedBuilder) { + this.resolvedBuilder = resolvedBuilder; } /** diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeNameGeneric.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeNameGeneric.java index 505aabde..f32bbcf5 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeNameGeneric.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/TypeNameGeneric.java @@ -44,8 +44,8 @@ public class TypeNameGeneric extends TypeName { private final List innerTypeArguments; - /** Builder type for the element type of this collection (if applicable). */ - private TypeName elementBuilderType; + /** Resolved builder for the element type of this collection (if applicable). */ + private ResolvedBuilder elementResolvedBuilder; /** * Creates a {@code TypeNameGeneric} based on another {@code TypeName} as outer type and a list of @@ -138,16 +138,26 @@ public Optional getInnerType() { * @return optional element builder type */ public Optional getElementBuilderType() { - return Optional.ofNullable(elementBuilderType); + return getElementResolvedBuilder().map(ResolvedBuilder::typeName); } /** - * Sets the builder type for the element type of this collection. + * Returns the resolved builder for the element type of this collection, including the + * instantiation paths generated code must use. * - * @param elementBuilderType the element builder type + * @return optional resolved element builder */ - public void setElementBuilderType(TypeName elementBuilderType) { - this.elementBuilderType = elementBuilderType; + public Optional getElementResolvedBuilder() { + return Optional.ofNullable(elementResolvedBuilder); + } + + /** + * Sets the resolved builder for the element type of this collection. + * + * @param elementResolvedBuilder the resolved element builder + */ + public void setElementResolvedBuilder(ResolvedBuilder elementResolvedBuilder) { + this.elementResolvedBuilder = elementResolvedBuilder; } @Override @@ -197,7 +207,7 @@ public boolean equals(Object o) { return new EqualsBuilder() .appendSuper(super.equals(o)) .append(innerTypeArguments, that.innerTypeArguments) - .append(elementBuilderType, that.elementBuilderType) + .append(elementResolvedBuilder, that.elementResolvedBuilder) .isEquals(); } @@ -206,7 +216,7 @@ public int hashCode() { return new HashCodeBuilder(17, 37) .appendSuper(super.hashCode()) .append(innerTypeArguments) - .append(elementBuilderType) + .append(elementResolvedBuilder) .toHashCode(); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java index 707bd3e4..cd65dbe5 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderProcessorTest.java @@ -1394,7 +1394,7 @@ public HelperAnno() {} generatedCode, "private TrackedValue helper = unsetValue();", "helperBuilderConsumer", - "HelperAnnoBuilder builder = this.helper.isSet() ? new HelperAnnoBuilder(this.helper.value()) : new HelperAnnoBuilder();", + "HelperAnnoBuilder builder = this.helper.isSet() ? new HelperAnnoBuilder(this.helper.value()) : HelperAnnoBuilder.create();", "helperBuilderConsumer.accept(builder);", "this.helper = changedValue(builder.build());"); } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java index 39957866..247560fb 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/BuilderScopeProcessingTest.java @@ -252,6 +252,65 @@ void usageScope_FallsBackToBuilderSuffixWhenUsageSuffixNotConfigured() { ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder"); } + @Test + void usageScope_InstantiatesViaStaticFactoryWhenPresent() { + JavaFileObject dto = dto("test", "FactoryUsageDto", "LibraryDto", "lib"); + JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto"); + JavaFileObject libraryDtoBuilder = manualBuilder("lib", "LibraryDtoBuilder", "LibraryDto"); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.builderGenerationPackages=test", + "-Asimplebuilder.builderUsagePackages=lib") + .compile(dto, libraryDto, libraryDtoBuilder); + + assertThat(compilation).succeeded(); + String generated = + ProcessorTestUtils.loadGeneratedSource(compilation, "FactoryUsageDtoBuilder"); + // The manualBuilder fixture offers a static create() but no create(T): the empty path calls + // the factory, the copy path still uses the constructor + ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder.create()"); + ProcessorAsserts.assertContaining(generated, "new LibraryDtoBuilder(this.referenced.value())"); + } + + @Test + void usageScope_InstantiatesViaFactoriesOnly() { + JavaFileObject dto = dto("test", "FactoryOnlyDto", "LibraryDto", "lib"); + JavaFileObject libraryDto = unannotatedDto("lib", "LibraryDto"); + JavaFileObject libraryDtoBuilder = + ProcessorTestUtils.forSource( + """ + package lib; + public class LibraryDtoBuilder { + private LibraryDto value; + private LibraryDtoBuilder() {} + public static LibraryDtoBuilder create() { return new LibraryDtoBuilder(); } + public static LibraryDtoBuilder of(LibraryDto v) { + LibraryDtoBuilder b = create(); + b.value = v; + return b; + } + public LibraryDto build() { return value != null ? value : new LibraryDto(); } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.builderGenerationPackages=test", + "-Asimplebuilder.builderUsagePackages=lib") + .compile(dto, libraryDto, libraryDtoBuilder); + + assertThat(compilation).succeeded(); + String generated = ProcessorTestUtils.loadGeneratedSource(compilation, "FactoryOnlyDtoBuilder"); + // A builder whose only instantiation paths are static factories satisfies the contract and + // is emitted via factory calls, never via new + ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder.of(this.referenced.value())"); + ProcessorAsserts.assertContaining(generated, "LibraryDtoBuilder.create()"); + ProcessorAsserts.assertNotContaining(generated, "new LibraryDtoBuilder"); + } + @Test void usageScope_DoesNotReferenceBuilderWhenClassNotFoundWithUsageSuffix() { JavaFileObject dto = dto("test", "MissingBuilderDto", "LibraryDto", "lib"); 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 ddd28c1c..0839110d 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 @@ -26,6 +26,7 @@ import static com.google.testing.compile.CompilationSubject.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; @@ -39,6 +40,8 @@ import javax.lang.model.element.TypeElement; 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; +import org.javahelpers.simple.builders.processor.model.type.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; import org.javahelpers.simple.builders.processor.processing.ProcessingTarget; @@ -71,7 +74,9 @@ public class LibHelper { public LibHelper() {} } assertThat(compilation).succeeded(); // First resolution with generation scope "lib" and registered → builder found - assertEquals("lib.LibHelperBuilder", ResolverProbeProcessor.first.get().getFullQualifiedName()); + assertEquals( + "lib.LibHelperBuilder", + ResolverProbeProcessor.first.get().typeName().getFullQualifiedName()); // After clearing registration and changing config to exclude "lib", the resolver returns // empty (not registered, not in scope) assertEquals(Optional.empty(), ResolverProbeProcessor.afterConfigurationChange); @@ -124,7 +129,7 @@ public class LibHelper { public LibHelper() {} } // With usage scope "lib" AND registration → builder resolved assertEquals( "lib.LibHelperBuilder", - ResolverProbeProcessor.usageAfterRegistration.get().getFullQualifiedName()); + ResolverProbeProcessor.usageAfterRegistration.get().typeName().getFullQualifiedName()); } @Test @@ -153,7 +158,7 @@ public LibHelperBuilder(LibHelper value) {} // Usage scope without @SimpleBuilder annotation — builder resolved by contract check assertEquals( "lib.LibHelperBuilder", - ResolverProbeProcessor.usageWithoutAnnotation.get().getFullQualifiedName()); + ResolverProbeProcessor.usageWithoutAnnotation.get().typeName().getFullQualifiedName()); } @Test @@ -211,6 +216,45 @@ public LibHelperBuilder(LibHelper value) {} assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); } + @Test + void resolverUsageScope_AcceptsBuilderWithStaticFactories() { + 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 { + private LibHelperBuilder() {} + public static LibHelperBuilder create() { return new LibHelperBuilder(); } + public static LibHelperBuilder of(LibHelper value) { return create(); } + public LibHelper build() { return new 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()); + assertEquals( + "create", + assertInstanceOf( + BuilderInstantiation.StaticFactoryCall.class, resolved.funcForEmptyBuilder()) + .methodName()); + assertEquals( + "of", + assertInstanceOf( + BuilderInstantiation.StaticFactoryCall.class, resolved.funcForPrefilledBuilder()) + .methodName()); + } + @Test void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() { ResolverProbeProcessor.reset(); @@ -238,7 +282,7 @@ public class LibHelperBuilder { // same package (builderPackage "lib")... assertEquals( "lib.LibHelperBuilder", - ResolverProbeProcessor.usagePackagePrivate.get().getFullQualifiedName()); + ResolverProbeProcessor.usagePackagePrivate.get().typeName().getFullQualifiedName()); // ...but not from a different one (builderPackage unset) assertEquals(Optional.empty(), ResolverProbeProcessor.usageWithoutAnnotation); } @@ -269,7 +313,7 @@ public LibHelperFactory(LibHelper value) {} // With builderUsageSuffix="Factory", the candidate name uses "Factory" assertEquals( "lib.LibHelperFactory", - ResolverProbeProcessor.usageWithSuffix.get().getFullQualifiedName()); + ResolverProbeProcessor.usageWithSuffix.get().typeName().getFullQualifiedName()); } @Test @@ -300,21 +344,21 @@ public LibHelperBuilder(LibHelper value) {} // Without builderUsageSuffix, the candidate name uses builderSuffix ("Builder") assertEquals( "lib.LibHelperBuilder", - ResolverProbeProcessor.usageDefaultSuffix.get().getFullQualifiedName()); + ResolverProbeProcessor.usageDefaultSuffix.get().typeName().getFullQualifiedName()); } private static final class ResolverProbeProcessor extends AbstractProcessor { - private static Optional first; - private static Optional second; - private static Optional afterConfigurationChange; - private static Optional beforeRegistration; - private static Optional afterRegistration; - private static Optional usageBeforeRegistration; - private static Optional usageAfterRegistration; - private static Optional usageWithoutAnnotation; - private static Optional usageWithSuffix; - private static Optional usageDefaultSuffix; - private static Optional usagePackagePrivate; + private static Optional first; + private static Optional second; + private static Optional afterConfigurationChange; + private static Optional beforeRegistration; + private static Optional afterRegistration; + private static Optional usageBeforeRegistration; + private static Optional usageAfterRegistration; + private static Optional usageWithoutAnnotation; + private static Optional usageWithSuffix; + private static Optional usageDefaultSuffix; + private static Optional usagePackagePrivate; private boolean captured; diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java index 215d487f..ffcdd35e 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ComprehensiveFeatureIntegrationTest.java @@ -462,7 +462,7 @@ public PersonDtoBuilder address(AddressDto address) { public PersonDtoBuilder address(Consumer addressBuilderConsumer) { AddressDtoBuilder builder = this.address.isSet() ? new AddressDtoBuilder(this.address.value()) - : new AddressDtoBuilder(); + : AddressDtoBuilder.create(); addressBuilderConsumer.accept(builder); this.address = changedValue(builder.build()); return this;