diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index bbb2244b..f63e4e14 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -21,6 +21,7 @@ jobs: permissions: contents: read pull-requests: write + checks: write steps: - uses: actions/checkout@v4 @@ -31,7 +32,7 @@ jobs: distribution: 'temurin' cache: maven - name: Build with Maven - run: mvn -B verify --file pom.xml + run: mvn -B verify -Pmetrics --file pom.xml - name: Upload JaCoCo HTML report (processor) if: always() @@ -49,7 +50,19 @@ jobs: path: processor/target/site/jacoco/jacoco.xml if-no-files-found: warn - - name: Upload coverage to Codecov + - name: Upload test results to Codecov (processor) + if: always() + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: | + processor/target/surefire-reports/*.xml + processor/target/failsafe-reports/*.xml + if-no-files-found: warn + fail_ci_if_error: true + verbose: false + + - name: Upload coverage to Codecov (processor) if: always() uses: codecov/codecov-action@v5 with: @@ -57,5 +70,5 @@ jobs: files: processor/target/site/jacoco/jacoco.xml flags: processor name: codecov-upload - verbose: true + verbose: false token: ${{ secrets.CODECOV_TOKEN }} diff --git a/processor/pom.xml b/processor/pom.xml index 8eeb34ad..c1f10f71 100644 --- a/processor/pom.xml +++ b/processor/pom.xml @@ -191,48 +191,7 @@ - - org.jacoco - jacoco-maven-plugin - ${plugin.jacoco.version} - - - prepare-agent - - prepare-agent - - - - report - verify - - report - - - - - **/*Mapper*.class - - **/processor/dtos/** - - - - - - - ${project.build.directory}/jacoco.exec - ${project.reporting.outputDirectory}/jacoco - - html - xml - csv - - - - **/generated/** - - - + @@ -308,6 +267,55 @@ + + metrics + + + + org.jacoco + jacoco-maven-plugin + ${plugin.jacoco.version} + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + **/*Mapper*.class + + **/processor/dtos/** + + + + + + + ${project.build.directory}/jacoco.exec + ${project.reporting.outputDirectory}/jacoco + + html + xml + csv + + + + **/generated/** + + + + + + \ No newline at end of file diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java index 9a5746bc..d081130b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/BuilderDefinitionDto.java @@ -25,6 +25,7 @@ package org.javahelpers.simple.builders.processor.dtos; import java.util.LinkedList; +import java.util.List; /** BuilderDefinitionDto holds all information for generating a builder. */ public class BuilderDefinitionDto { @@ -34,17 +35,20 @@ public class BuilderDefinitionDto { /** Type of builder. Containing package and name of DTO. */ private TypeName builderTypeName; + /** Generic parameters declared on the target DTO (e.g., ). */ + private final List generics = new LinkedList<>(); + /** * List of all methods of target DTO which are supported by builder and not targeting a specific * DTO field. */ - private final LinkedList methodsForBuilder = new LinkedList<>(); + private final List methodsForBuilder = new LinkedList<>(); /** * List of all fields of target DTO which are supported by builder. A field could be supported by * several functions in builder. */ - private final LinkedList setterFieldsForBuilder = new LinkedList<>(); + private final List setterFieldsForBuilder = new LinkedList<>(); /** * Getting type of builder. @@ -99,7 +103,7 @@ public void addMethod(MethodDto member) { * @return list of methods with type {@code * org.javahelpers.simple.builders.internal.dtos.MethodDto} */ - public LinkedList getMethodsForBuilder() { + public List getMethodsForBuilder() { return methodsForBuilder; } @@ -117,7 +121,17 @@ public void addField(FieldDto field) { * * @return list of fields with type {@code org.javahelpers.simple.builders.internal.dtos.FieldDto} */ - public LinkedList getSetterFieldsForBuilder() { + public List getSetterFieldsForBuilder() { return setterFieldsForBuilder; } + + /** Adds a generic parameter definition. */ + public void addGeneric(GenericParameterDto generic) { + generics.add(generic); + } + + /** Returns the list of generic parameters of the target DTO, in declared order. */ + public List getGenerics() { + return generics; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java index 8189de4d..77a5359b 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/FieldDto.java @@ -41,6 +41,12 @@ public class FieldDto { /** List of all methods in builder, which change the field. */ private final List fieldSetterMethodsList = new ArrayList<>(); + /** Optional Javadoc of the field extracted from setter or constructor. */ + private String javaDoc; + + /** Method-level generics declared on the setter, to be reused in builder methods. */ + private final List fieldGenerics = new ArrayList<>(); + /** * Getting name of field. * @@ -95,4 +101,35 @@ public List getMethods() { public void addMethod(MethodDto methodDto) { this.fieldSetterMethodsList.add(methodDto); } + + /** + * Getting extracted Javadoc. + * + * @return Javadoc text or null if none present + */ + public String getJavaDoc() { + return javaDoc; + } + + /** + * Setting extracted Javadoc. + * + * @param javaDoc Javadoc text + */ + public void setJavaDoc(String javaDoc) { + this.javaDoc = javaDoc; + } + + /** Returns generics declared on the setter (field-specific). */ + public List getFieldGenerics() { + return fieldGenerics; + } + + /** Replaces generics list with the provided entries. */ + public void setFieldGenerics(List generics) { + this.fieldGenerics.clear(); + if (generics != null) { + this.fieldGenerics.addAll(generics); + } + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/GenericParameterDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/GenericParameterDto.java new file mode 100644 index 00000000..960479e4 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/GenericParameterDto.java @@ -0,0 +1,57 @@ +/* + * 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 generic type parameter (e.g., T extends Number & Comparable). */ +public class GenericParameterDto { + /** The name of the generic parameter (e.g., "T"). */ + private String name; + + /** Optional upper bounds in declared order (empty if unbounded). */ + private final List upperBounds = new LinkedList<>(); + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + /** Add an upper bound. */ + public void addUpperBound(TypeName bound) { + if (bound != null) { + upperBounds.add(bound); + } + } + + /** Returns all upper bounds (possibly empty). */ + public List getUpperBounds() { + return upperBounds; + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java index 165e9f0b..a9a45482 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/MethodDto.java @@ -27,6 +27,7 @@ import static org.javahelpers.simple.builders.processor.dtos.MethodTypes.PROXY; import java.util.LinkedList; +import java.util.List; import java.util.Optional; import javax.lang.model.element.Modifier; import org.apache.commons.lang3.StringUtils; @@ -51,6 +52,9 @@ public class MethodDto { /** Definition of inner implementation for method. */ private final MethodCodeDto methodCodeDto = new MethodCodeDto(); + /** Method-level type parameters (e.g., ). */ + private final List methodGenerics = new LinkedList<>(); + /** * Getting type of method for source code generation. Defaults to {@code * org.javahelpers.simple.builders.internal.dtos.MethodTypes.PROXY}. @@ -159,6 +163,19 @@ public LinkedList getParameters() { return parameters; } + /** Replaces method-level generic parameter list with the provided entries. */ + public void setMethodGenerics(List generics) { + this.methodGenerics.clear(); + if (generics != null) { + this.methodGenerics.addAll(generics); + } + } + + /** Returns declared method-level generic parameters. */ + public List getMethodGenerics() { + return methodGenerics; + } + /** * Getting the access modifier for method. Optional for usage in stream-notation. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/TypeNameVariable.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/TypeNameVariable.java new file mode 100644 index 00000000..ca1e59f3 --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/dtos/TypeNameVariable.java @@ -0,0 +1,35 @@ +/* + * 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; + +/** Represents a type variable usage like T, K, V in a generic signature. */ +public class TypeNameVariable extends TypeName { + /** + * Creates a TypeName representing a type variable (no package). The variable name is stored as + * className for simplicity across the existing TypeName API. + */ + public TypeNameVariable(String variableName) { + super("", variableName); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/AnnotationValidator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/AnnotationValidator.java index b1d0027f..6149e108 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/AnnotationValidator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/AnnotationValidator.java @@ -27,6 +27,7 @@ import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; import javax.lang.model.element.Modifier; +import javax.lang.model.element.NestingKind; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; import org.javahelpers.simple.builders.processor.exceptions.BuilderException; @@ -56,5 +57,15 @@ public static void validateAnnotatedElement(Element annotatedElement) throws Bui annotatedElement, "The " + SimpleBuilder.class.getSimpleName() + " should not be abstract"); } + + // Only allow @SimpleBuilder on top-level classes + if (annotatedElement instanceof javax.lang.model.element.TypeElement typeElement + && typeElement.getNestingKind() != NestingKind.TOP_LEVEL) { + throw new BuilderException( + annotatedElement, + "The " + + SimpleBuilder.class.getSimpleName() + + " should be declared on a top-level class only"); + } } } 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 8cf50293..2930faf9 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 @@ -37,12 +37,7 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; -import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.Element; -import javax.lang.model.element.ExecutableElement; -import javax.lang.model.element.Modifier; -import javax.lang.model.element.TypeElement; -import javax.lang.model.element.VariableElement; +import javax.lang.model.element.*; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; @@ -74,11 +69,14 @@ public static BuilderDefinitionDto extractFromElement( BuilderDefinitionDto result = new BuilderDefinitionDto(); String packageName = elementUtils.getPackageOf(annotatedType).getQualifiedName().toString(); - String simpleClassName = - StringUtils.removeStart(annotatedElement.getSimpleName().toString(), packageName + "."); + String simpleClassName = annotatedType.getSimpleName().toString(); result.setBuilderTypeName(new TypeName(packageName, simpleClassName + BUILDER_SUFFIX)); result.setBuildingTargetTypeName(new TypeName(packageName, simpleClassName)); + // Extract generics from the annotated type via mapper (stream-based) + JavaLangMapper.map2GenericParameterDtos(annotatedType, elementUtils, typeUtils) + .forEach(result::addGeneric); + List allMembers = elementUtils.getAllMembers(annotatedType); List methods = ElementFilter.methodsIn(allMembers); @@ -134,36 +132,47 @@ private static Optional createMethodDto( return Optional.of(result); } - private static Optional createFieldDto( - ExecutableElement mth, Elements elementUtils, Types typeUtils) { - String methodName = mth.getSimpleName().toString(); - String fieldName = StringUtils.uncapitalize(StringUtils.removeStart(methodName, "set")); - - FieldDto result = new FieldDto(); - result.setFieldName(fieldName); - List parameters = mth.getParameters(); - if (parameters.size() != 1) { - // TODO: Logging - // Sollte eigentlich nie vorkommen, da das vorher raus gefiltert wurde - return Optional.empty(); + private static void addAdditionalHelperMethodsForField( + FieldDto result, String fieldName, TypeName fieldType) { + if (isList(fieldType)) { + result.addMethod( + createFieldSetterWithTransform( + fieldName, + "List.of(%s)", + new TypeNameArray(fieldType.getInnerType().get(), false), + result.getFieldGenerics())); + return; } - VariableElement fieldParameter = parameters.get(0); - TypeMirror fieldTypeMirror = fieldParameter.asType(); - TypeElement fieldTypeElement = (TypeElement) typeUtils.asElement(fieldTypeMirror); - - // extracting type of field - MethodParameterDto fieldParameterDto = - map2MethodParameter(fieldParameter, elementUtils, typeUtils); - if (fieldParameterDto == null) { - // TODO: Logging - return Optional.empty(); + if (isSet(fieldType)) { + result.addMethod( + createFieldSetterWithTransform( + fieldName, + "Set.of(%s)", + new TypeNameArray(fieldType.getInnerType().get(), true), + result.getFieldGenerics())); + return; } - TypeName fieldType = fieldParameterDto.getParameterType(); - - // simple setter - result.addMethod(createFieldSetter(fieldName, fieldType)); + if (isMap(fieldType)) { + // TODO MAP (having 2 inner classes, TypeNameGeneric is not able to address that yet) + } + } - // setting value by builder + private static void addConsumerMethodsForField( + FieldDto result, + String fieldName, + TypeName fieldType, + VariableElement fieldParameter, + TypeElement fieldTypeElement, + Elements elementUtils, + Types typeUtils) { + // Do not generate supplier methods for generic type variables (e.g., T) + if (fieldType instanceof TypeNameVariable) { + return; + } + // Skip consumer generation for functional interfaces + if (isFunctionalInterface(fieldTypeElement)) { + return; + } Optional builderTypeOpt = findBuilderType(fieldParameter, elementUtils, typeUtils); if (builderTypeOpt.isPresent()) { TypeName builderType = builderTypeOpt.get(); @@ -180,32 +189,84 @@ && hasEmptyConstructor(fieldTypeElement, elementUtils)) { result.addMethod( createFieldConsumerWithBuilder( fieldName, map2TypeName(ArrayListBuilder.class), fieldType.getInnerType().get())); - result.addMethod( - createFieldSetterWithTransform( - fieldName, "List.of(%s)", new TypeNameArray(fieldType.getInnerType().get(), false))); } else if (isMap(fieldType)) { // TODO MAP (having 2 inner classes, TypeNameGeneric is not able to adress that yet) } else if (isSet(fieldType)) { result.addMethod( createFieldConsumerWithBuilder( fieldName, map2TypeName(HashSetBuilder.class), fieldType.getInnerType().get())); - result.addMethod( - createFieldSetterWithTransform( - fieldName, "Set.of(%s)", new TypeNameArray(fieldType.getInnerType().get(), true))); } + } - // setting value by supplier - result.addMethod(createFieldSupplier(fieldName, fieldType)); - - return Optional.of(result); + private static void addSupplierMethodsForField( + FieldDto result, String fieldName, TypeName fieldType, TypeElement fieldTypeElement) { + // Skip supplier generation for functional interfaces + if (isFunctionalInterface(fieldTypeElement)) { + return; + } + result.addMethod(createFieldSupplier(fieldName, fieldType, result.getFieldGenerics())); } - private static MethodDto createFieldSetter(String fieldName, TypeName fieldType) { - return createFieldSetterWithTransform(fieldName, null, fieldType); + private static Optional createFieldDto( + ExecutableElement mth, Elements elementUtils, Types typeUtils) { + String methodName = mth.getSimpleName().toString(); + String fieldName = StringUtils.uncapitalize(StringUtils.removeStart(methodName, "set")); + + FieldDto result = new FieldDto(); + result.setFieldName(fieldName); + List parameters = mth.getParameters(); + if (parameters.size() != 1) { + // TODO: Logging + // Sollte eigentlich nie vorkommen, da das vorher raus gefiltert wurde + return Optional.empty(); + } + VariableElement fieldParameter = parameters.get(0); + TypeMirror fieldTypeMirror = fieldParameter.asType(); + Element rawElement = typeUtils.asElement(fieldTypeMirror); + TypeElement fieldTypeElement = rawElement instanceof TypeElement te ? te : null; + + // Extract only the @param Javadoc for the single setter parameter (if present) + String fullJavaDoc = elementUtils.getDocComment(mth); + String parameterJavaDocExtracted = + JavaLangAnalyser.extractParamJavaDoc(fullJavaDoc, fieldParameter); + String parameterJavaDoc = + parameterJavaDocExtracted == null ? fieldName : parameterJavaDocExtracted; + result.setJavaDoc(parameterJavaDoc); + + // extracting type of field + MethodParameterDto fieldParameterDto = + map2MethodParameter(fieldParameter, elementUtils, typeUtils); + if (fieldParameterDto == null) { + // TODO: Logging + return Optional.empty(); + } + TypeName fieldType = fieldParameterDto.getParameterType(); + + // Extract generics declared on the setter itself (field-specific), e.g., + List fieldGenerics = + mth.getTypeParameters().stream() + .map(tp -> JavaLangMapper.map2GenericParameterDto(tp, elementUtils, typeUtils)) + .toList(); + result.setFieldGenerics(fieldGenerics); + + // simple setter + result.addMethod(createFieldSetterWithTransform(fieldName, null, fieldType, fieldGenerics)); + + // add consumer/supplier generation via helpers + addConsumerMethodsForField( + result, fieldName, fieldType, fieldParameter, fieldTypeElement, elementUtils, typeUtils); + addSupplierMethodsForField(result, fieldName, fieldType, fieldTypeElement); + addAdditionalHelperMethodsForField(result, fieldName, fieldType); + + return Optional.of(result); } private static MethodDto createFieldSetterWithTransform( - String fieldName, String transform, TypeName fieldType) { + String fieldName, + String transform, + TypeName fieldType, + List fieldGenerics) { MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName); parameter.setParameterTypeName(fieldType); @@ -214,6 +275,7 @@ private static MethodDto createFieldSetterWithTransform( methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.PROXY); + methodDto.setMethodGenerics(fieldGenerics); String params; if (StringUtils.isBlank(transform)) { params = parameter.getParameterName(); @@ -282,7 +344,8 @@ private static MethodDto createFieldConsumerWithBuilder(String fieldName, TypeNa return methodDto; } - private static MethodDto createFieldSupplier(String fieldName, TypeName fieldType) { + private static MethodDto createFieldSupplier( + String fieldName, TypeName fieldType, List fieldGenerics) { TypeNameGeneric supplierType = new TypeNameGeneric(map2TypeName(Supplier.class), fieldType); MethodParameterDto parameter = new MethodParameterDto(); parameter.setParameterName(fieldName + "Supplier"); @@ -292,6 +355,7 @@ private static MethodDto createFieldSupplier(String fieldName, TypeName fieldTyp methodDto.addParameter(parameter); methodDto.setModifier(Modifier.PUBLIC); methodDto.setMethodType(MethodTypes.SUPPLIER); + methodDto.setMethodGenerics(fieldGenerics); methodDto.setCode( """ instance.$dtoMethod:N($dtoMethodParam:N.get()); 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 c8106bf9..8e14c365 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 @@ -70,37 +70,57 @@ public JavaCodeGenerator(Filer filer) { * @throws BuilderException if there is an error in source code generation */ public void generateBuilder(BuilderDefinitionDto builderDef) throws BuilderException { - ClassName builderClass = map2ClassName(builderDef.getBuilderTypeName()); - ClassName dtoClass = map2ClassName(builderDef.getBuildingTargetTypeName()); + ClassName builderBaseClass = map2ClassName(builderDef.getBuilderTypeName()); + ClassName dtoBaseClass = map2ClassName(builderDef.getBuildingTargetTypeName()); + com.palantir.javapoet.TypeName builderTypeName; + com.palantir.javapoet.TypeName dtoTypeName; + if (builderDef.getGenerics().isEmpty()) { + builderTypeName = builderBaseClass; + dtoTypeName = dtoBaseClass; + } else { + builderTypeName = + map2ParameterizedTypeName(builderDef.getBuilderTypeName(), builderDef.getGenerics()); + dtoTypeName = + map2ParameterizedTypeName( + builderDef.getBuildingTargetTypeName(), builderDef.getGenerics()); + } + TypeSpec.Builder classBuilder = - TypeSpec.classBuilder(builderClass) - .addJavadoc(createJavadocForClass(dtoClass)) - .addSuperinterface(createInterfaceBuilderBase(dtoClass)) - .addField(createFieldDtoInstance(dtoClass)); + TypeSpec.classBuilder(builderBaseClass) + .addTypeVariables(map2TypeVariables(builderDef.getGenerics())) + .addJavadoc(createJavadocForClass(dtoBaseClass)) + .addSuperinterface(createInterfaceBuilderBase(dtoTypeName)) + .addField(createFieldDtoInstance(dtoTypeName)); // TODO: Constructors sollten in der BuilderDefinitionDto definiert werden, nur für Klassen mit // leerem Constructor ist auch ein Builder mit leerem Constructor möglich - classBuilder.addMethod(createConstructorWithInstance(dtoClass)); - classBuilder.addMethod(createEmptyConstructor(dtoClass)); + classBuilder.addMethod(createConstructorWithInstance(dtoBaseClass, dtoTypeName)); + classBuilder.addMethod(createEmptyConstructor(dtoBaseClass, builderDef.getGenerics())); // Generate Methods in Builder without being setter or getter for (MethodDto methodDto : builderDef.getMethodsForBuilder()) { - classBuilder.addMethod(createMethod(methodDto, builderClass)); + classBuilder.addMethod(createMethod(methodDto, builderTypeName)); } // Generate Fields and Fieldspecific funtions in Builder for (FieldDto fieldDto : builderDef.getSetterFieldsForBuilder()) { - List methodSpecs = createFieldMethods(fieldDto, builderClass); + List methodSpecs = createFieldMethods(fieldDto, builderTypeName); classBuilder.addMethods(methodSpecs); } // Adding builder-specific methods - classBuilder.addMethod(createMethodBuild(dtoClass)); - classBuilder.addMethod(createMethodStaticCreate(builderClass, dtoClass)); + classBuilder.addMethod(createMethodBuild(dtoTypeName)); + classBuilder.addMethod( + createMethodStaticCreate( + builderBaseClass, + builderTypeName, + dtoBaseClass, + dtoTypeName, + builderDef.getGenerics())); // Adding annotations classBuilder.addAnnotation(createAnnotationGenerated()); - classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoClass)); + classBuilder.addAnnotation(createAnnotationBuilderImplementation(dtoBaseClass)); writeClassToFile(builderDef.getBuilderTypeName().getPackageName(), classBuilder.build()); } @@ -117,8 +137,8 @@ private CodeBlock createJavadocForClass(ClassName dtoClass) { return CodeBlock.of("Builder for {@code $1N.$2T}.", dtoClass.packageName(), dtoClass); } - private ParameterizedTypeName createInterfaceBuilderBase(ClassName dtoClass) { - return ParameterizedTypeName.get(ClassName.get(IBuilderBase.class), dtoClass); + private ParameterizedTypeName createInterfaceBuilderBase(com.palantir.javapoet.TypeName dtoType) { + return ParameterizedTypeName.get(ClassName.get(IBuilderBase.class), dtoType); } private AnnotationSpec createAnnotationGenerated() { @@ -136,29 +156,37 @@ private AnnotationSpec createAnnotationBuilderImplementation(ClassName dtoClass) .build(); } - private FieldSpec createFieldDtoInstance(ClassName dtoClassName) { - return FieldSpec.builder(dtoClassName, "instance", Modifier.PRIVATE, Modifier.FINAL) + private FieldSpec createFieldDtoInstance(com.palantir.javapoet.TypeName dtoTypeName) { + return FieldSpec.builder(dtoTypeName, "instance", Modifier.PRIVATE, Modifier.FINAL) .addJavadoc("Inner instance of builder.") .build(); } - private MethodSpec createEmptyConstructor(ClassName dtoClass) { - return MethodSpec.constructorBuilder() - .addModifiers(Modifier.PUBLIC) - .addJavadoc( - """ + private MethodSpec createEmptyConstructor( + ClassName dtoClass, List generics) { + MethodSpec.Builder constructorBuilder = + MethodSpec.constructorBuilder() + .addModifiers(Modifier.PUBLIC) + .addJavadoc( + """ Empty constructor of builder for {@code $1N.$2T}. """, - dtoClass.packageName(), - dtoClass) - .addStatement("this.instance = new $1T()", dtoClass) - .build(); + dtoClass.packageName(), + dtoClass); + if (generics.isEmpty()) { + constructorBuilder.addStatement("this.instance = new $1T()", dtoClass); + } else { + // Use diamond operator only if the DTO is generic; otherwise, use raw constructor + constructorBuilder.addStatement("this.instance = new $1T<>()", dtoClass); + } + return constructorBuilder.build(); } - private MethodSpec createConstructorWithInstance(ClassName dtoClass) { + private MethodSpec createConstructorWithInstance( + ClassName dtoBaseClass, com.palantir.javapoet.TypeName dtoType) { return MethodSpec.constructorBuilder() .addModifiers(Modifier.PUBLIC) - .addParameter(dtoClass, "instance") + .addParameter(dtoType, "instance") .addStatement("this.instance = instance") .addJavadoc( """ @@ -166,12 +194,12 @@ private MethodSpec createConstructorWithInstance(ClassName dtoClass) { @param instance object instance for initialisiation """, - dtoClass.packageName(), - dtoClass) + dtoBaseClass.packageName(), + dtoBaseClass) .build(); } - private MethodSpec createMethodBuild(ClassName returnType) { + private MethodSpec createMethodBuild(com.palantir.javapoet.TypeName returnType) { return MethodSpec.methodBuilder("build") .addModifiers(PUBLIC) .returns(returnType) @@ -183,36 +211,72 @@ private MethodSpec createMethodBuild(ClassName returnType) { .build(); } - private MethodSpec createMethodStaticCreate(ClassName builderType, ClassName buildTargetType) { - return MethodSpec.methodBuilder("create") - .addModifiers(STATIC, PUBLIC) - .returns(builderType) - .addJavadoc( - """ + private MethodSpec createMethodStaticCreate( + com.palantir.javapoet.ClassName builderBaseClass, + com.palantir.javapoet.TypeName builderType, + com.palantir.javapoet.ClassName dtoBaseClass, + com.palantir.javapoet.TypeName dtoType, + List generics) { + MethodSpec.Builder methodBuilder = + MethodSpec.methodBuilder("create") + .addModifiers(STATIC, PUBLIC) + .addJavadoc( + """ Creating a new builder for {@code $1N.$2T}. @return builder for {@code $1N.$2T} """, - buildTargetType.packageName(), - buildTargetType) - .addCode( - """ - $1T instance = new $1T(); - return new $2T(instance); - """, - buildTargetType, - builderType) - .build(); + dtoBaseClass.packageName(), + dtoBaseClass); + if (generics.isEmpty()) { + methodBuilder + .returns(builderBaseClass) + .addCode( + """ + $1T instance = new $1T(); + return new $2T(instance); + """, + dtoBaseClass, + builderBaseClass); + } else { + methodBuilder + .returns(builderType) + .addCode( + """ + $1T instance = new $2T<>(); + return new $3T<>(instance); + """, + dtoType, + dtoBaseClass, + builderBaseClass) + .addTypeVariables(map2TypeVariables(generics)); + } + return methodBuilder.build(); } - private List createFieldMethods(FieldDto fieldDto, ClassName builderClassName) { - return fieldDto.getMethods().stream().map(m -> createMethod(m, builderClassName)).toList(); + private List createFieldMethods( + FieldDto fieldDto, com.palantir.javapoet.TypeName builderTypeName) { + return fieldDto.getMethods().stream() + .map(m -> createMethod(m, builderTypeName, fieldDto.getJavaDoc())) + .toList(); } - private MethodSpec createMethod(MethodDto methodDto, ClassName returnType) { + private MethodSpec createMethod(MethodDto methodDto, com.palantir.javapoet.TypeName returnType) { + // TODO: Remove when switched to fields instead of instance in builder + return createMethod(methodDto, returnType, ""); + } + + private MethodSpec createMethod( + MethodDto methodDto, + com.palantir.javapoet.TypeName returnType, + String optionalFieldParamJavaDoc) { MethodSpec.Builder methodBuilder = MethodSpec.methodBuilder(methodDto.getMethodName()).returns(returnType); methodDto.getModifier().ifPresent(methodBuilder::addModifiers); + // add method-level generics if present (e.g., ) + if (!methodDto.getMethodGenerics().isEmpty()) { + methodBuilder.addTypeVariables(map2TypeVariables(methodDto.getMethodGenerics())); + } int maxIndexParameters = methodDto.getParameters().size() - 1; // Adding javadoc for method @@ -246,24 +310,26 @@ private MethodSpec createMethod(MethodDto methodDto, ClassName returnType) { // Extending Javadoc with parameters switch (methodDto.getMethodType()) { case PROXY -> - methodBuilder.addJavadoc("\n@param $1N value for $1N.", paramDto.getParameterName()); + methodBuilder.addJavadoc( + "\n@param $1N $2N", paramDto.getParameterName(), optionalFieldParamJavaDoc); case CONSUMER -> methodBuilder.addJavadoc( - "\n@param $1N consumer providing instance of field $2N.", + "\n@param $1N consumer providing an instance of $2N", paramDto.getParameterName(), - methodDto.getMethodName()); + optionalFieldParamJavaDoc); case CONSUMER_BY_BUILDER -> methodBuilder.addJavadoc( - "\n@param $1N consumer providing instance of a builder for field $2N.", + "\n@param $1N consumer providing an instance of a builder for $2N", paramDto.getParameterName(), - methodDto.getMethodName()); + optionalFieldParamJavaDoc); case SUPPLIER -> methodBuilder.addJavadoc( - "\n@param $1N supplier for field $2N.", + "\n@param $1N supplier for $2N", paramDto.getParameterName(), - methodDto.getMethodName()); + optionalFieldParamJavaDoc); } } + CodeBlock codeBlock = map2CodeBlock(methodDto.getMethodCodeDto()); methodBuilder.addCode(codeBlock).addJavadoc("\n@return current instance of builder"); return methodBuilder.build(); 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 333e8b0d..89d8b635 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 @@ -35,9 +35,11 @@ import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; import javax.lang.model.util.ElementFilter; import javax.lang.model.util.Elements; import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; /** Helperclass for extrating specific information from existing classes. */ public final class JavaLangAnalyser { @@ -129,6 +131,9 @@ public static boolean hasEmptyConstructor(TypeElement typeElement, Elements elem */ public static Optional findAnnotation( Element methodElement, Class annotationClass) { + if (methodElement == null) { + return Optional.empty(); + } for (AnnotationMirror annotationMirror : methodElement.getAnnotationMirrors()) { if (annotationMirror @@ -141,4 +146,86 @@ public static Optional findAnnotation( return Optional.empty(); } + + /** + * Determines whether a given type element is a functional interface. + * + *

Prefers the explicit @FunctionalInterface annotation. Otherwise, returns true only if the + * element is an interface and declares exactly one abstract instance method (ignoring static and + * default methods). Inherited abstract methods are ignored for simplicity. + */ + public static boolean isFunctionalInterface(TypeElement typeElement) { + if (typeElement == null) { + return false; + } + + // Prefer explicit annotation + if (JavaLangAnalyser.findAnnotation(typeElement, FunctionalInterface.class).isPresent()) { + return true; + } + // Only interfaces can be functional interfaces + if (typeElement.getKind() != javax.lang.model.element.ElementKind.INTERFACE) { + return false; + } + // Heuristic: exactly one abstract method declared (ignores inherited ones for simplicity) + long abstractDeclared = + ElementFilter.methodsIn(typeElement.getEnclosedElements()).stream() + .filter(m -> !m.getModifiers().contains(javax.lang.model.element.Modifier.STATIC)) + .filter(m -> !m.getModifiers().contains(javax.lang.model.element.Modifier.DEFAULT)) + .count(); + return abstractDeclared == 1; + } + + /** + * Extracts the Javadoc text following the @param tag for the given parameter name. Continuation + * lines are supported until the next Javadoc tag (starting with '@') or an empty line. + * + * @param javaDoc the full raw Javadoc as returned by Elements.getDocComment(...) + * @param parameter the parameter to look for + * @return the extracted text or null if not found or empty + */ + public static String extractParamJavaDoc(String javaDoc, VariableElement parameter) { + if (javaDoc == null || parameter == null) { + return null; + } + String parameterName = parameter.getSimpleName().toString(); + String[] lines = javaDoc.split("\r?\n"); + boolean capture = false; + StringBuilder sb = new StringBuilder(); + for (String rawLine : lines) { + String line = rawLine.trim(); + if (line.startsWith("*")) { + line = line.substring(1).trim(); + } + if (line.startsWith("@")) { + // new tag begins; stop capturing unless this is our @param start + capture = false; + if (line.startsWith("@param ")) { + // format: @param text... + String rest = line.substring("@param ".length()).trim(); + int sp = rest.indexOf(' '); + String name = sp >= 0 ? rest.substring(0, sp) : rest; + String text = sp >= 0 ? rest.substring(sp + 1).trim() : ""; + if (Strings.CI.equals(parameterName, name)) { + if (!text.isEmpty()) { + sb.append(text); + } + capture = true; + } + } + continue; + } + if (capture) { + if (line.isEmpty()) { + break; + } + if (sb.length() > 0) sb.append(' '); + sb.append(line); + } else { + // TODO: add logging for not found javadoc for parameter + } + } + String result = sb.toString().trim(); + return result.isEmpty() ? null : result; + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangMapper.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangMapper.java index 52e0b8ea..68c50fe8 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangMapper.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/util/JavaLangMapper.java @@ -33,20 +33,17 @@ import java.util.Set; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; +import javax.lang.model.element.TypeParameterElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.ArrayType; import javax.lang.model.type.DeclaredType; import javax.lang.model.type.PrimitiveType; -import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; +import javax.lang.model.type.TypeVariable; import javax.lang.model.util.Elements; import javax.lang.model.util.SimpleTypeVisitor14; import javax.lang.model.util.Types; -import org.javahelpers.simple.builders.processor.dtos.MethodParameterDto; -import org.javahelpers.simple.builders.processor.dtos.TypeName; -import org.javahelpers.simple.builders.processor.dtos.TypeNameArray; -import org.javahelpers.simple.builders.processor.dtos.TypeNameGeneric; -import org.javahelpers.simple.builders.processor.dtos.TypeNamePrimitive; +import org.javahelpers.simple.builders.processor.dtos.*; /** Helper functions to create simple builder types from java.lang types. */ public final class JavaLangMapper { @@ -78,6 +75,26 @@ public static TypeName map2TypeName(Class clazz) { return new TypeName(clazz.getPackageName(), clazz.getSimpleName()); } + /** Maps the declared type parameters of the given type element into GenericParameterDto list. */ + public static List map2GenericParameterDtos( + TypeElement type, Elements elementUtils, Types typeUtils) { + return type.getTypeParameters().stream() + .map(tp -> map2GenericParameterDto(tp, elementUtils, typeUtils)) + .toList(); + } + + /** Maps a single {@code TypeParameterElement} to {@code GenericParameterDto}. */ + public static GenericParameterDto map2GenericParameterDto( + TypeParameterElement tp, Elements elementUtils, Types typeUtils) { + GenericParameterDto g = new GenericParameterDto(); + g.setName(tp.getSimpleName().toString()); + tp.getBounds().stream() + .filter(b -> !"java.lang.Object".equals(b.toString())) + .map(b -> extractType(b, elementUtils, typeUtils)) + .forEach(g::addUpperBound); + return g; + } + /** * Mapping a method parameter to {@code * org.javahelpers.simple.builders.processor.dtos.MethodParameterDto}. @@ -142,7 +159,7 @@ public TypeName visitDeclared(DeclaredType t, Void p) { TypeName rawType = new TypeName(packageName, simpleClassName); TypeMirror enclosingType = t.getEnclosingType(); TypeName enclosing = - (enclosingType.getKind() != TypeKind.NONE) + (enclosingType.getKind() != NONE) && !t.asElement().getModifiers().contains(Modifier.STATIC) ? enclosingType.accept(this, null) : null; @@ -166,6 +183,12 @@ public TypeNameArray visitArray(ArrayType t, Void _p) { extractType(t.getComponentType(), elementUtils, typeUtils), false); } + @Override + public TypeName visitTypeVariable(TypeVariable t, Void _p) { + String name = t.asElement().getSimpleName().toString(); + return new TypeNameVariable(name); + } + @Override protected TypeName defaultAction(TypeMirror e, Void _p) { throw new IllegalArgumentException("Unexpected type mirror: " + e); 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 0164b988..fd271988 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 @@ -24,13 +24,13 @@ package org.javahelpers.simple.builders.processor.util; -import static org.javahelpers.simple.builders.processor.dtos.TypeNamePrimitive.PrimitiveTypeEnum.BOOLEAN; - import com.palantir.javapoet.ArrayTypeName; import com.palantir.javapoet.ClassName; import com.palantir.javapoet.CodeBlock; import com.palantir.javapoet.ParameterizedTypeName; import com.palantir.javapoet.TypeName; +import com.palantir.javapoet.TypeVariableName; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -100,6 +100,31 @@ public static ClassName map2ClassName( return ClassName.get(typeName.getPackageName(), typeName.getClassName()); } + public static ParameterizedTypeName map2ParameterizedTypeName( + org.javahelpers.simple.builders.processor.dtos.TypeName baseType, + List builderGenerics) { + ClassName baseTypeClassName = map2ClassName(baseType); + return ParameterizedTypeName.get( + baseTypeClassName, map2TypeVariables(builderGenerics).toArray(new TypeVariableName[0])); + } + + public static List map2TypeVariables( + List builderGenerics) { + List javapoetGenerics = new ArrayList<>(); + for (GenericParameterDto g : builderGenerics) { + List bounds = new ArrayList<>(); + for (org.javahelpers.simple.builders.processor.dtos.TypeName b : g.getUpperBounds()) { + bounds.add(map2ParameterType(b)); + } + TypeVariableName tv = + bounds.isEmpty() + ? TypeVariableName.get(g.getName()) + : TypeVariableName.get(g.getName(), bounds.toArray(new TypeName[0])); + javapoetGenerics.add(tv); + } + return javapoetGenerics; + } + /** * CodeBlock creating by definition in {@code MethodCodeDto}. * 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 6a96fba7..2670f420 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 @@ -3,6 +3,7 @@ import static com.google.testing.compile.CompilationSubject.assertThat; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.assertGenerationSucceeded; import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.contains; +import static org.javahelpers.simple.builders.processor.testing.ProcessorAsserts.notContains; import static org.javahelpers.simple.builders.processor.testing.ProcessorTestUtils.loadGeneratedSource; import com.google.testing.compile.Compilation; @@ -31,12 +32,13 @@ protected void setUp() { void shouldFailWhenAnnotationPlacedOnInterface() { // Given: @SimpleBuilder used on an interface instead of a class JavaFileObject source = - JavaFileObjects.forSourceString( - "test.WrongTargetInterface", - "package test;\n" - + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;\n" - + "@SimpleBuilder\n" - + "public interface WrongTargetInterface { void x(); }\n"); + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public interface WrongTargetInterface { void x(); } + """); // When Compilation compilation = @@ -54,12 +56,13 @@ void shouldFailWhenAnnotationPlacedOnInterface() { void shouldFailWhenAnnotationPlacedOnAbstractClass() { // Given: @SimpleBuilder used on an abstract class JavaFileObject source = - JavaFileObjects.forSourceString( - "test.AbstractAnnotated", - "package test;\n" - + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;\n" - + "@SimpleBuilder\n" - + "public abstract class AbstractAnnotated { }\n"); + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public abstract class AbstractAnnotated { } + """); // When Compilation compilation = @@ -74,7 +77,7 @@ void shouldFailWhenAnnotationPlacedOnAbstractClass() { } @Test - @Disabled("Constructor-Parameters are not supported yet") + @Disabled("TODO: Constructor-Parameters are not supported yet") void shouldIgnoreConstructorParametersAndOnlyUseSetters() { // Given String packageName = "test"; @@ -95,7 +98,7 @@ public CtorAndSetter() {} public String getName() { return name; } public void setName(String name) { this.name = name; } // a has no setter intentionally - """); + """); // When Compilation compilation = compile(sourceFile); @@ -104,36 +107,33 @@ public CtorAndSetter() {} String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Setter-based API should appear (name) and Constructor params (a) - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public CtorAndSetterBuilder a(int a)"), - contains("instance.setA(a);"), - contains("public CtorAndSetterBuilder name(String name)"), - contains("instance.setName(name);")); + "public CtorAndSetterBuilder a(int a)", + "instance.setA(a);", + "public CtorAndSetterBuilder name(String name)", + "instance.setName(name);"); } @Test - @Disabled("Records are not supported for builder generation yet") + @Disabled("TODO: Records are not supported for builder generation yet") void shouldHandleRecordWithoutGeneratingBuilder() { // Given - String packageName = "test"; String recordName = "PersonRecord"; String builderClassName = recordName + "Builder"; - String source = - """ - package test; - - import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + JavaFileObject recordFile = + ProcessorTestUtils.forSource( + """ + package test; - @SimpleBuilder - public record PersonRecord(String name, int age) { - public String upperName() { return name.toUpperCase(); } - } - """; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; - JavaFileObject recordFile = - JavaFileObjects.forSourceString(packageName + "." + recordName, source); + @SimpleBuilder + public record PersonRecord(String name, int age) { + public String upperName() { return name.toUpperCase(); } + } + """); // When Compilation compilation = compile(recordFile); @@ -142,12 +142,12 @@ public record PersonRecord(String name, int age) { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Setter-based API should support record Constructor params (name, age) - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public PersonRecordBuilder name(String name)"), - contains("instance.setName(name);"), - contains("public PersonRecordBuilder age(int age)"), - contains("instance.setAge(age);")); + "public PersonRecordBuilder name(String name)", + "instance.setName(name);", + "public PersonRecordBuilder age(int age)", + "instance.setAge(age);"); } @Test @@ -174,7 +174,7 @@ void shouldGenerateCollectionSettersAndProviders() { public java.util.Map getMap() { return map; } public void setMap(java.util.Map map) { this.map = map; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -184,19 +184,19 @@ void shouldGenerateCollectionSettersAndProviders() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Direct setters and varargs convenience for List and Set; direct setter for Map - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public WithCollectionsBuilder names(List names)"), - contains("public WithCollectionsBuilder names(Supplier> namesSupplier)"), - contains("public WithCollectionsBuilder names(String... names)"), - contains("instance.setNames(List.of(names));"), - contains("public WithCollectionsBuilder tags(Set tags)"), - contains("public WithCollectionsBuilder tags(Supplier> tagsSupplier)"), - contains("public WithCollectionsBuilder tags(String... tags)"), - contains("instance.setTags(Set.of(tags));"), - contains("public WithCollectionsBuilder map(Map map)"), - contains("public WithCollectionsBuilder map(Supplier> mapSupplier)"), - contains("instance.setMap(map);")); + "public WithCollectionsBuilder names(List names)", + "public WithCollectionsBuilder names(Supplier> namesSupplier)", + "public WithCollectionsBuilder names(String... names)", + "instance.setNames(List.of(names));", + "public WithCollectionsBuilder tags(Set tags)", + "public WithCollectionsBuilder tags(Supplier> tagsSupplier)", + "public WithCollectionsBuilder tags(String... tags)", + "instance.setTags(Set.of(tags));", + "public WithCollectionsBuilder map(Map map)", + "public WithCollectionsBuilder map(Supplier> mapSupplier)", + "instance.setMap(map);"); } @Test @@ -212,7 +212,7 @@ void shouldDeclareInstanceFieldAsPrivateFinalWithJavadoc() { className, """ private int x; public int getX(){return x;} public void setX(int x){this.x=x;} - """); + """); // When Compilation compilation = compile(sourceFile); @@ -221,10 +221,8 @@ void shouldDeclareInstanceFieldAsPrivateFinalWithJavadoc() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, - contains("Inner instance of builder."), - contains("private final FieldDoc instance;")); + ProcessorAsserts.assertContaining( + generatedCode, "Inner instance of builder.", "private final FieldDoc instance;"); } @Test @@ -239,8 +237,10 @@ void shouldGenerateCreateMethodWithModifiersAndJavadoc() { packageName, className, """ - private int x; public int getX(){return x;} public void setX(int x){this.x=x;} - """); + private int x; + public int getX(){return x;} + public void setX(int x){this.x=x;} + """); // When Compilation compilation = compile(sourceFile); @@ -249,11 +249,11 @@ void shouldGenerateCreateMethodWithModifiersAndJavadoc() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public static CreateDocBuilder create()"), - contains("Creating a new builder for {"), - contains("@return builder for {")); + "public static CreateDocBuilder create()", + "Creating a new builder for {@code test.CreateDoc}", + "@return builder for {@code test.CreateDoc}"); } @Test @@ -268,8 +268,10 @@ void shouldGenerateBuildOverrideAndReturnInstance() { packageName, className, """ - private int x; public int getX(){return x;} public void setX(int x){this.x=x;} - """); + private int x; + public int getX(){return x;} + public void setX(int x){this.x=x;} + """); // When Compilation compilation = compile(sourceFile); @@ -278,11 +280,8 @@ void shouldGenerateBuildOverrideAndReturnInstance() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, - contains("@Override"), - contains("public BuildDoc build()"), - contains("return instance;")); + ProcessorAsserts.assertContaining( + generatedCode, "@Override", "public BuildDoc build()", "return instance;"); } @Test @@ -298,7 +297,7 @@ void shouldReturnThisFromFluentMethods() { className, """ private String name; public String getName(){return name;} public void setName(String name){this.name=name;} - """); + """); // When Compilation compilation = compile(sourceFile); @@ -307,8 +306,7 @@ void shouldReturnThisFromFluentMethods() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, contains("instance.setName(name);"), contains("return this;")); + ProcessorAsserts.assertContaining(generatedCode, "instance.setName(name);", "return this;"); } @Test @@ -330,25 +328,23 @@ void shouldContainReturnJavadocForConsumerSupplierAndConsumerByBuilder() { public void setName(String name) { this.name = name; } public void setHelper(HelperAnno helper) { this.helper = helper; } public void setHelperPlain(HelperPlain helperPlain) { this.helperPlain = helperPlain; } - """); + """); JavaFileObject helperAnno = - JavaFileObjects.forSourceString( - packageName + ".HelperAnno", - "package " - + packageName - + ";\n" - + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;\n" - + "@SimpleBuilder\n" - + "public class HelperAnno { public HelperAnno() {} }\n"); + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class HelperAnno { public HelperAnno() {} } + """); JavaFileObject helperPlain = - JavaFileObjects.forSourceString( - packageName + ".HelperPlain", - "package " - + packageName - + ";\n" - + "public class HelperPlain { public HelperPlain() {} }\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class HelperPlain { public HelperPlain() {} } + """); // When Compilation compilation = compile(dto, helperAnno, helperPlain); @@ -358,14 +354,14 @@ void shouldContainReturnJavadocForConsumerSupplierAndConsumerByBuilder() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // '@return current instance of builder' should be present for all method types - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("helperBuilderConsumer"), - contains("@return current instance of builder"), - contains("helperPlainConsumer"), - contains("@return current instance of builder"), - contains("nameSupplier"), - contains("@return current instance of builder")); + "helperBuilderConsumer", + "@return current instance of builder", + "helperPlainConsumer", + "@return current instance of builder", + "nameSupplier", + "@return current instance of builder"); } @Test @@ -381,7 +377,7 @@ void shouldGenerateProxyWithMixedParamsAndArrayVarargs() { className, """ public void doMixed(int a, String b, int[] nums) { /* no-op */ } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -391,10 +387,8 @@ public void doMixed(int a, String b, int[] nums) { /* no-op */ } assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Expect last parameter mapped to varargs in builder signature and proper call - ProcessorAsserts.assertingResult( - generatedCode, - contains("doMixed(int a, String b, int... nums)"), - contains("instance.doMixed(a,b,nums);")); + ProcessorAsserts.assertContaining( + generatedCode, "doMixed(int a, String b, int... nums)", "instance.doMixed(a,b,nums);"); } @Test @@ -420,8 +414,7 @@ void shouldIncludeNestedPackageInClassJavadoc() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Javadoc line should contain the nested package name and the class indicator - ProcessorAsserts.assertingResult( - generatedCode, contains("Builder for {"), contains("test.nested")); + ProcessorAsserts.assertContaining(generatedCode, "Builder for {", "test.nested"); } @Test @@ -440,7 +433,7 @@ void shouldGenerateClassJavadocAndBaseInterface() { public int getN() { return n; } public void setN(int n) { this.n = n; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -450,8 +443,8 @@ void shouldGenerateClassJavadocAndBaseInterface() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Class javadoc and interface - ProcessorAsserts.assertingResult( - generatedCode, contains("Builder for {"), contains("implements IBuilderBase")); + ProcessorAsserts.assertContaining( + generatedCode, "Builder for {", "implements IBuilderBase"); } @Test @@ -469,7 +462,7 @@ void shouldGenerateBothConstructorsWithJavadocs() { private String x; public String getX() { return x; } public void setX(String x) { this.x = x; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -478,13 +471,13 @@ void shouldGenerateBothConstructorsWithJavadocs() { String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("Empty constructor of builder for"), - contains("Initialisation of builder for"), - contains("@param instance object instance for initialisiation"), - contains("this.instance = instance;"), - contains("this.instance = new CtorDoc();")); + "Empty constructor of builder for", + "Initialisation of builder for", + "@param instance object instance for initialisiation", + "this.instance = instance;", + "this.instance = new CtorDoc();"); } @Test @@ -508,14 +501,13 @@ public void doSomething(int a, String b) { /* no-op */ } """); JavaFileObject helper = - JavaFileObjects.forSourceString( - packageName + ".HelperPlain", - "package " - + packageName - + ";\n" - + "public class HelperPlain {\n" - + " public HelperPlain() {}\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class HelperPlain { + public HelperPlain() {} + } + """); // When Compilation compilation = compile(dto, helper); @@ -525,22 +517,56 @@ public void doSomething(int a, String b) { /* no-op */ } assertGenerationSucceeded(compilation, builderClassName, generatedCode); // PROXY method javadoc has @param for each parameter and @return - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("Calling doSomething on dto-instance with parameters."), - contains("@param a value for a."), - contains("@param b value for b."), - contains("@return current instance of builder")); + "Calling doSomething on dto-instance with parameters.", + "@param a", + "@param b", + "@return current instance of builder"); + + // Setter method javadoc and code (default values, when nothing is set) + ProcessorAsserts.assertContaining(generatedCode, "@param name name"); // SUPPLIER method javadoc and code - ProcessorAsserts.assertingResult( - generatedCode, contains("supplier for field"), contains("nameSupplier")); + ProcessorAsserts.assertContaining(generatedCode, "@param nameSupplier supplier for name"); // CONSUMER method javadoc and code - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( + generatedCode, "@param helperPlainConsumer consumer providing an instance of helperPlain"); + } + + @Test + void shouldPropagateFieldJavadocToBuilderParameter() { + // Given + String packageName = "test"; + String className = "FieldWithDoc"; + String builderClassName = className + "Builder"; + + JavaFileObject dto = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private String name; + + public String getName() { return name; } + /** + * @param name the person name + */ + public void setName(String name) { this.name = name; } + """); + + // When + Compilation compilation = compile(dto); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect the field's Javadoc text to appear on the builder method parameter + ProcessorAsserts.assertContaining( generatedCode, - contains("consumer providing instance of field"), - contains("helperPlainConsumer")); + "@param name the person name", + "public FieldWithDocBuilder name(String name)"); } @Test @@ -559,19 +585,18 @@ void shouldGenerateConsumerByBuilderForAnnotatedField() { public HelperAnno getHelper() { return helper; } public void setHelper(HelperAnno helper) { this.helper = helper; } - """); + """); JavaFileObject helper = - JavaFileObjects.forSourceString( - packageName + ".HelperAnno", - "package " - + packageName - + ";\n" - + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;\n" - + "@SimpleBuilder\n" - + "public class HelperAnno {\n" - + " public HelperAnno() {}\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class HelperAnno { + public HelperAnno() {} + } + """); // When Compilation compilation = compile(dto, helper); @@ -581,12 +606,12 @@ void shouldGenerateConsumerByBuilderForAnnotatedField() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Expect consumer-by-builder method using HelperAnnoBuilder and builder.build() - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("helperBuilderConsumer"), - contains("HelperAnnoBuilder builder = new HelperAnnoBuilder();"), - contains("helperBuilderConsumer.accept(builder);"), - contains("instance.setHelper(builder.build());")); + "helperBuilderConsumer", + "HelperAnnoBuilder builder = new HelperAnnoBuilder();", + "helperBuilderConsumer.accept(builder);", + "instance.setHelper(builder.build());"); } @Test @@ -605,17 +630,16 @@ void shouldGenerateConsumerForCustomTypeWithEmptyConstructor() { public HelperPlain getHelperPlain() { return helperPlain; } public void setHelperPlain(HelperPlain helperPlain) { this.helperPlain = helperPlain; } - """); + """); JavaFileObject helper = - JavaFileObjects.forSourceString( - packageName + ".HelperPlain", - "package " - + packageName - + ";\n" - + "public class HelperPlain {\n" - + " public HelperPlain() {}\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class HelperPlain { + public HelperPlain() {} + } + """); // When Compilation compilation = compile(dto, helper); @@ -625,11 +649,11 @@ void shouldGenerateConsumerForCustomTypeWithEmptyConstructor() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Expect consumer method with local var named 'consumer' of HelperPlain - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("HelperPlain consumer = new HelperPlain();"), - contains("helperPlainConsumer.accept(consumer);"), - contains("instance.setHelperPlain(consumer);")); + "HelperPlain consumer = new HelperPlain();", + "helperPlainConsumer.accept(consumer);", + "instance.setHelperPlain(consumer);"); } @Test @@ -648,7 +672,7 @@ void shouldGenerateSupplierMethodForField() { public String getName() { return name; } public void setName(String name) { this.name = name; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -658,8 +682,8 @@ void shouldGenerateSupplierMethodForField() { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Expect supplier-based setter usage - ProcessorAsserts.assertingResult( - generatedCode, contains("nameSupplier"), contains("instance.setName(nameSupplier.get());")); + ProcessorAsserts.assertContaining( + generatedCode, "nameSupplier", "instance.setName(nameSupplier.get());"); } protected Compilation compile(JavaFileObject... sourceFiles) { @@ -670,12 +694,13 @@ protected Compilation compile(JavaFileObject... sourceFiles) { void shouldFailCompilationOnLowerReleaseOption() { // Given: a minimal @SimpleBuilder-annotated class JavaFileObject source = - JavaFileObjects.forSourceString( - "test.ForcedOldRelease", - "package test;\n" - + "import org.javahelpers.simple.builders.core.annotations.SimpleBuilder;\n" - + "@SimpleBuilder\n" - + "public class ForcedOldRelease { public ForcedOldRelease() {} }\n"); + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class ForcedOldRelease { public ForcedOldRelease() {} } + """); // When: compile with a lower language level to simulate older Java (no production code change) Compilation compilation = @@ -721,7 +746,7 @@ public int getAge() { public void setAge(int age) { this.age = age; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -731,17 +756,17 @@ public void setAge(int age) { assertGenerationSucceeded(compilation, builderClassName, generatedCode); // Verify the generated code contains builder methods (build/create are checked centrally) - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public PersonBuilder name(String name)"), - contains("public PersonBuilder age(int age)")); + "public PersonBuilder name(String name)", + "public PersonBuilder age(int age)"); } @Test - void shouldGenerateSetterForSetOfStrings_whenImplemented() { + void shouldGenerateSetterForClassInDifferentPackage() { // Given String packageName = "test"; - String className = "HasSetString"; + String className = "UsesOtherPackageHelper"; String builderClassName = className + "Builder"; JavaFileObject sourceFile = @@ -749,20 +774,33 @@ void shouldGenerateSetterForSetOfStrings_whenImplemented() { packageName, className, """ - private java.util.Set tags; + private otherpkg.Helper helper; - public java.util.Set getTags() { return tags; } - public void setTags(java.util.Set tags) { this.tags = tags; } - """); + public otherpkg.Helper getHelper() { + return helper; + } + public void setHelper(otherpkg.Helper helper) { + this.helper = helper; + } + """); + + JavaFileObject helperSource = + ProcessorTestUtils.forSource( + """ + package otherpkg; + public class Helper { + public Helper() {} + } + """); // When - Compilation compilation = compile(sourceFile); + Compilation compilation = compile(sourceFile, helperSource); // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, contains("public HasSetStringBuilder tags(Set tags)")); + ProcessorAsserts.assertContaining( + generatedCode, "public UsesOtherPackageHelperBuilder helper(Helper helper)"); } @Test @@ -781,48 +819,25 @@ void shouldHandleSetOfStrings() { public java.util.Set getTags() { return tags; } public void setTags(java.util.Set tags) { this.tags = tags; } - """); + """); // When Compilation compilation = compile(sourceFile); // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded( compilation, builderClassName, loadGeneratedSource(compilation, builderClassName)); - // Currently no positive assertion; future expectation covered by test below - } - - @Test - void shouldGenerateSetterForHelperInDifferentPackage_whenImplemented() { - // Given - String packageName = "test"; - String className = "UsesOtherPackageHelper"; - String builderClassName = className + "Builder"; - - JavaFileObject sourceFile = - ProcessorTestUtils.simpleBuilderClass( - packageName, - className, - """ - private otherpkg.Helper helper; - - public otherpkg.Helper getHelper() { return helper; } - public void setHelper(otherpkg.Helper helper) { this.helper = helper; } - """); - - JavaFileObject helperSource = - JavaFileObjects.forSourceString( - "otherpkg.Helper", - "package otherpkg;\npublic class Helper {\n public Helper() {}\n}\n"); - - // When - Compilation compilation = compile(sourceFile, helperSource); - - // Then - String generatedCode = loadGeneratedSource(compilation, builderClassName); - assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, contains("public UsesOtherPackageHelperBuilder helper(Helper helper)")); + ProcessorAsserts.assertContaining( + generatedCode, + "public HasSetStringBuilder tags(Set tags)", + "instance.setTags(tags);", + "public HasSetStringBuilder tags(Consumer> tagsBuilderConsumer)", + "instance.setTags(builder.build());", + "public HasSetStringBuilder tags(String... tags)", + "instance.setTags(Set.of(tags));", + "public HasSetStringBuilder tags(Supplier> tagsSupplier)", + "instance.setTags(tagsSupplier.get());"); } @Test @@ -841,54 +856,16 @@ void shouldHandleSetOfCustomType() { public java.util.Set getHelpers() { return helpers; } public void setHelpers(java.util.Set helpers) { this.helpers = helpers; } - """); + """); JavaFileObject helperSource = - JavaFileObjects.forSourceString( - packageName + ".Helper", - "package " - + packageName - + ";\n" - + "public class Helper {\n" - + " public Helper() {}\n" - + "}\n"); - - // When - Compilation compilation = compile(sourceFile, helperSource); - - // Then - assertGenerationSucceeded( - compilation, builderClassName, loadGeneratedSource(compilation, builderClassName)); - // build/create are checked centrally; positive setter check covered by @Disabled test below - } - - @Test - void shouldGenerateSetterForSetOfCustomType_whenImplemented() { - // Given - String packageName = "test"; - String className = "HasSetCustom"; - String builderClassName = className + "Builder"; - - JavaFileObject sourceFile = - ProcessorTestUtils.simpleBuilderClass( - packageName, - className, + ProcessorTestUtils.forSource( """ - private java.util.Set helpers; - - public java.util.Set getHelpers() { return helpers; } - public void setHelpers(java.util.Set helpers) { this.helpers = helpers; } - """); - - JavaFileObject helperSource = - JavaFileObjects.forSourceString( - packageName + ".Helper", - "package " - + packageName - + ";\n" - + "public class Helper {\n" - + " public Helper() {}\n" - + "}\n"); + package test; + public class Helper { + public Helper() {} + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -896,8 +873,16 @@ void shouldGenerateSetterForSetOfCustomType_whenImplemented() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, contains("public HasSetCustomBuilder helpers(Set helpers)")); + ProcessorAsserts.assertContaining( + generatedCode, + "public HasSetCustomBuilder helpers(Set helpers)", + "instance.setHelpers(helpers);", + "public HasSetCustomBuilder helpers(Consumer> helpersBuilderConsumer)", + "instance.setHelpers(builder.build());", + "public HasSetCustomBuilder helpers(Helper... helpers)", + "instance.setHelpers(Set.of(helpers));", + "public HasSetCustomBuilder helpers(Supplier> helpersSupplier)", + "instance.setHelpers(helpersSupplier.get());"); } @Test @@ -916,12 +901,16 @@ void shouldHandleHelperInDifferentPackage() { public otherpkg.Helper getHelper() { return helper; } public void setHelper(otherpkg.Helper helper) { this.helper = helper; } - """); + """); JavaFileObject helperSource = - JavaFileObjects.forSourceString( - "otherpkg.Helper", - "package otherpkg;\n" + "public class Helper {\n" + " public Helper() {}\n" + "}\n"); + ProcessorTestUtils.forSource( + """ + package otherpkg; + public class Helper { + public Helper() {} + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -947,12 +936,16 @@ void shouldFilterOutNonRelevantMethods() { // valid proxy candidate public void ok() {} + // valid setter + public void setOk(int ok) {} + // should be filtered - private void hidden() {} - public static void util() {} - public void risky() throws Exception {} + private void setHidden(int hidden) {} + public static void setUtil(int util) {} + public void setRisky(int risk) throws Exception {} public int returnsInt() { return 42; } - """); + public int getJustGetter() { return 25; } + """); // When Compilation compilation = compile(sourceFile); @@ -965,13 +958,13 @@ public void risky() throws Exception {} generatedCode, contains("public HasVariousMethodsBuilder ok()"), contains("instance.ok();"), - contains("return this;")); - ProcessorAsserts.assertNotContaining( - generatedCode, - ProcessorAsserts.notContains("hidden()"), - ProcessorAsserts.notContains("util()"), - ProcessorAsserts.notContains("risky()"), - ProcessorAsserts.notContains("returnsInt(")); + contains("public HasVariousMethodsBuilder ok(int ok)"), + contains("instance.setOk(ok);"), + notContains("hidden"), + notContains("util(int util)"), + notContains("risky(int risk)"), + notContains("returnsInt"), + notContains("justGetter")); } @Test @@ -994,7 +987,7 @@ void shouldNotGenerateConsumersForPrimitiveAndArrayFields() { public String[] getNames() { return names; } public void setNames(String[] names) { this.names = names; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1005,16 +998,10 @@ void shouldNotGenerateConsumersForPrimitiveAndArrayFields() { // Expect simple setters and suppliers only; no consumer methods for primitive/array ProcessorAsserts.assertingResult( generatedCode, - contains("public PrimAndArrayBuilder count("), - contains("countSupplier)"), - contains("public PrimAndArrayBuilder names("), - contains("namesSupplier)")); - ProcessorAsserts.assertNotContaining( - generatedCode, - ProcessorAsserts.notContains("countConsumer"), - ProcessorAsserts.notContains("namesConsumer"), - ProcessorAsserts.notContains("countBuilderConsumer"), - ProcessorAsserts.notContains("namesBuilderConsumer")); + contains("public PrimAndArrayBuilder count(Supplier"), + contains("public PrimAndArrayBuilder names(Supplier"), + notContains("public PrimAndArrayBuilder count(Consumer"), + notContains("public PrimAndArrayBuilder names(Consumer")); } @Test @@ -1033,7 +1020,7 @@ void shouldNotGenerateSpecialMethodsForMapBeyondSetterAndSupplier() { public java.util.Map getMap() { return map; } public void setMap(java.util.Map map) { this.map = map; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1044,13 +1031,13 @@ void shouldNotGenerateSpecialMethodsForMapBeyondSetterAndSupplier() { // Expect only direct setter and supplier; no varargs/consumer for Map ProcessorAsserts.assertingResult( - generatedCode, contains("public HasMapBuilder map("), contains("mapSupplier)")); - ProcessorAsserts.assertNotContaining( generatedCode, - ProcessorAsserts.notContains("map(String..."), - ProcessorAsserts.notContains("map(java.lang.String..."), - ProcessorAsserts.notContains("mapBuilderConsumer"), - ProcessorAsserts.notContains("mapConsumer")); + contains("public HasMapBuilder map("), + contains("mapSupplier)"), + notContains("map(String..."), + notContains("map(java.lang.String..."), + notContains("mapBuilderConsumer"), + notContains("mapConsumer")); } @Test @@ -1069,17 +1056,16 @@ void shouldNotGenerateConsumerForAbstractHelper() { public HelperAbs getHelperAbs() { return helperAbs; } public void setHelperAbs(HelperAbs helperAbs) { this.helperAbs = helperAbs; } - """); + """); JavaFileObject helperAbs = - JavaFileObjects.forSourceString( - packageName + ".HelperAbs", - "package " - + packageName - + ";\n" - + "public abstract class HelperAbs {\n" - + " public HelperAbs() {}\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public abstract class HelperAbs { + public HelperAbs() {} + } + """); // When Compilation compilation = compile(dto, helperAbs); @@ -1090,9 +1076,7 @@ void shouldNotGenerateConsumerForAbstractHelper() { // No consumer method should be generated for abstract helper type ProcessorAsserts.assertNotContaining( - generatedCode, - ProcessorAsserts.notContains("helperAbsConsumer"), - ProcessorAsserts.notContains("helperAbsBuilderConsumer")); + generatedCode, "helperAbsConsumer", "helperAbsBuilderConsumer"); } @Test @@ -1110,7 +1094,7 @@ void shouldGenerateAnnotationsAndCreateBuildMethods() { private int a; public int getA() { return a; } public void setA(int a) { this.a = a; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1118,14 +1102,14 @@ void shouldGenerateAnnotationsAndCreateBuildMethods() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("@Generated("), - contains("@BuilderImplementation("), - contains("public static AnnoTargetBuilder create()"), - contains("return new AnnoTargetBuilder(instance);"), - contains("public AnnoTarget build()"), - contains("return instance;")); + "@Generated(", + "@BuilderImplementation(", + "public static AnnoTargetBuilder create()", + "return new AnnoTargetBuilder(instance);", + "public AnnoTarget build()", + "return instance;"); } @Test @@ -1148,7 +1132,7 @@ void shouldGenerateVarargsConvenienceForCollections() { public java.util.Set getTags() { return tags; } public void setTags(java.util.Set tags) { this.tags = tags; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1156,44 +1140,12 @@ void shouldGenerateVarargsConvenienceForCollections() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( + ProcessorAsserts.assertContaining( generatedCode, - contains("public HasCollectionsConvenienceBuilder names(String... names)"), - contains("instance.setNames(List.of(names));"), - contains("public HasCollectionsConvenienceBuilder tags(String... tags)"), - contains("instance.setTags(Set.of(tags));")); - } - - @Test - void shouldGenerateProxyMethodIncludingVarargsParameters() { - // Given - String packageName = "test"; - String className = "HasActionMethods"; - String builderClassName = className + "Builder"; - - JavaFileObject sourceFile = - ProcessorTestUtils.simpleBuilderClass( - packageName, - className, - """ - public void reset(int level, String[] notes) {} - public void clearAll() {} - """); - - // When - Compilation compilation = compile(sourceFile); - - // Then - String generatedCode = loadGeneratedSource(compilation, builderClassName); - assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // Method proxies return builder and forward to instance, last array parameter becomes varargs - ProcessorAsserts.assertingResult( - generatedCode, - contains("public HasActionMethodsBuilder reset(int level, String... notes)"), - contains("instance.reset(level,notes);"), - contains("public HasActionMethodsBuilder clearAll()"), - contains("instance.clearAll();"), - contains("return this;")); + "public HasCollectionsConvenienceBuilder names(String... names)", + "instance.setNames(List.of(names));", + "public HasCollectionsConvenienceBuilder tags(String... tags)", + "instance.setTags(Set.of(tags));"); } @Test @@ -1212,43 +1164,18 @@ void shouldHandleJavaTimeField() { public java.time.LocalDate getDate() { return date; } public void setDate(java.time.LocalDate date) { this.date = date; } - """); + """); // When Compilation compilation = compile(sourceFile); // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); - // Currently no positive assertion; future expectation covered by @Disabled test below assertGenerationSucceeded(compilation, builderClassName, generatedCode); - } - - @Test - void shouldGenerateSetterForJavaTime_whenImplemented() { - // Given - String packageName = "test"; - String className = "HasLocalDate"; - String builderClassName = className + "Builder"; - - JavaFileObject sourceFile = - ProcessorTestUtils.simpleBuilderClass( - packageName, - className, - """ - private java.time.LocalDate date; - - public java.time.LocalDate getDate() { return date; } - public void setDate(java.time.LocalDate date) { this.date = date; } - """); - - // When - Compilation compilation = compile(sourceFile); - - // Then - String generatedCode = loadGeneratedSource(compilation, builderClassName); - assertGenerationSucceeded(compilation, builderClassName, generatedCode); - ProcessorAsserts.assertingResult( - generatedCode, contains("public HasLocalDateBuilder date(LocalDate date)")); + ProcessorAsserts.assertContaining( + generatedCode, + "public HasLocalDateBuilder date(LocalDate date)", + "instance.setDate(date);"); } @Test @@ -1283,7 +1210,7 @@ void shouldHandleMixedFields() { public java.time.LocalDate getDate() { return date; } public void setDate(java.time.LocalDate date) { this.date = date; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1291,7 +1218,27 @@ void shouldHandleMixedFields() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // build/create are checked centrally; no additional builder setter checks here + // Expect signatures for all supported setter patterns per field kind + ProcessorAsserts.assertingResult( + generatedCode, + // primitive int: direct and supplier (boxed) + contains("public HasMixedBuilder a(int a)"), + contains("public HasMixedBuilder a(Supplier aSupplier)"), + // array String[]: direct and supplier only (no varargs, no collection-builder consumers) + contains("public HasMixedBuilder names(String... names)"), + contains("public HasMixedBuilder names(Supplier namesSupplier)"), + // List: direct, supplier, varargs convenience and consumer with ArrayListBuilder + contains("public HasMixedBuilder list(List list)"), + contains("public HasMixedBuilder list(Supplier> listSupplier)"), + contains("public HasMixedBuilder list(String... list)"), + contains( + "public HasMixedBuilder list(Consumer> listBuilderConsumer)"), + // Map: direct and supplier only + contains("public HasMixedBuilder map(Map map)"), + contains("public HasMixedBuilder map(Supplier> mapSupplier)"), + // LocalDate: direct and supplier + contains("public HasMixedBuilder date(LocalDate date)"), + contains("public HasMixedBuilder date(Supplier dateSupplier)")); } @Test @@ -1310,7 +1257,7 @@ void shouldHandleObjectArrayField() { public String[] getNames() { return names; } public void setNames(String[] names) { this.names = names; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1318,7 +1265,15 @@ void shouldHandleObjectArrayField() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // build/create are checked centrally; no additional builder setter checks here + // Arrays should get setter and supplier + ProcessorAsserts.assertingResult( + generatedCode, + contains("public HasObjectArrayBuilder names(Supplier namesSupplier)"), + contains("public HasObjectArrayBuilder names(String... names)"), + notContains("public HasObjectArrayBuilder names(String[] names)"), + notContains("public HasObjectArrayBuilder names(List names)"), // TODO: feature + notContains("Consumer getNames() { return names; } public void setNames(java.util.List names) { this.names = names; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1424,16 +1384,68 @@ void shouldHandleListField() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // build/create are checked centrally; no additional builder setter checks here + // Expect direct setter, supplier, varargs convenience, and consumer with ArrayListBuilder + ProcessorAsserts.assertContaining( + generatedCode, + "public HasListBuilder names(List names)", + "public HasListBuilder names(Supplier> namesSupplier)", + "public HasListBuilder names(String... names)", + "public HasListBuilder names(Consumer> namesBuilderConsumer)"); } @Test - void shouldBoxPrimitiveTypeArgumentsInGenerics() { + void shouldBoxPrimitiveTypeArguments() { // Given String packageName = "test"; String className = "HasConsumer"; String builderClassName = className + "Builder"; + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private int intValue; + private long longValue; + private double doubleValue; + private boolean booleanValue; + + public int getIntValue() { return intValue; } + public void setIntValue(int intValue) { this.intValue = intValue; } + public long getLongValue() { return longValue; } + public void setLongValue(long longValue) { this.longValue = longValue; } + public double getDoubleValue() { return doubleValue; } + public void setDoubleValue(double doubleValue) { this.doubleValue = doubleValue; } + public boolean getBooleanValue() { return booleanValue; } + public void setBooleanValue(boolean booleanValue) { this.booleanValue = booleanValue; } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Ensure builder compiles and does not use primitive type argument like + ProcessorAsserts.assertingResult( + generatedCode, + notContains(""), + notContains(""), + notContains(""), + notContains(""), + contains("public HasConsumerBuilder intValue(Supplier intValueSupplier)"), + contains("public HasConsumerBuilder longValue(Supplier longValueSupplier)"), + contains("public HasConsumerBuilder doubleValue(Supplier doubleValueSupplier)"), + contains("public HasConsumerBuilder booleanValue(Supplier booleanValueSupplier)")); + } + + @Test + void shouldHandleFunctionalInterfaces() { + // Given + String packageName = "test"; + String className = "HasFunctionalInterface"; + String builderClassName = className + "Builder"; + JavaFileObject sourceFile = ProcessorTestUtils.simpleBuilderClass( packageName, @@ -1443,7 +1455,7 @@ void shouldBoxPrimitiveTypeArgumentsInGenerics() { public java.util.function.Consumer getConsumer() { return consumer; } public void setConsumer(java.util.function.Consumer consumer) { this.consumer = consumer; } - """); + """); // When Compilation compilation = compile(sourceFile); @@ -1451,8 +1463,45 @@ void shouldBoxPrimitiveTypeArgumentsInGenerics() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - // Ensure builder compiles and does not use primitive type argument like - ProcessorAsserts.assertNotContaining(generatedCode, ProcessorAsserts.notContains("")); + // Ensure builder compiles and not generates Consumer of Consumer or Supplier of Consumer + ProcessorAsserts.assertNotContaining(generatedCode, "Consumer"); + } + + @Test + void shouldGenerateBuilderForParentClassSetter() { + // Given + String className = "ChildInheritsSetter"; + String builderClassName = className + "Builder"; + + JavaFileObject parentSource = + ProcessorTestUtils.forSource( + """ + package test; + public class ParentWithSetter { + private String name; + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + """); + + JavaFileObject childSource = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class ChildInheritsSetter extends ParentWithSetter { } + """); + + // When + Compilation compilation = compile(childSource, parentSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect builder to expose setter for parent class property + ProcessorAsserts.assertingResult( + generatedCode, contains("public ChildInheritsSetterBuilder name(String name)")); } @Test @@ -1471,21 +1520,20 @@ void shouldHandleNestedTypeWithEmptyConstructor() { public Helper getHelper() { return helper; } public void setHelper(Helper helper) { this.helper = helper; } - """); + """); // And a top-level helper class with empty constructor JavaFileObject helperSource = - JavaFileObjects.forSourceString( - packageName + ".Helper", - "package " - + packageName - + ";\n" - + "public class Helper {\n" - + " public Helper() {}\n" - + " private String v;\n" - + " public String getV() { return v; }\n" - + " public void setV(String v) { this.v = v; }\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class Helper { + public Helper() {} + private String v; + public String getV() { return v; } + public void setV(String v) { this.v = v; } + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -1493,8 +1541,12 @@ void shouldHandleNestedTypeWithEmptyConstructor() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - - // build/create are checked centrally; no additional builder setter checks here + // Expect direct setter, supplier and consumer creating instance via empty constructor + ProcessorAsserts.assertContaining( + generatedCode, + "public OuterWithHelperBuilder helper(Helper helper)", + "public OuterWithHelperBuilder helper(Supplier helperSupplier)", + "public OuterWithHelperBuilder helper(Consumer helperConsumer)"); } @Test @@ -1513,20 +1565,19 @@ void shouldHandleNestedTypeWithoutEmptyConstructor() { public HelperNoEmpty getHelper() { return helper; } public void setHelper(HelperNoEmpty helper) { this.helper = helper; } - """); + """); // And a top-level helper class without empty constructor JavaFileObject helperSource = - JavaFileObjects.forSourceString( - packageName + ".HelperNoEmpty", - "package " - + packageName - + ";\n" - + "public class HelperNoEmpty {\n" - + " private final int x;\n" - + " public HelperNoEmpty(int x) { this.x = x; }\n" - + " public int getX() { return x; }\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class HelperNoEmpty { + private final int x; + public HelperNoEmpty(int x) { this.x = x; } + public int getX() { return x; } + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -1534,8 +1585,61 @@ void shouldHandleNestedTypeWithoutEmptyConstructor() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect only direct setter and supplier. No consumer method should be generated. + ProcessorAsserts.assertingResult( + generatedCode, + contains("public OuterWithNoEmptyHelperBuilder helper(HelperNoEmpty helper)"), + contains( + "public OuterWithNoEmptyHelperBuilder helper(Supplier helperSupplier)"), + notContains( + "public OuterWithNoEmptyHelperBuilder helper(Consumer helperConsumer)")); + } - // build/create are checked centrally; no additional builder setter checks here + @Test + void shouldHandleListOfCustomTypeWithoutEmptyConstructor() { + // Given + String packageName = "test"; + String className = "HasListNoEmpty"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private java.util.List helpers; + + public java.util.List getHelpers() { return helpers; } + public void setHelpers(java.util.List helpers) { this.helpers = helpers; } + """); + + // And a top-level HelperNoEmpty class referenced by the list, without empty constructor + JavaFileObject helperSource = + ProcessorTestUtils.forSource( + """ + package test; + public class HelperNoEmpty { + private final int x; + public HelperNoEmpty(int x) { this.x = x; } + public int getX() { return x; } + } + """); + + // When + Compilation compilation = compile(sourceFile, helperSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect direct setter, varargs, supplier and consumer builder method + ProcessorAsserts.assertingResult( + generatedCode, + contains("public HasListNoEmptyBuilder helpers(List helpers)"), + contains("public HasListNoEmptyBuilder helpers(HelperNoEmpty... helpers)"), + contains( + "public HasListNoEmptyBuilder helpers(Supplier> helpersSupplier)"), + contains( + "public HasListNoEmptyBuilder helpers( Consumer> helpersBuilderConsumer)")); } @Test @@ -1554,18 +1658,17 @@ void shouldHandleListOfCustomType() { public java.util.List getHelpers() { return helpers; } public void setHelpers(java.util.List helpers) { this.helpers = helpers; } - """); + """); // And a top-level Helper class referenced by the list JavaFileObject helperSource = - JavaFileObjects.forSourceString( - packageName + ".Helper", - "package " - + packageName - + ";\n" - + "public class Helper {\n" - + " public Helper() {}\n" - + "}\n"); + ProcessorTestUtils.forSource( + """ + package test; + public class Helper { + public Helper() {} + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -1573,8 +1676,13 @@ void shouldHandleListOfCustomType() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); - - // build/create are checked centrally; no additional builder setter checks here + // Expect direct setter, supplier, varargs convenience, and consumer with ArrayListBuilder + ProcessorAsserts.assertContaining( + generatedCode, + "public HasListCustomBuilder helpers(List helpers)", + "public HasListCustomBuilder helpers(Supplier> helpersSupplier)", + "public HasListCustomBuilder helpers(Helper... helpers)", + "public HasListCustomBuilder helpers(Consumer> helpersBuilderConsumer)"); } @Test @@ -1593,18 +1701,18 @@ void shouldHandleMapWithCustomValueType() { public java.util.Map getMap() { return map; } public void setMap(java.util.Map map) { this.map = map; } - """); + """); // And a top-level Helper class referenced by the map JavaFileObject helperSource = JavaFileObjects.forSourceString( packageName + ".Helper", - "package " - + packageName - + ";\n" - + "public class Helper {\n" - + " public Helper() {}\n" - + "}\n"); + """ + package test; + public class Helper { + public Helper() {} + } + """); // When Compilation compilation = compile(sourceFile, helperSource); @@ -1612,7 +1720,243 @@ void shouldHandleMapWithCustomValueType() { // Then String generatedCode = loadGeneratedSource(compilation, builderClassName); assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect only direct setter and supplier for Map; no varargs or collection-builder consumer + ProcessorAsserts.assertingResult( + generatedCode, + contains("public HasMapCustomBuilder map(Map map)"), + contains("public HasMapCustomBuilder map(Supplier> mapSupplier)"), + notContains("public HasMapCustomBuilder map(String..."), + notContains("Consumer { + private String value; + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + } + """); + + // When + Compilation compilation = compile(genericDtoSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertingResult( + generatedCode, + // builder preserves type parameter T + contains("class GenericDtoBuilder"), + // build returns GenericDto + contains("public GenericDto build()"), + // create() exposes generic as well + contains("public static GenericDtoBuilder create()")); + } + + @Test + void shouldGenerateGenericBuilderWithTypeParameterInField() { + // Given + String builderClassName = "GenericDtoBuilder"; + + JavaFileObject genericDtoSource = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class GenericDto { + private T value; + public T getValue() { return value; } + public void setValue(T value) { this.value = value; } + } + """); + + // When + Compilation compilation = compile(genericDtoSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertingResult( + generatedCode, + // builder preserves type parameter T + contains("class GenericDtoBuilder"), + // setter keeps T + contains("public GenericDtoBuilder value(T value)"), + // supplier-based setter retains T + contains("public GenericDtoBuilder value(Supplier valueSupplier)"), + // no direct consumer possible for unknown T (no empty ctor info) + notContains("public GenericDtoBuilder value(Consumer valueConsumer)"), + // build returns GenericDto + contains("public GenericDto build()"), + // create() exposes generic as well + contains("public static GenericDtoBuilder create()")); + } + + @Test + void shouldGenerateGenericBuilderWithMultipleTypeParameterInFields() { + // Given + String builderClassName = "GenericDtoBuilder"; + + JavaFileObject genericDtoSource = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class GenericDto { + private T valueT; + private U valueU; + public T getValueT() { return valueT; } + public void setValueT(T valueT) { this.valueT = valueT; } + public U getValueU() { return valueU; } + public void setValueU(U valueU) { this.valueU = valueU; } + } + """); + + // When + Compilation compilation = compile(genericDtoSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertThat(compilation).succeededWithoutWarnings(); + ProcessorAsserts.assertingResult( + generatedCode, + // builder preserves type parameter T + contains("class GenericDtoBuilder"), + // setter keeps T + contains("public GenericDtoBuilder valueT(T valueT)"), + // setter keeps U + contains("public GenericDtoBuilder valueU(U valueU)"), + // supplier-based setter retains T + contains("public GenericDtoBuilder valueT(Supplier valueTSupplier)"), + // supplier-based setter retains U + contains("public GenericDtoBuilder valueU(Supplier valueUSupplier)"), + // no direct consumer possible for unknown T (no empty ctor info) + notContains("public GenericDtoBuilder valueT(Consumer valueTConsumer)"), + // no direct consumer possible for unknown U (no empty ctor info) + notContains("public GenericDtoBuilder valueU(Consumer valueUConsumer)"), + // build returns GenericDto + contains("public GenericDto build()"), + // create() exposes generic as well + contains("public static GenericDtoBuilder create()")); + } + + @Test + void shouldGenerateBuilderRetainingFieldspecificTypeParameter() { + // Given + String builderClassName = "GenericFieldDtoBuilder"; + + JavaFileObject genericDtoSource = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + @SimpleBuilder + public class GenericFieldDto { + private java.io.Serializable value; + public java.io.Serializable getValue() { return value; } + public void setValue(T value) { this.value = value; } + } + """); + + // When + Compilation compilation = compile(genericDtoSource); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + ProcessorAsserts.assertingResult( + generatedCode, + // builder preserves type parameter T + contains("class GenericFieldDtoBuilder"), + // new import for Serializable + contains("import java.io.Serializable;"), + // setter keeps T + contains("public GenericFieldDtoBuilder value(T value)"), + // supplier-based setter retains T + contains( + "public GenericFieldDtoBuilder value(Supplier valueSupplier)"), + // no direct consumer possible for unknown T (no empty ctor info) + notContains("GenericFieldDtoBuilder value(Consumer"), + // build returns GenericFieldDto + contains("public GenericFieldDto build()"), + // create() exposes no generic, because the Builder is not generic + contains("public static GenericFieldDtoBuilder create()")); + } + + @Test + void shouldNotGenerateBuilderWhenNestedBuilderInterfaceExists() { + // Given: an annotated class that already declares a nested interface named like the would-be + // builder + JavaFileObject source = + ProcessorTestUtils.forSource( + """ + package test; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + public class HasInnerClassWithBuilder { + @SimpleBuilder + public class InnerClass { + private int x; + public int getX(){return x;} + public void setX(int x){this.x=x;} + } + } + """); + + // When + Compilation compilation = compile(source); + + // Then: compilation succeeds and no builder class with the conflicting name is generated + assertThat(compilation).succeeded(); + org.junit.jupiter.api.Assertions.assertFalse( + compilation.generatedFiles().stream() + .anyMatch(f -> f.getName().endsWith("test/HasInnerClassWithBuilderBuilder.java")), + "Builder should not be generated when nested builder interface is present"); + } + + @Test + @Disabled("TODO: missing feature") + void shouldHandleOverloadedSettersForSameFieldWithoutConflicts() { + // Given + String packageName = "test"; + String className = "OverloadedNames"; + String builderClassName = className + "Builder"; + + JavaFileObject sourceFile = + ProcessorTestUtils.simpleBuilderClass( + packageName, + className, + """ + private java.util.List names; + + public java.util.List getNames() { return names; } + public void setNames(java.util.List names) { this.names = names; } + public void setNames(String... names) { this.names = java.util.List.of(names); } + """); + + // When + Compilation compilation = compile(sourceFile); + + // Then + String generatedCode = loadGeneratedSource(compilation, builderClassName); + assertGenerationSucceeded(compilation, builderClassName, generatedCode); + // Expect exactly the canonical builder methods without signature conflicts + ProcessorAsserts.assertContaining( + generatedCode, + "public OverloadedNamesBuilder names(List names)", + "public OverloadedNamesBuilder names(Supplier> namesSupplier)", + "public OverloadedNamesBuilder names(String... names)"); } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/JavaLangAnalyserTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/JavaLangAnalyserTest.java new file mode 100644 index 00000000..76a073f3 --- /dev/null +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JavaLangAnalyserTest.java @@ -0,0 +1,206 @@ +package org.javahelpers.simple.builders.processor; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.annotation.Annotation; +import java.util.List; +import java.util.Set; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.ElementVisitor; +import javax.lang.model.element.Modifier; +import javax.lang.model.element.Name; +import javax.lang.model.element.VariableElement; +import javax.lang.model.type.TypeMirror; +import org.javahelpers.simple.builders.processor.util.JavaLangAnalyser; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link JavaLangAnalyser}. */ +class JavaLangAnalyserTest { + + private static class TestVariableElement implements VariableElement { + private final String name; + + TestVariableElement(String name) { + this.name = name; + } + + @Override + public Name getSimpleName() { + return new Name() { + @Override + public String toString() { + return name; + } + + @Override + public boolean contentEquals(CharSequence cs) { + return name.contentEquals(cs); + } + + @Override + public int length() { + return name.length(); + } + + @Override + public char charAt(int index) { + return name.charAt(index); + } + + @Override + public CharSequence subSequence(int start, int end) { + return name.subSequence(start, end); + } + }; + } + + // Required methods with default implementations + @Override + public Object getConstantValue() { + return null; + } + + @Override + public R accept(ElementVisitor v, P p) { + return null; + } + + @Override + public Element getEnclosingElement() { + return null; + } + + @Override + public List getEnclosedElements() { + return List.of(); + } + + @Override + public TypeMirror asType() { + return null; + } + + @Override + public ElementKind getKind() { + return ElementKind.PARAMETER; + } + + @Override + public Set getModifiers() { + return Set.of(); + } + + @Override + public List getAnnotationMirrors() { + return List.of(); + } + + @Override + public A getAnnotation(Class annotationType) { + return null; + } + + @Override + public A[] getAnnotationsByType(Class annotationType) { + return null; + } + } + + private final VariableElement parameter = new TestVariableElement("testParam"); + + @Test + void extractParamJavaDoc_shouldReturnNull_whenJavaDocIsNull() { + assertNull(JavaLangAnalyser.extractParamJavaDoc(null, parameter)); + } + + @Test + void extractParamJavaDoc_shouldReturnNull_whenParameterIsNull() { + assertNull(JavaLangAnalyser.extractParamJavaDoc("Some Javadoc", null)); + } + + @Test + void extractParamJavaDoc_shouldReturnNull_whenParamTagNotFound() { + String javaDoc = + "/**\n * Some method description.\n * @param otherParam some other parameter\n * @return something\n */"; + assertNull(JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldExtractSingleLineParamDoc() { + String javaDoc = + "/**\n * Some method description.\n * @param testParam this is a test parameter\n * @return something\n */"; + assertEquals( + "this is a test parameter", JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldExtractMultiLineParamDoc() { + String javaDoc = + "/**\n * Some method description.\n * @param testParam this is a test parameter\n * that spans multiple lines\n * with additional details\n * @return something\n */"; + assertEquals( + "this is a test parameter that spans multiple lines with additional details", + JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldStopAtNextTag() { + String javaDoc = + "/**\n * Some method description.\n * @param testParam this is a test parameter\n * @return something\n * @since 1.0\n */"; + assertEquals( + "this is a test parameter", JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldHandleEmptyParamDoc() { + String javaDoc = + "/**\n * Some method description.\n * @param testParam\n * @return something\n */"; + assertNull(JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldHandleWhitespaceOnlyParamDoc() { + String javaDoc = + "/**\n * Some method description.\n * @param testParam \n * @return something\n */"; + assertNull(JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldHandleWindowsLineEndings() { + String javaDoc = + "/**\r\n * Some method description.\r\n * @param testParam this is a test parameter\r\n * @return something\r\n */"; + assertEquals( + "this is a test parameter", JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldStopAtEmptyLine() { + String javaDoc = + "/**\n * @param testParam this is a test parameter\n *\n * Some additional description\n * @return something\n */"; + assertEquals( + "this is a test parameter", JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldHandleEmptyParamTag() { + String javaDoc = "/**\n * @param testParam\n * @return something\n */"; + assertNull(JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldHandleWhitespaceOnlyParamTag() { + String javaDoc = + "/**\n * @param testParam \t\n * @return something\n */"; + assertNull(JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } + + @Test + void extractParamJavaDoc_shouldAddSpaceBetweenLines() { + String javaDoc = + "/**\n * @param testParam first line\n * second line\n * third line\n * @return something\n */"; + assertEquals( + "first line second line third line", + JavaLangAnalyser.extractParamJavaDoc(javaDoc, parameter)); + } +} diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java index c6e6786c..8d721199 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/testing/ProcessorAsserts.java @@ -16,6 +16,12 @@ public final class ProcessorAsserts { private ProcessorAsserts() {} + /** Collapse all whitespace (spaces, tabs, newlines) to single spaces and trim. */ + private static String normalizeWhitespace(String input) { + if (input == null) return null; + return input.replaceAll("\\s+", " ").trim(); + } + /** * Asserts compilation succeeded and that the basic builder API exists in the provided generated * source (build() and static create()). @@ -29,57 +35,78 @@ public static void assertGenerationSucceeded( ? builderSimpleName.substring(0, builderSimpleName.length() - "Builder".length()) : builderSimpleName; + String normalized = normalizeWhitespace(generated); Assertions.assertTrue( - generated.contains("public " + targetSimpleName + " build()"), + normalized.contains(normalizeWhitespace("public " + targetSimpleName + " build()")), "method missing: 'public " + targetSimpleName + " build()'"); Assertions.assertTrue( - generated.contains("public static " + builderSimpleName + " create()"), + normalized.contains( + normalizeWhitespace("public static " + builderSimpleName + " create()")), "method missing: 'public static " + builderSimpleName + " create()'"); } + /** Common interface for assertion records. */ + public interface AssertRecord { + String search(); + + String message(); + } + /** Coupling of a search string with an assertion message for positive contains checks. */ - public static record ContainsAssertRecord(String search, String message) {} + public static record ContainsAssertRecord(String search, String message) + implements AssertRecord {} /** Coupling of a search string with an assertion message for negative contains checks. */ - public static record NotContainsAssertRecord(String search, String message) {} - - /** Factory for positive contains check with custom message. */ - public static ContainsAssertRecord containsWithMessage(String search, String message) { - return new ContainsAssertRecord(search, message); - } + public static record NotContainsAssertRecord(String search, String message) + implements AssertRecord {} /** Factory for positive contains check with default message. */ public static ContainsAssertRecord contains(String search) { - return new ContainsAssertRecord(search, String.format("method missing: '%s'", search)); - } - - /** Factory for negative contains check with custom message. */ - public static NotContainsAssertRecord notContainsWithMessage(String search, String message) { - return new NotContainsAssertRecord(search, message); + return new ContainsAssertRecord(search, String.format("content missing: '%s'", search)); } /** Factory for negative contains check with default message. */ public static NotContainsAssertRecord notContains(String search) { - return new NotContainsAssertRecord(search, String.format("should not contain: '%s'", search)); + return new NotContainsAssertRecord( + search, String.format("content should not be found: '%s'", search)); } - /** Assert that generated code contains all provided checks. */ - public static void assertingResult(String generatedCode, ContainsAssertRecord... checks) { + /** Assert that generated code matches all provided checks (positive or negative). */ + public static void assertingResult(String generatedCode, AssertRecord... checks) { List executables = new ArrayList<>(); - for (ContainsAssertRecord check : checks) { - executables.add( - () -> Assertions.assertTrue(generatedCode.contains(check.search()), check.message())); + String normalizedGenerated = normalizeWhitespace(generatedCode); + for (AssertRecord check : checks) { + String normalizedSearch = normalizeWhitespace(check.search()); + if (check instanceof ContainsAssertRecord) { + executables.add( + () -> + Assertions.assertTrue( + normalizedGenerated.contains(normalizedSearch), check.message())); + } else if (check instanceof NotContainsAssertRecord) { + executables.add( + () -> + Assertions.assertFalse( + normalizedGenerated.contains(normalizedSearch), check.message())); + } } Assertions.assertAll(executables.toArray(new Executable[0])); } - /** Assert that generated code does not contain any of the provided checks. */ - public static void assertNotContaining(String generatedCode, NotContainsAssertRecord... checks) { - List executables = new ArrayList<>(); - for (NotContainsAssertRecord check : checks) { - executables.add( - () -> Assertions.assertFalse(generatedCode.contains(check.search()), check.message())); - } - Assertions.assertAll(executables.toArray(new Executable[0])); + /** Convenience overload: accept plain strings and convert to NotContainsAssertRecord. */ + public static void assertNotContaining(String generatedCode, String... searches) { + AssertRecord[] checks = + java.util.Arrays.stream(searches) + .map(ProcessorAsserts::notContains) + .toArray(AssertRecord[]::new); + assertingResult(generatedCode, checks); + } + + /** Convenience overload: accept plain strings and convert to ContainsAssertRecord. */ + public static void assertContaining(String generatedCode, String... searches) { + AssertRecord[] checks = + java.util.Arrays.stream(searches) + .map(ProcessorAsserts::contains) + .toArray(AssertRecord[]::new); + assertingResult(generatedCode, checks); } } 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 45b54ef8..589ee87b 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 @@ -3,6 +3,8 @@ import com.google.testing.compile.Compilation; import com.google.testing.compile.JavaFileObjects; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.tools.JavaFileObject; /** @@ -48,6 +50,42 @@ public static JavaFileObject simpleBuilderClass( return JavaFileObjects.forSourceString(fqcn, source); } + /** + * Creates a {@link JavaFileObject} from a full Java source string by extracting the package name + * and the top-level type name. + * + *

This is useful when you already have a complete source text block and want to avoid + * duplicating the fully qualified class name separately. + * + * @param source full Java source code (may or may not declare a package) + * @return JavaFileObject suitable for compilation testing + * @throws IllegalArgumentException if the top-level type name cannot be determined + */ + public static JavaFileObject forSource(String source) { + String pkg = extractPackageName(source); + String type = extractTopLevelTypeName(source); + if (type == null || type.isBlank()) { + throw new IllegalArgumentException("Cannot determine top-level type name from source."); + } + String fqcn = (pkg == null || pkg.isBlank()) ? type : (pkg + "." + type); + return JavaFileObjects.forSourceString(fqcn, source); + } + + private static String extractPackageName(String source) { + Matcher m = + Pattern.compile("(?m)^\\s*package\\s+([a-zA-Z_]\\w*(?:\\.[a-zA-Z_]\\w*)*)\\s*;") + .matcher(source); + return m.find() ? m.group(1) : null; + } + + private static String extractTopLevelTypeName(String source) { + Matcher m = + Pattern.compile( + "(?m)^\\s*(?:public|protected|private)?(?:\\s+(?:abstract|final|static|sealed|non-sealed|strictfp))*\\s*(?:class|interface|enum|record)\\s+([A-Za-z_]\\w*)\\b") + .matcher(source); + return m.find() ? m.group(1) : null; + } + /** * Load the generated builder source code by simple name from the given compilation. No assertions * are performed here; callers should assert separately. @@ -67,9 +105,6 @@ public static String loadGeneratedSource(Compilation compilation, String builder } } - // Assertion helpers moved to ProcessorAsserts; this class now only provides source building - // utilities. - private static String[] buildSourceLines( String packageName, String className, List bodyLines) { // Build lines with imports + annotation + class skeleton