How to inspect java method references (double colon) operator usages in classes during build time

Viewed 220

Is there a way to detect usages of java method reference (double colon) operator inside the code?

I need to discover all instance/static method references used in a given class in order to be able to detect some errors (must verify that the target method has a particular annotation - @Good in the below example) during build time. As by convention a method reference should be used only to some of the methods when it is passed to a constructor of some helper class (Info in the below example).

class X {

    Info init() {
        return new Info(X::beta);  // good code: target method has @Good annotation
        return new Info(X::alpha); // bad code:  target method has no @Good annotation
    }
    
    void alpha() {
    }
    
    @Good
    void beta() {
    }
}

The intention is to be able to click on the method reference as this makes it easy to follow as otherwise if just passing Method instance or just method name it would lack this ability.

(The example is not very good but I'm now allowed to share more details, sorry about that!)

I can see IntelliJ IDEA "knows" about them - when you ctrl+click on them it navigates to the target method so there should be some form of a static analysis used there.

I'm already using ObjectWeb ASM to detect invocations to certain methods but it seems it lacks the ability to detect method references (::)

EDIT: Just a note that you can also pass new Info(x -> x.alpha()) as @Thomas below mentioned in the comments but this would not pass our review process but I guess the additional ability to detect it would not hurt.

EDIT2: What exactly are you trying to achieve with these checks? What makes beta worthy of receiving the annotation?

Answer: When the init() method is called we obtain the Info instance and from it obtain the lambda which must be a method reference. Then we use javassist ProxyFactory and create a sub-class of class X then instantiate it and intercept all its methods via setting a method handler. So now it is safe to execute the lambda without allowing it to make any side effects - the method body is skipped and the only thing we do is to capture which is the X method that the lambda actually is calling - in the example this will lead to a java.lang.Method instance pointing to X.beta or X.alpha method. Then we can check if it has the @Good annotation and proceed accordingly - which is to call the lambda without any proxying, but that call might happen later, like a millisecond later or an hour later. If there is no @Good annotation we cannot proceed - it is a bug.

So the problem is that this will happen at runtime later and there might be a bug not caught early enough and that is the reason I would like to inspect the X class at build time and catch all the bugs :)

1 Answers

This is a bit of a shot in the dark, as I'm neither very proficient with ASM nor sure if this approach addresses your problem. Having said that, I found that, in a similar setting, asm.MethodVisitor calls MethodVisitor.visitInvokeDynamicInsn(...) for (some? all?) method references.

E.g., if I compile this variant of your class X along with an Info:

class Info {
    public Info(Runnable alpha) {}
}

class X {

    Info init() { return new Info(this::alpha); }
    void alpha() {}
}

... and I then feed the resulting X.class into a mini ClassVisitor + printing MethodVisitor (Groovy for brevity):

class MyMethodVisitor extends MethodVisitor {

    MyMethodVisitor(MethodVisitor parent) { super(Opcodes.ASM8, parent) }

    @Override
    void visitInvokeDynamicInsn(String name, String descriptor, Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
        println "visitInvokeDynamicInsn($name, $descriptor, $bootstrapMethodHandle, $bootstrapMethodArguments)"
        super.visitInvokeDynamicInsn(name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments)
    }
}

class MyClassVisitor extends ClassVisitor {
    MyClassVisitor() { super(Opcodes.ASM8) }

    @Override
    MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) {
        println "Starting method '$name'"
        new MyMethodVisitor(super.visitMethod(access, name, descriptor, signature, exceptions))
    }
}

def clr = new ClassReader(new File("./X.class").bytes)
clr.accept(new MyClassVisitor(), ClassReader.SKIP_FRAMES)

Then the method visitor prints, amongst other details, a call to visitInvokeDynamicInsn from within the method visitation of X::init with the desired X::alpha among the arguments (the xyz being my local package):

Visiting method '<init>'
Visiting method 'init'
visitInvokeDynamicInsn(run, (xyz/X;)Ljava/lang/Runnable;,
    java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; (6),
    [()V, xyz/X.alpha()V (5), ()V])
Visiting method 'alpha'

So it would seem possible to peel the method out of those arguments. I am not sure if this reliable (e.g., whether this bytecode is guaranteed by specification, or whether it can depend on compilation/optimization details).

Related