Repository: avaje/avaje-inject
Pull request #775 (2025-03-11 02:57:36)

[inject-test] Generate Method Handle Lookups
Since mockito internals are now forbidden, generate a lookup class and use `VarHandle`s

- use `VarHandle` for getting/setting test fields
- adds a new processor to generate test lookups
- adds new SPI to inject test


Patch:
diff --git a/inject-generator/src/main/java/module-info.java b/inject-generator/src/main/java/module-info.java
index 8d0464df..02a487fc 100644
--- a/inject-generator/src/main/java/module-info.java
+++ b/inject-generator/src/main/java/module-info.java
@@ -1,3 +1,6 @@
+import io.avaje.inject.generator.InjectProcessor;
+import io.avaje.inject.generator.InjectTestProcessor;
+
 module io.avaje.inject.generator {
 
   requires java.compiler;
@@ -11,5 +14,5 @@ module io.avaje.inject.generator {
 
   uses io.avaje.inject.spi.InjectExtension;
 
-  provides javax.annotation.processing.Processor with io.avaje.inject.generator.InjectProcessor;
+  provides javax.annotation.processing.Processor with InjectProcessor, InjectTestProcessor;
 }
diff --git a/inject/pom.xml b/inject/pom.xml
index 32bfe740..a0a3e0ac 100644
--- a/inject/pom.xml
+++ b/inject/pom.xml
@@ -53,7 +53,7 @@
     <dependency>
       <groupId>org.mockito</groupId>
       <artifactId>mockito-core</artifactId>
-      <version>5.15.2</version>
+      <version>5.16.0</version>
       <optional>true</optional>
     </dependency>
 
diff --git a/pom.xml b/pom.xml
index 48c99a82..0476e854 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,19 +61,19 @@
     <dependency>
       <groupId>net.bytebuddy</groupId>
       <artifactId>byte-buddy</artifactId>
-      <version>1.17.1</version>
+      <version>1.17.2</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>net.bytebuddy</groupId>
       <artifactId>byte-buddy-agent</artifactId>
-      <version>1.17.1</version>
+      <version>1.17.2</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>org.mockito</groupId>
       <artifactId>mockito-core</artifactId>
-      <version>5.15.2</version>
+      <version>5.16.0</version>
       <scope>test</scope>
     </dependency>
   </dependencies>


Test patch:
diff --git a/inject-generator/src/main/java/io/avaje/inject/generator/InjectTestProcessor.java b/inject-generator/src/main/java/io/avaje/inject/generator/InjectTestProcessor.java
new file mode 100644
index 00000000..70e86298
--- /dev/null
+++ b/inject-generator/src/main/java/io/avaje/inject/generator/InjectTestProcessor.java
@@ -0,0 +1,73 @@
+package io.avaje.inject.generator;
+
+import static io.avaje.inject.generator.APContext.typeElement;
+
+import java.io.IOException;
+import java.util.Optional;
+import java.util.Set;
+
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.ProcessingEnvironment;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.TypeElement;
+
+@SupportedAnnotationTypes({"io.avaje.inject.test.InjectTest"})
+public final class InjectTestProcessor extends AbstractProcessor {
+
+  private boolean wroteLookup;
+
+  @Override
+  public SourceVersion getSupportedSourceVersion() {
+    return SourceVersion.latest();
+  }
+
+  @Override
+  public synchronized void init(ProcessingEnvironment processingEnv) {
+    super.init(processingEnv);
+  }
+
+  @Override
+  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+    if (!wroteLookup
+        && !Optional.ofNullable(typeElement("io.avaje.inject.test.InjectTest"))
+            .map(roundEnv::getElementsAnnotatedWith)
+            .orElse(Set.of())
+            .isEmpty()) {
+      wroteLookup = true;
+      writeLookup();
+    }
+    return false;
+  }
+
+  private void writeLookup() {
+    var template =
+        "package io.avaje.inject.test.lookup;\n"
+            + "\n"
+            + "import java.lang.invoke.MethodHandles;\n"
+            + "import java.lang.invoke.MethodHandles.Lookup;\n"
+            + "\n"
+            + "import io.avaje.inject.test.LookupProvider;\n"
+            + "\n"
+            + "public class TestLookup implements LookupProvider {\n"
+            + "\n"
+            + "  @Override\n"
+            + "  public Lookup provideLookup() {\n"
+            + "    return MethodHandles.lookup();\n"
+            + "  }\n"
+            + "}";
+
+    try (var writer =
+            APContext.createSourceFile("io.avaje.inject.test.lookup.TestLookup").openWriter();
+        var services =
+            ProcessingContext.createMetaInfWriterFor(
+                    "META-INF/services/io.avaje.inject.test.LookupProvider")
+                .openWriter()) {
+      writer.append(template);
+      services.append("io.avaje.inject.test.lookup.TestLookup");
+    } catch (IOException e) {
+      APContext.logWarn("failed to write lookup");
+    }
+  }
+}
diff --git a/inject-test/pom.xml b/inject-test/pom.xml
index 9486328f..69301acd 100644
--- a/inject-test/pom.xml
+++ b/inject-test/pom.xml
@@ -12,7 +12,7 @@
 
   <properties>
     <jupiter.version>5.12.0</jupiter.version>
-    <mockito.version>5.15.2</mockito.version>
+    <mockito.version>5.16.0</mockito.version>
   </properties>
 
   <dependencies>
@@ -45,12 +45,12 @@
     <dependency>
       <groupId>net.bytebuddy</groupId>
       <artifactId>byte-buddy</artifactId>
-      <version>1.17.1</version>
+      <version>1.17.2</version>
     </dependency>
     <dependency>
       <groupId>net.bytebuddy</groupId>
       <artifactId>byte-buddy-agent</artifactId>
-      <version>1.17.1</version>
+      <version>1.17.2</version>
     </dependency>
 
     <dependency>
diff --git a/inject-test/src/main/java/io/avaje/inject/test/LookupProvider.java b/inject-test/src/main/java/io/avaje/inject/test/LookupProvider.java
new file mode 100644
index 00000000..e42acc7c
--- /dev/null
+++ b/inject-test/src/main/java/io/avaje/inject/test/LookupProvider.java
@@ -0,0 +1,10 @@
+package io.avaje.inject.test;
+
+import java.lang.invoke.MethodHandles.Lookup;
+
+/** Provides a Lookup instance for accessing test fields. */
+public interface LookupProvider {
+
+  /** Return the Lookup. */
+  Lookup provideLookup();
+}
diff --git a/inject-test/src/main/java/io/avaje/inject/test/Lookups.java b/inject-test/src/main/java/io/avaje/inject/test/Lookups.java
new file mode 100644
index 00000000..e3f5aaaf
--- /dev/null
+++ b/inject-test/src/main/java/io/avaje/inject/test/Lookups.java
@@ -0,0 +1,57 @@
+package io.avaje.inject.test;
+
+import static java.util.stream.Collectors.toMap;
+
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.invoke.VarHandle;
+import java.lang.reflect.Field;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.util.Map;
+import java.util.ServiceLoader;
+
+/** Provides Lookup instances using potentially module specific Lookups. */
+final class Lookups {
+
+  private static final Map<String, Lookup> MODULE_LOOKUP_MAP =
+      ServiceLoader.load(LookupProvider.class).stream()
+          .collect(toMap(p -> p.type().getModule().getName(), p -> p.get().provideLookup()));
+
+  private static final Lookup DEFAULT_LOOKUP = MethodHandles.publicLookup();
+
+  /** Return a Lookup ideally for the module associated with the given type. */
+  static Lookup getLookup(Class<?> type) {
+    return MODULE_LOOKUP_MAP.getOrDefault(type.getModule().getName(), DEFAULT_LOOKUP);
+  }
+
+  static VarHandle getVarhandle(Class<?> testClass, Field field) {
+    try {
+      var lookup = getLookup(testClass);
+      lookup =
+          lookup.hasPrivateAccess()
+              ? MethodHandles.privateLookupIn(testClass, getLookup(testClass))
+              : lookup;
+
+      return lookup.unreflectVarHandle(field);
+    } catch (Exception e) {
+      throw new IllegalStateException("Can't access field " + field, e);
+    }
+  }
+
+  static Class<?> getClassFromType(Type generic) {
+    if (generic instanceof Class) {
+      return (Class<?>) generic;
+    }
+    if (generic instanceof ParameterizedType) {
+      Type actual = ((ParameterizedType) generic).getActualTypeArguments()[0];
+      if (actual instanceof Class) {
+        return (Class<?>) actual;
+      }
+      if (actual instanceof ParameterizedType) {
+        return (Class<?>) ((ParameterizedType) actual).getRawType();
+      }
+    }
+    return Object.class;
+  }
+}
diff --git a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
index ac02315c..5ab35916 100644
--- a/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
+++ b/inject-test/src/main/java/io/avaje/inject/test/MetaReader.java
@@ -1,6 +1,7 @@
 package io.avaje.inject.test;
 
 import java.lang.annotation.Annotation;
+import java.lang.invoke.VarHandle;
 import java.lang.reflect.Field;
 import java.lang.reflect.Modifier;
 import java.lang.reflect.ParameterizedType;
@@ -14,8 +15,6 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
 import org.mockito.Mock;
 import org.mockito.Spy;
-import org.mockito.internal.configuration.plugins.Plugins;
-import org.mockito.internal.util.reflection.GenericMaster;
 
 import io.avaje.inject.BeanScope;
 import io.avaje.inject.BeanScopeBuilder;
@@ -26,6 +25,7 @@ import jakarta.inject.Qualifier;
 final class MetaReader {
 
   private final SetupMethods methodFinder;
+  final Class<?> testClass;
   final List<Field> captors = new ArrayList<>();
   final List<FieldTarget> mocks = new ArrayList<>();
   final List<FieldTarget> spies = new ArrayList<>();
@@ -41,6 +41,7 @@ final class MetaReader {
   boolean instancePlugin;
 
   MetaReader(Class<?> testClass, Plugin plugin) {
+    this.testClass = testClass;
     this.plugin = plugin;
     final var hierarchy = typeHierarchy(testClass);
     this.methodFinder = new SetupMethods(hierarchy);
@@ -54,9 +55,8 @@ final class MetaReader {
   boolean hasMocksOrSpies(Object testInstance) {
     if (testInstance == null) {
       return hasStaticMocksOrSpies() || methodFinder.hasStaticMethods();
-    } else {
-      return hasInstanceMocksOrSpies(testInstance) || methodFinder.hasInstanceMethods();
     }
+    return hasInstanceMocksOrSpies(testInstance) || methodFinder.hasInstanceMethods();
   }
 
   private boolean hasInstanceMocksOrSpies(Object testInstance) {
@@ -154,7 +154,7 @@ final class MetaReader {
   }
 
   private FieldTarget newTarget(Field field) {
-    return new FieldTarget(field, name(field));
+    return new FieldTarget(field, name(field), Lookups.getVarhandle(testClass, field));
   }
 
   private String name(Field field) {
@@ -178,9 +178,8 @@ final class MetaReader {
   TestBeans setFromScope(TestBeans metaScope, Object testInstance) {
     if (testInstance != null) {
       return setForInstance(metaScope, testInstance);
-    } else {
-      return setForStatics(metaScope);
     }
+    return setForStatics(metaScope);
   }
 
   private TestBeans setForInstance(TestBeans metaScope, Object testInstance) {
@@ -189,7 +188,11 @@ final class MetaReader {
       BeanScope beanScope = metaScope.beanScope();
 
       for (Field field : captors) {
-        set(field, captorFor(field), testInstance);
+        set(
+            Modifier.isStatic(field.getModifiers()),
+            Lookups.getVarhandle(testClass, field),
+            captorFor(field),
+            testInstance);
       }
       for (FieldTarget target : mocks) {
         target.setFromScope(beanScope, testInstance);
@@ -239,9 +242,12 @@ final class MetaReader {
   private Object captorFor(Field field) {
     Class<?> type = field.getType();
     if (!ArgumentCaptor.class.isAssignableFrom(type)) {
-      throw new IllegalStateException("@Captor field must be of the type ArgumentCaptor.\n Field: '" + field.getName() + "' has wrong type");
+      throw new IllegalStateException(
+          "@Captor field must be of the type ArgumentCaptor.\n Field: '"
+              + field.getName()
+              + "' has wrong type");
     }
-    Class<?> cls = new GenericMaster().getGenericType(field);
+    Class<?> cls = Lookups.getClassFromType(field.getGenericType());
     return ArgumentCaptor.forClass(cls);
   }
 
@@ -308,8 +314,12 @@ final class MetaReader {
     builder.bean(target.name(), target.type(), value);
   }
 
-  void set(Field field, Object val, Object testInstance) throws IllegalAccessException {
-    Plugins.getMemberAccessor().set(field, testInstance, val);
+  void set(boolean isStatic, VarHandle fieldHandle, Object val, Object testInstance) {
+    if (isStatic) {
+      fieldHandle.set(val);
+    } else {
+      fieldHandle.set(testInstance, val);
+    }
   }
 
   class FieldTarget {
@@ -319,11 +329,13 @@ final class MetaReader {
     private final boolean isStatic;
     private boolean pluginInjection;
     private boolean valueAlreadyProvided;
+    private final VarHandle fieldHandle;
 
-    FieldTarget(Field field, String name) {
+    FieldTarget(Field field, String name, VarHandle fieldHandle) {
       this.field = field;
       this.isStatic = Modifier.isStatic(field.getModifiers());
       this.name = name;
+      this.fieldHandle = fieldHandle;
     }
 
     @Override
@@ -344,11 +356,7 @@ final class MetaReader {
     }
 
     Object get(Object instance) {
-      try {
-        return Plugins.getMemberAccessor().get(field, instance);
-      } catch (IllegalAccessException e) {
-        throw new RuntimeException(e);
-      }
+      return isStatic ? fieldHandle.get() : fieldHandle.get(instance);
     }
 
     void setFromScope(BeanScope beanScope, Object testInstance) throws IllegalAccessException {
@@ -356,28 +364,27 @@ final class MetaReader {
         return;
       }
       final var type = type();
-
       if (type instanceof ParameterizedType) {
         final var parameterizedType = (ParameterizedType) type;
         final var rawType = parameterizedType.getRawType();
         final var typeArguments = parameterizedType.getActualTypeArguments();
 
         if (rawType.equals(List.class)) {
-          set(field, beanScope.list(typeArguments[0]), testInstance);
+          set(isStatic, fieldHandle, beanScope.list(typeArguments[0]), testInstance);
           return;
         }
 
         if (rawType.equals(Optional.class)) {
-          set(field, beanScope.getOptional(typeArguments[0], name), testInstance);
+          set(isStatic, fieldHandle, beanScope.getOptional(typeArguments[0], name), testInstance);
           return;
         }
       }
 
-      set(field, beanScope.get(type, name), testInstance);
+      set(isStatic, fieldHandle, beanScope.get(type, name), testInstance);
     }
 
     void setFromPlugin(Object value, Object testInstance) throws IllegalAccessException {
-      set(field, value, testInstance);
+      set(isStatic, fieldHandle, value, testInstance);
     }
 
     void markForPluginInjection() {
diff --git a/inject-test/src/main/java/module-info.java b/inject-test/src/main/java/module-info.java
index 956aef2c..6b4dbba6 100644
--- a/inject-test/src/main/java/module-info.java
+++ b/inject-test/src/main/java/module-info.java
@@ -16,4 +16,5 @@ module io.avaje.inject.test {
 
   uses io.avaje.inject.test.TestModule;
   uses io.avaje.inject.test.Plugin;
+  uses io.avaje.inject.test.LookupProvider;
 }