getModifier() {
public void setModifier(Modifier modifier) {
this.modifier = Optional.ofNullable(modifier);
}
+
+ /**
+ * Gets the return type of the method.
+ *
+ * @return the return type as TypeName
+ */
+ public TypeName getReturnType() {
+ return returnType;
+ }
+
+ /**
+ * Sets the return type of the method.
+ *
+ * @param returnType the return type as TypeName
+ */
+ public void setReturnType(TypeName returnType) {
+ this.returnType = returnType;
+ }
+
+ /**
+ * Gets the Javadoc comment for the method.
+ *
+ * @return the Javadoc comment
+ */
+ public String getJavadoc() {
+ return javadoc;
+ }
+
+ /**
+ * Sets the Javadoc comment for the method.
+ *
+ * @param javadoc the Javadoc comment
+ */
+ public void setJavadoc(String javadoc) {
+ this.javadoc = javadoc;
+ }
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java
new file mode 100644
index 00000000..857049f6
--- /dev/null
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/NestedTypeDto.java
@@ -0,0 +1,96 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2025 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.dtos;
+
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * Represents a nested type (interface or class) to be generated inside the builder.
+ *
+ * For example, the "With" interface that allows DTOs to implement fluent modification methods.
+ */
+public class NestedTypeDto {
+
+ /** The simple name of the nested type (e.g., "With"). */
+ private String typeName;
+
+ /** The kind of nested type (INTERFACE or CLASS). */
+ private NestedTypeKind kind;
+
+ /** Whether this nested type should be public. */
+ private boolean isPublic = true;
+
+ /** Methods to be generated in this nested type. */
+ private final List methods = new LinkedList<>();
+
+ /** Javadoc comment for this nested type. */
+ private String javadoc;
+
+ public enum NestedTypeKind {
+ INTERFACE,
+ CLASS
+ }
+
+ public String getTypeName() {
+ return typeName;
+ }
+
+ public void setTypeName(String typeName) {
+ this.typeName = typeName;
+ }
+
+ public NestedTypeKind getKind() {
+ return kind;
+ }
+
+ public void setKind(NestedTypeKind kind) {
+ this.kind = kind;
+ }
+
+ public boolean isPublic() {
+ return isPublic;
+ }
+
+ public void setPublic(boolean isPublic) {
+ this.isPublic = isPublic;
+ }
+
+ public List getMethods() {
+ return methods;
+ }
+
+ public void addMethod(MethodDto method) {
+ this.methods.add(method);
+ }
+
+ public String getJavadoc() {
+ return javadoc;
+ }
+
+ public void setJavadoc(String javadoc) {
+ this.javadoc = javadoc;
+ }
+}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java
index 51d26f8a..a7514d66 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/BuilderDefinitionCreator.java
@@ -93,12 +93,16 @@ public static BuilderDefinitionDto extractFromElement(
BuilderDefinitionDto result = initializeBuilderDefinition(annotatedType, context);
- List constructorFields = extractConstructorFields(annotatedType, context);
+ List constructorFields = extractConstructorFields(annotatedType, result, context);
result.addAllFieldsInConstructor(constructorFields);
List setterFields = extractSetterFields(annotatedType, result, context);
result.addAllFields(setterFields);
+ // Create the With interface
+ NestedTypeDto withInterface = createWithInterface(result, context);
+ result.addNestedType(withInterface);
+
return result;
}
@@ -126,7 +130,7 @@ private static BuilderDefinitionDto initializeBuilderDefinition(
* @return list of fields extracted from constructor parameters
*/
private static List extractConstructorFields(
- TypeElement annotatedType, ProcessingContext context) {
+ TypeElement annotatedType, BuilderDefinitionDto builderDef, ProcessingContext context) {
List constructorFields = new LinkedList<>();
Optional constructorOpt = findConstructorForBuilder(annotatedType, context);
if (constructorOpt.isPresent()) {
@@ -136,7 +140,8 @@ private static List extractConstructorFields(
ctor.getSimpleName(), ctor.getParameters().size());
for (VariableElement param : ctor.getParameters()) {
Optional fieldFromCtor =
- createFieldFromConstructor(annotatedType, param, context);
+ createFieldFromConstructor(
+ annotatedType, param, builderDef.getBuilderTypeName(), context);
if (fieldFromCtor.isPresent()) {
FieldDto field = fieldFromCtor.get();
logFieldAddition(field, context);
@@ -174,7 +179,8 @@ private static List extractSetterFields(
mth.getSimpleName(), mth.getParameters().size());
if (isMethodRelevantForBuilder(mth, context)) {
- Optional maybeField = createFieldFromSetter(mth, context);
+ Optional maybeField =
+ createFieldFromSetter(mth, result.getBuilderTypeName(), context);
if (maybeField.isPresent()) {
processedCount++;
FieldDto field = maybeField.get();
@@ -234,12 +240,16 @@ private static boolean isMethodRelevantForBuilder(
}
private static void addAdditionalHelperMethodsForField(
- FieldDto result, String fieldName, TypeName fieldType, List annotations) {
+ FieldDto result,
+ String fieldName,
+ TypeName fieldType,
+ List annotations,
+ TypeName builderType) {
// Check for String type (not array) and add format method
if (isString(fieldType) && !(fieldType instanceof TypeNameArray)) {
result.addMethod(
createStringFormatMethodWithTransform(
- fieldName, "String.format(format, args)", annotations));
+ fieldName, "String.format(format, args)", annotations, builderType));
}
// Only process generic types (List, Set, Map, Optional, etc.)
@@ -253,29 +263,31 @@ private static void addAdditionalHelperMethodsForField(
if (isList(fieldType) && innerTypesCnt == 1) {
result.addMethod(
createFieldSetterWithTransform(
- fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false)));
+ fieldName, "List.of(%s)", new TypeNameArray(innerTypes.get(0), false), builderType));
} else if (isSet(fieldType) && innerTypesCnt == 1) {
result.addMethod(
createFieldSetterWithTransform(
- fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true)));
+ fieldName, "Set.of(%s)", new TypeNameArray(innerTypes.get(0), true), builderType));
} else if (isMap(fieldType) && innerTypesCnt == 2) {
TypeName mapEntryType =
new TypeNameArray(
new TypeNameGeneric("java.util", "Map.Entry", innerTypes.get(0), innerTypes.get(1)),
false);
result.addMethod(
- createFieldSetterWithTransform(fieldName, "Map.ofEntries(%s)", mapEntryType));
+ createFieldSetterWithTransform(
+ fieldName, "Map.ofEntries(%s)", mapEntryType, builderType));
} else if (isOptional(fieldType) && innerTypesCnt == 1) {
// Add setter that accepts the inner type T and wraps it in Optional.ofNullable()
result.addMethod(
- createFieldSetterWithTransform(fieldName, "Optional.ofNullable(%s)", innerTypes.get(0)));
+ createFieldSetterWithTransform(
+ fieldName, "Optional.ofNullable(%s)", innerTypes.get(0), builderType));
// If Optional, add format method
TypeName innerType = innerTypes.get(0);
if (isString(innerType)) {
result.addMethod(
createStringFormatMethodWithTransform(
- fieldName, "Optional.of(String.format(format, args))", List.of()));
+ fieldName, "Optional.of(String.format(format, args))", List.of(), builderType));
}
}
}
@@ -286,6 +298,7 @@ private static void addConsumerMethodsForField(
TypeName fieldType,
VariableElement fieldParameter,
TypeElement fieldTypeElement,
+ TypeName builderType,
ProcessingContext context) {
// Do not generate supplier methods for generic type variables (e.g., T)
if (fieldType instanceof TypeNameVariable) {
@@ -296,12 +309,13 @@ private static void addConsumerMethodsForField(
return;
}
- if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, context)
- && !tryAddFieldConsumer(result, fieldName, fieldType, fieldTypeElement, context)
- && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, context)
- && !tryAddMapConsumer(result, fieldName, fieldType)
- && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, context)) {
- tryAddStringBuilderConsumer(result, fieldName, fieldType);
+ if (!tryAddBuilderConsumer(result, fieldName, fieldParameter, builderType, context)
+ && !tryAddFieldConsumer(
+ result, fieldName, fieldType, fieldTypeElement, builderType, context)
+ && !tryAddListConsumer(result, fieldName, fieldType, fieldParameter, builderType, context)
+ && !tryAddMapConsumer(result, fieldName, fieldType, builderType)
+ && !tryAddSetConsumer(result, fieldName, fieldType, fieldParameter, builderType, context)) {
+ tryAddStringBuilderConsumer(result, fieldName, fieldType, builderType);
}
}
@@ -310,12 +324,14 @@ private static boolean tryAddBuilderConsumer(
FieldDto result,
String fieldName,
VariableElement fieldParameter,
+ TypeName builderType,
ProcessingContext context) {
- Optional builderTypeOpt = resolveBuilderType(fieldParameter, context);
- if (builderTypeOpt.isPresent()) {
- TypeName builderType = builderTypeOpt.get();
+ Optional fieldBuilderOpt = resolveBuilderType(fieldParameter, context);
+ if (fieldBuilderOpt.isPresent()) {
+ TypeName fieldBuilderType = fieldBuilderOpt.get();
result.addMethod(
- BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderType));
+ BuilderDefinitionCreator.createFieldConsumerWithBuilder(
+ fieldName, fieldBuilderType, builderType));
return true;
}
return false;
@@ -327,6 +343,7 @@ private static boolean tryAddFieldConsumer(
String fieldName,
TypeName fieldType,
TypeElement fieldTypeElement,
+ TypeName builderType,
ProcessingContext context) {
if (!isJavaClass(fieldType)
&& fieldTypeElement != null
@@ -334,7 +351,7 @@ private static boolean tryAddFieldConsumer(
&& !fieldTypeElement.getModifiers().contains(Modifier.ABSTRACT)
&& hasEmptyConstructor(fieldTypeElement, context)) {
// Only generate a Consumer for concrete classes with an accessible empty constructor
- result.addMethod(createFieldConsumer(fieldName, fieldType));
+ result.addMethod(createFieldConsumer(fieldName, fieldType, builderType));
return true;
}
return false;
@@ -342,11 +359,11 @@ && hasEmptyConstructor(fieldTypeElement, context)) {
/** Tries to add StringBuilder-based consumer for String and Optional. */
private static boolean tryAddStringBuilderConsumer(
- FieldDto result, String fieldName, TypeName fieldType) {
+ FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) {
if (shouldGenerateStringBuilderConsumer(fieldType)) {
String transform =
isOptionalString(fieldType) ? "Optional.of(builder.toString())" : "builder.toString()";
- result.addMethod(createStringBuilderConsumer(fieldName, transform));
+ result.addMethod(createStringBuilderConsumer(fieldName, transform, builderType));
return true;
}
return false;
@@ -358,6 +375,7 @@ private static boolean tryAddListConsumer(
String fieldName,
TypeName fieldType,
VariableElement fieldParameter,
+ TypeName builderType,
ProcessingContext context) {
if (!(isList(fieldType)
&& fieldType instanceof TypeNameGeneric fieldTypeGeneric
@@ -376,23 +394,27 @@ private static boolean tryAddListConsumer(
if (elementBuilderType.isPresent()) {
// Element type has a builder - use ArrayListBuilderWithElementBuilders
- TypeName builderType =
+ TypeName collectionBuilderType =
new TypeNameGeneric(
map2TypeName(ArrayListBuilderWithElementBuilders.class),
elementType,
elementBuilderType.get());
result.addMethod(
- createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get()));
+ createFieldConsumerWithElementBuilders(
+ fieldName, collectionBuilderType, elementBuilderType.get(), builderType));
} else {
// Regular ArrayListBuilder
- TypeName builderType = map2TypeName(ArrayListBuilder.class);
- result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType));
+ TypeName collectionBuilderType = map2TypeName(ArrayListBuilder.class);
+ result.addMethod(
+ createFieldConsumerWithBuilder(
+ fieldName, collectionBuilderType, elementType, builderType));
}
return true;
}
/** Tries to add Map-specific consumer methods. Returns true if handled. */
- private static boolean tryAddMapConsumer(FieldDto result, String fieldName, TypeName fieldType) {
+ private static boolean tryAddMapConsumer(
+ FieldDto result, String fieldName, TypeName fieldType, TypeName builderType) {
if (!(isMap(fieldType)
&& fieldType instanceof TypeNameGeneric fieldTypeGeneric
&& fieldTypeGeneric.getInnerTypeArguments().size() == 2)) {
@@ -405,7 +427,8 @@ private static boolean tryAddMapConsumer(FieldDto result, String fieldName, Type
fieldTypeGeneric.getInnerTypeArguments().get(0),
fieldTypeGeneric.getInnerTypeArguments().get(1));
MethodDto mapConsumerWithBuilder =
- BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTargetTypeName);
+ BuilderDefinitionCreator.createFieldConsumerWithBuilder(
+ fieldName, builderTargetTypeName, builderType);
result.addMethod(mapConsumerWithBuilder);
return true;
}
@@ -416,6 +439,7 @@ private static boolean tryAddSetConsumer(
String fieldName,
TypeName fieldType,
VariableElement fieldParameter,
+ TypeName builderType,
ProcessingContext context) {
if (!(isSet(fieldType)
&& fieldType instanceof TypeNameGeneric fieldTypeGeneric
@@ -434,34 +458,40 @@ private static boolean tryAddSetConsumer(
if (elementBuilderType.isPresent()) {
// Element type has a builder - use HashSetBuilderWithElementBuilders
- TypeName builderType =
+ TypeName collectionBuilderType =
new TypeNameGeneric(
map2TypeName(HashSetBuilderWithElementBuilders.class),
elementType,
elementBuilderType.get());
result.addMethod(
- createFieldConsumerWithElementBuilders(fieldName, builderType, elementBuilderType.get()));
+ createFieldConsumerWithElementBuilders(
+ fieldName, collectionBuilderType, elementBuilderType.get(), builderType));
} else {
// Regular HashSetBuilder
- TypeName builderType = map2TypeName(HashSetBuilder.class);
- result.addMethod(createFieldConsumerWithBuilder(fieldName, builderType, elementType));
+ TypeName collectionBuilderType = map2TypeName(HashSetBuilder.class);
+ result.addMethod(
+ createFieldConsumerWithBuilder(
+ fieldName, collectionBuilderType, elementType, builderType));
}
return true;
}
private static void addSupplierMethodsForField(
- FieldDto result, String fieldName, TypeName fieldType, TypeElement fieldTypeElement) {
+ FieldDto result,
+ String fieldName,
+ TypeName fieldType,
+ TypeElement fieldTypeElement,
+ TypeName builderType) {
// Skip supplier generation for functional interfaces
if (isFunctionalInterface(fieldTypeElement)) {
return;
}
-
// For all fields including Optional, use the real field type for suppliers
- result.addMethod(createFieldSupplier(fieldName, fieldType));
+ result.addMethod(createFieldSupplier(fieldName, fieldType, builderType));
}
private static Optional createFieldFromSetter(
- ExecutableElement mth, ProcessingContext context) {
+ ExecutableElement mth, TypeName builderType, ProcessingContext context) {
String methodName = mth.getSimpleName().toString();
String fieldName = StringUtils.uncapitalize(Strings.CI.removeStart(methodName, "set"));
@@ -494,7 +524,7 @@ private static Optional createFieldFromSetter(
javaDoc = fieldName;
}
- return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, context);
+ return createFieldDto(fieldName, javaDoc, fieldParameter, dtoType, builderType, context);
}
/**
@@ -502,14 +532,18 @@ private static Optional createFieldFromSetter(
* the constructor argument.
*/
private static Optional createFieldFromConstructor(
- TypeElement dtoType, VariableElement param, ProcessingContext context) {
+ TypeElement annotatedType,
+ VariableElement param,
+ TypeName builderType,
+ ProcessingContext context) {
String fieldName = param.getSimpleName().toString();
// Set javadoc (default to field name if no javadoc found)
- String javaDoc = JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(dtoType), param);
+ String javaDoc =
+ JavaLangAnalyser.extractParamJavaDoc(context.getDocComment(annotatedType), param);
if (javaDoc == null) {
javaDoc = fieldName;
}
- return createFieldDto(fieldName, javaDoc, param, dtoType, context);
+ return createFieldDto(fieldName, javaDoc, param, annotatedType, builderType, context);
}
/**
@@ -528,6 +562,7 @@ private static Optional createFieldDto(
String javaDoc,
VariableElement param,
TypeElement dtoType,
+ TypeName builderType,
ProcessingContext context) {
MethodParameterDto paramDto = map2MethodParameter(param, context);
if (paramDto == null) {
@@ -558,12 +593,14 @@ private static Optional createFieldDto(
}
// Add basic setter method with annotations
- field.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType, annotations));
+ field.addMethod(
+ createFieldSetterWithTransform(fieldName, null, fieldType, annotations, builderType));
// Add consumer/supplier/helper methods
- addConsumerMethodsForField(field, fieldName, fieldType, param, fieldTypeElement, context);
- addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement);
- addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations);
+ addConsumerMethodsForField(
+ field, fieldName, fieldType, param, fieldTypeElement, builderType, context);
+ addSupplierMethodsForField(field, fieldName, fieldType, fieldTypeElement, builderType);
+ addAdditionalHelperMethodsForField(field, fieldName, fieldType, annotations, builderType);
return Optional.of(field);
}
@@ -577,8 +614,8 @@ private static Optional createFieldDto(
* @return the method DTO for the setter
*/
private static MethodDto createFieldSetterWithTransform(
- String fieldName, String transform, TypeName fieldType) {
- return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of());
+ String fieldName, String transform, TypeName fieldType, TypeName builderType) {
+ return createFieldSetterWithTransform(fieldName, transform, fieldType, List.of(), builderType);
}
/**
@@ -591,7 +628,11 @@ private static MethodDto createFieldSetterWithTransform(
* @return the method DTO for the setter
*/
private static MethodDto createFieldSetterWithTransform(
- String fieldName, String transform, TypeName fieldType, List annotations) {
+ String fieldName,
+ String transform,
+ TypeName fieldType,
+ List annotations,
+ TypeName builderType) {
MethodParameterDto parameter = new MethodParameterDto();
parameter.setParameterName(fieldName);
parameter.setParameterTypeName(fieldType);
@@ -599,6 +640,7 @@ private static MethodDto createFieldSetterWithTransform(
annotations.forEach(parameter::addAnnotation);
MethodDto methodDto = new MethodDto();
methodDto.setMethodName(fieldName);
+ methodDto.setReturnType(builderType);
methodDto.addParameter(parameter);
methodDto.setModifier(Modifier.PUBLIC);
methodDto.setMethodType(MethodTypes.PROXY);
@@ -619,13 +661,15 @@ private static MethodDto createFieldSetterWithTransform(
return methodDto;
}
- private static MethodDto createFieldConsumer(String fieldName, TypeName fieldType) {
+ private static MethodDto createFieldConsumer(
+ String fieldName, TypeName fieldType, TypeName builderType) {
TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), fieldType);
MethodParameterDto parameter = new MethodParameterDto();
parameter.setParameterName(fieldName + SUFFIX_CONSUMER);
parameter.setParameterTypeName(consumerType);
MethodDto methodDto = new MethodDto();
methodDto.setMethodName(fieldName);
+ methodDto.setReturnType(builderType);
methodDto.addParameter(parameter);
methodDto.setModifier(Modifier.PUBLIC);
methodDto.setMethodType(MethodTypes.CONSUMER);
@@ -643,8 +687,9 @@ private static MethodDto createFieldConsumer(String fieldName, TypeName fieldTyp
return methodDto;
}
- private static MethodDto createStringBuilderConsumer(String fieldName, String transform) {
- TypeName stringBuilderType = new TypeName("java.lang", "StringBuilder");
+ private static MethodDto createStringBuilderConsumer(
+ String fieldName, String transform, TypeName builderType) {
+ TypeName stringBuilderType = map2TypeName(StringBuilder.class);
TypeNameGeneric consumerType =
new TypeNameGeneric(map2TypeName(Consumer.class), stringBuilderType);
MethodParameterDto parameter = new MethodParameterDto();
@@ -666,18 +711,30 @@ private static MethodDto createStringBuilderConsumer(String fieldName, String tr
methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName());
methodDto.addArgument("transform", transform);
methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE);
+ methodDto.setReturnType(builderType);
return methodDto;
}
private static MethodDto createFieldConsumerWithBuilder(
- String fieldName, TypeName builderType, TypeName builderTargetType) {
- TypeNameGeneric builderTypeGeneric = new TypeNameGeneric(builderType, builderTargetType);
- return BuilderDefinitionCreator.createFieldConsumerWithBuilder(fieldName, builderTypeGeneric);
+ String fieldName,
+ TypeName consumerBuilderType,
+ TypeName builderTargetType,
+ TypeName returnBuilderType) {
+ TypeNameGeneric builderTypeGeneric =
+ new TypeNameGeneric(consumerBuilderType, builderTargetType);
+ return BuilderDefinitionCreator.createFieldConsumerWithBuilder(
+ fieldName, builderTypeGeneric, returnBuilderType);
}
- private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeName builderType) {
+ private static MethodDto createFieldConsumerWithBuilder(
+ String fieldName, TypeName consumerBuilderType, TypeName returnBuilderType) {
return createFieldConsumerWithBuilder(
- fieldName, builderType, "this.$fieldName:N.value()", "", Map.of());
+ fieldName,
+ consumerBuilderType,
+ "this.$fieldName:N.value()",
+ "",
+ Map.of(),
+ returnBuilderType);
}
/**
@@ -685,13 +742,17 @@ private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeNa
* ArrayListBuilderWithElementBuilders and HashSetBuilderWithElementBuilders.
*/
private static MethodDto createFieldConsumerWithElementBuilders(
- String fieldName, TypeName collectionBuilderType, TypeName elementBuilderType) {
+ String fieldName,
+ TypeName collectionBuilderType,
+ TypeName elementBuilderType,
+ TypeName returnBuilderType) {
return createFieldConsumerWithBuilder(
fieldName,
collectionBuilderType,
"this.$fieldName:N.value(), $elementBuilderType:T::create",
"$elementBuilderType:T::create",
- Map.of("elementBuilderType", elementBuilderType));
+ Map.of("elementBuilderType", elementBuilderType),
+ returnBuilderType);
}
/**
@@ -707,16 +768,19 @@ private static MethodDto createFieldConsumerWithElementBuilders(
*/
private static MethodDto createFieldConsumerWithBuilder(
String fieldName,
- TypeName builderType,
+ TypeName consumerBuilderType,
String constructorArgsWithValue,
- String constructorArgsEmpty,
- Map additionalArguments) {
- TypeNameGeneric consumerType = new TypeNameGeneric(map2TypeName(Consumer.class), builderType);
+ String additionalConstructorArgs,
+ Map additionalArguments,
+ TypeName returnBuilderType) {
+ TypeNameGeneric consumerType =
+ new TypeNameGeneric(map2TypeName(Consumer.class), consumerBuilderType);
MethodParameterDto parameter = new MethodParameterDto();
parameter.setParameterName(fieldName + BUILDER_SUFFIX + SUFFIX_CONSUMER);
parameter.setParameterTypeName(consumerType);
MethodDto methodDto = new MethodDto();
methodDto.setMethodName(fieldName);
+ methodDto.setReturnType(returnBuilderType);
methodDto.addParameter(parameter);
methodDto.setModifier(Modifier.PUBLIC);
methodDto.setMethodType(MethodTypes.CONSUMER_BY_BUILDER);
@@ -727,22 +791,24 @@ private static MethodDto createFieldConsumerWithBuilder(
this.$fieldName:N = $builderFieldWrapper:T.changedValue(builder.build());
return this;
"""
- .formatted(constructorArgsWithValue, constructorArgsEmpty));
+ .formatted(constructorArgsWithValue, additionalConstructorArgs));
methodDto.addArgument(ARG_FIELD_NAME, fieldName);
methodDto.addArgument(ARG_DTO_METHOD_PARAM, parameter.getParameterName());
- methodDto.addArgument(ARG_HELPER_TYPE, builderType);
+ methodDto.addArgument(ARG_HELPER_TYPE, consumerBuilderType);
additionalArguments.forEach(methodDto::addArgument);
methodDto.addArgument(ARG_BUILDER_FIELD_WRAPPER, TRACKED_VALUE_TYPE);
return methodDto;
}
- private static MethodDto createFieldSupplier(String fieldName, TypeName fieldType) {
+ private static MethodDto createFieldSupplier(
+ String fieldName, TypeName fieldType, TypeName builderType) {
TypeNameGeneric supplierType = new TypeNameGeneric(map2TypeName(Supplier.class), fieldType);
MethodParameterDto parameter = new MethodParameterDto();
parameter.setParameterName(fieldName + SUFFIX_SUPPLIER);
parameter.setParameterTypeName(supplierType);
MethodDto methodDto = new MethodDto();
methodDto.setMethodName(fieldName);
+ methodDto.setReturnType(builderType);
methodDto.addParameter(parameter);
methodDto.setModifier(Modifier.PUBLIC);
methodDto.setMethodType(MethodTypes.SUPPLIER);
@@ -758,8 +824,8 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp
}
private static MethodDto createStringFormatMethodWithTransform(
- String fieldName, String transform, List annotations) {
- TypeName stringType = new TypeName("java.lang", "String");
+ String fieldName, String transform, List annotations, TypeName builderType) {
+ TypeName stringType = map2TypeName(String.class);
MethodParameterDto formatParam = new MethodParameterDto();
formatParam.setParameterName("format");
@@ -773,6 +839,7 @@ private static MethodDto createStringFormatMethodWithTransform(
MethodDto methodDto = new MethodDto();
methodDto.setMethodName(fieldName);
+ methodDto.setReturnType(builderType);
methodDto.addParameter(formatParam);
methodDto.addParameter(argsParam);
methodDto.setModifier(Modifier.PUBLIC);
@@ -898,4 +965,123 @@ private static TypeMirror extractFirstTypeArgument(TypeMirror typeMirror) {
}
return null;
}
+
+ /**
+ * Creates the "With" interface definition that allows the DTO to implement fluent modification
+ * methods.
+ *
+ * @param builderDef the builder definition containing type information
+ * @param context the processing context
+ * @return the nested type definition for the With interface
+ */
+ private static NestedTypeDto createWithInterface(
+ BuilderDefinitionDto builderDef, ProcessingContext context) {
+ context.debug(
+ "Creating With interface for: %s", builderDef.getBuilderTypeName().getClassName());
+
+ NestedTypeDto withInterface = new NestedTypeDto();
+ withInterface.setTypeName("With");
+ withInterface.setKind(NestedTypeDto.NestedTypeKind.INTERFACE);
+ withInterface.setPublic(true);
+ withInterface.setJavadoc(
+ "Interface that can be implemented by the DTO to provide fluent modification methods.");
+
+ // Create the first method: DtoType with(Consumer b)
+ MethodDto withConsumerMethod = createWithConsumerMethod(builderDef);
+ withInterface.addMethod(withConsumerMethod);
+
+ // Create the second method: BuilderType with()
+ MethodDto withBuilderMethod = createWithBuilderMethod(builderDef);
+ withInterface.addMethod(withBuilderMethod);
+
+ return withInterface;
+ }
+
+ /**
+ * Creates the `DtoType with(Consumer b)` method definition.
+ *
+ * @param builderDef the builder definition
+ * @return the method definition
+ */
+ private static MethodDto createWithConsumerMethod(BuilderDefinitionDto builderDef) {
+ MethodDto method = new MethodDto();
+ method.setMethodName("with");
+
+ // Return type is the DTO type
+ TypeName dtoType = builderDef.getBuildingTargetTypeName();
+ method.setReturnType(dtoType);
+
+ // Parameter: Consumer b
+ MethodParameterDto parameter = new MethodParameterDto();
+ parameter.setParameterName("b");
+ // For interface methods, we store the full type as a string
+ TypeNameGeneric consumerType =
+ new TypeNameGeneric(map2TypeName(Consumer.class), builderDef.getBuilderTypeName());
+ parameter.setParameterTypeName(consumerType);
+ method.addParameter(parameter);
+
+ // Add implementation with validation to catch wrong implementations
+ method.setCode(
+ """
+ $builderType:T builder;
+ try {
+ builder = new $builderType:T($dtoType:T.class.cast(this));
+ } catch ($classcastexception:T ex) {
+ throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ """);
+ method.addArgument("builderType", builderDef.getBuilderTypeName());
+ method.addArgument("dtoType", builderDef.getBuildingTargetTypeName());
+ method.addArgument("classcastexception", map2TypeName(ClassCastException.class));
+ method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class));
+
+ method.setJavadoc(
+ """
+ Applies modifications to a builder initialized from this instance and returns the built object.
+
+ @param b the consumer to apply modifications
+ @return the modified instance
+ """);
+
+ return method;
+ }
+
+ /**
+ * Creates the `BuilderType with()` method definition.
+ *
+ * @param builderDef the builder definition
+ * @return the method definition
+ */
+ private static MethodDto createWithBuilderMethod(BuilderDefinitionDto builderDef) {
+ MethodDto method = new MethodDto();
+ method.setMethodName("with");
+
+ // Return type is the Builder type
+ method.setReturnType(builderDef.getBuilderTypeName());
+
+ // Add implementation with validation to catch wrong implementations
+ method.setCode(
+ """
+ try {
+ return new $builderType:T($dtoType:T.class.cast(this));
+ } catch ($classcastexception:T ex) {
+ throw new $illegalargumentexception:T("The interface '$builderType:T.With' should only be implemented by classes, which could be casted to '$dtoType:T'", ex);
+ }
+ """);
+ method.addArgument("builderType", builderDef.getBuilderTypeName());
+ method.addArgument("dtoType", builderDef.getBuildingTargetTypeName());
+ method.addArgument("classcastexception", map2TypeName(ClassCastException.class));
+ method.addArgument("illegalargumentexception", map2TypeName(IllegalArgumentException.class));
+
+ method.setJavadoc(
+ """
+ Creates a builder initialized from this instance.
+
+ @return a builder initialized with this instance's values
+ """);
+
+ return method;
+ }
}
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java
index 017d972a..4654de9a 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaCodeGenerator.java
@@ -51,6 +51,9 @@
/** JavaCodeGenerator generates with BuilderDefinitionDto JavaCode for the builder. */
public class JavaCodeGenerator {
/** Util class for source code generation of type {@code javax.annotation.processing.Filer}. */
+ private static final String METHOD_NAME_CREATE = "create";
+
+ private static final String THROW_EXCEPTION_FORMAT = "throw new $T($S)";
private final Filer filer;
/** Logger for debug output during code generation. */
@@ -149,6 +152,13 @@ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderExcep
classBuilder.addMethod(createMethodConditional(builderTypeName));
classBuilder.addMethod(createMethodConditionalPositiveOnly(builderTypeName));
+ // Adding nested types (e.g., With interface)
+ for (NestedTypeDto nestedType : builderDef.getNestedTypes()) {
+ TypeSpec nestedTypeSpec = createNestedType(nestedType);
+ classBuilder.addType(nestedTypeSpec);
+ logger.debug(" Generated nested type: %s", nestedType.getTypeName());
+ }
+
// Adding annotations
classBuilder.addAnnotation(createAnnotationGenerated());
classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoBaseClass));
@@ -246,7 +256,7 @@ private void addFieldInitializationWithValidation(
if (field.isNonNullable()) {
cb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName())
.addStatement(
- "throw new $T($S)",
+ THROW_EXCEPTION_FORMAT,
IllegalArgumentException.class,
"Cannot initialize builder from instance: field '"
+ field.getFieldName()
@@ -292,13 +302,13 @@ private MethodSpec createMethodBuild(
if (field.isNonNullable()) {
mb.beginControlFlow("if (!this.$N.isSet())", field.getFieldName())
.addStatement(
- "throw new $T($S)",
+ THROW_EXCEPTION_FORMAT,
IllegalStateException.class,
"Required field '" + field.getFieldName() + "' must be set before calling build()")
.endControlFlow();
mb.beginControlFlow("if (this.$N.value() == null)", field.getFieldName())
.addStatement(
- "throw new $T($S)",
+ THROW_EXCEPTION_FORMAT,
IllegalStateException.class,
"Field '"
+ field.getFieldName()
@@ -316,7 +326,7 @@ private MethodSpec createMethodBuild(
field.getFieldName(),
field.getFieldName())
.addStatement(
- "throw new $T($S)",
+ THROW_EXCEPTION_FORMAT,
IllegalStateException.class,
"Field '"
+ field.getFieldName()
@@ -353,7 +363,7 @@ private MethodSpec createMethodStaticCreate(
com.palantir.javapoet.ClassName dtoBaseClass,
List generics) {
MethodSpec.Builder methodBuilder =
- MethodSpec.methodBuilder("create")
+ MethodSpec.methodBuilder(METHOD_NAME_CREATE)
.addModifiers(STATIC, PUBLIC)
.addJavadoc(
"""
@@ -430,6 +440,80 @@ private MethodSpec createMethodConditionalPositiveOnly(
.build();
}
+ /**
+ * Creates a TypeSpec for a nested type (e.g., With interface).
+ *
+ * @param nestedType the nested type definition
+ * @return the TypeSpec for the nested type
+ */
+ private TypeSpec createNestedType(NestedTypeDto nestedType) {
+ TypeSpec.Builder typeBuilder;
+
+ boolean isInterface = nestedType.getKind() == NestedTypeDto.NestedTypeKind.INTERFACE;
+ if (isInterface) {
+ typeBuilder = TypeSpec.interfaceBuilder(nestedType.getTypeName());
+ } else {
+ typeBuilder = TypeSpec.classBuilder(nestedType.getTypeName());
+ }
+
+ if (nestedType.isPublic()) {
+ typeBuilder.addModifiers(PUBLIC);
+ }
+
+ if (nestedType.getJavadoc() != null) {
+ typeBuilder.addJavadoc(nestedType.getJavadoc());
+ }
+
+ // Add methods to the nested type
+ for (MethodDto method : nestedType.getMethods()) {
+ MethodSpec methodSpec = createNestedTypeMethod(method, isInterface);
+ typeBuilder.addMethod(methodSpec);
+ }
+
+ return typeBuilder.build();
+ }
+
+ /**
+ * Creates a MethodSpec for a method of a nested type (e.g., With interface).
+ *
+ * @param methodDto the method to create
+ * @param isInterface whether the nested type is an interface
+ * @return the MethodSpec
+ */
+ private MethodSpec createNestedTypeMethod(MethodDto methodDto, boolean isInterface) {
+ MethodSpec.Builder methodBuilder =
+ MethodSpec.methodBuilder(methodDto.getMethodName()).addModifiers(PUBLIC);
+
+ // Set return type using mapper
+ methodBuilder.returns(JavapoetMapper.map2ParameterType(methodDto.getReturnType()));
+
+ // Add parameters using mapper
+ for (MethodParameterDto paramDto : methodDto.getParameters()) {
+ methodBuilder.addParameter(createParameter(paramDto));
+ }
+
+ // Add modifiers if defined
+ methodDto.getModifier().ifPresent(methodBuilder::addModifiers);
+
+ // Add Javadoc
+ if (methodDto.getJavadoc() != null) {
+ methodBuilder.addJavadoc(methodDto.getJavadoc());
+ }
+
+ // Add method body if present
+ MethodCodeDto codeDto = methodDto.getMethodCodeDto();
+ if (codeDto != null) {
+ // Add default modifier for interface methods with implementation
+ if (isInterface) {
+ methodBuilder.addModifiers(javax.lang.model.element.Modifier.DEFAULT);
+ }
+
+ methodBuilder.addCode(map2CodeBlock(codeDto));
+ }
+
+ return methodBuilder.build();
+ }
+
private List createFieldMethods(
FieldDto fieldDto, com.palantir.javapoet.TypeName builderTypeName) {
return fieldDto.getMethods().stream()
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java
index bc33c47a..1230f537 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangAnalyser.java
@@ -363,14 +363,13 @@ public static Optional findGetterForField(
if (dtoType == null || fieldName == null || fieldTypeMirror == null) {
return Optional.empty();
}
- String cap = StringUtils.capitalize(fieldName);
- String getterCandidate = "get" + cap;
- String booleanGetterCandidate = "is" + cap;
List classMethods = ElementFilter.methodsIn(context.getAllMembers(dtoType));
- // Prefer boolean-style getter if present
+
+ // Check for accessor methods:
+ // Record-style (fieldName), boolean-style (isXxx), or standard (getXxx)
for (ExecutableElement candidate : classMethods) {
String name = candidate.getSimpleName().toString();
- if ((name.equals(booleanGetterCandidate) || name.equals(getterCandidate))
+ if (Strings.CI.equalsAny(name, fieldName, "is" + fieldName, "get" + fieldName)
&& candidate.getParameters().isEmpty()
&& context.isSameType(candidate.getReturnType(), fieldTypeMirror)) {
return Optional.of(candidate);
diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java
index 58ebf185..20001890 100644
--- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java
+++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavapoetMapper.java
@@ -35,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
import org.javahelpers.simple.builders.processor.dtos.*;
/** Helper functions to create JavaPoet types from DTOs of simple builder. */
@@ -106,7 +107,11 @@ public static TypeName map2ParameterType(
*/
public static ClassName map2ClassName(
org.javahelpers.simple.builders.processor.dtos.TypeName typeName) {
- return ClassName.get(typeName.getPackageName(), typeName.getClassName());
+ if (StringUtils.isNoneEmpty(typeName.getPackageName())) {
+ return ClassName.get(typeName.getPackageName(), typeName.getClassName());
+ } else {
+ return ClassName.bestGuess(typeName.getClassName());
+ }
}
/**
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java
index 606b5404..b795d1bc 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java
@@ -1,10 +1,11 @@
package org.javahelpers.simple.builders.processor;
import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose;
import com.google.testing.compile.Compilation;
-import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import javax.tools.JavaFileObject;
import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
@@ -14,9 +15,9 @@
class AnnotationCopyTest {
private Compilation compileSources(JavaFileObject... sources) {
- BuilderProcessor processor = new BuilderProcessor();
- Compiler compiler = Compiler.javac().withProcessors(processor);
- return compiler.compile(sources);
+ Compilation compilation = createCompiler().compile(sources);
+ printDiagnosticsOnVerbose(compilation);
+ return compilation;
}
@Test
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java
index 7ed759e4..c6e917a0 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ConditionalExecutionTest.java
@@ -1,10 +1,11 @@
package org.javahelpers.simple.builders.processor;
import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose;
import com.google.testing.compile.Compilation;
-import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import javax.tools.JavaFileObject;
import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
@@ -14,7 +15,9 @@
class ConditionalExecutionTest {
private Compilation compileSources(JavaFileObject... sources) {
- return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources);
+ Compilation compilation = createCompiler().compile(sources);
+ printDiagnosticsOnVerbose(compilation);
+ return compilation;
}
@Test
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java
index 41a1e3ea..022b5618 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/NullConstraintTest.java
@@ -25,10 +25,11 @@
package org.javahelpers.simple.builders.processor;
import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose;
import com.google.testing.compile.Compilation;
-import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import javax.tools.JavaFileObject;
import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
@@ -38,7 +39,9 @@
class NullConstraintTest {
private Compilation compileSources(JavaFileObject... sources) {
- return Compiler.javac().withProcessors(new BuilderProcessor()).compile(sources);
+ Compilation compilation = createCompiler().compile(sources);
+ printDiagnosticsOnVerbose(compilation);
+ return compilation;
}
@Test
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java
index dcc6002b..24dc31e1 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/ReadmeExampleTest.java
@@ -1,10 +1,11 @@
package org.javahelpers.simple.builders.processor;
import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose;
import com.google.testing.compile.Compilation;
-import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import javax.tools.JavaFileObject;
import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
@@ -13,9 +14,9 @@
class ReadmeExampleTest {
private Compilation compileSources(JavaFileObject... sources) {
- BuilderProcessor processor = new BuilderProcessor();
- Compiler compiler = Compiler.javac().withProcessors(processor);
- return compiler.compile(sources);
+ Compilation compilation = createCompiler().compile(sources);
+ printDiagnosticsOnVerbose(compilation);
+ return compilation;
}
@Test
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java
new file mode 100644
index 00000000..3dd32698
--- /dev/null
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/WithInterfaceTest.java
@@ -0,0 +1,232 @@
+package org.javahelpers.simple.builders.processor;
+
+import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.createCompiler;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource;
+import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.printDiagnosticsOnVerbose;
+
+import com.google.testing.compile.Compilation;
+import com.google.testing.compile.JavaFileObjects;
+import javax.tools.JavaFileObject;
+import org.javahelpers.simple.builders.processor.testing.ProcessorAsserts;
+import org.junit.jupiter.api.Test;
+
+/** Tests for With interface generation in builders. */
+class WithInterfaceTest {
+
+ private Compilation compileSources(JavaFileObject... sources) {
+ Compilation compilation = createCompiler().compile(sources);
+ printDiagnosticsOnVerbose(compilation); // Print diagnostics when verbose mode is enabled
+ return compilation;
+ }
+
+ @Test
+ void withInterface_generatedInBuilder() {
+ String packageName = "test.withinterface";
+
+ JavaFileObject project =
+ JavaFileObjects.forSourceString(
+ packageName + ".Project",
+ """
+ package test.withinterface;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class Project {
+ private String name;
+ private String description;
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+ }
+ """);
+
+ Compilation compilation = compileSources(project);
+ String generatedCode = loadGeneratedSource(compilation, "ProjectBuilder");
+ ProcessorAsserts.assertGenerationSucceeded(compilation, "ProjectBuilder", generatedCode);
+
+ // Verify complete With interface is generated with default implementations
+ String expectedWithInterface =
+ """
+ /**
+ * Interface that can be implemented by the DTO to provide fluent modification methods.
+ */
+ public interface With {
+ /**
+ * Applies modifications to a builder initialized from this instance and returns the built object.
+ *
+ * @param b the consumer to apply modifications
+ * @return the modified instance
+ */
+ default Project with(Consumer b) {
+ ProjectBuilder builder;
+ try {
+ builder = new ProjectBuilder(Project.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ }
+
+ /**
+ * Creates a builder initialized from this instance.
+ *
+ * @return a builder initialized with this instance's values
+ */
+ default ProjectBuilder with() {
+ try {
+ return new ProjectBuilder(Project.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'ProjectBuilder.With' should only be implemented by classes, which could be casted to 'Project'", ex);
+ }
+ }
+ }
+ """;
+
+ ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface));
+ }
+
+ @Test
+ void withInterface_worksWithConstructorFields() {
+ String packageName = "test.withinterface.constructor";
+
+ JavaFileObject user =
+ JavaFileObjects.forSourceString(
+ packageName + ".User",
+ """
+ package test.withinterface.constructor;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class User {
+ private final String username;
+ private String email;
+
+ public User(String username) {
+ this.username = username;
+ }
+
+ public String getUsername() { return username; }
+ public String getEmail() { return email; }
+ public void setEmail(String email) { this.email = email; }
+ }
+ """);
+
+ Compilation compilation = compileSources(user);
+ String generatedCode = loadGeneratedSource(compilation, "UserBuilder");
+ ProcessorAsserts.assertGenerationSucceeded(compilation, "UserBuilder", generatedCode);
+
+ // Verify complete With interface with default implementations for constructor fields
+ String expectedWithInterface =
+ """
+ /**
+ * Interface that can be implemented by the DTO to provide fluent modification methods.
+ */
+ public interface With {
+ /**
+ * Applies modifications to a builder initialized from this instance and returns the built object.
+ *
+ * @param b the consumer to apply modifications
+ * @return the modified instance
+ */
+ default User with(Consumer b) {
+ UserBuilder builder;
+ try {
+ builder = new UserBuilder(User.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ }
+
+ /**
+ * Creates a builder initialized from this instance.
+ *
+ * @return a builder initialized with this instance's values
+ */
+ default UserBuilder with() {
+ try {
+ return new UserBuilder(User.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'UserBuilder.With' should only be implemented by classes, which could be casted to 'User'", ex);
+ }
+ }
+ }
+ """;
+
+ ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface));
+ }
+
+ @Test
+ void withInterface_correctTypeNames() {
+ String packageName = "test.withinterface.types";
+
+ JavaFileObject config =
+ JavaFileObjects.forSourceString(
+ packageName + ".Config",
+ """
+ package test.withinterface.types;
+ import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;
+
+ @SimpleBuilder
+ public class Config {
+ private int timeout;
+ private boolean enabled;
+
+ public int getTimeout() { return timeout; }
+ public void setTimeout(int timeout) { this.timeout = timeout; }
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+ }
+ """);
+
+ Compilation compilation = compileSources(config);
+ String generatedCode = loadGeneratedSource(compilation, "ConfigBuilder");
+ ProcessorAsserts.assertGenerationSucceeded(compilation, "ConfigBuilder", generatedCode);
+
+ // Verify complete With interface with correct type names (primitives)
+ String expectedWithInterface =
+ """
+ /**
+ * Interface that can be implemented by the DTO to provide fluent modification methods.
+ */
+ public interface With {
+ /**
+ * Applies modifications to a builder initialized from this instance and returns the built object.
+ *
+ * @param b the consumer to apply modifications
+ * @return the modified instance
+ */
+ default Config with(Consumer b) {
+ ConfigBuilder builder;
+ try {
+ builder = new ConfigBuilder(Config.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex);
+ }
+ b.accept(builder);
+ return builder.build();
+ }
+
+ /**
+ * Creates a builder initialized from this instance.
+ *
+ * @return a builder initialized with this instance's values
+ */
+ default ConfigBuilder with() {
+ try {
+ return new ConfigBuilder(Config.class.cast(this));
+ } catch (ClassCastException ex) {
+ throw new IllegalArgumentException("The interface 'ConfigBuilder.With' should only be implemented by classes, which could be casted to 'Config'", ex);
+ }
+ }
+ }
+ """;
+
+ ProcessorAsserts.assertingResult(generatedCode, contains(expectedWithInterface));
+ }
+}
diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
index 589ee87b..b1810942 100644
--- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
+++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorTestUtils.java
@@ -1,11 +1,14 @@
package org.javahelpers.simple.builders.processor.testing;
import com.google.testing.compile.Compilation;
+import com.google.testing.compile.Compiler;
import com.google.testing.compile.JavaFileObjects;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.tools.JavaFileObject;
+import org.apache.commons.lang3.Strings;
+import org.javahelpers.simple.builders.processor.BuilderProcessor;
/**
* Utilities to simplify annotation-processor tests by reducing boilerplate for building sources,
@@ -15,6 +18,115 @@ public final class ProcessorTestUtils {
private ProcessorTestUtils() {}
+ /**
+ * Creates a configured {@link Compiler} instance with the BuilderProcessor.
+ *
+ * This method checks for the system property {@code simplebuilder.verbose} (or {@code
+ * Averbose}) and automatically adds {@code -Averbose=true} to the compiler options if either is
+ * set to "true".
+ *
+ *
This allows developers to enable verbose processor output for all tests by running: {@code
+ * mvn test -Dsimplebuilder.verbose=true}
+ *
+ * @return a Compiler instance configured with BuilderProcessor and optional verbose output
+ */
+ public static Compiler createCompiler() {
+ Compiler compiler = Compiler.javac().withProcessors(new BuilderProcessor());
+
+ // Check for verbose flag from Maven property
+ if (isVerboseEnabled()) {
+ compiler = compiler.withOptions("-Averbose=true");
+ }
+
+ return compiler;
+ }
+
+ /**
+ * Checks if verbose mode is enabled via system properties.
+ *
+ * @return true if simplebuilder.verbose or Averbose is set to "true"
+ */
+ public static boolean isVerboseEnabled() {
+ String verboseProperty = System.getProperty("simplebuilder.verbose");
+ String averboseProperty = System.getProperty("Averbose");
+ return Strings.CI.equalsAny("true", verboseProperty, averboseProperty);
+ }
+
+ /**
+ * Prints compilation diagnostics (notes, warnings, errors) and generated source files to
+ * System.out if verbose mode is enabled.
+ *
+ *
This is useful for debugging test failures, as it makes the processor's debug output and
+ * generated code visible in the test console output and CI logs.
+ *
+ * @param compilation the compilation result to print diagnostics from
+ */
+ public static void printDiagnosticsOnVerbose(Compilation compilation) {
+ if (!isVerboseEnabled()) {
+ return;
+ }
+
+ System.out.println("\n========== Compilation Diagnostics ==========");
+
+ // Print notes (includes debug messages)
+ if (!compilation.notes().isEmpty()) {
+ System.out.println("--- NOTES ---");
+ compilation.notes().forEach(diag -> System.out.println(diag.getMessage(null)));
+ }
+
+ // Print warnings
+ if (!compilation.warnings().isEmpty()) {
+ System.out.println("\n--- WARNINGS ---");
+ compilation.warnings().forEach(diag -> System.out.println(diag.getMessage(null)));
+ }
+
+ // Print errors
+ if (!compilation.errors().isEmpty()) {
+ System.out.println("\n--- ERRORS ---");
+ compilation.errors().forEach(diag -> System.out.println(diag.getMessage(null)));
+ }
+
+ System.out.println("=============================================\n");
+
+ // Print generated source files
+ printGeneratedSourcesOnVerbose(compilation);
+ }
+
+ /**
+ * Prints all generated source files to System.out if verbose mode is enabled.
+ *
+ *
This displays the actual generated code before assertions run, making it easy to compare
+ * expected vs actual output without debugging.
+ *
+ * @param compilation the compilation result containing generated files
+ */
+ public static void printGeneratedSourcesOnVerbose(Compilation compilation) {
+ if (!isVerboseEnabled()) {
+ return;
+ }
+
+ var generatedFiles = compilation.generatedSourceFiles();
+ if (generatedFiles.isEmpty()) {
+ System.out.println("========== No Source Files Generated ==========\n");
+ return;
+ }
+
+ System.out.println("========== Generated Source Files ==========");
+ generatedFiles.forEach(
+ file -> {
+ try {
+ String fileName = file.getName();
+ String content = file.getCharContent(false).toString();
+ System.out.println("\n--- " + fileName + " ---");
+ System.out.println(content);
+ System.out.println("--- End of " + fileName + " ---");
+ } catch (Exception e) {
+ System.err.println("Failed to read generated file: " + e.getMessage());
+ }
+ });
+ System.out.println("=============================================\n");
+ }
+
/**
* Creates a {@link JavaFileObject} for a simple class annotated with @SimpleBuilder. You pass the
* inner body lines (fields/methods); imports and annotation are handled for you.