From abdf0b0559ddd1bd2d0b449112148239e744f48d Mon Sep 17 00:00:00 2001 From: Algent Date: Thu, 3 Sep 2026 09:45:33 +0200 Subject: [PATCH] test: characterize ClassInfo metadata lookup --- .../test/ClassInfoFunctionalTest.java | 243 ++++++++++++ .../asm/ClassInfoCharacterizationTest.java | 357 ++++++++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 src/functionalTest/java/codechicken/multipart/test/ClassInfoFunctionalTest.java create mode 100644 src/test/java/codechicken/multipart/asm/ClassInfoCharacterizationTest.java diff --git a/src/functionalTest/java/codechicken/multipart/test/ClassInfoFunctionalTest.java b/src/functionalTest/java/codechicken/multipart/test/ClassInfoFunctionalTest.java new file mode 100644 index 0000000..c439ee0 --- /dev/null +++ b/src/functionalTest/java/codechicken/multipart/test/ClassInfoFunctionalTest.java @@ -0,0 +1,243 @@ +package codechicken.multipart.test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.objectweb.asm.Opcodes.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; + +import codechicken.multipart.asm.ASMMixinCompiler; +import codechicken.multipart.asm.ASMMixinCompiler$; +import codechicken.multipart.asm.ASMMixinCompiler.ClassInfo; +import codechicken.multipart.asm.ASMMixinCompiler.ClassInfo$; +import codechicken.multipart.asm.ASMMixinCompiler.MethodInfo; +import scala.Option; +import scala.collection.Iterable; +import scala.collection.Iterator; +import scala.collection.mutable.Map; + +class ClassInfoFunctionalTest { + + @Test + void choosesJavaNodesAndReflectionFallbackWithTheirDistinctRootSemantics() throws Exception { + ASMMixinCompiler$ compiler = ASMMixinCompiler$.MODULE$; + ClassInfo object = compiler.getClassInfo(Object.class); + assertEquals( + "codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ReflectionClassInfo", + object.getClass().getName()); + assertEquals("java/lang/Object", object.name()); + assertFalse(object.superClass().isDefined()); + assertTrue(object.interfaces().isEmpty()); + assertEquals(Object.class.getMethods().length, object.methods().size()); + + ClassInfo child = compiler.getClassInfo(LoadedChild.class); + assertEquals("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ClassNodeInfo", child.getClass().getName()); + assertSame(compiler.getClassInfo(LoadedParent.class), child.superClass().get()); + assertEquals(Arrays.asList("java/lang/Runnable", "java/lang/AutoCloseable"), names(child.interfaces())); + MethodInfo inherited = child.findPublicImpl("inherited", "()I").get(); + assertSame(compiler.getClassInfo(LoadedParent.class), inherited.owner()); + assertTrue(child.findPublicImpl("protectedMethod", "()V").isDefined()); + assertFalse(child.findPublicImpl("privateMethod", "()V").isDefined()); + assertFalse(child.findPublicImpl("run", "()V").isDefined()); + + ClassInfo reflected = construct("ReflectionClassInfo", Class.class, LoadedChild.class); + assertSame(child.superClass().get(), reflected.superClass().get()); + assertEquals(names(child.interfaces()), names(reflected.interfaces())); + // Bytecode nodes preserve Some(null) for a null superclass; reflection represents the root as None. + ClassNode root = new ClassNode(); + root.name = "test/Root"; + Option superClass = construct("ClassNodeInfo", ClassNode.class, root).superClass(); + assertTrue(superClass.isDefined()); + assertNull(superClass.get()); + compiler.remClassInfo((String) null); + } + + @Test + void cachesExactStringKeysAndUsesOnlyTheNameOfSuppliedNodes() { + ASMMixinCompiler$ compiler = ASMMixinCompiler$.MODULE$; + String slash = Type.getInternalName(CacheSubject.class), dotted = CacheSubject.class.getName(); + compiler.remClassInfo(slash); + compiler.remClassInfo(dotted); + try { + ClassInfo first = compiler.getClassInfo(slash); + assertSame(first, compiler.getClassInfo(CacheSubject.class)); + assertSame(first, ASMMixinCompiler.getClassInfo(slash)); + ClassNode misleading = new ClassNode(); + misleading.name = slash; + misleading.superName = "test/Missing"; + assertSame(first, compiler.getClassInfo(misleading)); + assertTrue(first.methods().exists(new scala.runtime.AbstractFunction1() { + + @Override + public Object apply(MethodInfo method) { + return method.name().equals("actual"); + } + })); + ClassInfo alias = compiler.getClassInfo(dotted); + assertNotSame(first, alias); + assertEquals(first.name(), alias.name()); + assertSame(alias, compiler.getClassInfo(dotted)); + assertSame(first, compiler.remClassInfo(slash).get()); + assertFalse(compiler.remClassInfo(slash).isDefined()); + assertNotSame(first, compiler.getClassInfo(slash)); + assertSame(alias, compiler.getClassInfo(dotted)); + } finally { + compiler.remClassInfo(slash); + compiler.remClassInfo(dotted); + } + } + + @Test + void cachesNullResultsButDoesNotCacheFailuresOrTreatUncachedLookupsAsCached() throws Exception { + ASMMixinCompiler$ compiler = ASMMixinCompiler$.MODULE$; + Map cache = state("infoCache"); + Option previousNull = cache.remove(null); + String missing = "codechicken/multipart/test/metadata/DefinitelyAbsent"; + try { + assertNull(compiler.getClassInfo((Class) null)); + assertFalse(cache.contains(null)); + assertThrows(NullPointerException.class, () -> compiler.getClassInfo((ClassNode) null)); + assertNull(compiler.getClassInfo((String) null)); + assertTrue(cache.contains(null)); + assertTrue(compiler.remClassInfo((String) null).isDefined()); + assertThrows(ClassNotFoundException.class, () -> compiler.getClassInfo(missing)); + assertFalse(cache.contains(missing)); + assertThrows(ClassNotFoundException.class, () -> compiler.getClassInfo(missing)); + + String name = Type.getInternalName(CacheSubject.class); + ClassInfo first = ClassInfo$.MODULE$.obtainInfo(name); + ClassInfo second = ClassInfo$.MODULE$.obtainInfo(name); + assertNotSame(first, second); + assertEquals(first.name(), second.name()); + } finally { + cache.remove(null); + if (previousNull.isDefined()) cache.put(null, previousNull.get()); + compiler.remClassInfo(missing); + } + } + + @Test + void internalDefineInvalidatesOnlyTheNormalizedMetadataKey() throws Exception { + ASMMixinCompiler$ compiler = ASMMixinCompiler$.MODULE$; + String slash = "codechicken/multipart/test/metadata/CacheProbe", dotted = slash.replace('/', '.'); + Map bytes = state("traitByteMap"); + Map cache = state("infoCache"); + Option oldBytes = bytes.get(slash); + Option oldSlash = cache.get(slash), oldDotted = cache.get(dotted); + try { + compiler.internalDefine(slash, definition(slash, "before")); + ClassInfo before = compiler.getClassInfo(slash); + ClassInfo alias = compiler.getClassInfo(dotted); + assertTrue(before.findPublicImpl("before", "()V").isDefined()); + compiler.internalDefine(dotted, definition(slash, "after")); + ClassInfo after = compiler.getClassInfo(slash); + assertNotSame(before, after); + assertTrue(after.findPublicImpl("after", "()V").isDefined()); + assertFalse(after.findPublicImpl("before", "()V").isDefined()); + assertSame(alias, compiler.getClassInfo(dotted)); + assertTrue(alias.findPublicImpl("before", "()V").isDefined()); + assertEquals("after", compiler.classNode(slash).methods.get(0).name); + } finally { + restore(bytes, slash, oldBytes); + restore(cache, slash, oldSlash); + restore(cache, dotted, oldDotted); + } + } + + @Test + void readsScalaTraitAndCompanionMetadataFromTheRetainedSignatures() throws Exception { + ASMMixinCompiler$ compiler = ASMMixinCompiler$.MODULE$; + String traitName = "codechicken/multipart/test/ExternalScalaMicroblockFixture"; + ClassInfo trait = compiler.getClassInfo(traitName); + assertEquals("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ScalaClassInfo", trait.getClass().getName()); + assertTrue(trait.isScala()); + assertTrue(trait.isTrait()); + assertFalse(trait.isObject()); + assertEquals("codechicken/microblock/Microblock", trait.superClass().get().name()); + codechicken.multipart.asm.ScalaSignature signature = (codechicken.multipart.asm.ScalaSignature) trait.getClass() + .getMethod("sig").invoke(trait); + assertTrue(signature.findClass(traitName.replace('/', '.')).isDefined()); + assertSame( + trait.getClass().getMethod("csym").invoke(trait).getClass(), + signature.findClass(traitName.replace('/', '.')).get().getClass()); + assertEquals(traitName, trait.moduleName()); + + String module = "codechicken/multipart/asm/StackAnalyser$"; + ClassInfo info = compiler.getClassInfo(module); + assertEquals("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ScalaClassInfo", info.getClass().getName()); + assertTrue(info.isScala()); + assertTrue(info.isObject()); + assertFalse(info.isTrait()); + // The companion is resolved from the base class's signature and bytecode node. + assertEquals(module.substring(0, module.length() - 1), info.name()); + assertEquals(info.name(), info.moduleName()); + // The current decoder cannot interpret this object's TypeRefType as a ClassType. + assertThrows(ClassCastException.class, info::superClass); + assertThrows(ClassCastException.class, info::interfaces); + assertTrue( + ((codechicken.multipart.asm.ScalaSignature.ClassSymbolRef) info.getClass().getMethod("csym") + .invoke(info)).isObject()); + } + + private static ClassInfo construct(String nestedName, Class parameter, Object value) throws Exception { + return (ClassInfo) Class.forName("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$" + nestedName) + .getConstructor(parameter).newInstance(value); + } + + private static List names(Iterable infos) { + List result = new ArrayList<>(); + Iterator iterator = infos.iterator(); + while (iterator.hasNext()) result.add(iterator.next().name()); + return result; + } + + private static byte[] definition(String name, String method) { + ClassWriter writer = new ClassWriter(0); + writer.visit(V1_6, ACC_PUBLIC, name, null, "java/lang/Object", null); + org.objectweb.asm.MethodVisitor body = writer.visitMethod(ACC_PUBLIC, method, "()V", null, null); + body.visitCode(); + body.visitInsn(RETURN); + body.visitMaxs(0, 1); + body.visitEnd(); + writer.visitEnd(); + return writer.toByteArray(); + } + + @SuppressWarnings("unchecked") + private static Map state(String name) throws Exception { + Field field = ASMMixinCompiler$.class.getDeclaredField(name); + field.setAccessible(true); + return (Map) field.get(ASMMixinCompiler$.MODULE$); + } + + private static void restore(Map map, String key, Option old) { + map.remove(key); + if (old.isDefined()) map.put(key, old.get()); + } + + public static class LoadedParent { + + public int inherited() { + return 3; + } + + protected void protectedMethod() {} + + private void privateMethod() {} + } + + public abstract static class LoadedChild extends LoadedParent implements Runnable, AutoCloseable { + } + + public static class CacheSubject { + + public void actual() {} + } +} diff --git a/src/test/java/codechicken/multipart/asm/ClassInfoCharacterizationTest.java b/src/test/java/codechicken/multipart/asm/ClassInfoCharacterizationTest.java new file mode 100644 index 0000000..ea90852 --- /dev/null +++ b/src/test/java/codechicken/multipart/asm/ClassInfoCharacterizationTest.java @@ -0,0 +1,357 @@ +package codechicken.multipart.asm; + +import static org.junit.jupiter.api.Assertions.*; +import static org.objectweb.asm.Opcodes.*; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.MethodNode; + +import codechicken.multipart.asm.ASMMixinCompiler.ClassInfo; +import codechicken.multipart.asm.ASMMixinCompiler.MethodInfo; +import scala.Option; +import scala.collection.Iterable; +import scala.collection.IterableView; +import scala.collection.Iterator; +import scala.collection.mutable.ListBuffer; + +class ClassInfoCharacterizationTest { + + @Test + void traversesOwnMethodsThenSuperclassThenInterfacesWithoutDeduplicating() { + StubClass root = new StubClass("Root"), parent = new StubClass("Parent"), first = new StubClass("First"), + second = new StubClass("Second"), child = new StubClass("Child"); + StubMethod shared = root.add("shared", "()V", ACC_PUBLIC); + StubMethod inherited = parent.add("run", "()V", ACC_PUBLIC); + StubMethod own = child.add("run", "()V", ACC_PUBLIC); + StubMethod iface = first.add("run", "(I)V", ACC_PUBLIC); + parent.parent = Option.apply(root); + first.parents.$plus$eq(root); + second.parents.$plus$eq(root); + child.parent = Option.apply(parent); + child.parents.$plus$eq(first); + child.parents.$plus$eq(second); + + assertEquals(Arrays.asList(own, inherited, shared, iface, shared, shared), list(child.allMethods())); + assertEquals(Arrays.asList(inherited, shared, iface, shared, shared), list(child.parentMethods())); + assertSame(own, child.findPublicImpl("run", "()V").get()); + assertSame(iface, child.findPublicImpl("run", "(I)V").get()); + assertFalse(child.findPublicImpl("missing", "()V").isDefined()); + assertFalse(child.isScala()); + assertFalse(child.isTrait()); + assertFalse(child.isObject()); + assertEquals("Child", child.moduleName()); + assertEquals("StubClass(Child)", child.toString()); + assertEquals("Child.run()V", own.toString()); + } + + @Test + void capturesParentIdentitiesButReadsTheirMethodsLazilyOnEachTraversal() { + StubClass parent = new StubClass("Parent"), iface = new StubClass("Interface"), child = new StubClass("Child"); + StubMethod original = parent.add("original", "()V", ACC_PUBLIC); + child.parent = Option.apply(parent); + child.parents.$plus$eq(iface); + + IterableView> view = child.parentMethods(); + assertEquals(0, parent.methodReads); + assertEquals(0, iface.methodReads); + child.parent = Option.empty(); + child.parents.clear(); + StubMethod later = iface.add("later", "()V", ACC_PUBLIC); + assertEquals(Arrays.asList(original, later), list(view)); + int reads = parent.methodReads; + parent.declared.clear(); + assertEquals(Arrays.asList(later), list(view)); + assertTrue(parent.methodReads > reads); + assertTrue(list(child.parentMethods()).isEmpty()); + } + + @Test + void allMethodsMaterializesStrictInputsButKeepsAViewInputLazy() { + StubClass parent = new StubClass("Parent"), child = new StubClass("Child"); + StubMethod own = child.add("own", "()V", ACC_PUBLIC), inherited = parent.add("inherited", "()V", ACC_PUBLIC); + child.parent = Option.apply(parent); + Iterable snapshot = child.allMethods(); + assertTrue(parent.methodReads > 0); + child.declared.clear(); + parent.declared.clear(); + assertEquals(Arrays.asList(own, inherited), list(snapshot)); + + child.useView = true; + Iterable live = child.allMethods(); + StubMethod added = child.add("added", "()V", ACC_PUBLIC); + assertEquals(Arrays.asList(added), list(live)); + } + + @Test + void findsFirstConcreteNonPrivateMatchWithShortCircuitingAndVirtualDispatch() { + List calls = new ArrayList<>(); + StubClass owner = new StubClass("Owner"); + ListBuffer candidates = new ListBuffer<>(); + candidates.$plus$eq(tracked(owner, "different", "()V", 0, calls)); + candidates.$plus$eq(tracked(owner, "run", "(I)V", 0, calls)); + candidates.$plus$eq(tracked(owner, "run", "()V", ACC_ABSTRACT, calls)); + candidates.$plus$eq(tracked(owner, "run", "()V", ACC_PRIVATE, calls)); + MethodInfo protectedMethod = tracked(owner, "run", "()V", ACC_PROTECTED, calls); + candidates.$plus$eq(protectedMethod); + candidates.$plus$eq(null); // Finding a match must not evaluate a later candidate. + StubClass query = new StubClass("Query") { + + @Override + public Iterable allMethods() { + calls.add("allMethods"); + return candidates; + } + }; + assertSame(protectedMethod, query.findPublicImpl("run", "()V").get()); + assertEquals( + Arrays.asList( + "allMethods", + "name", + "name", + "desc", + "name", + "desc", + "abstract", + "name", + "desc", + "abstract", + "private", + "name", + "desc", + "abstract", + "private"), + calls); + owner.add(null, null, 0); + assertTrue(owner.findPublicImpl(null, null).isDefined()); + assertThrows(NullPointerException.class, () -> query.findPublicImpl("missing", "()V")); + } + + @Test + void nodeMethodWrappersFollowMutableMetadataAndRetainTheirOuterOwner() throws Exception { + ClassNode node = new ClassNode(); + node.name = "test/Node"; + MethodNode method = new MethodNode(ACC_PRIVATE, "read", "(I)J", null, new String[] { "java/io/IOException" }); + node.methods.add(method); + ClassInfo info = nodeInfo(node); + MethodInfo wrapped = info.methods().head(); + assertSame(node, call(info, "cnode")); + assertSame(info, wrapped.owner()); + assertSame(method, call(wrapped, "mnode")); + assertEquals("test/Node.read(I)J", wrapped.toString()); + assertTrue(wrapped.isPrivate()); + assertFalse(wrapped.isAbstract()); + assertArrayEquals(new String[] { "java/io/IOException" }, wrapped.exceptions()); + String[] exceptions = wrapped.exceptions(); + exceptions[0] = "changed"; + assertArrayEquals(new String[] { "java/io/IOException" }, wrapped.exceptions()); + + method.name = "changed"; + method.desc = "()V"; + method.access = ACC_ABSTRACT | ACC_PUBLIC; + method.exceptions.add("java/lang/Exception"); + node.name = "test/Renamed"; + assertEquals("test/Renamed.changed()V", wrapped.toString()); + assertFalse(wrapped.isPrivate()); + assertTrue(wrapped.isAbstract()); + assertArrayEquals(new String[] { "java/io/IOException", "java/lang/Exception" }, wrapped.exceptions()); + assertEquals(wrapped, copy(wrapped, MethodNode.class, call(wrapped, "copy$default$1"))); + assertSame(info, copy(wrapped, MethodNode.class, method).owner()); + assertEquals(1, ((scala.Product) wrapped).productArity()); + assertSame(method, ((scala.Product) wrapped).productElement(0)); + assertNotEquals(wrapped, nodeInfo(node).methods().head()); + Iterable previous = info.methods(); + node.methods.add(new MethodNode(ACC_PUBLIC, "added", "()I", null, null)); + assertEquals(1, previous.size()); + assertEquals(2, info.methods().size()); + assertNotSame(wrapped, info.methods().head()); + } + + @Test + void reflectionWrappersPreserveJdkOrderDescriptorsExceptionsAndCaseClassOwner() throws Exception { + ClassInfo info = reflectionInfo(Reflected.class); + Method[] reflected = Reflected.class.getMethods(); + List methods = list(info.methods()); + assertEquals(reflected.length, methods.size()); + for (int i = 0; i < reflected.length; i++) { + assertSame(info, methods.get(i).owner()); + assertEquals(reflected[i].getName(), methods.get(i).name()); + assertEquals(Type.getMethodDescriptor(reflected[i]), methods.get(i).desc()); + } + Method method = Reflected.class.getDeclaredMethod("read", long.class, String[].class); + MethodInfo wrapped = reflectionMethod(info, method); + assertEquals("(J[Ljava/lang/String;)D", wrapped.desc()); + assertArrayEquals( + new String[] { "java/io/IOException", "java/lang/ReflectiveOperationException" }, + wrapped.exceptions()); + assertFalse(wrapped.isPrivate()); + assertTrue(wrapped.isAbstract()); + assertSame(info, copy(wrapped, Method.class, call(wrapped, "copy$default$1")).owner()); + assertEquals(wrapped, copy(wrapped, Method.class, method)); + assertNotEquals(wrapped, reflectionMethod(reflectionInfo(Reflected.class), method)); + assertSame(method, ((scala.Product) wrapped).productElement(0)); + assertEquals(info.name() + ".read(J[Ljava/lang/String;)D", wrapped.toString()); + MethodInfo hidden = reflectionMethod(info, Reflected.class.getDeclaredMethod("hidden")); + assertTrue(hidden.isPrivate()); + assertFalse(hidden.isAbstract()); + } + + // Scala nests these classes under ClassInfo$ without the extra '$' javac expects in their binary names. + private static ClassInfo nodeInfo(ClassNode node) throws Exception { + return (ClassInfo) Class.forName("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ClassNodeInfo") + .getConstructor(ClassNode.class).newInstance(node); + } + + private static ClassInfo reflectionInfo(Class type) throws Exception { + return (ClassInfo) Class.forName("codechicken.multipart.asm.ASMMixinCompiler$ClassInfo$ReflectionClassInfo") + .getConstructor(Class.class).newInstance(type); + } + + private static MethodInfo reflectionMethod(ClassInfo owner, Method method) throws Exception { + Object factory = call(owner, "ReflectionMethodInfo"); + return (MethodInfo) factory.getClass().getMethod("apply", Method.class).invoke(factory, method); + } + + private static Object call(Object target, String method) throws Exception { + return target.getClass().getMethod(method).invoke(target); + } + + private static MethodInfo copy(MethodInfo source, Class parameter, Object value) throws Exception { + return (MethodInfo) source.getClass().getMethod("copy", parameter).invoke(source, value); + } + + private static StubMethod tracked(StubClass owner, String name, String desc, int access, List calls) { + return new StubMethod(owner, name, desc, access) { + + @Override + public String name() { + calls.add("name"); + return super.name(); + } + + @Override + public String desc() { + calls.add("desc"); + return super.desc(); + } + + @Override + public boolean isAbstract() { + calls.add("abstract"); + return super.isAbstract(); + } + + @Override + public boolean isPrivate() { + calls.add("private"); + return super.isPrivate(); + } + }; + } + + private static List list(Iterable values) { + List result = new ArrayList<>(); + Iterator iterator = values.iterator(); + while (iterator.hasNext()) result.add(iterator.next()); + return result; + } + + private static class StubClass extends ClassInfo { + + private final String name; + private Option parent = Option.empty(); + private final ListBuffer parents = new ListBuffer<>(); + private final ListBuffer declared = new ListBuffer<>(); + private int methodReads; + private boolean useView; + + private StubClass(String name) { + this.name = name; + } + + private StubMethod add(String method, String desc, int access) { + StubMethod result = new StubMethod(this, method, desc, access); + declared.$plus$eq(result); + return result; + } + + @Override + public String name() { + return name; + } + + @Override + public Option superClass() { + return parent; + } + + @Override + public Iterable interfaces() { + return parents; + } + + @Override + public Iterable methods() { + methodReads++; + return useView ? scala.collection.IterableLike$class.view(declared) : declared; + } + } + + private static class StubMethod extends MethodInfo { + + private final StubClass owner; + private final String name, desc; + private final int access; + + private StubMethod(StubClass owner, String name, String desc, int access) { + this.owner = owner; + this.name = name; + this.desc = desc; + this.access = access; + } + + @Override + public ClassInfo owner() { + return owner; + } + + @Override + public String name() { + return name; + } + + @Override + public String desc() { + return desc; + } + + @Override + public String[] exceptions() { + return new String[0]; + } + + @Override + public boolean isPrivate() { + return (access & ACC_PRIVATE) != 0; + } + + @Override + public boolean isAbstract() { + return (access & ACC_ABSTRACT) != 0; + } + } + + public abstract static class Reflected { + + public abstract double read(long value, String[] names) throws IOException, ReflectiveOperationException; + + private void hidden() {} + } +}