diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java index 7ff9b14e3..a25aa35df 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleBuilder.java @@ -785,6 +785,26 @@ */ OptionState usingExistingBuilders() default OptionState.UNSET; + /** + * Prefill a reused builder that offers no value-accepting creation path by calling one of its + * field functions per readable property of the referenced type: the generated builder emits a + * private {@code prefill(T value)} method that obtains an empty instance and calls the + * field function matching each property (fluent {@code name(v)}, JavaBeans {@code setName(v)}, + * or a detected convention like {@code withName(v)}). Properties without a matching function + * stay unset; the convention is trusted once it covers the majority of the readable properties. + *
+ * When disabled, such builders still resolve but helpers that would silently drop state are + * skipped. Only consulted when {@link #usingExistingBuilders()} is enabled; the analysis + * (property enumeration plus method scan per referenced type) is a separate option because it + * adds compile cost. {@link SimpleMinimalBuilder} deactivates this option. + * + *

Default: ENABLED
+ * Compiler option: -Asimplebuilder.usingFieldFunctionPrefill + * + * @return the option state for prefilling builders through field functions + */ + OptionState usingFieldFunctionPrefill() default OptionState.UNSET; + // === Naming === /** * Suffix to append to the DTO name to generate the builder class name.
diff --git a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java index 54d4777a4..937900bb3 100644 --- a/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java +++ b/core/src/main/java/org/javahelpers/simple/builders/core/annotations/SimpleMinimalBuilder.java @@ -95,6 +95,7 @@ copyTypeAnnotations = OptionState.DISABLED, implementsBuilderBase = OptionState.DISABLED, usingExistingBuilders = OptionState.DISABLED, + usingFieldFunctionPrefill = OptionState.DISABLED, builderSuffix = "Builder", setterSuffix = "", formattingMode = "lightweight")) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1b6469dad..c2406c0b3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -762,18 +762,27 @@ same-package candidate below. Accessible nested types satisfying the contract are checked first (e.g. `Person.PersonBuilder` or `Person.Builder`), then a static parameterless factory on the type — `builder()` preferred — whose return type must be accessible and declare a no-arg `build()` method (Lombok/Immutables -style). The factory itself is the empty-instantiation path; the seeded path is -the builder's own contract path, a static `builder(T)`-style method, or an -instance method on the value like `toBuilder()`. +style). The factory itself is the empty-instantiation path; the prefilled path is +the builder's own contract path, a static `builder(T)`-style method, an +instance method on the value like `toBuilder()`, or — with +`usingFieldFunctionPrefill` enabled — prefilling by field functions. The processor constructs the candidate builder name using `builderUsageSuffix` (or `builderSuffix` if not configured) and verifies the builder contract: a way to create an empty builder (a no-arg constructor or a static parameterless -factory like `create()`), a way to create one seeded with the value (a +factory like `create()`), a way to create one prefilled 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 +`new` when the builder offers one. With `usingFieldFunctionPrefill` enabled and +no prefilled path found, the builder is tried against field-function prefilling: +the generated builder emits a private static `prefill` method that +prefills each readable property having a matching field function (`name(v)`, +`setName(v)`, or a detected convention like `withName(v)`); the convention is +trusted once it covers the majority of the properties, uncovered ones stay +unset. With less coverage or the option disabled, the builder still resolves, +but helpers that would silently drop state (builder consumers) are skipped and +a debug note is logged. 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. @@ -805,6 +814,22 @@ type falls back to a plain setter, opting out of false-positive detections on types that coincidentally look like builders. `@SimpleMinimalBuilder` disables this option. +#### `usingFieldFunctionPrefill` + +**Default**: `ENABLED` | **Compiler Option**: `-Asimplebuilder.usingFieldFunctionPrefill=DISABLED` + +Prefills a reused builder that offers no value-accepting creation path by +calling one field function per readable property of the referenced type: the +generated builder emits a private static `prefill(T value)` method +that obtains an empty instance and calls the field function matching each +property. The naming convention is detected on the builder — fluent `name(v)` +and JavaBeans `setName(v)` first, then any other camel-case prefix shared by +several methods (e.g. `withName(v)`); a convention is trusted once it covers +the majority of the properties, matched param-type-exact, void- or +fluent-returning. Only consulted when `usingExistingBuilders` is enabled; the +per-type analysis (property enumeration plus method scan) is a separate option +because it adds compile cost. `@SimpleMinimalBuilder` disables this option. + ### Component Filtering #### `deactivateGenerationComponents` @@ -1065,7 +1090,7 @@ the suffix used for own builder generation. 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 +one prefilled 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 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 f39ccd73b..313739622 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,17 +23,21 @@ */ package org.javahelpers.simple.builders.processor.analysis; +import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.function.Predicate; import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeKind; import javax.lang.model.util.ElementFilter; +import org.apache.commons.lang3.StringUtils; 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; @@ -65,6 +69,7 @@ private record ResolutionInputs(BuilderConfiguration configuration, String build // Factory method names preferred when a builder offers several candidates private static final List PREFERRED_FACTORY_NAMES = List.of("create", "of"); + private static final char[] UPPERCASE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); private final ProcessingContext context; private ResolutionInputs cachedResolutionInputs; @@ -104,13 +109,13 @@ public BuilderScopeResolver(ProcessingContext context) { * same-package candidate: first accessible nested types satisfying the builder contract * (e.g. {@code Person.PersonBuilder}), then static parameterless factory methods on the * type returning a builder type with a no-arg {@code build()} (e.g. {@code - * Person.builder()}), with a seeded path on the type ({@code builder(T)}/{@code + * Person.builder()}), with a prefilled path on the type ({@code builder(T)}/{@code * toBuilder()}) or on the builder. *

  • Otherwise, the candidate builder name is constructed using {@code builderUsageSuffix} * (falling back to {@code builderSuffix} if not configured). The candidate is looked up on * the classpath and returned if it satisfies the builder contract: an instantiation path * 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 + * instantiation path prefilled with the value (constructor accepting the referenced type or * static factory like {@code create(T)}/{@code of(T)}), and an accessible, parameterless * method returning it (the build method, regardless of name) - each accessible from the * generated builder's package. The contract check is annotation-agnostic, so builders @@ -242,49 +247,212 @@ private Optional resolve(TypeElement referencedType) { // the builder contract. String suffix = context.getConfiguration().getBuilderUsageSuffix(); TypeName candidate = JavaLangMapper.createBuilderTypeName(referencedType, context, suffix); - return resolveByBuilderContract(candidate, referencedTypeName); + return resolveByBuilderContract(candidate, referencedType, referencedTypeName); } /** * Looks up the candidate builder type on the classpath and verifies it satisfies the builder * 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 an - * accessible, parameterless method returning it (the build method, regardless of name) - 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}. + * factory like {@code create()}), a way to create an instance prefilled with a value (a + * constructor accepting the referenced type or a static factory like {@code create(T)}/{@code + * of(T)}), and an accessible, parameterless method returning it (the build method, regardless of + * name) - 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 referencedType the type element being referenced * @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, TypeName expectedType) { + TypeName candidate, TypeElement referencedType, TypeName expectedType) { TypeElement builderTypeElement = context.getTypeElement(candidate.getFullQualifiedName()); if (builderTypeElement == null) { return Optional.empty(); } Optional funcForEmptyBuilder = resolveFuncForEmptyBuilder(builderTypeElement); - Optional funcForPrefilledBuilder = - resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); Optional buildMethod = findBuildMethod(builderTypeElement, expectedType); - if (funcForEmptyBuilder.isEmpty() - || funcForPrefilledBuilder.isEmpty() - || buildMethod.isEmpty()) { + if (funcForEmptyBuilder.isEmpty() || buildMethod.isEmpty()) { return Optional.empty(); } + Optional funcForPrefilledBuilder = + resolveFuncForPrefilledBuilder(builderTypeElement, expectedType); + // the builder can be created empty but offers no prefilled path: try its field functions + if (funcForPrefilledBuilder.isEmpty() + && context.getConfiguration().shouldUseFieldFunctionPrefill()) { + funcForPrefilledBuilder = + resolveFieldFunctionPrefill( + referencedType, + builderTypeElement, + candidate, + expectedType, + funcForEmptyBuilder.get()); + } + if (funcForPrefilledBuilder.isEmpty() + && context.getConfiguration().shouldUseFieldFunctionPrefill()) { + context.debug( + "Builder %s for %s offers no way to prefill a value - consumer helpers are skipped", + candidate.getFullQualifiedName(), expectedType.getFullQualifiedName()); + } return Optional.of( new ResolvedBuilder( candidate, funcForEmptyBuilder.get(), - funcForPrefilledBuilder.get(), + funcForPrefilledBuilder, buildMethod.get().getSimpleName().toString())); } + /** + * Resolves prefilling by field functions: the builder must offer a field function for the + * majority of the referenced type's readable properties, all sharing one naming convention - + * {@code name(v)}, {@code setName(v)}, or a detected prefix like {@code withName(v)}. The empty + * path is then reused and the generated builder prefills the instance property by property; + * properties the builder does not accept stay unset. Resolving less than the majority distrusts + * the convention, so a builder whose functions coincide by name is never picked. A type exposing + * no readable properties resolves empty as well - nothing is provably copyable. + * + * @param referencedType the type element being referenced + * @param builderElement the candidate builder type + * @param builderTypeName the candidate builder type name + * @param referencedTypeName the referenced type, prefilled into the builder + * @param funcForEmptyBuilder how the prefilling obtains the empty builder instance + * @return the prefilling instantiation to emit, or empty when the convention covers too little + */ + private Optional resolveFieldFunctionPrefill( + TypeElement referencedType, + TypeElement builderElement, + TypeName builderTypeName, + TypeName referencedTypeName, + BuilderInstantiation funcForEmptyBuilder) { + List properties = + JavaLangAnalyser.findReadableProperties(referencedType, context); + if (properties.isEmpty()) { + return Optional.empty(); + } + List fieldFunctions = + JavaLangAnalyser.findFieldFunctions(builderElement, context); + ConventionCoverage best = + candidatePrefixes(properties, fieldFunctions).stream() + .map( + prefix -> + new ConventionCoverage( + prefix, collectPrefilledFields(properties, fieldFunctions, prefix))) + .sorted( + Comparator.comparingInt( + (ConventionCoverage convention) -> convention.prefilledFields().size()) + .reversed()) + .findFirst() + .orElseThrow(); + // a convention is trusted once it covers the majority of the readable properties + if (2 * best.prefilledFields().size() <= properties.size()) { + return Optional.empty(); + } + return Optional.of( + new BuilderInstantiation.PrefillCall( + builderTypeName, + referencedTypeName, + funcForEmptyBuilder, + best.prefix(), + best.prefilledFields())); + } + + /** + * A naming convention paired with the prefilled fields the builder covers under it: the + * convention the most properties follow wins. + */ + private record ConventionCoverage( + String prefix, List prefilledFields) {} + + /** + * Candidate field-function prefixes: {@code ""} (fluent {@code name(v)}) and {@code set} + * (JavaBeans) are always tried, any other camel-case prefix found on the builder's + * single-parameter methods is only trusted when enough methods share it - one finding per + * property, at least two when there are several. + * + * @param properties the readable properties to cover + * @param fieldFunctions the builder's single-parameter non-static methods + * @return the prefixes to evaluate + */ + private List candidatePrefixes( + List properties, List fieldFunctions) { + Map prefixCounts = new LinkedHashMap<>(); + for (ExecutableElement method : fieldFunctions) { + String name = method.getSimpleName().toString(); + int boundary = StringUtils.indexOfAny(name, UPPERCASE_CHARS); + if (boundary > 0) { + prefixCounts.merge(StringUtils.substring(name, 0, boundary), 1, Integer::sum); + } + } + List prefixes = new ArrayList<>(List.of("", "set")); + int minOccurrences = Math.min(2, properties.size()); + prefixCounts.entrySet().stream() + .filter(entry -> entry.getValue() >= minOccurrences) + .map(Map.Entry::getKey) + .filter(Predicate.not(prefixes::contains)) + .forEach(prefixes::add); + return prefixes; + } + + /** + * Collects the prefilled fields whose properties the builder covers under one naming convention: + * {@code (v)} when the prefix is empty, otherwise {@code (v)}. + * + * @param properties the readable properties to cover + * @param fieldFunctions the builder's single-parameter non-static methods + * @param prefix the naming convention to try + * @return the prefilled fields in property order + */ + private List collectPrefilledFields( + List properties, + List fieldFunctions, + String prefix) { + return properties.stream() + .filter(property -> findFieldFunction(fieldFunctions, property, prefix).isPresent()) + .map(BuilderScopeResolver::prefilledField) + .toList(); + } + + /** + * Creates the prefilled field for a property the builder covers: the property name plus how it is + * read from the source value. + * + * @param property the readable property to prefill + * @return the prefilled field to emit + */ + private static BuilderInstantiation.PrefillCall.Field prefilledField( + JavaLangAnalyser.ReadableProperty property) { + return new BuilderInstantiation.PrefillCall.Field(property.name(), property.accessor()); + } + + /** + * Finds the builder's field function for one property under the given naming convention: a + * single-parameter method named {@code } when the prefix is empty, otherwise {@code + * }, taking the property's type. + * + * @param fieldFunctions the builder's single-parameter non-static methods + * @param property the property to match + * @param prefix the naming convention to try + * @return the matching method, or empty + */ + private Optional findFieldFunction( + List fieldFunctions, + JavaLangAnalyser.ReadableProperty property, + String prefix) { + String expected = + prefix.isEmpty() ? property.name() : prefix + StringUtils.capitalize(property.name()); + return fieldFunctions.stream() + .filter( + method -> + expected.contentEquals(method.getSimpleName()) + && context.isSameType( + method.getParameters().get(0).asType(), property.typeMirror())) + .findFirst(); + } + /** * Resolves the instantiation path for an empty builder instance: a static parameterless factory * when the builder offers one, the no-arg constructor otherwise. @@ -302,12 +470,12 @@ private Optional resolveFuncForEmptyBuilder( } /** - * Resolves the instantiation path for a builder instance seeded with a value of the referenced + * Resolves the instantiation path for a builder instance prefilled with a value of the referenced * type: a static factory accepting the type when the builder offers one, the constructor * accepting the type otherwise. * * @param builderTypeElement the candidate builder type to inspect - * @param expectedType the referenced type to seed the builder with + * @param expectedType the referenced type to prefill the builder with * @return the instantiation to emit, or empty when the builder offers neither */ private Optional resolveFuncForPrefilledBuilder( @@ -336,7 +504,9 @@ private Optional resolveInTypeBuilder( TypeElement referencedType, TypeName referencedTypeName) { Optional nestedBuilder = nestedBuilderCandidates(referencedType).stream() - .map(candidate -> resolveByBuilderContract(candidate, referencedTypeName)) + .map( + candidate -> + resolveByBuilderContract(candidate, referencedType, referencedTypeName)) .flatMap(Optional::stream) .findFirst(); return nestedBuilder.or(() -> resolveAnchoredBuilder(referencedType, referencedTypeName)); @@ -407,14 +577,17 @@ private Optional resolveAnchoredBuilder( if (!context.isMemberAccessibleFromBuilderPackage(builderElement) || buildMethod.isEmpty()) { continue; } + BuilderInstantiation funcForEmptyBuilder = + new BuilderInstantiation.AnchorFactoryCall( + referencedTypeName, factory.getSimpleName().toString()); Optional funcForPrefilledBuilder = - resolveAnchoredPrefilledBuilder(referencedType, referencedTypeName, builderElement); + resolveAnchoredPrefilledBuilder( + referencedType, referencedTypeName, builderElement, funcForEmptyBuilder); if (funcForPrefilledBuilder.isPresent()) { return Optional.of( new ResolvedBuilder( JavaLangMapper.mapToTypeName(builderElement, context), - new BuilderInstantiation.AnchorFactoryCall( - referencedTypeName, factory.getSimpleName().toString()), + funcForEmptyBuilder, funcForPrefilledBuilder.get(), buildMethod.get().getSimpleName().toString())); } @@ -423,38 +596,80 @@ private Optional resolveAnchoredBuilder( } /** - * Resolves the seeded-instantiation path for an anchored builder: the builder's own contract path - * first, then a static factory on the referenced type accepting it (e.g. {@code + * Resolves the prefilled-instantiation path for an anchored builder: the builder's own contract + * path first, then a static factory on the referenced type accepting it (e.g. {@code * Person.builder(person)}), then an instance method on the value (e.g. {@code - * person.toBuilder()}). + * person.toBuilder()}), then prefilling by field functions. * * @param referencedType the type element being referenced * @param referencedTypeName the referenced type, passed to the contract check * @param builderElement the anchored builder type - * @return the instantiation to emit, or empty when no seeded path exists + * @param funcForEmptyBuilder how the empty builder is obtained, reused by field-function + * prefilling + * @return the instantiation to emit, or empty when no prefilled path exists */ private Optional resolveAnchoredPrefilledBuilder( - TypeElement referencedType, TypeName referencedTypeName, TypeElement builderElement) { + TypeElement referencedType, + TypeName referencedTypeName, + TypeElement builderElement, + BuilderInstantiation funcForEmptyBuilder) { + TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderElement, context); Optional func = resolveFuncForPrefilledBuilder(builderElement, referencedTypeName); - if (func.isPresent()) { - return func; + if (func.isEmpty()) { + func = resolveAnchoredStaticFactory(referencedType, referencedTypeName, builderTypeName); } - TypeName builderTypeName = JavaLangMapper.mapToTypeName(builderElement, context); - func = - JavaLangAnalyser.findMethodsStatic( - referencedType, List.of(referencedTypeName), builderTypeName, context) - .stream() - .min( - Comparator.comparingInt( - method -> "builder".contentEquals(method.getSimpleName()) ? 0 : 1)) - .map( - method -> - new BuilderInstantiation.AnchorFactoryCall( - referencedTypeName, method.getSimpleName().toString())); - if (func.isPresent()) { - return func; + if (func.isEmpty()) { + func = resolveAnchoredInstanceFactory(referencedType, referencedTypeName, builderTypeName); + } + if (func.isEmpty() && context.getConfiguration().shouldUseFieldFunctionPrefill()) { + func = + resolveFieldFunctionPrefill( + referencedType, + builderElement, + builderTypeName, + referencedTypeName, + funcForEmptyBuilder); } + return func; + } + + /** + * Resolves the prefilled path as a static factory anchored on the referenced type accepting the + * value (e.g. {@code Person.builder(person)}); a conventionally named {@code builder} method wins + * over other names. + * + * @param referencedType the type element being referenced + * @param referencedTypeName the referenced type, the factory's parameter + * @param builderTypeName the builder type the factory must return + * @return the instantiation calling the anchored factory, or empty + */ + private Optional resolveAnchoredStaticFactory( + TypeElement referencedType, TypeName referencedTypeName, TypeName builderTypeName) { + return JavaLangAnalyser.findMethodsStatic( + referencedType, List.of(referencedTypeName), builderTypeName, context) + .stream() + .min( + Comparator.comparingInt( + method -> "builder".contentEquals(method.getSimpleName()) ? 0 : 1)) + .map( + method -> + new BuilderInstantiation.AnchorFactoryCall( + referencedTypeName, method.getSimpleName().toString())); + } + + /** + * Resolves the prefilled path as an instance method on the value returning the builder (e.g. + * {@code person.toBuilder()}); a conventionally named {@code toBuilder} method wins over other + * names. + * + * @param referencedType the type element being referenced + * @param referencedTypeName the referenced type, the instance's type + * @param builderTypeName the builder type the method must return + * @return the instantiation calling the instance factory, or empty + */ + private Optional resolveAnchoredInstanceFactory( + TypeElement referencedType, TypeName referencedTypeName, TypeName builderTypeName) { return JavaLangAnalyser.findMethods(referencedType, List.of(), builderTypeName, context) .stream() .filter(JavaLangAnalyser::isNotStatic) 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 560f56286..8ccc5a9a1 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 @@ -30,6 +30,7 @@ import java.lang.annotation.Annotation; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,6 +40,7 @@ import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; @@ -249,31 +251,45 @@ public static List findAnnotationInstances( Class annotationClass, Class containerClass, ProcessingContext context) { - List instances = new ArrayList<>(); if (element == null) { - return instances; + return List.of(); } + List instances = new ArrayList<>(); for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) { String annotationName = annotationMirror.getAnnotationType().toString(); if (annotationName.equals(annotationClass.getCanonicalName())) { instances.add(annotationMirror); } else if (annotationName.equals(containerClass.getCanonicalName())) { - Optional containerValue = - findAnnotationAttribute(annotationMirror, "value", context); - if (containerValue.isPresent() - && containerValue.get().getValue() instanceof List entries) { - for (Object entry : entries) { - if (entry instanceof AnnotationValue entryValue - && entryValue.getValue() instanceof AnnotationMirror entryMirror) { - instances.add(entryMirror); - } - } - } + instances.addAll(unwrapContainerInstances(annotationMirror, context)); } } return instances; } + /** + * Unwraps the annotation instances repeated inside a container annotation's {@code value} + * attribute. + * + * @param containerMirror the container annotation to unwrap + * @param context the processing context providing element utilities + * @return the annotation mirrors wrapped by the container + */ + private static List unwrapContainerInstances( + AnnotationMirror containerMirror, ProcessingContext context) { + Optional containerValue = + findAnnotationAttribute(containerMirror, "value", context); + if (containerValue.isEmpty() || !(containerValue.get().getValue() instanceof List entries)) { + return List.of(); + } + return entries.stream() + .filter(AnnotationValue.class::isInstance) + .map(AnnotationValue.class::cast) + .map(AnnotationValue::getValue) + .filter(AnnotationMirror.class::isInstance) + .map(AnnotationMirror.class::cast) + .toList(); + } + /** * Reads the value of one attribute of an annotation mirror, including default values. * @@ -400,6 +416,98 @@ private static boolean hasType(TypeMirror typeMirror, TypeName expectedType) { return typeMirror.toString().equals(expectedType.getFullQualifiedName()); } + /** + * A readable property of a type: the name used to match field functions ({@code name} or {@code + * setName}), how the property is read from a value, and the type it carries. + * + * @param name the property name + * @param accessor how the property is read (e.g. {@code name()}, {@code getName()} or {@code + * name}) + * @param typeMirror the property's type + */ + public record ReadableProperty(String name, String accessor, TypeMirror typeMirror) {} + + /** + * Collects the readable properties of a type in declaration order: record components, accessible + * parameterless non-static methods returning a value (getters {@code getX()}/{@code isX()} or + * record-style accessors {@code x()}) and accessible non-static fields. Method-derived property + * names strip the {@code get}/{@code is} prefix; the first source found wins when several spell + * out the same property. + * + * @param typeElement the type to inspect + * @param context the processing context, used to access all members + * @return the readable properties in declaration order + */ + public static List findReadableProperties( + TypeElement typeElement, ProcessingContext context) { + if (typeElement == null) { + return List.of(); + } + Map properties = new LinkedHashMap<>(); + for (RecordComponentElement component : + Optional.ofNullable(typeElement.getRecordComponents()).orElse(List.of())) { + String name = component.getSimpleName().toString(); + properties.put(name, new ReadableProperty(name, name + "()", component.asType())); + } + for (ExecutableElement method : ElementFilter.methodsIn(context.getAllMembers(typeElement))) { + if (isNotStatic(method) + && hasNoParameters(method) + && method.getReturnType().getKind() != VOID + && isNoMethodOfObjectClass(method, context) + && context.isMemberAccessibleFromBuilderPackage(method)) { + String name = propertyNameOf(method); + properties.putIfAbsent( + name, + new ReadableProperty(name, method.getSimpleName() + "()", method.getReturnType())); + } + } + for (VariableElement field : ElementFilter.fieldsIn(context.getAllMembers(typeElement))) { + if (!field.getModifiers().contains(STATIC) + && context.isMemberAccessibleFromBuilderPackage(field)) { + String name = field.getSimpleName().toString(); + properties.putIfAbsent(name, new ReadableProperty(name, name, field.asType())); + } + } + return List.copyOf(properties.values()); + } + + /** + * Finds the builder's field functions: accessible non-static single-parameter methods that are + * not members of {@link Object}. These are the candidates a naming convention may use to prefill + * the builder property by property. + * + * @param type the builder type to inspect + * @param context the processing context, used to access all members + * @return the field functions in declaration order + */ + public static List findFieldFunctions( + TypeElement type, ProcessingContext context) { + return ElementFilter.methodsIn(context.getAllMembers(type)).stream() + .filter(JavaLangAnalyser::isNotStatic) + .filter(method -> isNoMethodOfObjectClass(method, context)) + .filter(context::isMemberAccessibleFromBuilderPackage) + .filter(method -> method.getParameters().size() == 1) + .toList(); + } + + /** + * Derives the property name of an accessor method: {@code getX()}/{@code isX()} map to {@code x}, + * any other name is used unchanged. + * + * @param method the accessor method to derive the property name from + * @return the property name + */ + private static String propertyNameOf(ExecutableElement method) { + String name = method.getSimpleName().toString(); + if (name.startsWith("get") && name.length() > 3 && Character.isUpperCase(name.charAt(3))) { + return StringUtils.uncapitalize(name.substring(3)); + } + if (name.startsWith("is") && name.length() > 2 && Character.isUpperCase(name.charAt(2))) { + return StringUtils.uncapitalize(name.substring(2)); + } + return name; + } + /** * 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/generators/builder/FieldPrefillEnhancer.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/FieldPrefillEnhancer.java new file mode 100644 index 000000000..d9c3e61eb --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/generators/builder/FieldPrefillEnhancer.java @@ -0,0 +1,83 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package org.javahelpers.simple.builders.processor.generators.builder; + +import java.util.Optional; +import org.javahelpers.simple.builders.processor.generators.BuilderEnhancer; +import org.javahelpers.simple.builders.processor.generators.util.MethodGeneratorUtil; +import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; +import org.javahelpers.simple.builders.processor.model.core.FieldDto; +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; + +/** + * Enhancer that adds the private prefilling methods backing {@link + * BuilderInstantiation.PrefillCall} resolutions: one {@code prefill(T value)} method per + * referenced builder type, obtaining an empty builder and calling one field function per readable + * property. Several fields referencing the same type share one prefilling method. + */ +public class FieldPrefillEnhancer implements BuilderEnhancer { + + private static final int PRIORITY = 15; + + @Override + public int getPriority() { + return PRIORITY; + } + + @Override + public boolean appliesTo( + BuilderDefinitionDto builderDto, TypeName dtoType, ProcessingContext context) { + return builderDto.getAllFieldsForBuilder().stream() + .anyMatch(field -> fieldPrefill(field).isPresent()); + } + + @Override + public void enhanceBuilder(BuilderDefinitionDto builderDto, ProcessingContext context) { + builderDto.getAllFieldsForBuilder().stream() + .map(FieldPrefillEnhancer::fieldPrefill) + .flatMap(Optional::stream) + .distinct() + .map(MethodGeneratorUtil::createFieldPrefillMethod) + .forEach(builderDto::addMethod); + } + + /** + * Returns the field-prefilling instantiation of a field's resolved builder, when the prefilled + * path is realized by field functions. + * + * @param field the field to inspect + * @return the field-prefilling call, or empty + */ + private static Optional fieldPrefill(FieldDto field) { + return field + .getFieldType() + .getResolvedBuilder() + .flatMap(ResolvedBuilder::funcForPrefilledBuilder) + .filter(BuilderInstantiation.PrefillCall.class::isInstance) + .map(BuilderInstantiation.PrefillCall.class::cast); + } +} 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 ed7e45c91..059798d34 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 @@ -100,7 +100,7 @@ public boolean appliesTo(FieldDto field, TypeName dtoType, ProcessingContext con public List generateMethods( FieldDto field, TypeName builderType, ProcessingContext context) { Optional fieldBuilderOpt = field.getFieldType().getResolvedBuilder(); - if (fieldBuilderOpt.isEmpty()) { + if (fieldBuilderOpt.isEmpty() || fieldBuilderOpt.get().funcForPrefilledBuilder().isEmpty()) { return Collections.emptyList(); } 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 8c7b6bfde..fa190c465 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 @@ -35,6 +35,7 @@ import org.javahelpers.simple.builders.processor.model.javadoc.JavadocDto; 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.BuilderInstantiation; 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; @@ -247,6 +248,7 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( .formatted( fieldBuilder .funcForPrefilledBuilder() + .orElseThrow() .instantiationCode("$helperType:T", existingValueConstructorArgs), fieldBuilder .funcForEmptyBuilder() @@ -272,6 +274,42 @@ public static BuilderMethodDto createFieldConsumerWithBuilder( return methodDto; } + /** + * Creates the private prefilling method backing a {@link BuilderInstantiation.PrefillCall}: + * obtains an empty builder and calls one field function per prefilled property of the value. + * + * @param prefillCall the resolved field-prefilling instantiation + * @return the method DTO for the prefilling method + */ + public static BuilderMethodDto createFieldPrefillMethod( + BuilderInstantiation.PrefillCall prefillCall) { + MethodParameterDto parameter = new MethodParameterDto(); + parameter.setParameterName("value"); + parameter.setParameterTypeName(prefillCall.sourceType()); + BuilderMethodDto methodDto = new BuilderMethodDto(); + methodDto.setModifier(AccessModifier.PRIVATE); + methodDto.setStatic(true); + methodDto.setOrdering(2100); // last: private helpers go below toString + methodDto.setMethodName(prefillCall.methodName()); + methodDto.setReturnType(prefillCall.builderType()); + methodDto.addParameter(parameter); + StringBuilder code = new StringBuilder(); + code.append("$builderType:T builder = ") + .append(prefillCall.funcForEmptyBuilder().instantiationCode("$builderType:T", "")) + .append(";\n"); + for (BuilderInstantiation.PrefillCall.Field prefilledField : prefillCall.prefilledFields()) { + code.append("builder.") + .append(prefillCall.builderMethodFor(prefilledField)) + .append("(value.") + .append(prefilledField.accessor()) + .append(");\n"); + } + code.append("return builder;"); + methodDto.setCode(code.toString()); + methodDto.addArgument("builderType", prefillCall.builderType()); + return methodDto; + } + /** * Creates a Consumer type. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java index 8d98f3ddc..45edb180b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/BuilderConfiguration.java @@ -73,6 +73,9 @@ * scope is usable) * @param usingExistingBuilders Reuse builders not generated by this processor (classpath lookup and * in-type anchors); when disabled only same-round generated builders qualify + * @param usingFieldFunctionPrefill Prefill a reused builder property by property through its field + * functions when it offers no value-accepting creation path; only consulted when {@code + * usingExistingBuilders} is enabled * @param builderSuffix Suffix for builder class name * @param builderUsageSuffix Suffix for builder class name when referencing builders from the usage * scope; null means fall back to {@code builderSuffix} @@ -112,6 +115,7 @@ public record BuilderConfiguration( PackageScopes builderGenerationPackages, PackageScopes builderUsagePackages, OptionState usingExistingBuilders, + OptionState usingFieldFunctionPrefill, String builderSuffix, String builderUsageSuffix, String setterSuffix, @@ -156,6 +160,7 @@ public record BuilderConfiguration( .builderGenerationPackages(PackageScopes.unscoped()) .builderUsagePackages(PackageScopes.unscoped()) .usingExistingBuilders(ENABLED) + .usingFieldFunctionPrefill(ENABLED) .builderSuffix("Builder") .setterSuffix("") .formattingMode(FormattingMode.JDT.getOptionValue()) @@ -300,6 +305,10 @@ public boolean shouldUseExistingBuilders() { return usingExistingBuilders == ENABLED; } + public boolean shouldUseFieldFunctionPrefill() { + return usingFieldFunctionPrefill == ENABLED; + } + public String getBuilderSuffix() { return builderSuffix; } @@ -398,6 +407,8 @@ public BuilderConfiguration merge(BuilderConfiguration other) { .builderUsagePackages(overrideScopes(other.builderUsagePackages, this.builderUsagePackages)) .usingExistingBuilders( mergeOptionState(other.usingExistingBuilders, this.usingExistingBuilders)) + .usingFieldFunctionPrefill( + mergeOptionState(other.usingFieldFunctionPrefill, this.usingFieldFunctionPrefill)) .builderSuffix(mergeString(other.builderSuffix, this.builderSuffix)) .builderUsageSuffix(mergeString(other.builderUsageSuffix, this.builderUsageSuffix)) .setterSuffix(mergeString(other.setterSuffix, this.setterSuffix)) @@ -488,6 +499,7 @@ public String toString() { .appendIfNotEmpty("builderGenerationPackages", builderGenerationPackages.toString()) .appendIfNotEmpty("builderUsagePackages", builderUsagePackages.toString()) .appendValueIfSet("usingExistingBuilders", usingExistingBuilders) + .appendValueIfSet("usingFieldFunctionPrefill", usingFieldFunctionPrefill) .appendIfNotEmpty("builderSuffix", builderSuffix) .appendIfNotEmpty("builderUsageSuffix", builderUsageSuffix) .appendIfNotEmpty("setterSuffix", setterSuffix) @@ -584,6 +596,7 @@ public static class Builder { private PackageScopes builderGenerationPackages = null; private PackageScopes builderUsagePackages = null; private OptionState usingExistingBuilders = OptionState.UNSET; + private OptionState usingFieldFunctionPrefill = OptionState.UNSET; // === Error Handling === private OptionState strict = OptionState.UNSET; @@ -709,6 +722,16 @@ public Builder usingExistingBuilders(boolean value) { return this; } + public Builder usingFieldFunctionPrefill(OptionState value) { + this.usingFieldFunctionPrefill = value; + return this; + } + + public Builder usingFieldFunctionPrefill(boolean value) { + this.usingFieldFunctionPrefill = value ? ENABLED : DISABLED; + return this; + } + public Builder generateVarArgsHelpers(OptionState value) { this.generateVarArgsHelpers = value; return this; @@ -941,6 +964,7 @@ public BuilderConfiguration build() { builderGenerationPackages, builderUsagePackages, usingExistingBuilders, + usingFieldFunctionPrefill, builderSuffix, builderUsageSuffix, setterSuffix, diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/type/BuilderInstantiation.java index 7fed91c41..583546be9 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,9 @@ */ package org.javahelpers.simple.builders.processor.model.type; +import java.util.List; +import org.apache.commons.lang3.StringUtils; + /** * The instantiation path generated code uses to obtain an instance of a resolved builder. * @@ -112,4 +115,68 @@ public String methodReference(String builderTypeExpression) { return anchor.getFullQualifiedName() + "::" + methodName; } } + + /** + * Instantiation by prefilling the builder through its field functions: a private static {@code + * prefill} method on the generated builder obtains an empty instance via {@code + * funcForEmptyBuilder} and calls one field function per prefilled property of the source value. + * {@link #instantiationCode} renders the call to that generated method. + * + * @param builderType the resolved builder type (return type of the prefilling method) + * @param sourceType the referenced type the builder is prefilled from + * @param funcForEmptyBuilder how the prefilling method obtains the empty builder + * @param builderMethodPrefix the field-function prefix recognized on the builder ({@code ""} for + * {@code name(v)}, {@code set} for {@code setName(v)}, or a detected convention like {@code + * with}) + * @param prefilledFields the properties to prefill, in the order of the source's properties + */ + record PrefillCall( + TypeName builderType, + TypeName sourceType, + BuilderInstantiation funcForEmptyBuilder, + String builderMethodPrefix, + List prefilledFields) + implements BuilderInstantiation { + + @Override + public String instantiationCode(String builderTypeExpression, String arguments) { + return methodName() + "(" + arguments + ")"; + } + + @Override + public String methodReference(String builderTypeExpression) { + return "this::" + methodName(); + } + + /** + * The name of the prefilling method generated on the target builder. + * + * @return {@code prefill} followed by the builder's class name + */ + public String methodName() { + return "prefill" + builderType.getClassName(); + } + + /** + * The builder's field function for a prefilled field: {@code } when the prefix is + * empty, otherwise {@code } (e.g. {@code setName} or {@code withName}). + * + * @param field the prefilled field + * @return the field function's name on the builder + */ + public String builderMethodFor(Field field) { + return builderMethodPrefix.isEmpty() + ? field.property() + : builderMethodPrefix + StringUtils.capitalize(field.property()); + } + + /** + * One property prefilled inside the prefilling method: {@code + * builder.(value.)}. + * + * @param property the property name (e.g. {@code name}) + * @param accessor how the property is read from the value (e.g. {@code name()} or {@code name}) + */ + public record Field(String property, String accessor) {} + } } 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 ebac36e56..a279746cf 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,6 +15,8 @@ */ 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. @@ -25,15 +27,16 @@ * @param typeName the builder type to reference * @param funcForEmptyBuilder how to obtain an empty builder instance (e.g. {@code new B()} or * {@code B.create()}) - * @param funcForPrefilledBuilder how to obtain a builder instance seeded with a value (e.g. {@code - * new B(value)} or {@code B.of(value)}) + * @param funcForPrefilledBuilder how to obtain a builder instance prefilled with a value (e.g. + * {@code new B(value)} or {@code B.of(value)}), or empty when the builder offers no prefilled + * path; helpers needing one are then not generated * @param buildMethodName the name of the parameterless method returning the built value (e.g. * {@code build}) */ public record ResolvedBuilder( TypeName typeName, BuilderInstantiation funcForEmptyBuilder, - BuilderInstantiation funcForPrefilledBuilder, + Optional funcForPrefilledBuilder, String buildMethodName) { /** @@ -54,7 +57,7 @@ public ResolvedBuilder(TypeName typeName) { * * @param typeName the builder type to reference * @param funcForEmptyBuilder how to obtain an empty builder instance - * @param funcForPrefilledBuilder how to obtain a builder instance seeded with a value + * @param funcForPrefilledBuilder how to obtain a builder instance prefilled with a value */ public ResolvedBuilder( TypeName typeName, @@ -63,6 +66,26 @@ public ResolvedBuilder( this(typeName, funcForEmptyBuilder, funcForPrefilledBuilder, "build"); } + /** + * A resolution with a custom-named terminal build method. + * + * @param typeName the builder type to reference + * @param funcForEmptyBuilder how to obtain an empty builder instance + * @param funcForPrefilledBuilder how to obtain a builder instance prefilled with a value + * @param buildMethodName the name of the parameterless method returning the built value + */ + public ResolvedBuilder( + TypeName typeName, + BuilderInstantiation funcForEmptyBuilder, + BuilderInstantiation funcForPrefilledBuilder, + String buildMethodName) { + this( + typeName, + funcForEmptyBuilder, + Optional.ofNullable(funcForPrefilledBuilder), + buildMethodName); + } + /** * Renders the terminal call producing the built value: {@code * builderExpression.buildMethodName()}. diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java index 70bcc60db..6fa81777e 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/CompilerArgumentsEnum.java @@ -161,6 +161,10 @@ public enum CompilerArgumentsEnum { /** Option for reusing builders not generated by this processor. */ USING_EXISTING_BUILDERS("usingExistingBuilders", optionState(Builder::usingExistingBuilders)), + /** Option for prefilling reused builders through their field functions. */ + USING_FIELD_FUNCTION_PREFILL( + "usingFieldFunctionPrefill", optionState(Builder::usingFieldFunctionPrefill)), + // === Naming === /** Option for builder class name suffix. */ BUILDER_SUFFIX("builderSuffix", string(Builder::builderSuffix)), diff --git a/processor/src/main/resources/META-INF/services/org.javahelpers.simple.builders.processor.generators.Generator b/processor/src/main/resources/META-INF/services/org.javahelpers.simple.builders.processor.generators.Generator index a94202381..3bad8341a 100644 --- a/processor/src/main/resources/META-INF/services/org.javahelpers.simple.builders.processor.generators.Generator +++ b/processor/src/main/resources/META-INF/services/org.javahelpers.simple.builders.processor.generators.Generator @@ -29,3 +29,4 @@ org.javahelpers.simple.builders.processor.generators.builder.CoreMethodsEnhancer org.javahelpers.simple.builders.processor.generators.builder.ConstructorEnhancer org.javahelpers.simple.builders.processor.generators.builder.WithInterfaceEnhancer org.javahelpers.simple.builders.processor.generators.builder.ConditionalEnhancer +org.javahelpers.simple.builders.processor.generators.builder.FieldPrefillEnhancer 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 cd65dbe53..90a83d423 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 @@ -95,8 +95,8 @@ void shouldLogDebugMessagesWhenVerboseModeEnabled() { "[DEBUG] Starting BuilderProcessor...", "[DEBUG] Loaded global configuration from compiler arguments: BuilderConfiguration[]", "[DEBUG] Initializing generator registry", - "[DEBUG] ├─ Loaded 15 method generators and 9 builder enhancers total", - "[DEBUG] └─ Initialized GeneratorRegistry with 15 method generators and 9 builder", + "[DEBUG] ├─ Loaded 15 method generators and 10 builder enhancers total", + "[DEBUG] └─ Initialized GeneratorRegistry with 15 method generators and 10 builder", // Round 1 — start "simple-builders: PROCESSING ROUND START", "[DEBUG] simple-builders: Processing round started.", 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 247560fb3..7e6f57703 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 @@ -311,6 +311,88 @@ public static LibraryDtoBuilder of(LibraryDto v) { ProcessorAsserts.assertNotContaining(generated, "new LibraryDtoBuilder"); } + @Test + void usageScope_PrefillsViaFieldFunctionsWhenNoPrefilledPath() { + JavaFileObject dto = dto("test", "PrefillUsageDto", "LibraryDto", "lib"); + JavaFileObject libraryDto = + ProcessorTestUtils.forSource( + """ + package lib; + public class LibraryDto { + private final String name; + public LibraryDto(String name) { this.name = name; } + public String getName() { return name; } + } + """); + JavaFileObject libraryDtoBuilder = + ProcessorTestUtils.forSource( + """ + package lib; + public class LibraryDtoBuilder { + private String name; + public LibraryDtoBuilder() {} + public LibraryDtoBuilder name(String name) { this.name = name; return this; } + public LibraryDto build() { return new LibraryDto(name); } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.builderGenerationPackages=test", + "-Asimplebuilder.builderUsagePackages=lib", + "-Asimplebuilder.usingFieldFunctionPrefill=ENABLED") + .compile(dto, libraryDto, libraryDtoBuilder); + + assertThat(compilation).succeeded(); + String generated = + ProcessorTestUtils.loadGeneratedSource(compilation, "PrefillUsageDtoBuilder"); + // No ctor(T) or factory(T): the generated builder prefills the referenced builder itself via + // its field functions, one per readable property + ProcessorAsserts.assertContaining( + generated, + "prefillLibraryDtoBuilder(this.referenced.value())", + "private static LibraryDtoBuilder prefillLibraryDtoBuilder(LibraryDto value)", + "builder.name(value.getName())"); + } + + @Test + void usageScope_SkipsConsumerWhenPropertiesUncovered() { + JavaFileObject dto = dto("test", "UncoveredUsageDto", "LibraryDto", "lib"); + JavaFileObject libraryDto = + ProcessorTestUtils.forSource( + """ + package lib; + public class LibraryDto { + private String name; + public String getName() { return name; } + } + """); + JavaFileObject libraryDtoBuilder = + ProcessorTestUtils.forSource( + """ + package lib; + public class LibraryDtoBuilder { + public LibraryDtoBuilder() {} + public LibraryDto build() { return new LibraryDto(); } + } + """); + + Compilation compilation = + ProcessorTestUtils.createCompiler() + .withOptions( + "-Asimplebuilder.builderGenerationPackages=test", + "-Asimplebuilder.builderUsagePackages=lib", + "-Asimplebuilder.usingFieldFunctionPrefill=ENABLED") + .compile(dto, libraryDto, libraryDtoBuilder); + + assertThat(compilation).succeeded(); + // name has no field function on the builder: prefilling is unsafe, so the consumer helper is + // not generated — the plain setter still is + assertNoBuilderConsumer( + compilation, "UncoveredUsageDtoBuilder", "LibraryDto", "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 2e2bbf704..dfd6f73e8 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 @@ -158,7 +158,7 @@ void resolverUsageScope_ResolvesContractVariants( ResolvedBuilder resolved = probeResult.get().get(); assertEquals(expectedBuilder, resolved.typeName().getFullQualifiedName()); BuilderInstantiation empty = resolved.funcForEmptyBuilder(); - BuilderInstantiation prefilled = resolved.funcForPrefilledBuilder(); + BuilderInstantiation prefilled = resolved.funcForPrefilledBuilder().get(); assertInstanceOf(expectedEmptyPath, empty); assertInstanceOf(expectedPrefilledPath, prefilled); assertFactoryMethod(expectedEmptyMethod, empty); @@ -258,7 +258,7 @@ public LibHelperBuilder(lib.LibHelper value) {} assertThat(compilation).succeeded(); // The type's own builder() declaration anchors the builder, even in another package: - // the empty path calls LibHelper.builder(), the seeded path the builder's ctor + // the empty path calls LibHelper.builder(), the prefilled path the builder's ctor ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); assertEquals("builders.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); BuilderInstantiation.AnchorFactoryCall empty = @@ -267,7 +267,7 @@ public LibHelperBuilder(lib.LibHelper value) {} assertEquals("builder", empty.methodName()); assertEquals("lib.LibHelper", empty.anchor().getFullQualifiedName()); assertInstanceOf( - BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder().get()); } @Test @@ -315,7 +315,7 @@ public LibHelperBuilder(lib.LibHelper value) {} } @Test - void resolverUsageScope_ResolvesStaticSeededPath() { + void resolverUsageScope_ResolvesStaticPrefilledPath() { Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -339,19 +339,19 @@ private LibHelperBuilder() {} """)); assertThat(compilation).succeeded(); - // No seeded path on the builder itself: the type's static builder(T) becomes the seeded + // No prefilled path on the builder itself: the type's static builder(T) becomes the prefilled // path — two candidates match, the conventional name wins ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); assertInstanceOf(BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder()); BuilderInstantiation.AnchorFactoryCall prefilled = assertInstanceOf( - BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForPrefilledBuilder().get()); assertEquals("builder", prefilled.methodName()); } @Test - void resolverUsageScope_InstanceSeededPathPrefersToBuilder() { + void resolverUsageScope_InstancePrefilledPathPrefersToBuilder() { Compilation compilation = Compiler.javac() .withProcessors(new ResolverProbeProcessor()) @@ -376,7 +376,7 @@ public static class LibHelperBuilder { assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); BuilderInstantiation.ValueFactoryCall prefilled = assertInstanceOf( - BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder().get()); assertEquals("toBuilder", prefilled.methodName()); } @@ -416,7 +416,7 @@ public Other() {} assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); assertInstanceOf( - BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder().get()); } @Test @@ -440,13 +440,13 @@ public static class LibHelperBuilder { assertThat(compilation).succeeded(); // Delombok output of @Builder(toBuilder = true): builder() anchors the empty path, - // toBuilder() the seeded one; the nested builder's package-private ctor is never called + // toBuilder() the prefilled one; the nested builder's package-private ctor is never called ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); assertEquals("lib.LibHelper.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); assertInstanceOf(BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder()); BuilderInstantiation.ValueFactoryCall prefilled = assertInstanceOf( - BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.ValueFactoryCall.class, resolved.funcForPrefilledBuilder().get()); assertEquals("toBuilder", prefilled.methodName()); } @@ -480,7 +480,373 @@ public LibHelperBuilder(LibHelper value) {} assertEquals("lib.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); assertInstanceOf( - BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder()); + BuilderInstantiation.ConstructorCall.class, resolved.funcForPrefilledBuilder().get()); + } + + @Test + void resolverUsageScope_ResolvesFreeBuilderShape() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private String name; + public String getName() { return name; } + public static class Builder { + private String name; + public Builder() {} + public Builder mergeFrom(LibHelper value) { + name = value.getName(); + return this; + } + public Builder name(String name) { this.name = name; return this; } + public Builder setName(String name) { return name(name); } + public LibHelper build() { + LibHelper helper = new LibHelper(); + helper.name = name; + return helper; + } + } + } + """)); + + assertThat(compilation).succeeded(); + // org.inferred.freebuilder generated shape: the nested Builder offers no ctor(T) and + // mergeFrom is an instance method, but its field functions cover every readable property, + // so the generated builder prefills the instance itself — the unprefixed name wins over setName + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + assertEquals("lib.LibHelper.Builder", resolved.typeName().getFullQualifiedName()); + assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("", prefilled.builderMethodPrefix()); + assertEquals("name", prefilled.prefilledFields().get(0).property()); + assertEquals("getName()", prefilled.prefilledFields().get(0).accessor()); + } + + @Test + void resolverUsageScope_SkipsFieldPrefillWhenDisabled() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private String name; + public String getName() { return name; } + public static class Builder { + private String name; + public Builder() {} + public Builder name(String name) { this.name = name; return this; } + public LibHelper build() { + LibHelper helper = new LibHelper(); + helper.name = name; + return helper; + } + } + } + """)); + + assertThat(compilation).succeeded(); + // usingFieldFunctionPrefill=DISABLED: field functions are not tried, so the builder resolves + // without a prefilled path + assertEquals( + Optional.empty(), + ResolverProbeProcessor.usagePrefillDisabled.get().funcForPrefilledBuilder()); + } + + @Test + void resolverUsageScope_ResolvesAutoValueShape() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private final String name; + private final boolean active; + public LibHelper(String name, boolean active) { + this.name = name; + this.active = active; + } + public static AutoValue_LibHelper.Builder create() { + return new AutoValue_LibHelper.Builder(); + } + public String getName() { return name; } + public boolean isActive() { return active; } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class AutoValue_LibHelper { + public static class Builder { + private String name; + private boolean active; + Builder() {} + public Builder setName(String name) { this.name = name; return this; } + public Builder setActive(boolean active) { this.active = active; return this; } + public LibHelper build() { return new LibHelper(name, active); } + } + } + """)); + + assertThat(compilation).succeeded(); + // com.google.auto.value generated shape: create() anchors the builder on the generated + // sibling; the JavaBeans setName convention covers the readable properties, so the + // generated builder prefills the instance itself + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + assertEquals("lib.AutoValue_LibHelper.Builder", resolved.typeName().getFullQualifiedName()); + assertInstanceOf(BuilderInstantiation.AnchorFactoryCall.class, resolved.funcForEmptyBuilder()); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("set", prefilled.builderMethodPrefix()); + assertEquals("name", prefilled.prefilledFields().get(0).property()); + } + + @Test + void resolverUsageScope_ResolvesDetectedPrefixShape() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private final String name; + private final boolean active; + public LibHelper(String name, boolean active) { + this.name = name; + this.active = active; + } + public String getName() { return name; } + public boolean isActive() { return active; } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + private String name; + private boolean active; + public LibHelperBuilder() {} + public LibHelperBuilder withName(String name) { this.name = name; return this; } + public LibHelperBuilder withActive(boolean active) { + this.active = active; + return this; + } + public LibHelper build() { return new LibHelper(name, active); } + } + """)); + + assertThat(compilation).succeeded(); + // Builder-framework style: several field functions share the camel-case prefix "with", + // which is detected as the builder's convention + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("with", prefilled.builderMethodPrefix()); + assertEquals(2, prefilled.prefilledFields().size()); + } + + @Test + void resolverUsageScope_ResolvesDetectedPrefixForSingleProperty() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private final String name; + public LibHelper(String name) { this.name = name; } + public String getName() { return name; } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + private String name; + public LibHelperBuilder() {} + public LibHelperBuilder putName(String name) { this.name = name; return this; } + public LibHelper build() { return new LibHelper(name); } + } + """)); + + assertThat(compilation).succeeded(); + // For a single-property type, one prefixed method is enough evidence for the convention + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("put", prefilled.builderMethodPrefix()); + } + + @Test + void resolverUsageScope_ResolvesRecordShape() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public record LibHelper(String name) {} + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + private String name; + public LibHelperBuilder() {} + public LibHelperBuilder name(String name) { this.name = name; return this; } + public LibHelper build() { return new LibHelper(name); } + } + """)); + + assertThat(compilation).succeeded(); + // Record components count as readable properties via the name() accessor + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("", prefilled.builderMethodPrefix()); + assertEquals("name", prefilled.prefilledFields().get(0).property()); + assertEquals("name()", prefilled.prefilledFields().get(0).accessor()); + } + + @Test + void resolverUsageScope_ResolvesFieldShape() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public String name = ""; + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + private String name; + public LibHelperBuilder() {} + public LibHelperBuilder name(String name) { this.name = name; return this; } + public LibHelper build() { + LibHelper helper = new LibHelper(); + helper.name = name; + return helper; + } + } + """)); + + assertThat(compilation).succeeded(); + // Accessible fields count as readable properties via direct name access + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals("", prefilled.builderMethodPrefix()); + assertEquals("name", prefilled.prefilledFields().get(0).property()); + assertEquals("name", prefilled.prefilledFields().get(0).accessor()); + } + + @Test + void resolverUsageScope_ResolvesWithoutPrefilledPathWhenPropertyUncovered() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + public String getName() { return ""; } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + public LibHelperBuilder() {} + public LibHelper build() { return new LibHelper(); } + } + """)); + + assertThat(compilation).succeeded(); + // No prefilled path and name has no field function: the builder still resolves, but + // funcForPrefilledBuilder stays empty so only helpers not needing a prefilled path generate + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + assertEquals("lib.LibHelperBuilder", resolved.typeName().getFullQualifiedName()); + assertInstanceOf(BuilderInstantiation.ConstructorCall.class, resolved.funcForEmptyBuilder()); + assertEquals(Optional.empty(), resolved.funcForPrefilledBuilder()); + } + + @Test + void resolverUsageScope_PrefillsCoveredFieldsWhenMajorityMatches() { + Compilation compilation = + Compiler.javac() + .withProcessors(new ResolverProbeProcessor()) + .compile( + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelper { + private final String name; + private final boolean active; + private final int age; + public LibHelper(String name, boolean active, int age) { + this.name = name; + this.active = active; + this.age = age; + } + public String getName() { return name; } + public boolean isActive() { return active; } + public int getAge() { return age; } + } + """), + ProcessorTestUtils.forSource( + """ + package lib; + public class LibHelperBuilder { + private String name; + private boolean active; + public LibHelperBuilder() {} + public LibHelperBuilder name(String name) { this.name = name; return this; } + public LibHelperBuilder active(boolean active) { + this.active = active; + return this; + } + public LibHelper build() { return new LibHelper(name, active, 0); } + } + """)); + + assertThat(compilation).succeeded(); + // age has no field function but two of three properties are covered: the convention is + // trusted and the covered fields are prefilled + ResolvedBuilder resolved = ResolverProbeProcessor.usageWithoutAnnotation.get(); + BuilderInstantiation.PrefillCall prefilled = + assertInstanceOf( + BuilderInstantiation.PrefillCall.class, resolved.funcForPrefilledBuilder().get()); + assertEquals(2, prefilled.prefilledFields().size()); + assertEquals("name", prefilled.prefilledFields().get(0).property()); + assertEquals("active", prefilled.prefilledFields().get(1).property()); } @Test @@ -514,7 +880,7 @@ public LibHelperBuilder(LibHelper value) {} assertEquals("lib.LibHelperBuilder", generated.typeName().getFullQualifiedName()); assertInstanceOf(BuilderInstantiation.StaticFactoryCall.class, generated.funcForEmptyBuilder()); assertInstanceOf( - BuilderInstantiation.ConstructorCall.class, generated.funcForPrefilledBuilder()); + BuilderInstantiation.ConstructorCall.class, generated.funcForPrefilledBuilder().get()); } private static final String LIB_HELPER = @@ -687,7 +1053,7 @@ public NoContract() {} """ }), // Delombok output of @Builder without toBuilder: builder() anchors the empty path, - // but no seeded path exists, so the anchored candidate is rejected + // but no prefilled path exists, so the anchored candidate is rejected Arguments.argumentSet( "LombokBuilderShapeWithoutToBuilder", (Object) @@ -703,47 +1069,6 @@ public static class LibHelperBuilder { } """ }), - // org.inferred.freebuilder generated shape: nested Builder with a public ctor and - // mergeFrom(T) — mergeFrom is an instance method, so the contract's seeded path fails - Arguments.argumentSet( - "FreeBuilderShape", - (Object) - new String[] { - """ - package lib; - public class LibHelper { - public static class Builder { - public Builder() {} - public Builder mergeFrom(LibHelper value) { return this; } - public LibHelper build() { return new LibHelper(); } - } - } - """ - }), - // com.google.auto.value generated shape: create() anchors a builder on the generated - // sibling, but AutoValue offers no seeded path (no ctor(T), no toBuilder) - Arguments.argumentSet( - "AutoValueShape", - (Object) - new String[] { - """ - package lib; - public class LibHelper { - public static AutoValue_LibHelper.Builder create() { - return new AutoValue_LibHelper.Builder(); - } - } - """, - """ - package lib; - public class AutoValue_LibHelper { - public static class Builder { - Builder() {} - public LibHelper build() { return new LibHelper(); } - } - } - """ - }), // org.immutables generated shape: builder() and toBuilder() live on the generated // sibling ImmutableLibHelper, unreachable from the referenced type itself Arguments.argumentSet( @@ -950,6 +1275,7 @@ private static final class ResolverProbeProcessor extends AbstractProcessor { private static Optional usagePackagePrivate; private static Optional usageWithExistingDisabled; private static Optional usageGeneratedWithExistingDisabled; + private static Optional usagePrefillDisabled; private static Optional contractResultString; private boolean captured; @@ -968,6 +1294,7 @@ static void reset() { usagePackagePrivate = null; usageWithExistingDisabled = null; usageGeneratedWithExistingDisabled = null; + usagePrefillDisabled = null; contractResultString = null; } @@ -1039,6 +1366,10 @@ public boolean process(Set annotations, RoundEnvironment resolver.registerGeneratedBuilder( new TypeName("lib", "LibHelper"), new TypeName("lib", "LibHelperBuilder")); usageGeneratedWithExistingDisabled = resolver.resolveUsableBuilderType(helper); + // usingFieldFunctionPrefill=DISABLED: field functions are not used as prefilled path + context.initProcessingTarget(new ProcessingTarget(prefillDisabledConfiguration("lib"), "")); + resolver.resetGeneratedBuilders(); + usagePrefillDisabled = resolver.resolveUsableBuilderType(helper); // Builder-contract probe for java.lang.String: StringBuilder must not resolve because its // only parameterless method returning String is toString(), an Object signature context.initProcessingTarget(new ProcessingTarget(usageOnlyConfiguration("java.lang"), "")); @@ -1063,6 +1394,14 @@ private static BuilderConfiguration usageOnlyConfiguration(String packageName) { BuilderConfiguration.builder().builderUsagePackages(packageName).build()); } + private static BuilderConfiguration prefillDisabledConfiguration(String packageName) { + return BuilderConfiguration.DEFAULT.merge( + BuilderConfiguration.builder() + .builderUsagePackages(packageName) + .usingFieldFunctionPrefill(OptionState.DISABLED) + .build()); + } + private static BuilderConfiguration existingBuildersDisabledConfiguration(String packageName) { return BuilderConfiguration.DEFAULT.merge( BuilderConfiguration.builder()