Skip to content

[kotlin2cpg] Add support for KtCallableReferenceExpression#5775

Open
SuperUserDone wants to merge 7 commits intomasterfrom
louis/kotlin2cpg-callablerefrences
Open

[kotlin2cpg] Add support for KtCallableReferenceExpression#5775
SuperUserDone wants to merge 7 commits intomasterfrom
louis/kotlin2cpg-callablerefrences

Conversation

@SuperUserDone
Copy link
Contributor

No description provided.

@SuperUserDone SuperUserDone requested a review from ml86 January 21, 2026 14:01
@SuperUserDone SuperUserDone force-pushed the louis/kotlin2cpg-callablerefrences branch from 8d8282f to 47108a6 Compare February 10, 2026 07:46
@SuperUserDone SuperUserDone force-pushed the louis/kotlin2cpg-callablerefrences branch from 47108a6 to ee3e39e Compare February 10, 2026 07:50
@SuperUserDone
Copy link
Contributor Author

The latest set of changes are major overhaul of the methodology of this PR. This now closely follows what the kotlin compiler does.

For unbound references to a global function ::globalFunction, it works similarly to the lambda method @johannescoetzee and I worked through for the javasrc2cpg case. It creates a MethodRef node, along with a dummy Type, TypeDecl and bindings for the method being overridden in the SAM Interface (Eg invoke for a function). If the SAM Interface is generic, an type-erased binding is also created.

For bound references to a companion object Type::function and to a regular method instance::method, a new Type and TypeDecl is created. The TypeDecl inherits from kotlin.jvm.internals.CallableReference. This class provides an this.receiver object. A constructor and implementation of the SAM Method is lowered into the CPG. The TypeDecl contains bindings for the method with concrete types, and if applicable a type-erased binding is also generated.

This does not work around kotlin compiler bug https://youtrack.jetbrains.com/issue/KT-54316, so the behavior is identical to the Kotlin compiler in the documented case. This only affects the companion case for assigned references.

This affects cases like this. This code won't compile because the Utils::validate ref has a broken signature of (Utils, Int) -> Boolean.

class Utils {
    companion object {
        fun validate(x: Int): Boolean = x > 0
    }
}

val ref: (Int) -> Boolean = Utils::validate

This case is fine tough and types are resolved correctly

class Utils {
    companion object {
        fun validate(x: Int): Boolean = x > 0
    }
}

fun bar(ref: (Int) -> Boolean) {}

bar(Utils::validate)

Copy link
Contributor

@johannescoetzee johannescoetzee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've only checked the tests so far, but have left a few comments about the higher-level design and some nitpicks. I'll probably only get to reviewing the implementation tomorrow.

Comment on lines 50 to 52
val forwardedArgs = processCall.argument.isIdentifier.l
forwardedArgs.size shouldBe 2
forwardedArgs.map(_.typeFullName).toSet shouldBe Set("int", "java.lang.String")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the way this is written, it's possible to miss non-identifier arguments or to have the correct arguments in the wrong order without the test catching it. It's a nitpick, but in my opinion it is better to test explicitly, so something like

processCall.argument.map(_.typeFullName).toList shouldBe List(<something>, "int", "java.lang.String")

or

inside(processCall.argument.l) { case List(<something>, intArg: Identifier, stringArg: Identifier) =>
  intArg.typeFullName shouldBe "int"
  ...
}

It's more cumbersome to write and can be harder to read, but is also occasionally useful for catching weird bugs

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check that REF edges from the process arguments to the invoke parameters are being created correctly as well (in this method, but also wherever an Identifier node is added to the CPG). A lot of dataflow issues in the past were caused by missing REF edges (or REF edges referring to the wrong local/param) and testing it in the frontend AST tests is much faster than figuring it out through dataflow debugging

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have adjusted some of the tests to use this pattern where I thought it made sense. In most of the tests also added checks for the ref edges to the identifier nodes. There was a bug that I caught through that and resolved.

}

"create a constructor call for the synthetic type with receiver as parameter" in {
val ctorCalls = cpg.call.nameExact("<init>").methodFullName(".*Function2Impl.*<init>.*").l
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
val ctorCalls = cpg.call.nameExact("<init>").methodFullName(".*Function2Impl.*<init>.*").l
val ctorCalls = cpg.call.methodFullName(".*Function2Impl.*<init>.*").l

should return the same, unless there's a type decl with <init> in the name. You could also use methodFullNameExact with the exact method full name to avoid any conflicts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is perhaps a stylistic thing, but I also prefer to write these in a way that gives more information about where the call occurs, for example cpg.method("test").call.... How far to take this depends on the complexity of the code in question though and here just finding the <init> directly is probably fine

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants