From bf0715c021a7f1905cd5c3ed12f92382b9c72a86 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:49:00 +0000 Subject: [PATCH 01/13] Support static factory methods for referenced builders (#309) The builder contract now accepts static factories as instantiation paths alongside constructors: a parameterless create()/of()-style method for the empty instance and a create(T)/of(T) method for the value-seeded instance, each verified accessible like the other contract members. Factories are preferred over constructors when both exist, and generated consumer code calls them instead of new. Resolution results are carried in a new ResolvedBuilder record (builder TypeName + chosen factory methods) stored on TypeName/TypeNameGeneric in place of the plain builder TypeName, so generators can emit the right instantiation. Builders generated in the same round resolve with create() as their empty path since our generators always emit it. This also fixes a latent gap in createFieldConsumerWithElementBuilders, which unconditionally emitted ElementBuilder::create and broke for referenced builders without that factory - it now emits the resolved instantiation as a method reference (B::create or B::new). --- docs/CONFIGURATION.md | 26 ++++--- .../analysis/BuilderScopeResolver.java | 56 +++++++++----- .../processor/analysis/JavaLangAnalyser.java | 74 +++++++++++++++++++ .../processor/analysis/JavaLangMapper.java | 4 +- .../field/ListConsumerGenerator.java | 13 ++-- .../field/MapConsumerGenerator.java | 3 +- .../field/NestedBuilderConsumerGenerator.java | 15 ++-- .../field/SetConsumerGenerator.java | 13 ++-- .../generators/util/MethodGeneratorUtil.java | 38 +++++++--- .../processor/model/type/ResolvedBuilder.java | 46 ++++++++++++ .../processor/model/type/TypeName.java | 26 +++++-- .../processor/model/type/TypeNameGeneric.java | 28 ++++--- .../processor/BuilderProcessorTest.java | 2 +- .../processor/BuilderScopeProcessingTest.java | 59 +++++++++++++++ .../processor/BuilderScopeResolverTest.java | 68 ++++++++++++----- .../ComprehensiveFeatureIntegrationTest.java | 2 +- 16 files changed, 371 insertions(+), 102 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 96cd2291..faec7897 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -744,10 +744,13 @@ 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 +(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. @@ -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/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..0c996f12 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 @@ -31,6 +31,7 @@ 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.ResolvedBuilder; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.processing.ProcessingContext; @@ -58,7 +59,7 @@ private record ResolutionInputs(BuilderConfiguration configuration, String build 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 +92,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,7 +174,7 @@ public void resetGeneratedBuilders() { resolvedBuilderTypes.clear(); } - private Optional resolve(TypeElement referencedType) { + private Optional resolve(TypeElement referencedType) { if (referencedType == null || isIgnoredForBuilderGeneration(referencedType)) { return Optional.empty(); } @@ -193,7 +196,10 @@ private Optional resolve(TypeElement referencedType) { generatedBuilders.findBuilder( new TypeName(packageName, referencedType.getSimpleName().toString())); 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, Optional.of("create"), Optional.empty())); } // For types not generated in this round, look up the candidate on the classpath using @@ -206,33 +212,43 @@ private Optional resolve(TypeElement referencedType) { /** * 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 + * @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, String expectedType) { TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName()); if (builderTypeElement == null) { return Optional.empty(); } - if (!JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { + Optional emptyFactory = + JavaLangAnalyser.findStaticFactoryReturning(builderTypeElement, context); + if (emptyFactory.isEmpty() + && !JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { return Optional.empty(); } - if (!JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { + Optional copyFactory = + JavaLangAnalyser.findStaticFactoryAccepting(builderTypeElement, expectedType, context); + if (copyFactory.isEmpty() + && !JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { return Optional.empty(); } if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { return Optional.empty(); } - return Optional.of(candidate); + return Optional.of(new ResolvedBuilder(candidate, emptyFactory, copyFactory)); } 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..0be24bef 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 @@ -29,6 +29,7 @@ import static javax.lang.model.type.TypeKind.VOID; import java.lang.annotation.Annotation; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; @@ -50,6 +51,7 @@ /** Helperclass for extrating specific information from existing classes. */ public final class JavaLangAnalyser { + private static final List PREFERRED_FACTORY_NAMES = List.of("create", "of"); private static final String PARAM_TAG = "@param "; private static final String DEPRECATED_TAG = "@deprecated"; @@ -270,6 +272,78 @@ public static boolean hasBuildMethodReturning( && method.getReturnType().toString().equals(expectedReturnType)); } + /** + * Finds a static parameterless factory method on the given type returning the type itself, e.g. + * {@code public static B create()}. Factories named {@code create} or {@code of} are preferred + * over other matching methods. + * + *

    Only methods accessible from the generated builder's package qualify. This is part of the + * builder contract and provides an instantiation path alongside the no-arg constructor. + * + * @param builderType the candidate builder type element to inspect + * @param context the processing context, used to access all members + * @return the factory method name, or empty if none is accessible + */ + public static Optional findStaticFactoryReturning( + TypeElement builderType, ProcessingContext context) { + if (builderType == null) { + return Optional.empty(); + } + return findStaticFactory(builderType, 0, null, context); + } + + /** + * Finds a static factory method on the given type accepting the expected type and returning the + * builder type, e.g. {@code public static B create(T value)}. Factories named {@code create} or + * {@code of} are preferred over other matching methods. + * + *

    Only methods accessible from the generated builder's package qualify. This is part of the + * builder contract and provides the copy path alongside the constructor accepting the type. + * + * @param builderType the candidate builder type element to inspect + * @param expectedParameterType the qualified name of the type the factory must accept + * @param context the processing context, used to access all members + * @return the factory method name, or empty if none is accessible + */ + public static Optional findStaticFactoryAccepting( + TypeElement builderType, String expectedParameterType, ProcessingContext context) { + if (builderType == null) { + return Optional.empty(); + } + return findStaticFactory(builderType, 1, expectedParameterType, context); + } + + private static Optional findStaticFactory( + TypeElement builderType, + int parameterCount, + String expectedParameterType, + ProcessingContext context) { + String builderTypeName = builderType.getQualifiedName().toString(); + return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream() + .filter(context::isMemberAccessibleFromBuilderPackage) + .filter(method -> method.getModifiers().contains(STATIC)) + .filter(method -> method.getReturnType().toString().equals(builderTypeName)) + .filter( + method -> + method.getParameters().size() == parameterCount + && (expectedParameterType == null + || method + .getParameters() + .get(0) + .asType() + .toString() + .equals(expectedParameterType))) + .map(method -> method.getSimpleName().toString()) + .min( + Comparator.comparingInt(JavaLangAnalyser::factoryNameRank) + .thenComparing(Comparator.naturalOrder())); + } + + private static int factoryNameRank(String name) { + int index = PREFERRED_FACTORY_NAMES.indexOf(name); + return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index; + } + /** * Checks whether the given type has a constructor accepting the expected type. This is part of * the builder contract used when the field already has a value that is passed to the builder. 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..b8ef2cd6 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 @@ -177,7 +177,7 @@ private static void setBuilderTypeIfScopeMatches( context .getBuilderScopeResolver() .resolveUsableBuilderType(typeElement) - .ifPresent(typeName::setBuilderType); + .ifPresent(typeName::setResolvedBuilder); } /** @@ -228,7 +228,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..28d01ce5 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,15 @@ 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( + instantiationCode(fieldBuilder.copyFactoryMethod(), existingValueConstructorArgs), + instantiationCode(fieldBuilder.emptyFactoryMethod(), emptyConstructorArgs))); methodDto.addArgument("fieldName", field.getFieldNameInBuilder()); methodDto.addArgument("dtoMethodParam", parameter.getParameterName()); methodDto.addArgument("helperType", fieldBuilderType); @@ -320,7 +325,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,19 +334,29 @@ 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 factory + // method when present, the constructor reference otherwise + String elementSupplier = + "$elementBuilderType:T::" + elementBuilder.emptyFactoryMethod().orElse("new"); 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); } + private static String instantiationCode(Optional factoryMethod, String arguments) { + return factoryMethod + .map(name -> "$helperType:T." + name + "(" + arguments + ")") + .orElse("new $helperType:T(" + arguments + ")"); + } + /** * Creates a simple field consumer method that accepts a Consumer for the field value. * 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..4491f135 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/ResolvedBuilder.java @@ -0,0 +1,46 @@ +/* + * 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; + +import java.util.Optional; + +/** + * A builder type resolved for a referenced field or element type, together with the instantiation + * paths generated code must use to call it. + * + *

    {@code emptyFactoryMethod} and {@code copyFactoryMethod} carry the name of a static factory on + * the builder (e.g. {@code create} or {@code of}) when the contract check found one. When a + * component is empty, generated code instantiates the builder through its constructor instead - the + * builder contract guarantees the matching constructor exists then. + * + * @param typeName the builder type to reference + * @param emptyFactoryMethod static parameterless factory returning the builder, or empty to + * instantiate via {@code new B()} + * @param copyFactoryMethod static factory accepting the built type, or empty to instantiate via + * {@code new B(value)} + */ +public record ResolvedBuilder( + TypeName typeName, Optional emptyFactoryMethod, Optional copyFactoryMethod) { + + /** + * A resolution instantiated exclusively through constructors. + * + * @param typeName the builder type to reference + */ + public ResolvedBuilder(TypeName typeName) { + this(typeName, Optional.empty(), Optional.empty()); + } +} 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..8ddbfb8c 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 @@ -39,6 +39,7 @@ 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.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 +72,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 +127,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 +156,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 +214,37 @@ 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", resolved.emptyFactoryMethod().get()); + assertEquals("of", resolved.copyFactoryMethod().get()); + } + @Test void resolverUsageScope_PackagePrivateMembers_AccessibleOnlyFromSamePackage() { ResolverProbeProcessor.reset(); @@ -238,7 +272,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 +303,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 +334,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; From e4ebbacc0726c86435be1d01f480d42e031e19a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:48:11 +0000 Subject: [PATCH 02/13] Regenerate committed example builders for factory instantiation --- .../javahelpers/simple/builders/example/PersonDtoBuilder.java | 2 +- .../simple/builders/example/scoping/ScopedOwnerDtoBuilder.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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; From c9171d34a4c237f43ae8a5aee9eec6aa12684c4e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:56:38 +0000 Subject: [PATCH 03/13] Make the resolved instantiation path explicit via BuilderInstantiation ResolvedBuilder no longer carries factory method names as Optional where absence silently meant 'use the constructor'. The instantiation decision the contract check made is now recorded explicitly as a sealed BuilderInstantiation - ConstructorCall or StaticFactoryCall(methodName) - and generation sites render exactly that path for call sites and method references. --- .../analysis/BuilderScopeResolver.java | 17 +++- .../generators/util/MethodGeneratorUtil.java | 20 +++-- .../model/type/BuilderInstantiation.java | 77 +++++++++++++++++++ .../processor/model/type/ResolvedBuilder.java | 25 +++--- .../processor/BuilderScopeResolverTest.java | 13 +++- 5 files changed, 125 insertions(+), 27 deletions(-) create mode 100644 processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java 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 0c996f12..8b3b8c7d 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 @@ -31,6 +31,7 @@ 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; @@ -199,7 +200,11 @@ private Optional resolve(TypeElement referencedType) { // 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, Optional.of("create"), Optional.empty())); + 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 @@ -248,7 +253,15 @@ private Optional resolveByBuilderContract( if (!JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { return Optional.empty(); } - return Optional.of(new ResolvedBuilder(candidate, emptyFactory, copyFactory)); + return Optional.of( + new ResolvedBuilder( + candidate, + emptyFactory + .map(BuilderInstantiation.StaticFactoryCall::new) + .orElseGet(BuilderInstantiation.ConstructorCall::new), + copyFactory + .map(BuilderInstantiation.StaticFactoryCall::new) + .orElseGet(BuilderInstantiation.ConstructorCall::new))); } private void refreshForConfigurationIfNeeded() { 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 28d01ce5..60a5e698 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 @@ -244,8 +244,12 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( return this; """ .formatted( - instantiationCode(fieldBuilder.copyFactoryMethod(), existingValueConstructorArgs), - instantiationCode(fieldBuilder.emptyFactoryMethod(), emptyConstructorArgs))); + fieldBuilder + .copyInstantiation() + .instantiationCode("$helperType:T", existingValueConstructorArgs), + fieldBuilder + .emptyInstantiation() + .instantiationCode("$helperType:T", emptyConstructorArgs))); methodDto.addArgument("fieldName", field.getFieldNameInBuilder()); methodDto.addArgument("dtoMethodParam", parameter.getParameterName()); methodDto.addArgument("helperType", fieldBuilderType); @@ -337,10 +341,10 @@ public static BuilderMethodDto createFieldConsumerWithElementBuilders( ResolvedBuilder elementBuilder, TypeName returnBuilderType, ProcessingContext context) { - // The collection builder receives a supplier for element builders: the resolved factory - // method when present, the constructor reference otherwise + // The collection builder receives a supplier for element builders: the resolved + // instantiation path as a method reference String elementSupplier = - "$elementBuilderType:T::" + elementBuilder.emptyFactoryMethod().orElse("new"); + elementBuilder.emptyInstantiation().methodReference("$elementBuilderType:T"); return createFieldConsumerWithBuilder( field, new ResolvedBuilder(collectionBuilderType), @@ -351,12 +355,6 @@ public static BuilderMethodDto createFieldConsumerWithElementBuilders( context); } - private static String instantiationCode(Optional factoryMethod, String arguments) { - return factoryMethod - .map(name -> "$helperType:T." + name + "(" + arguments + ")") - .orElse("new $helperType:T(" + arguments + ")"); - } - /** * Creates a simple field consumer method that accepts a Consumer for the field value. * 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 index 4491f135..d0d5a9f0 100644 --- 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 @@ -15,25 +15,23 @@ */ package org.javahelpers.simple.builders.processor.model.type; -import java.util.Optional; - /** * A builder type resolved for a referenced field or element type, together with the instantiation * paths generated code must use to call it. * - *

    {@code emptyFactoryMethod} and {@code copyFactoryMethod} carry the name of a static factory on - * the builder (e.g. {@code create} or {@code of}) when the contract check found one. When a - * component is empty, generated code instantiates the builder through its constructor instead - the - * builder contract guarantees the matching constructor exists then. + *

    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 emptyFactoryMethod static parameterless factory returning the builder, or empty to - * instantiate via {@code new B()} - * @param copyFactoryMethod static factory accepting the built type, or empty to instantiate via - * {@code new B(value)} + * @param emptyInstantiation how to obtain an empty builder instance (e.g. {@code new B()} or {@code + * B.create()}) + * @param copyInstantiation 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, Optional emptyFactoryMethod, Optional copyFactoryMethod) { + TypeName typeName, + BuilderInstantiation emptyInstantiation, + BuilderInstantiation copyInstantiation) { /** * A resolution instantiated exclusively through constructors. @@ -41,6 +39,9 @@ public record ResolvedBuilder( * @param typeName the builder type to reference */ public ResolvedBuilder(TypeName typeName) { - this(typeName, Optional.empty(), Optional.empty()); + this( + typeName, + new BuilderInstantiation.ConstructorCall(), + new BuilderInstantiation.ConstructorCall()); } } 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 8ddbfb8c..e7bbcc83 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,7 @@ 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; @@ -241,8 +243,15 @@ private LibHelperBuilder() {} // No accessible constructors: both instantiation paths come from the static factories ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); assertEquals("lib.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); - assertEquals("create", resolved.emptyFactoryMethod().get()); - assertEquals("of", resolved.copyFactoryMethod().get()); + assertEquals( + "create", + assertInstanceOf( + BuilderInstantiation.StaticFactoryCall.class, resolved.emptyInstantiation()) + .methodName()); + assertEquals( + "of", + assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, resolved.copyInstantiation()) + .methodName()); } @Test From 299d12e9f9c30aadac9ad53bbcab10690884b5c5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:41:28 +0000 Subject: [PATCH 04/13] Split contract resolution into per-path finders and rename for clarity resolveByBuilderContract now reads as find-and-assign: resolving the empty and prefilled instantiation paths is delegated to resolveFuncForEmptyBuilder and resolveFuncForPrefilledBuilder, and the ResolvedBuilder components are renamed accordingly. findStaticFactory drops the (parameterCount, singleExpectedType) asymmetry for a List of expected parameter types matched by a hasParameters helper - the same shape for zero, one, or any arity. --- .../analysis/BuilderScopeResolver.java | 66 +++++++++++++------ .../processor/analysis/JavaLangAnalyser.java | 34 +++++----- .../generators/util/MethodGeneratorUtil.java | 6 +- .../processor/model/type/ResolvedBuilder.java | 12 ++-- .../processor/BuilderScopeResolverTest.java | 5 +- 5 files changed, 76 insertions(+), 47 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 8b3b8c7d..ac07baab 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 @@ -238,30 +238,56 @@ private Optional resolveByBuilderContract( if (builderTypeElement == null) { return Optional.empty(); } - Optional emptyFactory = - JavaLangAnalyser.findStaticFactoryReturning(builderTypeElement, context); - if (emptyFactory.isEmpty() - && !JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { + Optional funcForEmptyBuilder = + resolveFuncForEmptyBuilder(builderTypeElement); + Optional funcForPrefilledBuilder = + resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); + if (funcForEmptyBuilder.isEmpty() + || funcForPrefilledBuilder.isEmpty() + || !JavaLangAnalyser.hasBuildMethodReturning(builderTypeElement, expectedType, context)) { return Optional.empty(); } - Optional copyFactory = - JavaLangAnalyser.findStaticFactoryAccepting(builderTypeElement, expectedType, context); - if (copyFactory.isEmpty() - && !JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, 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 = + JavaLangAnalyser.findStaticFactoryReturning(builderTypeElement, context) + .map(BuilderInstantiation.StaticFactoryCall::new); + 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 qualified name of 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, String expectedType) { + Optional func = + JavaLangAnalyser.findStaticFactoryAccepting(builderTypeElement, expectedType, context) + .map(BuilderInstantiation.StaticFactoryCall::new); + if (func.isEmpty() + && JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { + func = Optional.of(new BuilderInstantiation.ConstructorCall()); } - return Optional.of( - new ResolvedBuilder( - candidate, - emptyFactory - .map(BuilderInstantiation.StaticFactoryCall::new) - .orElseGet(BuilderInstantiation.ConstructorCall::new), - copyFactory - .map(BuilderInstantiation.StaticFactoryCall::new) - .orElseGet(BuilderInstantiation.ConstructorCall::new))); + return func; } 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 0be24bef..464f08e2 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 @@ -289,7 +289,7 @@ public static Optional findStaticFactoryReturning( if (builderType == null) { return Optional.empty(); } - return findStaticFactory(builderType, 0, null, context); + return findStaticFactory(builderType, List.of(), context); } /** @@ -310,35 +310,37 @@ public static Optional findStaticFactoryAccepting( if (builderType == null) { return Optional.empty(); } - return findStaticFactory(builderType, 1, expectedParameterType, context); + return findStaticFactory(builderType, List.of(expectedParameterType), context); } private static Optional findStaticFactory( - TypeElement builderType, - int parameterCount, - String expectedParameterType, - ProcessingContext context) { + TypeElement builderType, List expectedParameterTypes, ProcessingContext context) { String builderTypeName = builderType.getQualifiedName().toString(); return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) .filter(method -> method.getModifiers().contains(STATIC)) .filter(method -> method.getReturnType().toString().equals(builderTypeName)) - .filter( - method -> - method.getParameters().size() == parameterCount - && (expectedParameterType == null - || method - .getParameters() - .get(0) - .asType() - .toString() - .equals(expectedParameterType))) + .filter(method -> hasParameters(method, expectedParameterTypes)) .map(method -> method.getSimpleName().toString()) .min( Comparator.comparingInt(JavaLangAnalyser::factoryNameRank) .thenComparing(Comparator.naturalOrder())); } + 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 (!parameters.get(i).asType().toString().equals(expectedParameterTypes.get(i))) { + return false; + } + } + return true; + } + private static int factoryNameRank(String name) { int index = PREFERRED_FACTORY_NAMES.indexOf(name); return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index; 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 60a5e698..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 @@ -245,10 +245,10 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( """ .formatted( fieldBuilder - .copyInstantiation() + .funcForPrefilledBuilder() .instantiationCode("$helperType:T", existingValueConstructorArgs), fieldBuilder - .emptyInstantiation() + .funcForEmptyBuilder() .instantiationCode("$helperType:T", emptyConstructorArgs))); methodDto.addArgument("fieldName", field.getFieldNameInBuilder()); methodDto.addArgument("dtoMethodParam", parameter.getParameterName()); @@ -344,7 +344,7 @@ public static BuilderMethodDto createFieldConsumerWithElementBuilders( // The collection builder receives a supplier for element builders: the resolved // instantiation path as a method reference String elementSupplier = - elementBuilder.emptyInstantiation().methodReference("$elementBuilderType:T"); + elementBuilder.funcForEmptyBuilder().methodReference("$elementBuilderType:T"); return createFieldConsumerWithBuilder( field, new ResolvedBuilder(collectionBuilderType), 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 index d0d5a9f0..b96fdeda 100644 --- 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 @@ -23,15 +23,15 @@ * call or static factory call - so generation sites emit exactly what was resolved. * * @param typeName the builder type to reference - * @param emptyInstantiation how to obtain an empty builder instance (e.g. {@code new B()} or {@code - * B.create()}) - * @param copyInstantiation how to obtain a builder instance seeded with a value (e.g. {@code new - * B(value)} or {@code B.of(value)}) + * @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 emptyInstantiation, - BuilderInstantiation copyInstantiation) { + BuilderInstantiation funcForEmptyBuilder, + BuilderInstantiation funcForPrefilledBuilder) { /** * A resolution instantiated exclusively through constructors. 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 e7bbcc83..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 @@ -246,11 +246,12 @@ private LibHelperBuilder() {} assertEquals( "create", assertInstanceOf( - BuilderInstantiation.StaticFactoryCall.class, resolved.emptyInstantiation()) + BuilderInstantiation.StaticFactoryCall.class, resolved.funcForEmptyBuilder()) .methodName()); assertEquals( "of", - assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, resolved.copyInstantiation()) + assertInstanceOf( + BuilderInstantiation.StaticFactoryCall.class, resolved.funcForPrefilledBuilder()) .methodName()); } From 17bc2585dfdb225a2bc14b56a5b7691d160db70c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:59:07 +0000 Subject: [PATCH 05/13] Move static factory lookup into StaticFactoryCall finders The resolver no longer composes a generic find with preferred-name selection: StaticFactoryCall.forEmptyBuilder and forPrefilledBuilder encapsulate the classpath lookup and the create/of preference, so resolveFuncForEmptyBuilder/resolveFuncForPrefilledBuilder read as a single find per path before the constructor check. --- .../analysis/BuilderScopeResolver.java | 7 ++- .../model/type/BuilderInstantiation.java | 54 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index ac07baab..5014b606 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 @@ -261,8 +261,7 @@ private Optional resolveByBuilderContract( private Optional resolveFuncForEmptyBuilder( TypeElement builderTypeElement) { Optional func = - JavaLangAnalyser.findStaticFactoryReturning(builderTypeElement, context) - .map(BuilderInstantiation.StaticFactoryCall::new); + BuilderInstantiation.StaticFactoryCall.forEmptyBuilder(builderTypeElement, context); if (func.isEmpty() && JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { func = Optional.of(new BuilderInstantiation.ConstructorCall()); } @@ -281,8 +280,8 @@ private Optional resolveFuncForEmptyBuilder( private Optional resolveFuncForPrefilledBuilder( TypeElement builderTypeElement, String expectedType) { Optional func = - JavaLangAnalyser.findStaticFactoryAccepting(builderTypeElement, expectedType, context) - .map(BuilderInstantiation.StaticFactoryCall::new); + BuilderInstantiation.StaticFactoryCall.forPrefilledBuilder( + builderTypeElement, expectedType, context); if (func.isEmpty() && JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { func = Optional.of(new BuilderInstantiation.ConstructorCall()); 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..ad110f3b 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 @@ -15,6 +15,13 @@ */ package org.javahelpers.simple.builders.processor.model.type; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import javax.lang.model.element.TypeElement; +import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; +import org.javahelpers.simple.builders.processor.processing.ProcessingContext; + /** * The instantiation path generated code uses to obtain an instance of a resolved builder. * @@ -64,6 +71,53 @@ public String methodReference(String builderTypeExpression) { * @param methodName name of the static factory method (e.g. {@code create}, {@code of}) */ record StaticFactoryCall(String methodName) implements BuilderInstantiation { + + // Factory method names preferred when a builder offers several candidates + private static final List PREFERRED_NAMES = List.of("create", "of"); + + /** + * Finds the static factory creating an empty builder instance: an accessible static + * parameterless function on the builder type returning the builder type. + * + * @param builderType the builder type element to inspect + * @param context the processing context, used to access all members + * @return the instantiation calling the found factory, or empty when none exists + */ + public static Optional forEmptyBuilder( + TypeElement builderType, ProcessingContext context) { + return find(builderType, List.of(), context); + } + + /** + * Finds the static factory creating a builder instance prefilled with a value of the referenced + * type: an accessible static function accepting the type and returning the builder type. + * + * @param builderType the builder type element to inspect + * @param expectedType the qualified name of the referenced type the factory must accept + * @param context the processing context, used to access all members + * @return the instantiation calling the found factory, or empty when none exists + */ + public static Optional forPrefilledBuilder( + TypeElement builderType, String expectedType, ProcessingContext context) { + return find(builderType, List.of(expectedType), context); + } + + private static Optional find( + TypeElement builderType, List parameterTypes, ProcessingContext context) { + return JavaLangAnalyser.findStaticFunction( + builderType, parameterTypes, builderType.getQualifiedName().toString(), context) + .stream() + .min( + Comparator.comparingInt(StaticFactoryCall::nameRank) + .thenComparing(Comparator.naturalOrder())) + .map(StaticFactoryCall::new); + } + + private static int nameRank(String name) { + int index = PREFERRED_NAMES.indexOf(name); + return index < 0 ? PREFERRED_NAMES.size() : index; + } + @Override public String instantiationCode(String builderTypeExpression, String arguments) { return builderTypeExpression + "." + methodName + "(" + arguments + ")"; From 5bc4eb52db6001f4872c71c4bf7bb6b3df29b238 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:59:07 +0000 Subject: [PATCH 06/13] Consolidate member scanning on findFunctionsBySignature One shared scan - accessible members filtered by parameter types and return type - now backs findStaticFunction (names of static matches, expectedReturnType null meaning void) and hasBuildMethodReturning (name 'build' additionally). hasParameters also serves both constructor checks, dropping the duplicated size/first-param comparisons. --- .../processor/analysis/JavaLangAnalyser.java | 116 ++++++++---------- 1 file changed, 51 insertions(+), 65 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 464f08e2..7c27402a 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 @@ -29,10 +29,10 @@ import static javax.lang.model.type.TypeKind.VOID; import java.lang.annotation.Annotation; -import java.util.Comparator; 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; @@ -51,7 +51,6 @@ /** Helperclass for extrating specific information from existing classes. */ public final class JavaLangAnalyser { - private static final List PREFERRED_FACTORY_NAMES = List.of("create", "of"); private static final String PARAM_TAG = "@param "; private static final String DEPRECATED_TAG = "@deprecated"; @@ -193,11 +192,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(constructor -> hasParameters(constructor, List.of())); } /** @@ -259,72 +256,69 @@ public static Optional findAnnotationAttribute( */ public static boolean hasBuildMethodReturning( TypeElement builderType, String expectedReturnType, ProcessingContext context) { - if (builderType == null) { - return false; - } - return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream() - .filter(context::isMemberAccessibleFromBuilderPackage) - .anyMatch( - method -> - method.getSimpleName().contentEquals("build") - && method.getParameters().isEmpty() - && method.getReturnType().getKind() != VOID - && method.getReturnType().toString().equals(expectedReturnType)); + return findFunctionsBySignature(builderType, List.of(), expectedReturnType, context).stream() + .anyMatch(method -> method.getSimpleName().contentEquals("build")); } /** - * Finds a static parameterless factory method on the given type returning the type itself, e.g. - * {@code public static B create()}. Factories named {@code create} or {@code of} are preferred - * over other matching methods. - * - *

    Only methods accessible from the generated builder's package qualify. This is part of the - * builder contract and provides an instantiation path alongside the no-arg constructor. + * Finds accessible static functions on the given type taking a single parameter of the expected + * type and returning the expected return type. * - * @param builderType the candidate builder type element to inspect + * @param type the type element to inspect + * @param expectedParameterType the qualified name of the required parameter type + * @param expectedReturnType the qualified name of the required return type, or {@code null} for + * void-returning functions * @param context the processing context, used to access all members - * @return the factory method name, or empty if none is accessible + * @return the names of all matching functions, in declaration order */ - public static Optional findStaticFactoryReturning( - TypeElement builderType, ProcessingContext context) { - if (builderType == null) { - return Optional.empty(); - } - return findStaticFactory(builderType, List.of(), context); + public static List findStaticFunction( + TypeElement type, + String expectedParameterType, + String expectedReturnType, + ProcessingContext context) { + return findStaticFunction(type, List.of(expectedParameterType), expectedReturnType, context); } /** - * Finds a static factory method on the given type accepting the expected type and returning the - * builder type, e.g. {@code public static B create(T value)}. Factories named {@code create} or - * {@code of} are preferred over other matching methods. - * - *

    Only methods accessible from the generated builder's package qualify. This is part of the - * builder contract and provides the copy path alongside the constructor accepting the type. + * Finds accessible static functions on the given type taking the expected parameter types and + * returning the expected return type. * - * @param builderType the candidate builder type element to inspect - * @param expectedParameterType the qualified name of the type the factory must accept + * @param type the type element to inspect + * @param expectedParameterTypes qualified names of the required parameter types + * @param expectedReturnType the qualified name of the required return type, or {@code null} for + * void-returning functions * @param context the processing context, used to access all members - * @return the factory method name, or empty if none is accessible + * @return the names of all matching functions, in declaration order */ - public static Optional findStaticFactoryAccepting( - TypeElement builderType, String expectedParameterType, ProcessingContext context) { - if (builderType == null) { - return Optional.empty(); - } - return findStaticFactory(builderType, List.of(expectedParameterType), context); + public static List findStaticFunction( + TypeElement type, + List expectedParameterTypes, + String expectedReturnType, + ProcessingContext context) { + return findFunctionsBySignature(type, expectedParameterTypes, expectedReturnType, context) + .stream() + .filter(Predicate.not(JavaLangAnalyser::isNotStatic)) + .map(method -> method.getSimpleName().toString()) + .toList(); } - private static Optional findStaticFactory( - TypeElement builderType, List expectedParameterTypes, ProcessingContext context) { - String builderTypeName = builderType.getQualifiedName().toString(); - return ElementFilter.methodsIn(context.getAllMembers(builderType)).stream() + private static List findFunctionsBySignature( + TypeElement type, + List expectedParameterTypes, + String expectedReturnType, + ProcessingContext context) { + if (type == null) { + return List.of(); + } + return ElementFilter.methodsIn(context.getAllMembers(type)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) - .filter(method -> method.getModifiers().contains(STATIC)) - .filter(method -> method.getReturnType().toString().equals(builderTypeName)) + .filter( + method -> + expectedReturnType == null + ? method.getReturnType().getKind() == VOID + : method.getReturnType().toString().equals(expectedReturnType)) .filter(method -> hasParameters(method, expectedParameterTypes)) - .map(method -> method.getSimpleName().toString()) - .min( - Comparator.comparingInt(JavaLangAnalyser::factoryNameRank) - .thenComparing(Comparator.naturalOrder())); + .toList(); } private static boolean hasParameters( @@ -341,11 +335,6 @@ private static boolean hasParameters( return true; } - private static int factoryNameRank(String name) { - int index = PREFERRED_FACTORY_NAMES.indexOf(name); - return index < 0 ? PREFERRED_FACTORY_NAMES.size() : index; - } - /** * Checks whether the given type has a constructor accepting the expected type. This is part of * the builder contract used when the field already has a value that is passed to the builder. @@ -362,10 +351,7 @@ public static boolean hasConstructorAccepting( } 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))); } /** From 5260985963e86f1cd4600bd2f6522073caa74951 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:14:49 +0000 Subject: [PATCH 07/13] Add hasNoParameters helper for the no-arg checks --- .../builders/processor/analysis/JavaLangAnalyser.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 7c27402a..c34c88c1 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 @@ -194,7 +194,7 @@ public static boolean isSetterForField(ExecutableElement mth) { public static boolean hasEmptyConstructor(TypeElement typeElement, ProcessingContext context) { return ElementFilter.constructorsIn(context.getAllMembers(typeElement)).stream() .filter(context::isMemberAccessibleFromBuilderPackage) - .anyMatch(constructor -> hasParameters(constructor, List.of())); + .anyMatch(JavaLangAnalyser::hasNoParameters); } /** @@ -321,6 +321,10 @@ private static List findFunctionsBySignature( .toList(); } + private static boolean hasNoParameters(ExecutableElement method) { + return method.getParameters().isEmpty(); + } + private static boolean hasParameters( ExecutableElement method, List expectedParameterTypes) { List parameters = method.getParameters(); From 8df0841e3d7af31cef7a6160ec833546433c533f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:18:52 +0000 Subject: [PATCH 08/13] Type signature checks with TypeName and rename to findMethods/findMethodsStatic Expected parameter and return types in the member-scanning API are now TypeName instead of qualified-name strings; the resolver carries the referenced type as TypeName end to end. findStaticFunction and findFunctionsBySignature are renamed to findMethodsStatic and findMethods to match the method-finding vocabulary. --- .../analysis/BuilderScopeResolver.java | 18 +++-- .../processor/analysis/JavaLangAnalyser.java | 65 ++++++++++--------- .../model/type/BuilderInstantiation.java | 14 ++-- 3 files changed, 52 insertions(+), 45 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 5014b606..57a40486 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 @@ -180,8 +180,9 @@ private Optional resolve(TypeElement referencedType) { return Optional.empty(); } - String referencedTypeFqn = referencedType.getQualifiedName().toString(); String packageName = context.getPackageName(referencedType); + TypeName referencedTypeName = + new TypeName(packageName, referencedType.getSimpleName().toString()); // 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 @@ -193,9 +194,7 @@ 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()) { // Our generators always emit a static create() and no create(T) - the empty path uses // the factory, the copy path the constructor @@ -212,7 +211,7 @@ 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); } /** @@ -227,13 +226,12 @@ private Optional resolve(TypeElement referencedType) { * 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 + * @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) { + TypeName candidate, TypeName expectedType) { TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName()); if (builderTypeElement == null) { return Optional.empty(); @@ -274,11 +272,11 @@ private Optional resolveFuncForEmptyBuilder( * accepting the type otherwise. * * @param builderTypeElement the candidate builder type to inspect - * @param expectedType the qualified name of the referenced type to seed the builder with + * @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, String expectedType) { + TypeElement builderTypeElement, TypeName expectedType) { Optional func = BuilderInstantiation.StaticFactoryCall.forPrefilledBuilder( builderTypeElement, expectedType, context); 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 c34c88c1..572ebcee 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 @@ -46,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. */ @@ -250,62 +251,59 @@ 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) { - return findFunctionsBySignature(builderType, List.of(), expectedReturnType, context).stream() + TypeElement builderType, TypeName expectedReturnType, ProcessingContext context) { + return findMethods(builderType, List.of(), expectedReturnType, context).stream() .anyMatch(method -> method.getSimpleName().contentEquals("build")); } /** - * Finds accessible static functions on the given type taking a single parameter of the expected + * Finds accessible static methods on the given type taking a single parameter of the expected * type and returning the expected return type. * * @param type the type element to inspect - * @param expectedParameterType the qualified name of the required parameter type - * @param expectedReturnType the qualified name of the required return type, or {@code null} for - * void-returning functions + * @param expectedParameterType the required parameter type + * @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 functions, in declaration order + * @return the names of all matching methods, in declaration order */ - public static List findStaticFunction( + public static List findMethodsStatic( TypeElement type, - String expectedParameterType, - String expectedReturnType, + TypeName expectedParameterType, + TypeName expectedReturnType, ProcessingContext context) { - return findStaticFunction(type, List.of(expectedParameterType), expectedReturnType, context); + return findMethodsStatic(type, List.of(expectedParameterType), expectedReturnType, context); } /** - * Finds accessible static functions on the given type taking the expected parameter types and + * 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 qualified names of the required parameter types - * @param expectedReturnType the qualified name of the required return type, or {@code null} for - * void-returning functions + * @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 functions, in declaration order + * @return the names of all matching methods, in declaration order */ - public static List findStaticFunction( + public static List findMethodsStatic( TypeElement type, - List expectedParameterTypes, - String expectedReturnType, + List expectedParameterTypes, + TypeName expectedReturnType, ProcessingContext context) { - return findFunctionsBySignature(type, expectedParameterTypes, expectedReturnType, context) - .stream() + return findMethods(type, expectedParameterTypes, expectedReturnType, context).stream() .filter(Predicate.not(JavaLangAnalyser::isNotStatic)) .map(method -> method.getSimpleName().toString()) .toList(); } - private static List findFunctionsBySignature( + private static List findMethods( TypeElement type, - List expectedParameterTypes, - String expectedReturnType, + List expectedParameterTypes, + TypeName expectedReturnType, ProcessingContext context) { if (type == null) { return List.of(); @@ -316,7 +314,10 @@ private static List findFunctionsBySignature( method -> expectedReturnType == null ? method.getReturnType().getKind() == VOID - : method.getReturnType().toString().equals(expectedReturnType)) + : method + .getReturnType() + .toString() + .equals(expectedReturnType.getFullQualifiedName())) .filter(method -> hasParameters(method, expectedParameterTypes)) .toList(); } @@ -326,13 +327,17 @@ private static boolean hasNoParameters(ExecutableElement method) { } private static boolean hasParameters( - ExecutableElement method, List expectedParameterTypes) { + ExecutableElement method, List expectedParameterTypes) { List parameters = method.getParameters(); if (parameters.size() != expectedParameterTypes.size()) { return false; } for (int i = 0; i < parameters.size(); i++) { - if (!parameters.get(i).asType().toString().equals(expectedParameterTypes.get(i))) { + if (!parameters + .get(i) + .asType() + .toString() + .equals(expectedParameterTypes.get(i).getFullQualifiedName())) { return false; } } @@ -344,12 +349,12 @@ private static boolean hasParameters( * 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; } 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 ad110f3b..26471c97 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 @@ -93,19 +93,23 @@ public static Optional forEmptyBuilder( * type: an accessible static function accepting the type and returning the builder type. * * @param builderType the builder type element to inspect - * @param expectedType the qualified name of the referenced type the factory must accept + * @param expectedType the referenced type the factory must accept * @param context the processing context, used to access all members * @return the instantiation calling the found factory, or empty when none exists */ public static Optional forPrefilledBuilder( - TypeElement builderType, String expectedType, ProcessingContext context) { + TypeElement builderType, TypeName expectedType, ProcessingContext context) { return find(builderType, List.of(expectedType), context); } private static Optional find( - TypeElement builderType, List parameterTypes, ProcessingContext context) { - return JavaLangAnalyser.findStaticFunction( - builderType, parameterTypes, builderType.getQualifiedName().toString(), context) + TypeElement builderType, List parameterTypes, ProcessingContext context) { + return JavaLangAnalyser.findMethodsStatic( + builderType, + parameterTypes, + new TypeName( + context.getPackageName(builderType), builderType.getSimpleName().toString()), + context) .stream() .min( Comparator.comparingInt(StaticFactoryCall::nameRank) From 6d19de7ce22da8de7bbf29c94b0e74027c95f860 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:19:52 +0000 Subject: [PATCH 09/13] Keep analyser lookups out of the model value types The factory-candidate lookup and create/of preference live in BuilderScopeResolver.findStaticFactoryCall again; StaticFactoryCall is back to a pure methodName value holder with no JavaLangAnalyser or ProcessingContext dependency. --- .../analysis/BuilderScopeResolver.java | 40 +++++++++++-- .../model/type/BuilderInstantiation.java | 58 ------------------- 2 files changed, 36 insertions(+), 62 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 57a40486..1a9a555d 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; @@ -57,6 +59,9 @@ 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(); @@ -258,8 +263,7 @@ private Optional resolveByBuilderContract( */ private Optional resolveFuncForEmptyBuilder( TypeElement builderTypeElement) { - Optional func = - BuilderInstantiation.StaticFactoryCall.forEmptyBuilder(builderTypeElement, context); + Optional func = findStaticFactoryCall(builderTypeElement, List.of()); if (func.isEmpty() && JavaLangAnalyser.hasEmptyConstructor(builderTypeElement, context)) { func = Optional.of(new BuilderInstantiation.ConstructorCall()); } @@ -278,8 +282,7 @@ private Optional resolveFuncForEmptyBuilder( private Optional resolveFuncForPrefilledBuilder( TypeElement builderTypeElement, TypeName expectedType) { Optional func = - BuilderInstantiation.StaticFactoryCall.forPrefilledBuilder( - builderTypeElement, expectedType, context); + findStaticFactoryCall(builderTypeElement, List.of(expectedType)); if (func.isEmpty() && JavaLangAnalyser.hasConstructorAccepting(builderTypeElement, expectedType, context)) { func = Optional.of(new BuilderInstantiation.ConstructorCall()); @@ -287,6 +290,35 @@ private Optional resolveFuncForPrefilledBuilder( 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 = + new TypeName( + context.getPackageName(builderTypeElement), + builderTypeElement.getSimpleName().toString()); + 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() { BuilderConfiguration configuration = context.getConfiguration(); ResolutionInputs inputs = new ResolutionInputs(configuration, context.getBuilderPackageName()); 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 26471c97..295f0ecc 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 @@ -15,13 +15,6 @@ */ package org.javahelpers.simple.builders.processor.model.type; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; -import javax.lang.model.element.TypeElement; -import org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser; -import org.javahelpers.simple.builders.processor.processing.ProcessingContext; - /** * The instantiation path generated code uses to obtain an instance of a resolved builder. * @@ -71,57 +64,6 @@ public String methodReference(String builderTypeExpression) { * @param methodName name of the static factory method (e.g. {@code create}, {@code of}) */ record StaticFactoryCall(String methodName) implements BuilderInstantiation { - - // Factory method names preferred when a builder offers several candidates - private static final List PREFERRED_NAMES = List.of("create", "of"); - - /** - * Finds the static factory creating an empty builder instance: an accessible static - * parameterless function on the builder type returning the builder type. - * - * @param builderType the builder type element to inspect - * @param context the processing context, used to access all members - * @return the instantiation calling the found factory, or empty when none exists - */ - public static Optional forEmptyBuilder( - TypeElement builderType, ProcessingContext context) { - return find(builderType, List.of(), context); - } - - /** - * Finds the static factory creating a builder instance prefilled with a value of the referenced - * type: an accessible static function accepting the type and returning the builder type. - * - * @param builderType the builder type element to inspect - * @param expectedType the referenced type the factory must accept - * @param context the processing context, used to access all members - * @return the instantiation calling the found factory, or empty when none exists - */ - public static Optional forPrefilledBuilder( - TypeElement builderType, TypeName expectedType, ProcessingContext context) { - return find(builderType, List.of(expectedType), context); - } - - private static Optional find( - TypeElement builderType, List parameterTypes, ProcessingContext context) { - return JavaLangAnalyser.findMethodsStatic( - builderType, - parameterTypes, - new TypeName( - context.getPackageName(builderType), builderType.getSimpleName().toString()), - context) - .stream() - .min( - Comparator.comparingInt(StaticFactoryCall::nameRank) - .thenComparing(Comparator.naturalOrder())) - .map(StaticFactoryCall::new); - } - - private static int nameRank(String name) { - int index = PREFERRED_NAMES.indexOf(name); - return index < 0 ? PREFERRED_NAMES.size() : index; - } - @Override public String instantiationCode(String builderTypeExpression, String arguments) { return builderTypeExpression + "." + methodName + "(" + arguments + ")"; From 2ba8d69cc892e7faac9bad23c96c1639d9f5f1a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:27:18 +0000 Subject: [PATCH 10/13] Map TypeElement to TypeName via JavaLangMapper.mapToTypeName A lightweight package+simple-name mapping alongside map2TypeName, which also resolves builder/constructor info and would recurse back into the resolver; both resolution sites now share the mapper. --- .../processor/analysis/BuilderScopeResolver.java | 8 ++------ .../processor/analysis/JavaLangMapper.java | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/BuilderScopeResolver.java index 1a9a555d..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 @@ -186,8 +186,7 @@ private Optional resolve(TypeElement referencedType) { } String packageName = context.getPackageName(referencedType); - TypeName referencedTypeName = - new TypeName(packageName, referencedType.getSimpleName().toString()); + 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 @@ -301,10 +300,7 @@ private Optional resolveFuncForPrefilledBuilder( */ private Optional findStaticFactoryCall( TypeElement builderTypeElement, List expectedParameterTypes) { - TypeName builderTypeName = - new TypeName( - context.getPackageName(builderTypeElement), - builderTypeElement.getSimpleName().toString()); + TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderTypeElement, context); return JavaLangAnalyser.findMethodsStatic( builderTypeElement, expectedParameterTypes, builderTypeName, context) .stream() 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 b8ef2cd6..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}. * From a3d3c5b0dcbd8ba1ba18ab3339c6ea544b39e02e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:30:15 +0000 Subject: [PATCH 11/13] Compare TypeMirror and TypeName via a single hasType helper Return-type and parameter-type checks shared the same toString-equals-fullQualifiedName comparison; both now go through hasType(TypeMirror, TypeName). --- .../processor/analysis/JavaLangAnalyser.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 572ebcee..b9668af7 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 @@ -314,10 +314,7 @@ private static List findMethods( method -> expectedReturnType == null ? method.getReturnType().getKind() == VOID - : method - .getReturnType() - .toString() - .equals(expectedReturnType.getFullQualifiedName())) + : hasType(method.getReturnType(), expectedReturnType)) .filter(method -> hasParameters(method, expectedParameterTypes)) .toList(); } @@ -333,17 +330,17 @@ private static boolean hasParameters( return false; } for (int i = 0; i < parameters.size(); i++) { - if (!parameters - .get(i) - .asType() - .toString() - .equals(expectedParameterTypes.get(i).getFullQualifiedName())) { + 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()); + } + /** * Checks whether the given type has a constructor accepting the expected type. This is part of * the builder contract used when the field already has a value that is passed to the builder. From e82f1a1e2d81218dad0160cb08ab002898cc9843 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:33:25 +0000 Subject: [PATCH 12/13] Trim javadoc on the single-parameter findMethodsStatic overload --- .../builders/processor/analysis/JavaLangAnalyser.java | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index b9668af7..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 @@ -262,14 +262,8 @@ public static boolean hasBuildMethodReturning( } /** - * Finds accessible static methods on the given type taking a single parameter of the expected - * type and returning the expected return type. - * - * @param type the type element to inspect - * @param expectedParameterType the required parameter type - * @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 + * Single-parameter variant of {@link #findMethodsStatic(TypeElement, List, TypeName, + * ProcessingContext)}. */ public static List findMethodsStatic( TypeElement type, From 2f9d3f642631369373a8b48fbbfde7d83f8eebd5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:42:36 +0000 Subject: [PATCH 13/13] Rewrap contract paragraph in CONFIGURATION.md --- docs/CONFIGURATION.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index faec7897..03d59e0f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -750,10 +750,10 @@ 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. +`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