From 01b93eb840af3e1731e99e132ae7ff6cbfe67058 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 5 Dec 2025 14:47:48 +0100 Subject: [PATCH 1/7] Annotation processor to validate converter and validators types --- pom.xml | 5 + .../jadconfig/ParameterTypesValidator.java | 149 ++++++++++++++++++ .../javax.annotation.processing.Processor | 1 + 3 files changed, 155 insertions(+) create mode 100644 src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java create mode 100644 src/main/resources/META-INF/services/javax.annotation.processing.Processor diff --git a/pom.xml b/pom.xml index 20408dd..9b06cae 100644 --- a/pom.xml +++ b/pom.xml @@ -158,6 +158,11 @@ org.apache.maven.plugins maven-compiler-plugin 3.14.1 + + + none + org.apache.maven.plugins diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java new file mode 100644 index 0000000..3c63849 --- /dev/null +++ b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java @@ -0,0 +1,149 @@ +package com.github.joschi.jadconfig; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.AnnotationValue; +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; +import javax.lang.model.type.PrimitiveType; +import javax.lang.model.type.TypeMirror; +import javax.lang.model.util.Types; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@javax.annotation.processing.SupportedAnnotationTypes("com.github.joschi.jadconfig.Parameter") +@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_8) +public class ParameterTypesValidator extends AbstractProcessor { + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + Types typeUtils = processingEnv.getTypeUtils(); + + for (TypeElement annotation : annotations) { + // Find elements annotated with MyCustomAnnotation + for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { + if (element.getKind().isField()) { + VariableElement field = (VariableElement) element; + + final String fieldName = getFieldName(field); + TypeMirror fieldType = getBoxedType(typeUtils, field.asType()); + + final AnnotationMirror annotationMirror = element.getAnnotationMirrors() + .stream() + .filter(mirror -> mirror.getAnnotationType().toString().equals( + Parameter.class.getCanonicalName())).findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen")); + + final String parameterName = annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("value")) + .map(entry -> (String) entry.getValue().getValue()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Value is mandatory!")); + + verifyConverterType(annotationMirror, typeUtils, fieldType, parameterName, fieldName); + verifyValidators(annotationMirror, typeUtils, fieldType, parameterName, fieldName); + } + } + } + return false; + } + + private static String getFieldName(VariableElement field) { + final String className = getParentClassName(field); + return className + "#" + field.getSimpleName().toString(); + } + + private static String getParentClassName(VariableElement field) { + final Element enclosingElement = field.getEnclosingElement(); + if (enclosingElement.getKind() == ElementKind.CLASS) { + final TypeElement classElement = (TypeElement) enclosingElement; + return classElement.getQualifiedName().toString(); + } else { + return enclosingElement.getSimpleName().toString(); + } + } + + private void verifyConverterType(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { + final Optional converter = annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("converter")) + .map(entry -> (TypeMirror) entry.getValue().getValue()) + .findFirst(); + + converter.ifPresent(converterValue -> { + TypeElement converterType = (TypeElement) types.asElement(converterValue); + + List members = + processingEnv.getElementUtils().getAllMembers(converterType); + + ExecutableElement convertFromMethod = members.stream() + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(e -> (ExecutableElement) e) + .filter(m -> m.getSimpleName().contentEquals("convertFrom")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen")); + + final TypeMirror converterReturnType = convertFromMethod.getReturnType(); + if (!types.isSameType(converterReturnType, fieldType)) { + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but converter expects " + converterReturnType); + } + }); + } + + private void verifyValidators(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { + annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("validators")) + .flatMap(entry -> { + List values = + (List) entry.getValue().getValue(); + return values.stream() + .map(v -> (TypeMirror) v.getValue()); + }) + .map(type -> (TypeElement) types.asElement(type)) + .forEach(validatorType -> { + List members = + processingEnv.getElementUtils().getAllMembers(validatorType); + + ExecutableElement validatorMethod = members.stream() + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(e -> (ExecutableElement) e) + .filter(m -> m.getSimpleName().contentEquals("validate")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen")); + + final TypeMirror acceptedValidatorType = getValidatorType(types, validatorMethod); + + if (!types.isSameType(acceptedValidatorType, fieldType)) { + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but validator expects " + acceptedValidatorType); + } + }); + + } + + private static TypeMirror getValidatorType(Types types, ExecutableElement convertFromMethod) { + final List converterReturnType = convertFromMethod.getParameters(); + final TypeMirror acceptedValidatorType = converterReturnType.get(1).asType(); + return getBoxedType(types, acceptedValidatorType); + + } + + private static TypeMirror getBoxedType(Types types, TypeMirror type) { + if (type.getKind().isPrimitive()) { + TypeElement boxed = types.boxedClass((PrimitiveType) type); + return boxed.asType(); + } else { + return type; + } + } +} diff --git a/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 0000000..7218a74 --- /dev/null +++ b/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +com.github.joschi.jadconfig.ParameterTypesValidator From f88c5232065acf4f65b6d74efb95d40449c3dbc6 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 9 Dec 2025 07:02:22 +0100 Subject: [PATCH 2/7] added unit test for annotation validation processor --- pom.xml | 6 ++++ .../ParameterTypesValidatorTest.java | 34 +++++++++++++++++++ .../MyAnnotationValidatorTestClass.java | 13 +++++++ 3 files changed, 53 insertions(+) create mode 100644 src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java create mode 100644 src/test/resources/com/github/joschi/jadconfig/MyAnnotationValidatorTestClass.java diff --git a/pom.xml b/pom.xml index 9b06cae..5f89d0b 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,12 @@ 3.0.2 test + + io.toolisticon.cute + cute + 1.9.0 + test + diff --git a/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java b/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java new file mode 100644 index 0000000..e40f3d2 --- /dev/null +++ b/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java @@ -0,0 +1,34 @@ +package com.github.joschi.jadconfig; + +import io.toolisticon.cute.Cute; +import io.toolisticon.cute.CuteApi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ParameterTypesValidatorTest { + + CuteApi.BlackBoxTestSourceFilesInterface compileTestBuilder; + + @BeforeEach + public void init() { + compileTestBuilder = Cute + .blackBoxTest() + .given() + .processor(ParameterTypesValidator.class); + } + + @Test + public void testPropertyValidatorProcessor() { + compileTestBuilder + .andSourceFiles("/com/github/joschi/jadconfig/MyAnnotationValidatorTestClass.java") + .whenCompiled().thenExpectThat() + .compilationFails() + .andThat() + .compilerMessage() + .ofKindError().equals("Property my_int assigned to field MyAnnotationValidatorTestClass#myIntField has type java.lang.Integer but converter expects java.lang.Long") + .andThat() + .compilerMessage() + .ofKindError().equals("Property my_duration assigned to field MyAnnotationValidatorTestClass#myDurationField has type java.time.Duration but validator expects com.github.joschi.jadconfig.util.Duration") + .executeTest(); + } +} \ No newline at end of file diff --git a/src/test/resources/com/github/joschi/jadconfig/MyAnnotationValidatorTestClass.java b/src/test/resources/com/github/joschi/jadconfig/MyAnnotationValidatorTestClass.java new file mode 100644 index 0000000..ff8a437 --- /dev/null +++ b/src/test/resources/com/github/joschi/jadconfig/MyAnnotationValidatorTestClass.java @@ -0,0 +1,13 @@ +import com.github.joschi.jadconfig.converters.LongConverter; +import com.github.joschi.jadconfig.Parameter; +import com.github.joschi.jadconfig.validators.PositiveDurationValidator; + +import java.time.Duration; + +public class MyAnnotationValidatorTestClass { + @Parameter(value = "my_int", converter = LongConverter.class) + private int myIntField = 10; + + @Parameter(value = "my_duration", validators = {PositiveDurationValidator.class}) + private Duration myDurationField = Duration.ofDays(1); +} From 4afc68f508dd58a68610d54c0ff6c2c6d2907009 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 9 Dec 2025 07:36:13 +0100 Subject: [PATCH 3/7] code cleanup --- .../jadconfig/ParameterTypesValidator.java | 133 ++++++++++-------- 1 file changed, 71 insertions(+), 62 deletions(-) diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java index 3c63849..e64af3b 100644 --- a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java +++ b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java @@ -31,33 +31,38 @@ public synchronized void init(ProcessingEnvironment processingEnv) { public boolean process(Set annotations, RoundEnvironment roundEnv) { Types typeUtils = processingEnv.getTypeUtils(); - for (TypeElement annotation : annotations) { - // Find elements annotated with MyCustomAnnotation - for (Element element : roundEnv.getElementsAnnotatedWith(annotation)) { - if (element.getKind().isField()) { - VariableElement field = (VariableElement) element; - - final String fieldName = getFieldName(field); - TypeMirror fieldType = getBoxedType(typeUtils, field.asType()); - - final AnnotationMirror annotationMirror = element.getAnnotationMirrors() - .stream() - .filter(mirror -> mirror.getAnnotationType().toString().equals( - Parameter.class.getCanonicalName())).findFirst() - .orElseThrow(() -> new IllegalStateException("This should not happen")); - - final String parameterName = annotationMirror.getElementValues().entrySet().stream() - .filter(entry -> entry.getKey().getSimpleName().toString().equals("value")) - .map(entry -> (String) entry.getValue().getValue()) - .findFirst() - .orElseThrow(() -> new IllegalStateException("Value is mandatory!")); - - verifyConverterType(annotationMirror, typeUtils, fieldType, parameterName, fieldName); - verifyValidators(annotationMirror, typeUtils, fieldType, parameterName, fieldName); - } - } - } - return false; + annotations.stream() + .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) + .map(element -> (VariableElement) element) + .forEach(element -> {processField(element,typeUtils);}); + return false; // do not claim this annotation, let other processors handle it as well + } + + private void processField(VariableElement field, Types typeUtils) { + final String fieldName = getFieldName(field); + TypeMirror fieldType = getBoxedType(typeUtils, field.asType()); + + final AnnotationMirror annotationMirror = getParameterAnnotation(field); + final String parameterName = getParameterValue(annotationMirror); + + verifyConverterType(annotationMirror, typeUtils, fieldType, parameterName, fieldName); + verifyValidators(annotationMirror, typeUtils, fieldType, parameterName, fieldName); + } + + private static String getParameterValue(AnnotationMirror annotationMirror) { + return annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("value")) + .map(entry -> (String) entry.getValue().getValue()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Value is mandatory!")); + } + + private static AnnotationMirror getParameterAnnotation(Element element) { + return element.getAnnotationMirrors() + .stream() + .filter(mirror -> mirror.getAnnotationType().toString().equals( + Parameter.class.getCanonicalName())).findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen, the element should be always annotated with Parameter annotation")); } private static String getFieldName(VariableElement field) { @@ -76,23 +81,10 @@ private static String getParentClassName(VariableElement field) { } private void verifyConverterType(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { - final Optional converter = annotationMirror.getElementValues().entrySet().stream() - .filter(entry -> entry.getKey().getSimpleName().toString().equals("converter")) - .map(entry -> (TypeMirror) entry.getValue().getValue()) - .findFirst(); - - converter.ifPresent(converterValue -> { + getConverter(annotationMirror).ifPresent(converterValue -> { TypeElement converterType = (TypeElement) types.asElement(converterValue); - - List members = - processingEnv.getElementUtils().getAllMembers(converterType); - - ExecutableElement convertFromMethod = members.stream() - .filter(e -> e.getKind() == ElementKind.METHOD) - .map(e -> (ExecutableElement) e) - .filter(m -> m.getSimpleName().contentEquals("convertFrom")) - .findFirst() - .orElseThrow(() -> new IllegalStateException("This should not happen")); + List members = processingEnv.getElementUtils().getAllMembers(converterType); + ExecutableElement convertFromMethod = getConvertFromMethod(members); final TypeMirror converterReturnType = convertFromMethod.getReturnType(); if (!types.isSameType(converterReturnType, fieldType)) { @@ -101,41 +93,58 @@ private void verifyConverterType(AnnotationMirror annotationMirror, Types types, }); } + private static ExecutableElement getConvertFromMethod(List members) { + return members.stream() + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(e -> (ExecutableElement) e) + .filter(m -> m.getSimpleName().contentEquals("convertFrom")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen, converter should contain a convertFrom method")); + } + + private static Optional getConverter(AnnotationMirror annotationMirror) { + return annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("converter")) + .map(entry -> (TypeMirror) entry.getValue().getValue()) + .findFirst(); + } + private void verifyValidators(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { annotationMirror.getElementValues().entrySet().stream() .filter(entry -> entry.getKey().getSimpleName().toString().equals("validators")) .flatMap(entry -> { - List values = - (List) entry.getValue().getValue(); - return values.stream() - .map(v -> (TypeMirror) v.getValue()); + List values = (List) entry.getValue().getValue(); + return values.stream() .map(v -> (TypeMirror) v.getValue()); }) .map(type -> (TypeElement) types.asElement(type)) - .forEach(validatorType -> { - List members = - processingEnv.getElementUtils().getAllMembers(validatorType); + .forEach(validatorType -> verifyValidator(types, fieldType, parameterName, fieldName, validatorType)); + } - ExecutableElement validatorMethod = members.stream() - .filter(e -> e.getKind() == ElementKind.METHOD) - .map(e -> (ExecutableElement) e) - .filter(m -> m.getSimpleName().contentEquals("validate")) - .findFirst() - .orElseThrow(() -> new IllegalStateException("This should not happen")); + private void verifyValidator(Types types, TypeMirror fieldType, String parameterName, String fieldName, TypeElement validatorType) { + final ExecutableElement validatorMethod = getValidateMethod(validatorType); + final TypeMirror acceptedValidatorType = getValidatorType(types, validatorMethod); - final TypeMirror acceptedValidatorType = getValidatorType(types, validatorMethod); + if (!types.isSameType(acceptedValidatorType, fieldType)) { + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but validator expects " + acceptedValidatorType); + } + } - if (!types.isSameType(acceptedValidatorType, fieldType)) { - processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but validator expects " + acceptedValidatorType); - } - }); + private ExecutableElement getValidateMethod(TypeElement validatorType) { + List members = + processingEnv.getElementUtils().getAllMembers(validatorType); + return members.stream() + .filter(e -> e.getKind() == ElementKind.METHOD) + .map(e -> (ExecutableElement) e) + .filter(m -> m.getSimpleName().contentEquals("validate")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("This should not happen")); } private static TypeMirror getValidatorType(Types types, ExecutableElement convertFromMethod) { final List converterReturnType = convertFromMethod.getParameters(); final TypeMirror acceptedValidatorType = converterReturnType.get(1).asType(); return getBoxedType(types, acceptedValidatorType); - } private static TypeMirror getBoxedType(Types types, TypeMirror type) { From 91b67d6b1e32d82c1d328bc64873b242f75bec59 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 9 Dec 2025 12:22:55 +0100 Subject: [PATCH 4/7] add position hints for errors --- .../jadconfig/ParameterTypesValidator.java | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java index e64af3b..f465ce2 100644 --- a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java +++ b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java @@ -34,7 +34,9 @@ public boolean process(Set annotations, RoundEnvironment annotations.stream() .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) .map(element -> (VariableElement) element) - .forEach(element -> {processField(element,typeUtils);}); + .forEach(element -> { + processField(element, typeUtils); + }); return false; // do not claim this annotation, let other processors handle it as well } @@ -45,8 +47,8 @@ private void processField(VariableElement field, Types typeUtils) { final AnnotationMirror annotationMirror = getParameterAnnotation(field); final String parameterName = getParameterValue(annotationMirror); - verifyConverterType(annotationMirror, typeUtils, fieldType, parameterName, fieldName); - verifyValidators(annotationMirror, typeUtils, fieldType, parameterName, fieldName); + verifyConverterType(annotationMirror, typeUtils, field, fieldType, parameterName, fieldName); + verifyValidators(annotationMirror, typeUtils, field, fieldType, parameterName, fieldName); } private static String getParameterValue(AnnotationMirror annotationMirror) { @@ -80,7 +82,7 @@ private static String getParentClassName(VariableElement field) { } } - private void verifyConverterType(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { + private void verifyConverterType(AnnotationMirror annotationMirror, Types types, VariableElement field, TypeMirror fieldType, String parameterName, String fieldName) { getConverter(annotationMirror).ifPresent(converterValue -> { TypeElement converterType = (TypeElement) types.asElement(converterValue); List members = processingEnv.getElementUtils().getAllMembers(converterType); @@ -88,7 +90,7 @@ private void verifyConverterType(AnnotationMirror annotationMirror, Types types, final TypeMirror converterReturnType = convertFromMethod.getReturnType(); if (!types.isSameType(converterReturnType, fieldType)) { - processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but converter expects " + converterReturnType); + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but converter expects " + converterReturnType, field); } }); } @@ -109,23 +111,23 @@ private static Optional getConverter(AnnotationMirror annotationMirr .findFirst(); } - private void verifyValidators(AnnotationMirror annotationMirror, Types types, TypeMirror fieldType, String parameterName, String fieldName) { + private void verifyValidators(AnnotationMirror annotationMirror, Types types, VariableElement field, TypeMirror fieldType, String parameterName, String fieldName) { annotationMirror.getElementValues().entrySet().stream() .filter(entry -> entry.getKey().getSimpleName().toString().equals("validators")) .flatMap(entry -> { List values = (List) entry.getValue().getValue(); - return values.stream() .map(v -> (TypeMirror) v.getValue()); + return values.stream().map(v -> (TypeMirror) v.getValue()); }) .map(type -> (TypeElement) types.asElement(type)) - .forEach(validatorType -> verifyValidator(types, fieldType, parameterName, fieldName, validatorType)); + .forEach(validatorType -> verifyValidator(types, field, fieldType, parameterName, fieldName, validatorType)); } - private void verifyValidator(Types types, TypeMirror fieldType, String parameterName, String fieldName, TypeElement validatorType) { + private void verifyValidator(Types types, VariableElement field, TypeMirror fieldType, String parameterName, String fieldName, TypeElement validatorType) { final ExecutableElement validatorMethod = getValidateMethod(validatorType); final TypeMirror acceptedValidatorType = getValidatorType(types, validatorMethod); if (!types.isSameType(acceptedValidatorType, fieldType)) { - processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but validator expects " + acceptedValidatorType); + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has type " + fieldType + " but validator expects " + acceptedValidatorType, field); } } From ae338496b3194b35486a24ff6267eb1c0fc9bc64 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Tue, 9 Dec 2025 12:22:55 +0100 Subject: [PATCH 5/7] add position hints for errors --- .../github/joschi/jadconfig/ParameterTypesValidatorTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java b/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java index e40f3d2..4e10bbd 100644 --- a/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java +++ b/src/test/java/com/github/joschi/jadconfig/ParameterTypesValidatorTest.java @@ -25,10 +25,10 @@ public void testPropertyValidatorProcessor() { .compilationFails() .andThat() .compilerMessage() - .ofKindError().equals("Property my_int assigned to field MyAnnotationValidatorTestClass#myIntField has type java.lang.Integer but converter expects java.lang.Long") + .ofKindError().atLine(9).atColumn(17).equals("Property my_int assigned to field MyAnnotationValidatorTestClass#myIntField has type java.lang.Integer but converter expects java.lang.Long") .andThat() .compilerMessage() - .ofKindError().equals("Property my_duration assigned to field MyAnnotationValidatorTestClass#myDurationField has type java.time.Duration but validator expects com.github.joschi.jadconfig.util.Duration") + .ofKindError().atLine(12).atColumn(22).equals("Property my_duration assigned to field MyAnnotationValidatorTestClass#myDurationField has type java.time.Duration but validator expects com.github.joschi.jadconfig.util.Duration") .executeTest(); } } \ No newline at end of file From bdb991fe2eeefefc9e72720f8a779986466643f4 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Wed, 10 Dec 2025 12:56:44 +0100 Subject: [PATCH 6/7] Annotation processor for documentation enforcing --- .../ParameterDocumentationValidator.java | 110 ++++++++++++++++++ .../javax.annotation.processing.Processor | 1 + .../ParameterDocumentationValidatorTest.java | 31 +++++ .../MyDocumentationValidatorTestClass.java | 20 ++++ 4 files changed, 162 insertions(+) create mode 100644 src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java create mode 100644 src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java create mode 100644 src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java new file mode 100644 index 0000000..bbb6161 --- /dev/null +++ b/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java @@ -0,0 +1,110 @@ +package com.github.joschi.jadconfig; + +import com.github.joschi.jadconfig.documentation.Documentation; +import org.apache.commons.lang3.StringUtils; + +import javax.annotation.processing.AbstractProcessor; +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.AnnotationMirror; +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import javax.lang.model.element.TypeElement; +import javax.lang.model.element.VariableElement; +import javax.lang.model.util.Types; +import java.util.Optional; +import java.util.Set; + +@javax.annotation.processing.SupportedAnnotationTypes("com.github.joschi.jadconfig.Parameter") +@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_8) +public class ParameterDocumentationValidator extends AbstractProcessor { + + @Override + public synchronized void init(ProcessingEnvironment processingEnv) { + super.init(processingEnv); + } + + @Override + public boolean process(Set annotations, RoundEnvironment roundEnv) { + Types typeUtils = processingEnv.getTypeUtils(); + + annotations.stream() + .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) + .map(element -> (VariableElement) element) + .forEach(element -> { + processField(element, typeUtils); + }); + return false; // do not claim this annotation, let other processors handle it as well + } + + private void processField(VariableElement field, Types typeUtils) { + final String fieldName = getFieldName(field); + + final AnnotationMirror parameterAnnotationMirror = getAnnotationMirror(field, Parameter.class) + .orElseThrow(() -> new IllegalStateException("This should not happen, field should be always annotated with @Parameter")); + + final Optional documentationAnnotationMirror = getAnnotationMirror(field, Documentation.class); + + final String parameterName = getParameterValue(parameterAnnotationMirror); + + documentationAnnotationMirror.ifPresentOrElse(documentationAnnotation -> { + final boolean isDocumentationVisible = isDocumentationVisible(documentationAnnotation); + final Optional documentationText = getDocumentationText(documentationAnnotation); + + if (isDocumentationVisible && documentationText.isEmpty()) { + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation value!", field); + } + }, () -> { + processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation!", field); + }); + } + + private Optional getDocumentationText(AnnotationMirror documentationAnnotationMirror) { + return documentationAnnotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("value")) + .map(entry -> (String) entry.getValue().getValue()) + .findFirst() + .filter(value -> !StringUtils.isEmpty(value)); + + } + + private static String getParameterValue(AnnotationMirror annotationMirror) { + return annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("value")) + .map(entry -> (String) entry.getValue().getValue()) + .findFirst() + .orElseThrow(() -> new IllegalStateException("Value is mandatory!")); + } + + private static boolean isDocumentationVisible(AnnotationMirror annotationMirror) { + return annotationMirror.getElementValues().entrySet().stream() + .filter(entry -> entry.getKey().getSimpleName().toString().equals("visible")) + .map(entry -> (Boolean) entry.getValue().getValue()) + .findFirst() + .orElse(true); + } + + private static Optional getAnnotationMirror(Element element, Class annotationClass) { + return element.getAnnotationMirrors() + .stream() + .filter(mirror -> mirror.getAnnotationType().toString().equals( + annotationClass.getCanonicalName())) + .findFirst(); + } + + private static String getFieldName(VariableElement field) { + final String className = getParentClassName(field); + return className + "#" + field.getSimpleName().toString(); + } + + private static String getParentClassName(VariableElement field) { + final Element enclosingElement = field.getEnclosingElement(); + if (enclosingElement.getKind() == ElementKind.CLASS) { + final TypeElement classElement = (TypeElement) enclosingElement; + return classElement.getQualifiedName().toString(); + } else { + return enclosingElement.getSimpleName().toString(); + } + } +} diff --git a/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/src/main/resources/META-INF/services/javax.annotation.processing.Processor index 7218a74..7aa6db8 100644 --- a/src/main/resources/META-INF/services/javax.annotation.processing.Processor +++ b/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -1 +1,2 @@ com.github.joschi.jadconfig.ParameterTypesValidator +com.github.joschi.jadconfig.ParameterDocumentationValidator diff --git a/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java b/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java new file mode 100644 index 0000000..1750a74 --- /dev/null +++ b/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java @@ -0,0 +1,31 @@ +package com.github.joschi.jadconfig; + +import io.toolisticon.cute.Cute; +import io.toolisticon.cute.CuteApi; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ParameterDocumentationValidatorTest { + + CuteApi.BlackBoxTestSourceFilesInterface compileTestBuilder; + + @BeforeEach + public void init() { + compileTestBuilder = Cute + .blackBoxTest() + .given() + .processor(ParameterDocumentationValidator.class); + } + + @Test + public void testPropertyValidatorProcessor() { + compileTestBuilder + .andSourceFiles("/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java") + .whenCompiled().thenExpectThat() + .compilationFails() + .andThat() + .compilerMessage() + .ofKindError().atLine(18).atColumn(22).equals("Property my_duration assigned to field MyDocumentationValidatorTestClass#myDurationField has no documentation available. Please, add @Documentation annotation!") + .executeTest(); + } +} \ No newline at end of file diff --git a/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java b/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java new file mode 100644 index 0000000..b57cde5 --- /dev/null +++ b/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java @@ -0,0 +1,20 @@ +import com.github.joschi.jadconfig.Parameter; +import com.github.joschi.jadconfig.documentation.Documentation; + +import java.time.Duration; + +public class MyDocumentationValidatorTestClass { + + @Documentation(visible = false) + @Parameter(value = "my_hidden_property") + private String hiddenProperty; + + @Documentation("configure some int value") + @Parameter(value = "my_int") + private int myIntField = 10; + + // here's no @Documentation annotation, should lead to an error + @Parameter(value = "my_duration") + private Duration myDurationField = Duration.ofDays(1); + +} From 1ba5888e3bbb74dcceb7eda1c38ab766c52386fc Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Fri, 12 Dec 2025 13:41:46 +0100 Subject: [PATCH 7/7] better documentation validation --- .../ParameterDocumentationValidator.java | 42 +++++++++++++------ .../jadconfig/ParameterTypesValidator.java | 2 +- .../documentation/Documentation.java | 3 ++ .../ParameterDocumentationValidatorTest.java | 2 +- .../MyDocumentationValidatorTestClass.java | 2 +- 5 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java index bbb6161..8b14e7a 100644 --- a/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java +++ b/src/main/java/com/github/joschi/jadconfig/ParameterDocumentationValidator.java @@ -12,33 +12,32 @@ import javax.lang.model.element.ElementKind; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; -import javax.lang.model.util.Types; +import java.util.Arrays; import java.util.Optional; import java.util.Set; @javax.annotation.processing.SupportedAnnotationTypes("com.github.joschi.jadconfig.Parameter") -@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_8) +@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_21) public class ParameterDocumentationValidator extends AbstractProcessor { + private static final int LINE_LENGTH_WARNING = 120; + @Override public synchronized void init(ProcessingEnvironment processingEnv) { super.init(processingEnv); } + @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { - Types typeUtils = processingEnv.getTypeUtils(); - annotations.stream() .flatMap(annotation -> roundEnv.getElementsAnnotatedWith(annotation).stream()) .map(element -> (VariableElement) element) - .forEach(element -> { - processField(element, typeUtils); - }); + .forEach(this::processField); return false; // do not claim this annotation, let other processors handle it as well } - private void processField(VariableElement field, Types typeUtils) { + private void processField(VariableElement field) { final String fieldName = getFieldName(field); final AnnotationMirror parameterAnnotationMirror = getAnnotationMirror(field, Parameter.class) @@ -52,12 +51,29 @@ private void processField(VariableElement field, Types typeUtils) { final boolean isDocumentationVisible = isDocumentationVisible(documentationAnnotation); final Optional documentationText = getDocumentationText(documentationAnnotation); - if (isDocumentationVisible && documentationText.isEmpty()) { - processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation value!", field); + if (isDocumentationVisible) { + + documentationText.ifPresentOrElse(docs -> { + if (Documentation.MISSING.equals(docs)) { + processingEnv.getMessager().printWarning("Property " + parameterName + " assigned to field " + fieldName + " has TDB documentation, please write proper doc!", field); + } + + final int maxLineLength = maxLineLength(docs); + if (maxLineLength > LINE_LENGTH_WARNING) { + processingEnv.getMessager().printWarning("Property " + parameterName + " assigned to field " + fieldName + " has too long lines (" + maxLineLength + "), please consider splitting it into multiple lines.", field); + } + }, () -> processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation value!", field)); + } - }, () -> { - processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation!", field); - }); + }, () -> processingEnv.getMessager().printError("Property " + parameterName + " assigned to field " + fieldName + " has no documentation available. Please, add @Documentation annotation!", field)); + } + + private int maxLineLength(String docs) { + return Arrays.stream(docs.split("\n")) + .map(String::trim) + .mapToInt(String::length) + .max() + .orElse(0); } private Optional getDocumentationText(AnnotationMirror documentationAnnotationMirror) { diff --git a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java index f465ce2..638e37b 100644 --- a/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java +++ b/src/main/java/com/github/joschi/jadconfig/ParameterTypesValidator.java @@ -19,7 +19,7 @@ import java.util.Set; @javax.annotation.processing.SupportedAnnotationTypes("com.github.joschi.jadconfig.Parameter") -@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_8) +@javax.annotation.processing.SupportedSourceVersion(SourceVersion.RELEASE_21) public class ParameterTypesValidator extends AbstractProcessor { @Override diff --git a/src/main/java/com/github/joschi/jadconfig/documentation/Documentation.java b/src/main/java/com/github/joschi/jadconfig/documentation/Documentation.java index 2e6995c..fe8eef3 100644 --- a/src/main/java/com/github/joschi/jadconfig/documentation/Documentation.java +++ b/src/main/java/com/github/joschi/jadconfig/documentation/Documentation.java @@ -13,6 +13,9 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface Documentation { + + String MISSING = "tbd"; + /** * We don't want to expose some configuration fields to users. They are internal, required for system packages functionality * or deprecated. Set to false if you want to hide this field from documentation. diff --git a/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java b/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java index 1750a74..0911baa 100644 --- a/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java +++ b/src/test/java/com/github/joschi/jadconfig/ParameterDocumentationValidatorTest.java @@ -18,7 +18,7 @@ public void init() { } @Test - public void testPropertyValidatorProcessor() { + public void testDocumentationProcessor() { compileTestBuilder .andSourceFiles("/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java") .whenCompiled().thenExpectThat() diff --git a/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java b/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java index b57cde5..ab2fc91 100644 --- a/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java +++ b/src/test/resources/com/github/joschi/jadconfig/MyDocumentationValidatorTestClass.java @@ -10,7 +10,7 @@ public class MyDocumentationValidatorTestClass { private String hiddenProperty; @Documentation("configure some int value") - @Parameter(value = "my_int") + @Parameter(value = "my_int", required = true) private int myIntField = 10; // here's no @Documentation annotation, should lead to an error