diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java index 3d4e1289..7f0fd9ac 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/FieldAnnotationExtractor.java @@ -44,6 +44,9 @@ /** Extractor for field annotations, converting them from Java model elements to DTOs. */ public final class FieldAnnotationExtractor { + /** Fully qualified name of {@link Deprecated}. */ + private static final String DEPRECATED_FQN = "java.lang.Deprecated"; + /** * List of predicates that determine which annotations should be skipped (not copied to the * builder). Each predicate receives the fully qualified annotation name and returns true if the @@ -60,6 +63,10 @@ public final class FieldAnnotationExtractor { name -> name.equals("javax.annotation.processing.Generated"), // Skip compiler-only annotations not relevant for builder parameters name -> name.equals("java.lang.SuppressWarnings"), + // Skip @Deprecated on parameters — deprecation is propagated to the builder method + // itself via DeprecationInfoDto, not to the builder method's parameter (which is a + // new declaration, not the original deprecated source parameter) + name -> name.equals(DEPRECATED_FQN), // Skip @Valid annotation for cascading validation (jakarta.validation.Valid / // javax.validation.Valid) - only meaningful on fields or method return types, not on // builder method parameters where individual values are set @@ -70,6 +77,39 @@ private FieldAnnotationExtractor() { // Private constructor to prevent instantiation } + /** + * Extracts the {@code @Deprecated} annotation from the given element, preserving its members + * (e.g. {@code since} and {@code forRemoval}). + * + *

Unlike {@link #extractAnnotations(VariableElement, ProcessingContext)}, this method targets + * a single annotation and works on any {@link Element} (method, field, parameter, record + * component, type), not just {@link VariableElement}s. + * + * @param element the element to inspect, or {@code null} + * @param context processing context + * @return an {@link Optional} containing the {@code @Deprecated} annotation DTO, or empty if the + * element is not deprecated + */ + public static Optional extractDeprecatedAnnotation( + Element element, ProcessingContext context) { + if (element == null) { + return Optional.empty(); + } + for (AnnotationMirror mirror : element.getAnnotationMirrors()) { + Element annotationElement = mirror.getAnnotationType().asElement(); + if (!(annotationElement instanceof TypeElement annotationType)) { + continue; + } + if (DEPRECATED_FQN.equals(annotationType.getQualifiedName().toString())) { + // Extract the @Deprecated annotation directly, WITHOUT applying ANNOTATION_FILTERS. + // The filters are for annotations copied to builder parameters; deprecation detection + // must always see @Deprecated regardless of whether it is copied to parameters. + return extractAnnotationWithoutFiltering(mirror, context); + } + } + return Optional.empty(); + } + /** * Extracts annotations from a field parameter. Filters out annotations that should not be copied * to the builder. @@ -134,6 +174,25 @@ private static Optional extractAnnotation( return Optional.empty(); } + return extractAnnotationWithoutFiltering(mirror, context); + } + + /** + * Extracts a single annotation from an AnnotationMirror without applying {@link + * #shouldSkipAnnotation} filters. Used by {@link #extractDeprecatedAnnotation} which needs to + * detect @Deprecated even though @Deprecated is filtered from parameter annotations. + * + * @param mirror the annotation mirror to process + * @param context processing context + * @return Optional containing the extracted annotation + */ + private static Optional extractAnnotationWithoutFiltering( + AnnotationMirror mirror, ProcessingContext context) { + Element annotationElement = mirror.getAnnotationType().asElement(); + if (!(annotationElement instanceof TypeElement annotationType)) { + return Optional.empty(); + } + // Create AnnotationDto AnnotationDto annotationDto = new AnnotationDto(); @@ -152,7 +211,7 @@ private static Optional extractAnnotation( annotationDto.addMember(memberName, memberValue); } - context.debug(" -> Added annotation: %s", annotationQualifiedName); + context.debug(" -> Added annotation: %s", annotationType.getQualifiedName()); return Optional.of(annotationDto); } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java index 1fa3b5c2..cad55271 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/analysis/JavaLangAnalyser.java @@ -33,6 +33,7 @@ import java.util.Optional; import javax.lang.model.element.AnnotationMirror; import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; @@ -48,6 +49,7 @@ public final class JavaLangAnalyser { private static final String PARAM_TAG = "@param "; + private static final String DEPRECATED_TAG = "@deprecated"; private JavaLangAnalyser() {} @@ -250,7 +252,7 @@ public static boolean isFunctionalInterface(TypeElement typeElement) { return true; } // Only interfaces can be functional interfaces - if (typeElement.getKind() != javax.lang.model.element.ElementKind.INTERFACE) { + if (typeElement.getKind() != ElementKind.INTERFACE) { return false; } // Heuristic: exactly one abstract method declared (ignores inherited ones for simplicity) @@ -287,6 +289,32 @@ public static String extractParamJavaDoc(String javaDoc, VariableElement paramet return extractParamText(lines, indexOfParamTag); } + /** + * Extracts the text following the {@code @deprecated} Javadoc tag. Continuation lines are + * supported until the next Javadoc tag (starting with '@') or an empty line. + * + *

This is used to propagate the migration hint from a deprecated DTO member to the generated + * builder members. + * + * @param javaDoc the full raw Javadoc as returned by {@code Elements.getDocComment(...)} , or + * {@code null} + * @return the extracted text or {@code null} if no {@code @deprecated} tag is present or the text + * is empty + */ + public static String extractDeprecatedJavaDoc(String javaDoc) { + if (javaDoc == null) { + return null; + } + String[] lines = javaDoc.split("\r?\n"); + + int indexOfDeprecatedTag = findTagLine(lines, DEPRECATED_TAG); + if (indexOfDeprecatedTag < 0) { + return null; + } + + return extractTagText(lines, indexOfDeprecatedTag, DEPRECATED_TAG, false); + } + /** * Finds the line index containing @param tag for the given parameter name. * @@ -309,6 +337,24 @@ private static int findParamTagLine(String[] lines, String parameterName) { return -1; } + /** + * Finds the line index containing the given Javadoc tag (without a name argument, e.g. {@code + * @deprecated}). + * + * @param lines the javadoc lines + * @param tag the tag including the leading '@' (e.g. {@code "@deprecated"}) + * @return the line index, or -1 if not found + */ + private static int findTagLine(String[] lines, String tag) { + for (int i = 0; i < lines.length; i++) { + String cleanedLine = cleanJavadocLine(lines[i]); + if (cleanedLine.startsWith(tag)) { + return i; + } + } + return -1; + } + /** * Removes leading asterisk and whitespace from a Javadoc line. * @@ -324,34 +370,37 @@ private static String cleanJavadocLine(String rawLine) { } /** - * Extracts the parameter documentation text starting from the @param line. + * Extracts the documentation text following a Javadoc tag. Continuation lines are appended until + * the next tag (starting with '@') or an empty line. + * + *

When {@code skipName} is {@code true}, the first word after the tag is treated as a name + * argument (e.g. the parameter name in {@code @param name the description}) and skipped. When + * {@code false}, the full text after the tag is returned (e.g. for {@code @deprecated use {@link + * #x()} instead}). * * @param lines the javadoc lines - * @param startIndex the index of the @param line - * @return the extracted documentation text or null if empty + * @param startIndex the index of the tag line + * @param tag the tag including the leading '@' (e.g. {@code "@param"}, {@code "@deprecated"}) + * @param skipName whether to skip the first word after the tag (name argument) + * @return the extracted text or {@code null} if empty */ - private static String extractParamText(String[] lines, int startIndex) { - StringBuilder sb = new StringBuilder(); - - // Extract initial text from the @param line + private static String extractTagText( + String[] lines, int startIndex, String tag, boolean skipName) { String firstLine = cleanJavadocLine(lines[startIndex]); - String rest = firstLine.substring(PARAM_TAG.length()).trim(); - int spaceIndex = rest.indexOf(' '); - if (spaceIndex >= 0) { - String initialText = rest.substring(spaceIndex + 1).trim(); - if (!initialText.isEmpty()) { - sb.append(initialText); - } + String rest = firstLine.substring(tag.length()).trim(); + String initialText = skipName ? StringUtils.substringAfter(rest, " ").trim() : rest; + + StringBuilder sb = new StringBuilder(); + if (!initialText.isEmpty()) { + sb.append(initialText); } // Append continuation lines until next tag or empty line for (int i = startIndex + 1; i < lines.length; i++) { String cleanedLine = cleanJavadocLine(lines[i]); - if (cleanedLine.isEmpty() || cleanedLine.startsWith("@")) { break; } - if (!sb.isEmpty()) { sb.append(' '); } @@ -362,6 +411,19 @@ private static String extractParamText(String[] lines, int startIndex) { return result.isEmpty() ? null : result; } + /** + * Extracts the parameter documentation text starting from the @param line. Delegates to {@link + * #extractTagText(String[], int, String, boolean)} with {@code skipName=true} to skip the + * parameter name. + * + * @param lines the javadoc lines + * @param startIndex the index of the @param line + * @return the extracted documentation text or null if empty + */ + private static String extractParamText(String[] lines, int startIndex) { + return extractTagText(lines, startIndex, PARAM_TAG, true); + } + /** * Finds the getter method for a given field on the specified DTO type. * @@ -397,6 +459,70 @@ public static Optional findGetterForField( return Optional.empty(); } + /** + * Finds a field element by name in the given class element. + * + * @param classElement the class to search in + * @param fieldName the simple field name to look for + * @return an {@link Optional} containing the field element, or empty if not found + */ + public static Optional findFieldElement( + TypeElement classElement, String fieldName) { + if (classElement == null || fieldName == null) { + return Optional.empty(); + } + return ElementFilter.fieldsIn(classElement.getEnclosedElements()).stream() + .filter(e -> e.getSimpleName().contentEquals(fieldName)) + .findFirst(); + } + + /** + * Finds a record component by name in the given type element. + * + * @param typeElement the type element to search in + * @param componentName the simple component name to look for + * @return an {@link Optional} containing the record component element, or empty if not found or + * the type is not a record + */ + public static Optional findRecordComponent( + TypeElement typeElement, String componentName) { + if (typeElement == null || componentName == null) { + return Optional.empty(); + } + return typeElement.getRecordComponents().stream() + .filter(rc -> rc.getSimpleName().contentEquals(componentName)) + .map(rc -> (javax.lang.model.element.RecordComponentElement) rc) + .findFirst(); + } + + /** + * Finds the setter method for a given field name on the specified DTO type. + * + *

The setter must follow JavaBean conventions ({@code setXxx} with exactly one parameter) and + * have a {@code void} return type. + * + * @param dtoType the enclosing DTO type element + * @param fieldName the field name (uncapitalized) + * @param context processing context + * @return Optional containing the setter ExecutableElement if found + */ + public static Optional findSetterForField( + TypeElement dtoType, String fieldName, ProcessingContext context) { + if (dtoType == null || fieldName == null) { + return Optional.empty(); + } + String setterName = "set" + StringUtils.capitalize(fieldName); + List methods = ElementFilter.methodsIn(context.getAllMembers(dtoType)); + for (ExecutableElement candidate : methods) { + if (candidate.getSimpleName().contentEquals(setterName) + && candidate.getParameters().size() == 1 + && candidate.getReturnType().getKind() == VOID) { + return Optional.of(candidate); + } + } + return Optional.empty(); + } + /** * Determines which constructor to use for builder initialization. Prioritizes constructors * annotated with {@link SimpleBuilderConstructor}. If none is annotated, selects the constructor diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java index df6cc2f0..5e3f5df1 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/classgen/roaster/RoasterCodeGenerator.java @@ -184,7 +184,13 @@ private void addClassMetadata(JavaClassSource source, GenerationTargetClassDto c private String renderClassSource(JavaClassSource source) { String rendered = source.toUnformattedString(); - return formatSource(rendered); + String formatted = formatSource(rendered); + // Roaster renders some java.lang annotations (e.g. @SuppressWarnings, @Deprecated with + // members) with their FQN (@java.lang.SuppressWarnings) even though java.lang types don't + // need qualification. Fix this by replacing @java.lang.Xxx with @Xxx for known annotations. + formatted = formatted.replace("@java.lang.SuppressWarnings", "@SuppressWarnings"); + formatted = formatted.replace("@java.lang.Deprecated", "@Deprecated"); + return formatted; } private void appendFields(JavaClassSource source, GenerationTargetClassDto classDef) { @@ -224,6 +230,7 @@ private void appendConstructor(JavaClassSource source, ConstructorDto constructo method.addParameter(mapType(param.getParameterType()), param.getParameterName()); } applyJavadoc(method, constructor.getJavadoc()); + applyAnnotations(method, constructor.getAnnotations()); applyCodeBlock(method, constructor.getMethodCodeDto()); } @@ -454,8 +461,14 @@ private void applyAnnotations( return; } for (AnnotationDto annotationDto : annotations) { - AnnotationSource annotation = - source.addAnnotation(annotationDto.getAnnotationType().getFullQualifiedName()); + TypeName type = annotationDto.getAnnotationType(); + // Use the simple name for java.lang annotations (e.g. @SuppressWarnings, @Deprecated) so + // Roaster renders them without the java.lang prefix. Other annotations use their FQN. + String annotationName = + "java.lang".equals(type.getPackageName()) + ? type.getClassName() + : type.getFullQualifiedName(); + AnnotationSource annotation = source.addAnnotation(annotationName); for (Map.Entry member : annotationDto.getMembers().entrySet()) { if ("value".equals(member.getKey())) { annotation.setLiteralValue(member.getValue()); diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/DeprecationInfoDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/DeprecationInfoDto.java new file mode 100644 index 00000000..2ca3e10e --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/DeprecationInfoDto.java @@ -0,0 +1,93 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.model.core; + +import java.util.Optional; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; +import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; + +/** + * Encapsulates deprecation metadata detected from source DTO property elements. + * + *

This DTO is used during builder definition generation to carry deprecation information from + * source elements (constructor parameter, record component, backing field, setter method) to the + * point where generated builder methods are annotated. It is an analysis-side DTO and is not + * directly used for class generation — the actual rendering uses the existing {@link + * org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto#getAnnotations() + * annotations list} and {@link org.javahelpers.simple.builders.processor.model.javadoc.JavadocDto + * javadoc} on the rendering DTOs. + * + *

The {@link #deprecatedAnnotation} preserves {@code @Deprecated(since = ..., forRemoval = ...)} + * attributes. The {@link #deprecatedJavaDoc} carries the {@code @deprecated} Javadoc tag text. + * + *

Note: getter method deprecation is not stored here. A deprecated getter means "don't call this + * accessor directly anymore", but the builder does not expose a get-API — only the from-instance + * constructor calls the getter internally, which is covered by the class-level + * {@code @SuppressWarnings}. The setter, however, IS a propagation source because the builder's + * fluent methods replace the setter as the write API for the property. + * + * @param deprecatedAnnotation the {@code @Deprecated} annotation DTO preserving {@code since} and + * {@code forRemoval} attributes, or {@code null} if not deprecated + * @param deprecatedJavaDoc the {@code @deprecated} Javadoc text, or {@code null} if no explicit tag + * is present + */ +public record DeprecationInfoDto(AnnotationDto deprecatedAnnotation, String deprecatedJavaDoc) { + + /** + * Returns whether this field is deprecated. + * + * @return {@code true} if a {@code @Deprecated} annotation is present + */ + public boolean isDeprecated() { + return deprecatedAnnotation != null; + } + + /** + * Returns the {@code @Deprecated} annotation DTO as an {@link Optional}. + * + * @return the annotation DTO, or empty if not deprecated + */ + public Optional getDeprecatedAnnotation() { + return Optional.ofNullable(deprecatedAnnotation); + } + + /** + * Returns the {@code @deprecated} Javadoc text as an {@link Optional}. + * + * @return the deprecated Javadoc text, or empty if none present + */ + public Optional getDeprecatedJavaDoc() { + return Optional.ofNullable(deprecatedJavaDoc); + } + + @Override + public String toString() { + return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) + .append("deprecatedAnnotation", deprecatedAnnotation) + .append("deprecatedJavaDoc", deprecatedJavaDoc) + .toString(); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java index 463956d3..4f00ce70 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/FieldDto.java @@ -66,10 +66,11 @@ public class FieldDto { private final List fieldGenerics = new ArrayList<>(); /** - * The name of the getter method to call, because in case of boolean it does not need to start - * with get (e.g., getName or isActive). + * Metadata about the getter method resolved for this field (name and deprecation status). The + * from-instance constructor calls the getter to initialise the builder. {@code null} when no + * getter was found. */ - private String getterName; + private GetterInfoDto getterInfo; /** * Whether this field is marked as non-nullable via annotations like @NotNull, @NonNull, etc. For @@ -91,6 +92,12 @@ public class FieldDto { */ private String defaultValue; + /** + * Deprecation metadata for this field, detected from relevant property elements (constructor + * parameter, record component, backing field). {@code null} when the field is not deprecated. + */ + private DeprecationInfoDto deprecationInfo; + /** * Gets the original field name from the DTO. This name is used for generating method names, * parameter names, and setter method names (e.g., "userName" becomes "setUserName"). @@ -248,22 +255,40 @@ public void setFieldGenerics(List generics) { } } + /** + * Returns the getter info for this field. + * + * @return the getter info, or {@code null} if no getter was resolved + */ + public GetterInfoDto getGetterInfo() { + return getterInfo; + } + + /** + * Sets the getter info for this field. + * + * @param getterInfo the getter info to set, or {@code null} to clear + */ + public void setGetterInfo(GetterInfoDto getterInfo) { + this.getterInfo = getterInfo; + } + /** * Returns the getter method name to use (without parentheses). * * @return Optional containing the getter name, or empty if not set */ public Optional getGetterName() { - return Optional.ofNullable(getterName); + return Optional.ofNullable(getterInfo).map(GetterInfoDto::getGetterName); } /** - * Sets the getter method name to use (without parentheses). + * Checks if the getter method for this field is {@code @Deprecated}. * - * @param getterName the getter method name to set + * @return {@code true} if the getter is deprecated */ - public void setGetterName(String getterName) { - this.getterName = getterName; + public boolean isGetterDeprecated() { + return getterInfo != null && getterInfo.deprecated(); } /** @@ -353,4 +378,31 @@ public void setDefaultValue(String defaultValue) { public boolean isRequired() { return nonNullable && defaultValue == null; } + + /** + * Gets the deprecation metadata for this field. + * + * @return the deprecation info, or {@code null} if the field is not deprecated + */ + public DeprecationInfoDto getDeprecationInfo() { + return deprecationInfo; + } + + /** + * Sets the deprecation metadata for this field. + * + * @param deprecationInfo the deprecation info, or {@code null} if not deprecated + */ + public void setDeprecationInfo(DeprecationInfoDto deprecationInfo) { + this.deprecationInfo = deprecationInfo; + } + + /** + * Checks if this field is deprecated (a relevant element is marked {@code @Deprecated}). + * + * @return {@code true} if the field is deprecated + */ + public boolean isDeprecated() { + return deprecationInfo != null && deprecationInfo.isDeprecated(); + } } diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GetterInfoDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GetterInfoDto.java new file mode 100644 index 00000000..8b17586d --- /dev/null +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/core/GetterInfoDto.java @@ -0,0 +1,75 @@ +/* + * MIT License + * + * Copyright (c) 2026 Andreas Igel + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package org.javahelpers.simple.builders.processor.model.core; + +import java.util.Objects; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +/** + * Encapsulates metadata about the getter method resolved for a field. + * + *

The getter name is needed for the from-instance constructor, which calls the getter to + * initialise the builder. The {@code deprecated} flag indicates whether the getter is annotated + * {@code @Deprecated}; this is used to decide whether class-level {@code @SuppressWarnings} is + * needed, because the from-instance constructor calls the getter internally. Getter deprecation is + * intentionally not propagated to generated builder methods. + * + *

A {@code GetterInfoDto} is only created when a getter was found, so {@link #getterName()} is + * always non-null. The absence of a getter is represented by {@code null} on {@link FieldDto}'s + * getter info field, not by a {@code GetterInfoDto} with a null name. + * + * @param getterName the simple name of the getter method; must not be {@code null} + * @param deprecated whether the getter method is annotated {@code @Deprecated} + */ +public record GetterInfoDto(String getterName, boolean deprecated) { + + /** + * Compact constructor enforcing that {@code getterName} is non-null — a {@code GetterInfoDto} + * only exists when a getter was found. + * + * @throws NullPointerException if {@code getterName} is {@code null} + */ + public GetterInfoDto { + Objects.requireNonNull(getterName, "getterName must not be null"); + } + + /** + * Returns the getter method name. + * + * @return the getter name, never {@code null} + */ + public String getGetterName() { + return getterName; + } + + @Override + public String toString() { + return new ToStringBuilder(this, ToStringStyle.SHORT_PREFIX_STYLE) + .append("getterName", getterName) + .append("deprecated", deprecated) + .toString(); + } +} diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/javadoc/JavadocDto.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/javadoc/JavadocDto.java index f3a60eb7..5ff0f3d2 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/model/javadoc/JavadocDto.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/model/javadoc/JavadocDto.java @@ -228,6 +228,28 @@ public JavadocDto addThrows(String exceptionName, String descriptionFormat, Obje return this; } + /** + * Adds a @deprecated tag with optional formatted description. + * + *

Supports String.format-style formatting when args are provided. + * + *

Examples: + * + *

{@code
+   * addDeprecated("use {@link #setLabel(String)} instead")
+   * addDeprecated("use {%s} instead", replacement)
+   * }
+ * + * @param descriptionFormat the format string for the deprecation description + * @param args optional arguments referenced by format specifiers in the description + * @return this JavadocDto for fluent chaining + */ + public JavadocDto addDeprecated(String descriptionFormat, Object... args) { + String deprecatedDescription = String.format(descriptionFormat, args); + addTag("deprecated", deprecatedDescription); + return this; + } + /** * Appends additional text to the existing description. * diff --git a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java index a722e76c..fc1b80cc 100644 --- a/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java +++ b/processor/src/main/java/org/javahelpers/simple/builders/processor/processing/BuilderDefinitionCreator.java @@ -27,6 +27,7 @@ import static java.util.stream.Collectors.toSet; import static org.javahelpers.simple.builders.processor.analysis.JavaLangAnalyser.*; import static org.javahelpers.simple.builders.processor.analysis.JavaLangMapper.map2MethodParameter; +import static org.javahelpers.simple.builders.processor.analysis.JavaLangMapper.map2TypeName; import static org.javahelpers.simple.builders.processor.processing.AnnotationValidator.validateAnnotatedElement; import java.util.ArrayList; @@ -38,7 +39,9 @@ import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.lang.model.element.*; +import javax.lang.model.type.DeclaredType; import javax.lang.model.type.TypeMirror; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Strings; @@ -53,9 +56,12 @@ import org.javahelpers.simple.builders.processor.model.annotation.AnnotationDto; import org.javahelpers.simple.builders.processor.model.core.BuilderDefinitionDto; import org.javahelpers.simple.builders.processor.model.core.ClassFieldDto; +import org.javahelpers.simple.builders.processor.model.core.DeprecationInfoDto; import org.javahelpers.simple.builders.processor.model.core.FieldDto; +import org.javahelpers.simple.builders.processor.model.core.GetterInfoDto; import org.javahelpers.simple.builders.processor.model.javadoc.JavadocDto; import org.javahelpers.simple.builders.processor.model.method.BuilderMethodDto; +import org.javahelpers.simple.builders.processor.model.method.ConstructorDto; import org.javahelpers.simple.builders.processor.model.method.MethodParameterDto; import org.javahelpers.simple.builders.processor.model.type.TypeName; import org.javahelpers.simple.builders.processor.model.type.TypeNameGeneric; @@ -66,6 +72,9 @@ public class BuilderDefinitionCreator { /** Annotation simple names recognized as default-value annotations, regardless of package. */ private static final Set DEFAULT_ANNOTATION_NAMES = Set.of("Default", "DefaultValue"); + /** TypeName for {@link Deprecated}. */ + private static final TypeName DEPRECATED_TYPE = map2TypeName(Deprecated.class); + private BuilderDefinitionCreator() { // Private constructor to prevent instantiation } @@ -102,6 +111,16 @@ public static BuilderDefinitionDto extractFromElement( // Apply builder enhancers (including With interface generation) context.getGeneratorRegistry().enhanceBuilder(result, result.getBuildingTargetTypeName()); + // Propagate @Deprecated from the DTO type to the generated builder class and its factory + // methods. Done after enhancement so the class javadoc, create() method and constructors + // already exist and can be annotated. + applyDtoDeprecation(result, annotatedType, context); + + // Suppress deprecation warnings inside generated code that legitimately calls deprecated + // DTO members (e.g. build() calls deprecated setters/constructor, from-instance constructor + // calls deprecated getters). + applyDeprecationSuppressions(result, annotatedType, context); + // Finalize the definition - convert to generic class representation finalizeDefinition(result, context); @@ -791,9 +810,14 @@ private static Optional createFieldDto( // Find matching getter on the DTO type using the builder field name TypeElement dtoTypeElement = context.getTypeElement(dtoType); - JavaLangAnalyser.findGetterForField( - dtoTypeElement, fieldNameInBuilder, fieldTypeMirror, context) - .ifPresent(getter -> field.setGetterName(getter.getSimpleName().toString())); + Optional getter = + JavaLangAnalyser.findGetterForField( + dtoTypeElement, fieldNameInBuilder, fieldTypeMirror, context); + getter.ifPresent( + g -> + field.setGetterInfo( + new GetterInfoDto( + g.getSimpleName().toString(), g.getAnnotation(Deprecated.class) != null))); // Extract annotations from the field parameter List annotations = FieldAnnotationExtractor.extractAnnotations(param, context); @@ -818,6 +842,11 @@ private static Optional createFieldDto( field.setDefaultValue( FieldAnnotationExtractor.formatDefaultExpression(rawDefault, fieldType))); + // Detect @Deprecated on relevant property elements (constructor parameter, record + // component, setter method, or the field type itself) and propagate it to all generated + // builder methods for this field. + detectAndApplyFieldDeprecation(field, param, fieldTypeMirror, dtoTypeElement, context); + // Builder and constructor information is now set when TypeName is created in JavaLangMapper // Use GeneratorRegistry to generate all methods for this field @@ -825,9 +854,330 @@ private static Optional createFieldDto( context.getGeneratorRegistry().generateAllMethods(field, dtoType, builderType); generatedMethods.forEach(field::addMethod); + // Apply @Deprecated annotation and @deprecated javadoc to every generated builder method for + // this field (basic setter, supplier, consumer, collection helpers, varargs, with…). + if (field.isDeprecated()) { + generatedMethods.forEach( + method -> applyDeprecationToMethod(method, field.getDeprecationInfo())); + } + return Optional.of(field); } + /** + * Detects {@code @Deprecated} on relevant property elements (constructor parameter, record + * component, setter method, field type) and records the result as a {@link DeprecationInfoDto} on + * the {@link FieldDto}. + * + *

The first element that carries {@code @Deprecated} (in the order: parameter, record + * component, setter, field type) provides the annotation DTO (preserving {@code since} and {@code + * forRemoval} attributes). The {@code @deprecated} javadoc text is extracted from the enclosing + * executable (setter method or constructor) of the parameter, falling back to the record + * component or the field type's Javadoc. + * + *

Note: getter methods and backing fields are intentionally not propagation sources. + * A deprecated getter means "don't call this accessor directly anymore", but the builder does not + * expose a get-API — only the from-instance constructor calls the getter internally, which is + * covered by the class-level {@code @SuppressWarnings}. The backing field is not checked because + * it is uncommon to deprecate a field without also deprecating the setter or constructor + * parameter. The setter, however, IS a propagation source because the builder's fluent methods + * replace the setter as the write API for the property. The field type is also a propagation + * source because the builder method exposes the deprecated type in its parameter — consumers + * calling the method would get deprecation warnings. + * + * @param field the field DTO to update + * @param param the constructor or setter parameter element + * @param fieldTypeMirror the type mirror of the field/parameter type + * @param dtoTypeElement the DTO type element + * @param context processing context + */ + private static void detectAndApplyFieldDeprecation( + FieldDto field, + VariableElement param, + TypeMirror fieldTypeMirror, + TypeElement dtoTypeElement, + ProcessingContext context) { + if (dtoTypeElement == null) { + return; + } + String fieldName = field.getOriginalFieldName(); + + Optional recordComponent = + JavaLangAnalyser.findRecordComponent(dtoTypeElement, fieldName); + Optional setter = + JavaLangAnalyser.findSetterForField(dtoTypeElement, fieldName, context); + + // Search the relevant elements in priority order for the @Deprecated annotation to propagate. + // Note: the backing field is intentionally NOT checked — it is uncommon to deprecate a + // field without also deprecating the setter or constructor parameter, and checking the + // field adds complexity without practical value. + record DeprecationMatch(Element source, AnnotationDto annotation) {} + + Optional match = + presentElements( + Optional.of(param), + recordComponent, + setter, + resolveFieldTypeElement(fieldTypeMirror)) + .map( + element -> + FieldAnnotationExtractor.extractDeprecatedAnnotation(element, context) + .map(annot -> new DeprecationMatch(element, annot))) + .flatMap(Optional::stream) + .findFirst(); + + if (match.isEmpty()) { + return; + } + DeprecationMatch deprecation = match.get(); + + // Extract the @deprecated javadoc text. Prefer the enclosing executable (setter/constructor) + // of the parameter, then the setter method, then the record component, then the field type. + Element enclosing = param.getEnclosingElement(); + String deprecatedJavaDoc = + presentElements( + Optional.ofNullable(enclosing), + setter, + recordComponent, + asTypeElement(deprecation.source())) + .map( + element -> + JavaLangAnalyser.extractDeprecatedJavaDoc(context.getDocComment(element))) + .filter(java.util.Objects::nonNull) + .findFirst() + .orElse(null); + + field.setDeprecationInfo(new DeprecationInfoDto(deprecation.annotation(), deprecatedJavaDoc)); + + context.debug( + "Field '%s' is deprecated (source: %s)", fieldName, deprecation.source().getSimpleName()); + } + + /** + * Filters out empty optionals and returns a stream of present elements. + * + * @param optionals variable number of optional elements + * @return stream of elements that are present in their optionals + */ + @SafeVarargs + private static Stream presentElements(Optional... optionals) { + return Stream.of(optionals).flatMap(Optional::stream).map(e -> (Element) e); + } + + /** + * Returns the element as an {@link Optional} if it is a {@link TypeElement}, otherwise empty. + * + * @param element the element to check + * @return optional containing the element if it is a TypeElement, otherwise empty + */ + private static Optional asTypeElement(Element element) { + return element instanceof TypeElement typeElement ? Optional.of(typeElement) : Optional.empty(); + } + + /** + * Resolves the {@link TypeElement} of a field type if it is a declared type. + * + * @param fieldTypeMirror the field type mirror + * @return optional containing the type element, or empty if not a declared type + */ + private static Optional resolveFieldTypeElement(TypeMirror fieldTypeMirror) { + if (fieldTypeMirror instanceof DeclaredType declaredType + && declaredType.asElement() instanceof TypeElement typeElement) { + return Optional.of(typeElement); + } + return Optional.empty(); + } + + /** + * Applies the {@code @Deprecated} annotation and {@code @deprecated} javadoc tag to a generated + * builder method for a deprecated field. + * + * @param method the generated builder method to annotate + * @param deprecationInfo the deprecation info providing the annotation and javadoc text + */ + private static void applyDeprecationToMethod( + BuilderMethodDto method, DeprecationInfoDto deprecationInfo) { + deprecationInfo.getDeprecatedAnnotation().ifPresent(method::addAnnotation); + deprecationInfo + .getDeprecatedJavaDoc() + .ifPresent(text -> method.getJavadoc().addDeprecated(text)); + } + + /** + * Propagates {@code @Deprecated} from the DTO type to the generated builder class and its factory + * methods (static {@code create()} and constructors). + * + *

When the DTO type is deprecated, the builder class itself, the {@code create()} factory + * method and all constructors are annotated with {@code @Deprecated} and the {@code @deprecated} + * javadoc text from the DTO is copied into their javadoc. + * + * @param builderDto the builder definition to update + * @param annotatedType the annotated DTO type element + * @param context processing context + */ + private static void applyDtoDeprecation( + BuilderDefinitionDto builderDto, TypeElement annotatedType, ProcessingContext context) { + Optional deprecatedAnnot = + FieldAnnotationExtractor.extractDeprecatedAnnotation(annotatedType, context); + if (deprecatedAnnot.isEmpty()) { + return; + } + AnnotationDto deprecated = deprecatedAnnot.get(); + String deprecatedJavaDoc = + JavaLangAnalyser.extractDeprecatedJavaDoc(context.getDocComment(annotatedType)); + + // Annotate the builder class + builderDto.addClassAnnotation(deprecated); + addDeprecatedJavadoc(builderDto.getClassJavadoc(), deprecatedJavaDoc); + + // Annotate the static create() factory method + for (BuilderMethodDto method : builderDto.getMethods()) { + if (method.isStatic() && "create".equals(method.getMethodName())) { + method.addAnnotation(deprecated); + addDeprecatedJavadoc(method.getJavadoc(), deprecatedJavaDoc); + } + } + + // Annotate constructors (factory entry points) + for (ConstructorDto constructor : builderDto.getConstructors()) { + constructor.addAnnotation(deprecated); + addDeprecatedJavadoc(constructor.getJavadoc(), deprecatedJavaDoc); + } + + context.debug("DTO type is deprecated; builder class and factory methods annotated"); + } + + /** + * Adds the {@code @deprecated} javadoc tag to the given javadoc. + * + *

The javadoc is guaranteed to be non-null because this method is called after the generation + * pipeline has run, which always creates javadoc for the builder class, factory methods, and + * constructors. + * + * @param javadoc the existing javadoc (non-null) + * @param deprecatedJavaDoc the deprecated text, or {@code null} to do nothing + */ + private static void addDeprecatedJavadoc(JavadocDto javadoc, String deprecatedJavaDoc) { + if (deprecatedJavaDoc == null) { + return; + } + javadoc.addDeprecated(deprecatedJavaDoc); + } + + /** + * Adds a class-level {@code @SuppressWarnings({"deprecation", "removal"})} to the generated + * builder when any internal code legitimately calls deprecated DTO members or uses deprecated + * types, so the generated builder compiles without deprecation warnings. + * + *

A single class-level annotation covers all internal call sites at once: + * + *

    + *
  • {@code build()} calls the DTO constructor and setter methods. + *
  • {@code create()} instantiates the builder class (deprecated when the DTO is). + *
  • The from-instance constructor calls the DTO getter methods. + *
  • Nested types (e.g. the {@code With} interface) reference the deprecated DTO/builder types + * in their default method implementations. + *
  • Collection helper methods call {@code create()} on deprecated element builders. + *
  • Builder methods for fields with deprecated types internally use the deprecated type. + *
+ * + *

The class-level suppression only silences warnings about deprecated members used + * inside the builder; it does not affect warnings shown to callers of the + * builder's own {@code @Deprecated} methods. + * + * @param builderDto the builder definition to update + * @param annotatedType the annotated DTO type element + * @param context processing context + */ + private static void applyDeprecationSuppressions( + BuilderDefinitionDto builderDto, TypeElement annotatedType, ProcessingContext context) { + boolean dtoDeprecated = annotatedType.getAnnotation(Deprecated.class) != null; + boolean constructorDeprecated = isConstructorDeprecated(annotatedType, context); + // Check getter method deprecation from the flag stored during field creation — a deprecated + // getter triggers compiler warnings in generated code (the from-instance constructor calls + // getters). Getter deprecation is intentionally NOT propagated to builder methods, so it + // must be checked separately from anyMethodDeprecated. + boolean anyGetterMethodDeprecated = + builderDto.getAllFieldsForBuilder().stream().anyMatch(FieldDto::isGetterDeprecated); + // If any generated builder method is @Deprecated (e.g. because the field type, parameter, + // setter, or record component is deprecated), other generated methods within the builder + // (varargs helpers, add-to-collection helpers, etc.) may call that deprecated method + // internally, so class-level suppression is needed. Deprecated setters are covered here + // because setter deprecation propagates to the generated builder methods. + boolean anyMethodDeprecated = + builderDto.getAllFieldsForBuilder().stream() + .flatMap(f -> f.getMethods().stream()) + .flatMap(m -> m.getAnnotations().stream()) + .map(AnnotationDto::getAnnotationType) + .anyMatch(DEPRECATED_TYPE::equals); + // Check if any collection field's element type is @Deprecated. The generated collection + // helper calls the element builder's create() method internally, which would produce + // deprecation warnings if the element builder is @Deprecated (which it will be if the + // element DTO is @Deprecated). May over-match slightly (element type deprecated but no + // element builder generated), but the extra @SuppressWarnings is harmless. + boolean anyElementTypeDeprecated = + builderDto.getAllFieldsForBuilder().stream() + .filter(f -> f.getFieldType() instanceof TypeNameGeneric) + .map(f -> ((TypeNameGeneric) f.getFieldType()).getInnerType()) + .filter(Optional::isPresent) + .map(Optional::get) + .map(context::getTypeElement) + .filter(java.util.Objects::nonNull) + .anyMatch(elementType -> elementType.getAnnotation(Deprecated.class) != null); + + boolean needsSuppress = + dtoDeprecated + || constructorDeprecated + || anyMethodDeprecated + || anyGetterMethodDeprecated + || anyElementTypeDeprecated; + + if (!needsSuppress) { + return; + } + + AnnotationDto suppressWarnings = createSuppressWarningsAnnotation(); + builderDto.addClassAnnotation(suppressWarnings); + + context.debug( + "Added class-level @SuppressWarnings for deprecation (dtoDeprecated=%s, ctorDeprecated=%s, methodDeprecated=%s, getterDeprecated=%s, elementTypeDeprecated=%s)", + dtoDeprecated, + constructorDeprecated, + anyMethodDeprecated, + anyGetterMethodDeprecated, + anyElementTypeDeprecated); + } + + /** + * Checks whether the constructor selected for builder generation is {@code @Deprecated}. + * + * @param annotatedType the annotated DTO type element + * @param context processing context + * @return {@code true} if the selected constructor is deprecated + */ + private static boolean isConstructorDeprecated( + TypeElement annotatedType, ProcessingContext context) { + return JavaLangAnalyser.findConstructorForBuilder(annotatedType, context) + .filter(ctor -> ctor.getAnnotation(Deprecated.class) != null) + .isPresent(); + } + + /** + * Creates a {@code @SuppressWarnings("deprecation")} annotation DTO. + * + *

Note: when {@code @Deprecated(forRemoval = true)} is used, the compiler emits "removal" + * warnings instead of (or in addition to) "deprecation" warnings. To cover both cases, the + * suppression includes both {@code "deprecation"} and {@code "removal"}. + * + * @return the annotation DTO + */ + private static AnnotationDto createSuppressWarningsAnnotation() { + AnnotationDto annotation = new AnnotationDto(); + annotation.setAnnotationType(new TypeName("java.lang", "SuppressWarnings")); + annotation.addMember("value", "{\"deprecation\", \"removal\"}"); + return annotation; + } + /** * Attempts to extract and apply a default value from the field declaration itself, if the field * carries a recognized default annotation (e.g. {@code @Default}). Used as a fallback when no @@ -852,20 +1202,4 @@ private static void tryApplyDefaultFromField( field.setDefaultValue( FieldAnnotationExtractor.formatDefaultExpression(rawDefault.get(), field.getFieldType())); } - - /** - * Finds a field element by name in the given class element. - * - * @param classElement the class to search in - * @param fieldName the simple field name to look for - * @return an {@link Optional} containing the field element, or empty if not found - */ - private static Optional findFieldElement( - TypeElement classElement, String fieldName) { - return classElement.getEnclosedElements().stream() - .filter(e -> e.getKind() == ElementKind.FIELD) - .filter(e -> e.getSimpleName().contentEquals(fieldName)) - .map(VariableElement.class::cast) - .findFirst(); - } } diff --git a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java index c78b800a..acdab6c8 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/AnnotationCopyTest.java @@ -304,13 +304,13 @@ void annotations_deprecatedCopied_suppressWarningsFiltered() { JavaFileObject service = JavaFileObjects.forSourceString( - packageName + ".Service", + packageName + ".MyDto", """ package test.javafilter; import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; @SimpleBuilder - public class Service { + public class MyDto { private String name; public String getName() { return name; } @@ -324,15 +324,591 @@ public void setName( """); Compilation compilation = compileSources(service); - String generatedCode = loadGeneratedSource(compilation, "ServiceBuilder"); - ProcessorAsserts.assertGenerationSucceeded(compilation, "ServiceBuilder", generatedCode); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); - // Verify that @Deprecated IS copied (developers need to know field is deprecated!) + // @Deprecated is applied to the generated builder method itself (not the parameter — + // the parameter is a new declaration, the deprecation is about the property). ProcessorAsserts.assertingResult( - generatedCode, contains("public ServiceBuilder name(@Deprecated String name)")); + generatedCode, + contains( + """ + @Deprecated + public MyDtoBuilder name(String name) {""")); + + // Class-level @SuppressWarnings is present because the deprecated builder method may be + // called internally by other generated methods (varargs helpers, etc.). + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase""")); + } + + @Test + void annotations_deprecatedSetterMethod_methodLevelDeprecatedAndBuildSuppressed() { + String packageName = "test.deprecated.setter"; + + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.setter; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private String name; + + public String getName() { return name; } + + @Deprecated + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // The generated builder method IS @Deprecated because the setter is the write API for the + // property and the builder method replaces it — deprecation propagates. + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated + public MyDtoBuilder name(String name) {""")); + + // build() calls the deprecated setter via result::setName; the whole builder class carries a + // class-level @SuppressWarnings so all internal calls to deprecated members are silenced. + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase""")); + } + + @Test + void annotations_deprecatedRecordComponent_methodLevelDeprecated() { + String packageName = "test.deprecated.record"; + + JavaFileObject book = + JavaFileObjects.forSourceString( + packageName + ".Book", + """ + package test.deprecated.record; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public record Book(@Deprecated String title, int pages) {} + """); + + Compilation compilation = compileSources(book); + String generatedCode = loadGeneratedSource(compilation, "BookBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "BookBuilder", generatedCode); + + // The generated builder method for the deprecated record component is @Deprecated (on the + // method, not the parameter — the parameter is a new declaration). + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated + public BookBuilder title(String title) {""")); + + // The non-deprecated field is not annotated — include the preceding Javadoc closing so + // that an @Deprecated annotation between them would break the match. + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + */ + public BookBuilder pages(int pages) {""")); + } + + @Test + void annotations_deprecatedDtoClass_builderAndFactoryMethodsDeprecated() { + String packageName = "test.deprecated.dto"; + + // The DTO class itself is @Deprecated. This must propagate @Deprecated to the generated + // builder class, both constructors, and the create() factory method. The builder class + // also needs class-level @SuppressWarnings because it internally instantiates the + // deprecated DTO and calls its constructor. The field-level builder method (name()) and + // build() are NOT @Deprecated because only the class is deprecated, not the field/param/ + // setter. Most builder options are DISABLED to keep the generated code minimal enough for + // a single full text-block comparison. + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.dto; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.enums.OptionState; + + @Deprecated + @SimpleBuilder(options = @SimpleBuilder.Options( + generateFieldSupplier = OptionState.DISABLED, + generateFieldConsumer = OptionState.DISABLED, + generateBuilderConsumer = OptionState.DISABLED, + generateConditionalHelper = OptionState.DISABLED, + generateVarArgsHelpers = OptionState.DISABLED, + generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, + generateWithInterface = OptionState.DISABLED + )) + public class MyDto { + private String name; + + public String getName() { return name; } + + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // Full text-block comparison of the generated builder class. With most options disabled, + // the output is minimal enough to compare comprehensively. The text block includes the + // complete class from annotations to closing brace — imports are omitted as they are not + // relevant to the deprecation feature. + ProcessorAsserts.assertContaining( + generatedCode, + """ + @Generated("Generated by org.javahelpers.simple.builders.processor.BuilderProcessor") + @BuilderImplementation(forClass = MyDto.class) + @Deprecated + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase { + + /** + * Tracked value for name: name. + */ + private TrackedValue name = unsetValue(); + + /** + * Empty constructor of builder for {@code test.deprecated.dto.MyDto}. + */ + @Deprecated + public MyDtoBuilder() { + } + + /** + * Initialisation of builder for {@code test.deprecated.dto.MyDto} by a instance. + * + * @param instance object instance for initialisiation + */ + @Deprecated + public MyDtoBuilder(MyDto instance) { + this.name = initialValue(instance.getName()); + } + + /** + * Creating a new builder for {@code test.deprecated.dto.MyDto}. + * + *

Example:

+ * + *
{@code
+           * MyDtoBuilder builder = MyDtoBuilder.create();
+           * }
+ * + * @return builder for {@code test.deprecated.dto.MyDto} + */ + @Deprecated + public static MyDtoBuilder create() { + return new MyDtoBuilder(); + } + + /** + * Sets the value for name. + *

+ * Generated from setter {@link MyDto#setName(String) setName(String name)} + * + *

Example:

+ * + *
{@code
+           * builder.name("example value");
+           * }
+ * + * @param name name + * @return current instance of builder + */ + public MyDtoBuilder name(String name) { + this.name = changedValue(name); + return this; + } + + /** + * Builds the configured DTO instance. + * + *

Example:

+ * + *
{@code
+           * MyDto result = builder.build();
+           * }
+ */ + @Override + public MyDto build() { + MyDto result = new MyDto(); + this.name.ifSet(result::setName); + return result; + } + + /** + * Returns a string representation of this builder, including only fields that have been set. + * + * @return string representation of the builder + */ + @Override + public String toString() { + return new ToStringBuilder(this, BuilderToStringStyle.INSTANCE).append("name", this.name).toString(); + } + } + """); + } + + @Test + void annotations_deprecatedDtoClassWithJavadoc_deprecatedJavadocPropagatedToBuilderClass() { + String packageName = "test.deprecated.dto.javadoc"; + + // The DTO class is @Deprecated AND has an @deprecated javadoc tag. The @deprecated javadoc + // text must be propagated to the generated builder class javadoc, the create() factory method + // javadoc, and the constructor javadoc. This covers the case where addDeprecatedJavadoc is + // called with a non-null deprecatedJavaDoc and a potentially null pre-existing javadoc. + JavaFileObject myDto = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.dto.javadoc; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + import org.javahelpers.simple.builders.core.enums.OptionState; + + /** + * A DTO that is now obsolete. + * + * @deprecated use {@link NewDto} instead + */ + @Deprecated + @SimpleBuilder(options = @SimpleBuilder.Options( + generateFieldSupplier = OptionState.DISABLED, + generateFieldConsumer = OptionState.DISABLED, + generateBuilderConsumer = OptionState.DISABLED, + generateConditionalHelper = OptionState.DISABLED, + generateVarArgsHelpers = OptionState.DISABLED, + generateStringFormatHelpers = OptionState.DISABLED, + generateAddToCollectionHelpers = OptionState.DISABLED, + generateWithInterface = OptionState.DISABLED + )) + public class MyDto { + private String name; + + public String getName() { return name; } + + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(myDto); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // The @deprecated javadoc from the DTO class is propagated to the builder class + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + * @deprecated use {@link NewDto} instead""")); + + // The builder class itself is @Deprecated (with @SuppressWarnings in between) + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase""")); + } + + @Test + void annotations_deprecatedJavadocText_propagatedToBuilderMethods() { + String packageName = "test.deprecated.javadoc"; + + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.javadoc; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private String name; + + public String getName() { return name; } + + /** + * Sets the name. + * + * @deprecated use {@link #label} instead + */ + @Deprecated + public void setName(String name) { + this.name = name; + } + } + """); - // But @SuppressWarnings is NOT copied (compiler-only, not relevant for users) - ProcessorAsserts.assertNotContaining(generatedCode, "@SuppressWarnings"); + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // The @deprecated javadoc text is propagated to the generated builder method + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + * @deprecated use {@link #label} instead""")); + + // The method itself is @Deprecated (detected from the deprecated setter) + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated + public MyDtoBuilder name(String name) {""")); + } + + @Test + void annotations_deprecatedWithSinceAndForRemoval_attributesPreserved() { + String packageName = "test.deprecated.attrs"; + + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.attrs; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private String name; + + public String getName() { return name; } + + @Deprecated(since = "1.2", forRemoval = true) + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // The since and forRemoval attributes are preserved on the generated builder method + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated(since = "1.2", forRemoval = true) + public MyDtoBuilder name(String name) {""")); + } + + @Test + void annotations_deprecatedGetter_fromInstanceConstructorSuppressed() { + String packageName = "test.deprecated.getter"; + + JavaFileObject service = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.getter; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private String name; + + @Deprecated + public String getName() { return name; } + + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(service); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // A deprecated getter does NOT propagate @Deprecated to the generated builder method + // (deprecated getter != deprecated property). The class carries a class-level + // @SuppressWarnings because the from-instance constructor calls the deprecated getter. + // The constructor itself does NOT carry its own @SuppressWarnings — suppression is only + // at class level. + ProcessorAsserts.assertingResult( + generatedCode, + // name() is not @Deprecated — include preceding Javadoc closing to prove it + contains( + """ + */ + public MyDtoBuilder name(String name) {"""), + // Class-level @SuppressWarnings is present + contains( + """ + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase"""), + // From-instance constructor: no own @SuppressWarnings (preceding Javadoc closing), + // and the body calls the deprecated getter (instance.getName()) + contains( + """ + */ + public MyDtoBuilder(MyDto instance) { + this.name = initialValue(instance.getName()); + }""")); + } + + @Test + void annotations_deprecatedFieldType_methodLevelDeprecatedAndClassSuppressed() { + String packageName = "test.deprecated.fieldtype"; + + // The field type itself is @Deprecated. The builder method for this field should be + // @Deprecated (consumers calling it would get deprecation warnings from the deprecated + // parameter type), and the class needs @SuppressWarnings because the builder internally + // uses the deprecated type. + JavaFileObject dto = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.fieldtype; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private OldType value; + + public OldType getValue() { return value; } + + public void setValue(OldType value) { + this.value = value; + } + } + """); + + JavaFileObject oldType = + JavaFileObjects.forSourceString( + packageName + ".OldType", + """ + package test.deprecated.fieldtype; + + @Deprecated + public class OldType { + private String data; + + public OldType() {} + + public String getData() { return data; } + + public void setData(String data) { + this.data = data; + } + } + """); + + Compilation compilation = compileSources(dto, oldType); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // The builder method is @Deprecated because the field type is deprecated + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @Deprecated + public MyDtoBuilder value(OldType value) {""")); + + // Class-level @SuppressWarnings is present because the builder internally uses the + // deprecated type (e.g. TrackedValue, from-instance constructor) + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase""")); + } + + @Test + void annotations_deprecatedElementBuilder_classLevelSuppressed() { + String packageName = "test.deprecated.elementbuilder"; + + // The element type has its own @SimpleBuilder, and the element type is @Deprecated. + // The generated collection helper in MyDtoBuilder calls ItemForDeprecationBuilder.create() + // internally, which produces deprecation warnings — so MyDtoBuilder needs + // class-level @SuppressWarnings. + JavaFileObject dto = + JavaFileObjects.forSourceString( + packageName + ".MyDto", + """ + package test.deprecated.elementbuilder; + import java.util.List; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @SimpleBuilder + public class MyDto { + private List items; + + public List getItems() { return items; } + + public void setItems(List items) { + this.items = items; + } + } + """); + + JavaFileObject itemDto = + JavaFileObjects.forSourceString( + packageName + ".ItemForDeprecation", + """ + package test.deprecated.elementbuilder; + import org.javahelpers.simple.builders.core.annotations.SimpleBuilder; + + @Deprecated + @SimpleBuilder + public class ItemForDeprecation { + private String name; + + public String getName() { return name; } + + public void setName(String name) { + this.name = name; + } + } + """); + + Compilation compilation = compileSources(dto, itemDto); + String generatedCode = loadGeneratedSource(compilation, "MyDtoBuilder"); + ProcessorAsserts.assertGenerationSucceeded(compilation, "MyDtoBuilder", generatedCode); + + // Class-level @SuppressWarnings is present because the builder internally calls + // ItemForDeprecationBuilder.create() which is @Deprecated + ProcessorAsserts.assertingResult( + generatedCode, + contains( + """ + @SuppressWarnings({"deprecation", "removal"}) + public class MyDtoBuilder implements IBuilderBase""")); } @Test 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 index ffdd15fa..c038b139 100644 --- a/processor/src/test/java/org/javahelpers/simple/builders/processor/JavaLangAnalyserTest.java +++ b/processor/src/test/java/org/javahelpers/simple/builders/processor/JavaLangAnalyserTest.java @@ -217,4 +217,82 @@ void findGetterForField_shouldReturnEmpty_whenFieldNameIsNull() { void findGetterForField_shouldReturnEmpty_whenFieldTypeMirrorIsNull() { assertTrue(JavaLangAnalyser.findGetterForField(null, "fieldName", null, null).isEmpty()); } + + @Test + void extractDeprecatedJavaDoc_shouldReturnNull_whenJavaDocHasNoDeprecatedTag() { + String javaDoc = + """ + /** + * Some method description. + * @param name the name + * @return something + */ + """; + assertNull(JavaLangAnalyser.extractDeprecatedJavaDoc(javaDoc)); + } + + @Test + void extractDeprecatedJavaDoc_shouldReturnNull_whenJavaDocIsNull() { + assertNull(JavaLangAnalyser.extractDeprecatedJavaDoc(null)); + } + + @Test + void extractDeprecatedJavaDoc_shouldExtractSingleLineDeprecatedText() { + String javaDoc = + """ + /** + * Some method description. + * @deprecated use {@link #newMethod} instead + * @return something + */ + """; + assertEquals( + "use {@link #newMethod} instead", JavaLangAnalyser.extractDeprecatedJavaDoc(javaDoc)); + } + + @Test + void extractDeprecatedJavaDoc_shouldExtractMultiLineDeprecatedText() { + String javaDoc = + """ + /** + * Some method description. + * @deprecated use {@link #newMethod} instead + * and migrate all callers + * @return something + */ + """; + assertEquals( + "use {@link #newMethod} instead and migrate all callers", + JavaLangAnalyser.extractDeprecatedJavaDoc(javaDoc)); + } + + @Test + void findFieldElement_shouldReturnEmpty_whenClassElementIsNull() { + assertTrue(JavaLangAnalyser.findFieldElement(null, "fieldName").isEmpty()); + } + + @Test + void findFieldElement_shouldReturnEmpty_whenFieldNameIsNull() { + assertTrue(JavaLangAnalyser.findFieldElement(null, null).isEmpty()); + } + + @Test + void findRecordComponent_shouldReturnEmpty_whenTypeElementIsNull() { + assertTrue(JavaLangAnalyser.findRecordComponent(null, "componentName").isEmpty()); + } + + @Test + void findRecordComponent_shouldReturnEmpty_whenComponentNameIsNull() { + assertTrue(JavaLangAnalyser.findRecordComponent(null, null).isEmpty()); + } + + @Test + void findSetterForField_shouldReturnEmpty_whenDtoTypeIsNull() { + assertTrue(JavaLangAnalyser.findSetterForField(null, "fieldName", null).isEmpty()); + } + + @Test + void findSetterForField_shouldReturnEmpty_whenFieldNameIsNull() { + assertTrue(JavaLangAnalyser.findSetterForField(null, null, null).isEmpty()); + } }