diff --git a/pom.xml b/pom.xml index 7b83bc80..e6dbe9ba 100644 --- a/pom.xml +++ b/pom.xml @@ -197,6 +197,18 @@ provided + + + org.reflections + reflections + 0.10.2 + + + org.javassist + javassist + 3.28.0-GA + + org.junit.jupiter diff --git a/src/test/java/ch/jalu/configme/NullabilityAnnotationConsistencyTest.java b/src/test/java/ch/jalu/configme/NullabilityAnnotationConsistencyTest.java new file mode 100644 index 00000000..a0c2b2f5 --- /dev/null +++ b/src/test/java/ch/jalu/configme/NullabilityAnnotationConsistencyTest.java @@ -0,0 +1,130 @@ +package ch.jalu.configme; + +import javassist.ClassPool; +import javassist.CtClass; +import javassist.CtConstructor; +import javassist.CtMethod; +import javassist.bytecode.AccessFlag; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.reflections.Reflections; +import org.reflections.scanners.Scanners; + +import java.lang.annotation.Annotation; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +class NullabilityAnnotationConsistencyTest { + + public static void main(String... args) throws Exception { + Reflections reflections = new Reflections("ch.jalu.configme", + Scanners.SubTypes.filterResultsBy(c -> true)); + + Set packageBlacklist = excludedPackages(); + + List> classes = reflections.getSubTypesOf(Object.class) + .stream() + .filter(clz -> !isTestClassOrWithin(clz)) + .filter(clz -> packageBlacklist.stream().noneMatch(bl -> clz.getName().startsWith(bl))) + .collect(Collectors.toList()); + + ClassPool pool = ClassPool.getDefault(); + + Map, List> declaredMethodsByClass = new HashMap<>(); + Map, List> declaredConstructorsByClass = new HashMap<>(); + + + for (Class clazz : classes) { + CtClass result = pool.get(clazz.getName()); + declaredMethodsByClass.put(clazz, Arrays.asList(result.getDeclaredMethods())); + declaredConstructorsByClass.put(clazz, Arrays.asList(result.getDeclaredConstructors())); + } + + List errors = new ArrayList<>(); + for (Map.Entry, List> classAndMethods : declaredMethodsByClass.entrySet()) { + + for (CtMethod method : classAndMethods.getValue()) { + if ((method.getMethodInfo().getAccessFlags() & AccessFlag.SYNTHETIC) != 0) { + continue; + } + + + List errorsForMethod = new ArrayList<>(); + if (!method.hasAnnotation(NotNull.class) + && !method.hasAnnotation(Nullable.class) + && !method.getReturnType().isPrimitive()) { + errorsForMethod.add("missing annotation on return value"); + } + + Object[][] annotations = method.getParameterAnnotations(); + CtClass[] parameterTypes = method.getParameterTypes(); + errorsForMethod.addAll(findErrorsForParams(annotations, parameterTypes)); + + if (!errorsForMethod.isEmpty()) { + String methodString = method.getDeclaringClass().getName() +"#" + method.getName() + "(" + + Arrays.stream(method.getParameterTypes()).map(CtClass::getSimpleName).collect(Collectors.joining(", ")) + + ")"; + errors.addAll(errorsForMethod.stream() + .map(err -> methodString + ": " + err) + .collect(Collectors.toList())); + + } + } + } + + for (Map.Entry, List> constructorsByClass : declaredConstructorsByClass.entrySet()) { + for (CtConstructor ctConstructor : constructorsByClass.getValue()) { + Object[][] annotations = ctConstructor.getParameterAnnotations(); + CtClass[] parameterTypes = ctConstructor.getParameterTypes(); + List errorsForConstructor = findErrorsForParams(annotations, parameterTypes); + if (!errorsForConstructor.isEmpty()) { + String constructorString = "Constructor " + ctConstructor.getLongName(); + errors.addAll(errorsForConstructor.stream() + .map(err -> constructorString + ": " + err) + .collect(Collectors.toList())); + } + } + } + + System.out.println(errors.size() + " errors"); + System.out.println(String.join("\n- ", errors)); + } + + private static List findErrorsForParams(Object[][] annotations, CtClass[] parameterTypes) { + List errors = new ArrayList<>(); + for (int i = 0; i < parameterTypes.length; i++) { + CtClass parameterType = parameterTypes[i]; + boolean hasNullabilityAnnotation = Arrays.stream(annotations[i]) + .map(anno -> ((Annotation) anno).annotationType()) + .anyMatch(annoType -> annoType == NotNull.class || annoType == Nullable.class); + + if (parameterType.isPrimitive() && hasNullabilityAnnotation) { + errors.add("param " + i + " is primitive but has a nullability annotation"); + } else if (!parameterType.isPrimitive() && !hasNullabilityAnnotation) { + errors.add("param " + i + " does not have a nullability annotation"); + } + } + return errors; + } + + private static Set excludedPackages() { + Set blacklist = new HashSet<>(); + blacklist.add("ch.jalu.configme.beanmapper.command."); + blacklist.add("ch.jalu.configme.beanmapper.typeissues."); + blacklist.add("ch.jalu.configme.beanmapper.worldgroup."); + blacklist.add("ch.jalu.configme.demo."); + blacklist.add("ch.jalu.configme.samples."); + return blacklist; + } + + private static boolean isTestClassOrWithin(Class clazz) { + return clazz.getName().endsWith("Test") + || clazz.getEnclosingClass() != null && clazz.getEnclosingClass().getName().endsWith("Test"); + } +}