7132df4810
Let plugins be able to control tab completion of commands and chat async. This will be useful for frameworks like ACF so we can define async safe completion handlers, and avoid going to main for tab completions. Especially useful if you need to query a database in order to obtain the results for tab completion, such as offline players. Also Enforces mutability of the existing TabCompleteEvent. Co-authored-by: Aikar <aikar@aikar.co>
369 lines
15 KiB
Java
369 lines
15 KiB
Java
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<String, ClassNode> foundClasses = new HashMap<>();
|
|
collectClasses(file, foundClasses);
|
|
|
|
final ArrayList<String> 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<ParameterNode> 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<String, ClassNode> 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<String, ClassNode> 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<String, ClassNode> 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<String, ClassNode> 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 ("<init>".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<? extends AnnotationNode> 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<? extends AnnotationNode> 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<ParameterNode> 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<String, ClassNode> 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<String> out, @NotNull ClassNode clazz, @NotNull MethodNode method, @NotNull String description) {
|
|
out.add(clazz.name + " \t" + method.name + " \t" + description);
|
|
}
|
|
}
|