package org.bukkit; import static org.junit.jupiter.api.Assertions.*; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; import org.objectweb.asm.ClassReader; import org.objectweb.asm.Opcodes; import org.objectweb.asm.Type; import org.objectweb.asm.tree.AnnotationNode; import org.objectweb.asm.tree.ClassNode; import org.objectweb.asm.tree.MethodNode; import org.objectweb.asm.tree.ParameterNode; public class AnnotationTest { private static final String[] ACCEPTED_ANNOTATIONS = { "Lorg/jetbrains/annotations/Nullable;", "Lorg/jetbrains/annotations/NotNull;", "Lorg/jetbrains/annotations/Contract;", "Lorg/bukkit/UndefinedNullability;", // Paper start "Lorg/checkerframework/checker/nullness/qual/MonotonicNonNull;", "Lorg/checkerframework/checker/nullness/qual/NonNull;", "Lorg/checkerframework/checker/nullness/qual/Nullable;", "Lorg/checkerframework/checker/nullness/qual/PolyNull;", // Paper end }; private static final String[] EXCLUDED_CLASSES = { // Internal technical classes "org/bukkit/plugin/java/JavaPluginLoader", "org/bukkit/util/io/BukkitObjectInputStream", "org/bukkit/util/io/BukkitObjectOutputStream", "org/bukkit/util/io/Wrapper", "org/bukkit/plugin/java/PluginClassLoader", // Generic functional interface "org/bukkit/util/Consumer", // Paper start "io/papermc/paper/util/TransformingRandomAccessList", "io/papermc/paper/util/TransformingRandomAccessList$TransformedListIterator", // Timings history is broken in terms of nullability due to guavas Function defining that the param is NonNull "co/aikar/timings/TimingHistory$2", "co/aikar/timings/TimingHistory$2$1", "co/aikar/timings/TimingHistory$2$1$1", "co/aikar/timings/TimingHistory$2$1$2", "co/aikar/timings/TimingHistory$3", "co/aikar/timings/TimingHistory$4", "co/aikar/timings/TimingHistoryEntry$1" // Paper end }; @Test public void testAll() throws IOException, URISyntaxException { URL loc = Bukkit.class.getProtectionDomain().getCodeSource().getLocation(); File file = new File(loc.toURI()); // Running from jar is not supported yet assertTrue(file.isDirectory(), "code must be in a directory"); final HashMap foundClasses = new HashMap<>(); collectClasses(file, foundClasses); final ArrayList errors = new ArrayList<>(); for (ClassNode clazz : foundClasses.values()) { if (!isClassIncluded(clazz, foundClasses)) { continue; } // Paper start - skip class if it's @NullMarked if (isClassNullMarked(clazz, foundClasses)) { continue; } // Paper end - skip class if it's @NullMarked for (MethodNode method : clazz.methods) { if (!isMethodIncluded(clazz, method, foundClasses)) { continue; } if (mustBeAnnotated(Type.getReturnType(method.desc)) && !isWellAnnotated(method.invisibleAnnotations)) { // Paper start - Allow use of TYPE_USE annotations boolean warn = true; if (isWellAnnotated(method.visibleTypeAnnotations)) { warn = false; } else if (method.invisibleTypeAnnotations != null) { dance: for (final org.objectweb.asm.tree.TypeAnnotationNode invisibleTypeAnnotation : method.invisibleTypeAnnotations) { final org.objectweb.asm.TypeReference ref = new org.objectweb.asm.TypeReference(invisibleTypeAnnotation.typeRef); if (ref.getSort() == org.objectweb.asm.TypeReference.METHOD_RETURN && java.util.Arrays.asList(ACCEPTED_ANNOTATIONS).contains(invisibleTypeAnnotation.desc)) { warn = false; break dance; // cha cha real smooth } } } if (warn) // Paper end warn(errors, clazz, method, "return value"); } Type[] paramTypes = Type.getArgumentTypes(method.desc); List parameters = method.parameters; dancing: // Paper for (int i = 0; i < paramTypes.length; i++) { if (mustBeAnnotated(paramTypes[i]) ^ isWellAnnotated(method.invisibleParameterAnnotations == null ? null : method.invisibleParameterAnnotations[i])) { // Paper start if (method.invisibleTypeAnnotations != null) { for (final org.objectweb.asm.tree.TypeAnnotationNode invisibleTypeAnnotation : method.invisibleTypeAnnotations) { final org.objectweb.asm.TypeReference ref = new org.objectweb.asm.TypeReference(invisibleTypeAnnotation.typeRef); if (ref.getSort() == org.objectweb.asm.TypeReference.METHOD_FORMAL_PARAMETER && ref.getTypeParameterIndex() == i && java.util.Arrays.asList(ACCEPTED_ANNOTATIONS).contains(invisibleTypeAnnotation.desc)) { continue dancing; } } } if (method.visibleTypeAnnotations != null) { for (final org.objectweb.asm.tree.TypeAnnotationNode invisibleTypeAnnotation : method.visibleTypeAnnotations) { final org.objectweb.asm.TypeReference ref = new org.objectweb.asm.TypeReference(invisibleTypeAnnotation.typeRef); if (ref.getSort() == org.objectweb.asm.TypeReference.METHOD_FORMAL_PARAMETER && ref.getTypeParameterIndex() == i && java.util.Arrays.asList(ACCEPTED_ANNOTATIONS).contains(invisibleTypeAnnotation.desc)) { continue dancing; } } } // Paper end - Allow use of TYPE_USE annotations ParameterNode paramNode = parameters == null ? null : parameters.get(i); String paramName = paramNode == null ? null : paramNode.name; warn(errors, clazz, method, "parameter " + i + (paramName == null ? "" : ": " + paramName)); } } } } if (errors.isEmpty()) { // Success return; } Collections.sort(errors); StringBuilder builder = new StringBuilder() .append("There ") .append(errors.size() != 1 ? "are " : "is ") .append(errors.size()) .append(" missing annotation") .append(errors.size() != 1 ? "s:\n" : ":\n"); for (String message : errors) { builder.append("\t").append(message).append("\n"); } fail(builder.toString()); } private static void collectClasses(@NotNull File from, @NotNull Map to) throws IOException { if (from.isDirectory()) { // Paper start - skip packages with @NullMarked final File packageInfo = new File(from, "package-info.class"); if (packageInfo.exists()) { try (final FileInputStream in = new FileInputStream(packageInfo)) { final ClassReader cr = new ClassReader(in); final ClassNode node = new ClassNode(); cr.accept(node, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); if (isClassNullMarked0(node)) { return; // skip packages with @NullMarked } } } // Paper end - skip packages with @NullMarked final File[] files = from.listFiles(); assert files != null; for (File file : files) { collectClasses(file, to); } return; } if (!from.getName().endsWith(".class")) { return; } try (FileInputStream in = new FileInputStream(from)) { final ClassReader cr = new ClassReader(in); final ClassNode node = new ClassNode(); cr.accept(node, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES); to.put(node.name, node); } } // Paper start - skip class if it's @NullMarked private static boolean isClassNullMarked(@NotNull ClassNode clazz, @NotNull Map allClasses) { if (clazz.nestHostClass != null) { final ClassNode nestHostNode = allClasses.get(clazz.nestHostClass); if (nestHostNode != null) { return isClassNullMarked0(nestHostNode); } } return isClassNullMarked0(clazz); } private static boolean isClassNullMarked0(@NotNull ClassNode clazz) { return clazz.visibleAnnotations != null && clazz.visibleAnnotations.stream().anyMatch(node -> "Lorg/jspecify/annotations/NullMarked;".equals(node.desc)); } // Paper end - skip class if it's @NullMarked private static boolean isClassIncluded(@NotNull ClassNode clazz, @NotNull Map allClasses) { // Exclude private, synthetic or deprecated classes and annotations, since their members can't be null if ((clazz.access & (Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC | Opcodes.ACC_DEPRECATED | Opcodes.ACC_ANNOTATION)) != 0) { return false; } if (isSubclassOf(clazz, "org/bukkit/material/MaterialData", allClasses)) { throw new AssertionError("Subclass of MaterialData must be deprecated: " + clazz.name); } if (isSubclassOf(clazz, "java/lang/Exception", allClasses) || isSubclassOf(clazz, "java/lang/RuntimeException", allClasses)) { // Exceptions are excluded return false; } // Paper start if (isInternal(clazz.invisibleAnnotations)) { return false; } // Paper end for (String excludedClass : EXCLUDED_CLASSES) { if (excludedClass.equals(clazz.name)) { return false; } } return true; } private static boolean isMethodIncluded(@NotNull ClassNode clazz, @NotNull MethodNode method, @NotNull Map allClasses) { // Exclude private, synthetic and deprecated methods if ((method.access & (Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC | Opcodes.ACC_DEPRECATED)) != 0 || (method.access & (Opcodes.ACC_PRIVATE | Opcodes.ACC_PROTECTED | Opcodes.ACC_PUBLIC)) == 0) { // Paper - ignore package-private return false; } // Exclude Java methods if (is(method, "toString", 0) || is(method, "clone", 0) || is(method, "equals", 1)) { return false; } // Exclude generated Enum methods if (isSubclassOf(clazz, "java/lang/Enum", allClasses) && (is(method, "values", 0) || is(method, "valueOf", 1))) { return false; } // Anonymous classes have generated constructors, which can't be annotated nor invoked if ("".equals(method.name) && isAnonymous(clazz)) { return false; } // Paper start if (isInternal(method.invisibleAnnotations)) { return false; } // Paper end return true; } // Paper start private static boolean isInternal(List annotations) { if (annotations == null) { return false; } for (AnnotationNode node : annotations) { if (node.desc.equals("Lorg/jetbrains/annotations/ApiStatus$Internal;")) { return true; } } return false; } // Paper end private static boolean isWellAnnotated(@Nullable List annotations) { // Paper if (annotations == null) { return false; } for (AnnotationNode node : annotations) { for (String acceptedAnnotation : ACCEPTED_ANNOTATIONS) { if (acceptedAnnotation.equals(node.desc)) { return true; } } } return false; } private static boolean mustBeAnnotated(@NotNull Type type) { return type.getSort() == Type.ARRAY || type.getSort() == Type.OBJECT; } private static boolean is(@NotNull MethodNode method, @NotNull String name, int parameters) { final List params = method.parameters; return method.name.equals(name) && (params == null || params.size() == parameters); } /** * Checks if the class is anonymous. * * @param clazz the class to check * @return true if given class is anonymous */ private static boolean isAnonymous(@NotNull ClassNode clazz) { final String name = clazz.name; if (name == null) { return false; } final int nestedSeparator = name.lastIndexOf('$'); if (nestedSeparator == -1 || nestedSeparator + 1 == name.length()) { return false; } // Nested classes have purely numeric names. Java classes can't begin with a number, // so if first character is a number, the class must be anonymous final char c = name.charAt(nestedSeparator + 1); return c >= '0' && c <= '9'; } private static boolean isSubclassOf(@NotNull ClassNode what, @NotNull String ofWhat, @NotNull Map allClasses) { if (ofWhat.equals(what.name) // Not only optimization: Super class may not be present in allClasses, so it is checked here || ofWhat.equals(what.superName)) { return true; } final ClassNode parent = allClasses.get(what.superName); if (parent != null && isSubclassOf(parent, ofWhat, allClasses)) { return true; } for (String superInterface : what.interfaces) { final ClassNode interfaceParent = allClasses.get(superInterface); if (interfaceParent != null && isSubclassOf(interfaceParent, ofWhat, allClasses)) { return true; } } return false; } private static void warn(@NotNull Collection out, @NotNull ClassNode clazz, @NotNull MethodNode method, @NotNull String description) { out.add(clazz.name + " \t" + method.name + " \t" + description); } }