Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fff0ff1
Make @SimpleBuilder @Inherited to match documentation (#244)
AndreasIgel Aug 15, 2026
13a8fc8
Rename to BuilderAnnotationInheritanceTest and cover Template inherit…
AndreasIgel Aug 15, 2026
105310a
Refactoring code to move assertNoBuilderGenerated to common asserts
AndreasIgel Aug 15, 2026
2c83b78
fixing codeformat
AndreasIgel Aug 15, 2026
aca9ef6
Document @Inherited behavior of @SimpleBuilder.Template
AndreasIgel Aug 15, 2026
bd3bf93
Document options-inheritance limitation for inherited subclass builders
AndreasIgel Aug 15, 2026
e8e0352
Fix issue reference: #245 -> #248
AndreasIgel Aug 15, 2026
532cef8
Feature-Implementation: Deprecated-Annotations should be taken over i…
AndreasIgel Aug 15, 2026
f37a533
Improving implementation
AndreasIgel Aug 16, 2026
ad668e1
Improving tests for "Deprecation-Annotation Copy"
AndreasIgel Aug 16, 2026
004a65d
Removing duplicated annotation on parameter in builder-methods and of…
AndreasIgel Aug 16, 2026
47423e0
Adepting usage of deprecated classes in builders, so that the suppres…
AndreasIgel Aug 16, 2026
1fb0014
Merge origin/main into feature/issue-235-deprecated-annotation-propag…
AndreasIgel Aug 16, 2026
be11b45
Refactoring dcode to reduce cognitive complexity
AndreasIgel Aug 16, 2026
18669f4
Improving test coverage
AndreasIgel Aug 16, 2026
5b470f7
Renaming Services to Dtos in AnnotationCopyTest
AndreasIgel Aug 16, 2026
61dc434
Merge origin/main into feature/issue-235-deprecated-annotation-propag…
AndreasIgel Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}).
*
* <p>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<AnnotationDto> 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.
Expand Down Expand Up @@ -134,6 +174,25 @@ private static Optional<AnnotationDto> 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<AnnotationDto> extractAnnotationWithoutFiltering(
AnnotationMirror mirror, ProcessingContext context) {
Element annotationElement = mirror.getAnnotationType().asElement();
if (!(annotationElement instanceof TypeElement annotationType)) {
return Optional.empty();
}

// Create AnnotationDto
AnnotationDto annotationDto = new AnnotationDto();

Expand All @@ -152,7 +211,7 @@ private static Optional<AnnotationDto> extractAnnotation(
annotationDto.addMember(memberName, memberValue);
}

context.debug(" -> Added annotation: %s", annotationQualifiedName);
context.debug(" -> Added annotation: %s", annotationType.getQualifiedName());
return Optional.of(annotationDto);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -48,6 +49,7 @@
public final class JavaLangAnalyser {

private static final String PARAM_TAG = "@param ";
private static final String DEPRECATED_TAG = "@deprecated";

private JavaLangAnalyser() {}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
*
* <p>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.
*
Expand All @@ -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.
*
Expand All @@ -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.
*
* <p>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(' ');
}
Expand All @@ -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.
*
Expand Down Expand Up @@ -397,6 +459,70 @@ public static Optional<ExecutableElement> 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<VariableElement> 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<javax.lang.model.element.RecordComponentElement> 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.
*
* <p>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<ExecutableElement> findSetterForField(
TypeElement dtoType, String fieldName, ProcessingContext context) {
if (dtoType == null || fieldName == null) {
return Optional.empty();
}
String setterName = "set" + StringUtils.capitalize(fieldName);
List<ExecutableElement> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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<String, String> member : annotationDto.getMembers().entrySet()) {
if ("value".equals(member.getKey())) {
annotation.setLiteralValue(member.getValue());
Expand Down
Loading
Loading