Skip to content

About

Kotlin compiler plugin that turns lambda literals into kexpression trees at compile time

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

kexpression

Maven Central Version

The Kotlin counterpart of C#'s Expression<Func<...>>. A compiler plugin (K2, IR) turns a lambda literal into code that builds an expression tree — at compile time. There is no reflection and no bytecode parsing at runtime. Kotlin Multiplatform: JVM, JS, Wasm, Native.

// C#: Expression<Func<int, int, int>> add = (a, b) => a + b * 2;
val add: Expr<(Int, Int) -> Int> = expr { a, b -> a + b * 2 }

add.parameters      // [a: kotlin.Int, b: kotlin.Int]
add.body            // BinaryExpression(PLUS, a, BinaryExpression(TIMES, b, 2))
add.toString()      // { a: Int, b: Int -> a + (b * 2) }
add(1, 3)           // 7 — compile() returns the original lambda, which is already compiled statically

For an example consumer of the trees — a LINQ-style SQL provider — see sample/README.md.

Two ways to get a tree

expr { } — explicit quoting, like assigning a lambda to an Expression<...> in C#:

val isAdult = expr { u: User -> u.age >= 18 && u.role == Role.USER }

@Quoted — for your own APIs: a lambda passed to an annotated parameter stays a regular function, but carries its tree with it.

class Query<T> {
    fun where(@Quoted predicate: (T) -> Boolean): Query<T> {
        val tree: LambdaExpression = predicate.toExpression()
        // ... process the tree
    }
}

users.where { it.age >= minAge }   // callers write a plain lambda

The argument must be a lambda literal. Passing a variable to expr() is a compile error; passing one to a @Quoted parameter is a warning.

The tree

Every node is a data class extending Expression and carries its static type (type):

Node Kotlin
ConstantExpression 42, "a", null, Role.USER, an object
ParameterExpression a lambda parameter
CapturedExpression a captured variable or this (its value at the time the tree is created)
BinaryExpression, UnaryExpression + - * / %, == != === < <= > >=, && ||, ?:, bitwise; ! - !!
MemberExpression u.name
CallExpression calls; receivers are kept separately: dispatchReceiver and extensionReceiver
NewExpression a constructor call, with parameter names (parameterNames)
ConditionalExpression if and when
TypeOperatorExpression is, !is, as, as?
StringTemplateExpression, ArrayExpression "${...}", vararg
LambdaExpression the root of the tree and nested lambdas
EvaluableExpression a subtree without lambda parameters (see below)

Traversal and rewriting: children, walk(), rewrite { } — the counterpart of C#'s ExpressionVisitor.

As in C#, the lambda body must be an expression. Local vals are allowed and inlined at their uses, while var, loops and assignments cause the compile error [kexpression] Cannot convert lambda to expression tree.

Funcletizer: subtrees without parameters

The largest subtrees that don't depend on the lambda's parameters (filter.minAge + 1, repo.activeIds()) become EvaluableExpression(original, evaluate): the structure plus an evaluator generated by the compiler. The evaluator runs the subtree's original code without reflection, on any platform.

val e = expr { u: User -> u.age > filter.minAge + 1 }
val node = (e.body as BinaryExpression).right as EvaluableExpression
node.original    // filter.minAge + 1 — the structure
node.evaluate()  // 19 — the value, read at the time of the call

The consumer of the tree decides what to evaluate on the client and what to process as structure, via EvaluationPolicy (EvaluationPolicy.Always and EvaluationPolicy.Never are provided).

  • What doesn't become an evaluator: constants and captured variables (they are already values), nested lambdas, and calls to @Translatable functions.
  • Side effects: an evaluator runs the original code every time it's called. Keep that in mind if the code has side effects.

@Translatable: functions the consumer translates itself

A meta-annotation for function annotations. Such a call is never evaluated on the client, and the annotation itself ends up in CallExpression.annotations. The call's arguments are processed as usual.

@Translatable
annotation class DbFunction(val name: String)

@DbFunction("upper")
fun dbUpper(s: String): String = throw UnsupportedOperationException("db only")

val e = expr { u: User -> u.name == dbUpper(prefix + "x") }
val call = (e.body as BinaryExpression).right as CallExpression
call.annotation<DbFunction>()?.name                         // "upper"
(call.arguments.single() as EvaluableExpression).evaluate()  // "abx"

Kotlin Multiplatform

The plugin works on IR, which is the same for every backend. Attach it to all compilations:

dependencies {
    implementation(project(":kexpression-runtime"))
    "kotlinCompilerPluginClasspath"(project(":kexpression-compiler-plugin"))        // JVM, JS, Wasm
    "kotlinNativeCompilerPluginClasspath"(project(":kexpression-compiler-plugin"))  // Native
}

Platform specifics:

  • JS: a class can't implement a function type, so a @Quoted lambda isn't wrapped — the tree is stored in a property of the JS function itself. On the other platforms it's a wrapper, and the number of parameters is limited to four.
  • Literal printing relies on the node's static type, not on the value: on JS, Int, Float and Double are the same number.

Modules

Module Contents
kexpression-runtime tree nodes, Expr<F>, expr {}, @Quoted, EvaluationPolicy, @Translatable, traversal and printing; KMP
kexpression-compiler-plugin the IR plugin: replaces expr { } with quoteExpr(lambda, <tree>) and lambdas in @Quoted parameters with quoteFunction(lambda, <tree>)
kexpression-tests shared plugin tests (commonTest) on JVM, JS, Wasm and Native
sample a SQL provider built on expression trees (JVM only) — see sample/README.md

Build and test

./gradlew :kexpression-tests:jvmTest :kexpression-tests:jsNodeTest :kexpression-tests:wasmJsNodeTest
./gradlew :kexpression-tests:macosArm64Test   # requires a full Xcode install

The Linux test binary (linkDebugTestLinuxArm64) can be built on macOS and run in a Linux container.

Publishing

kexpression-runtime (all KMP targets) and kexpression-compiler-plugin are published with vanniktech/gradle-maven-publish-plugin. Versions live in gradle/libs.versions.toml, Maven coordinates in gradle.properties (buildInfo.*).

./gradlew publishToMavenLocal
./gradlew publishAndReleaseToMavenCentral

Maven Central credentials and the signing key are read from ~/.gradle/gradle.properties and are never stored in this repository. Publications are signed only when a signing key is configured.

dependencies {
    implementation("ru.ttraum:kexpression-runtime:0.1.0")
    kotlinCompilerPluginClasspath("ru.ttraum:kexpression-compiler-plugin:0.1.0")
}

Limitations

  • Capture by value. Captured variables are recorded when the tree is created. A regular lambda sees later changes to a var; the tree doesn't.
  • Expressions only. Trees have no statements: no assignments, loops or var.
  • Argument names. They aren't kept for function calls; they are kept for constructor calls.
  • The plugin is built for Kotlin 2.4.20. The compiler's IR API is unstable, so your project must use the same Kotlin version.

About

Kotlin compiler plugin that turns lambda literals into kexpression trees at compile time

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages