A Gradle plugin that lets you use Kotlin/Native code directly from the JVM as if it were a regular JVM library. Classes, methods, properties, enums, nullable types, companion objects, exception propagation, callbacks — everything is transparent to the JVM developer.
Under the hood, the plugin generates FFM (Foreign Function & Memory API) bindings inspired by swift-java and swift-export-standalone.
Kotlin/Native source Plugin generates JVM developer sees
────────────────── ──────────────── ──────────────────
class Calculator { → @CName bridges (native) → class Calculator : AutoCloseable {
fun add(value: Int) + StableRef lifecycle fun add(value: Int): Int
val current: Int + FFM MethodHandles val current: Int
} + output-buffer strings // backed by native, via FFM
}
Pipeline:
- Plugin parses your
nativeMainsources via Kotlin PSI and extracts the public API - Generates
@CNamebridge functions withStableReffor object lifecycle (native side) - Generates JVM proxy classes with FFM
MethodHandledowncalls (JVM side) - Compiles to a shared library (
.so/.dylib/.dll) - Bundles the native library into the JAR under
kne/native/{os}-{arch}/ - Generates GraalVM reachability metadata (reflection, resources, FFM downcall descriptors)
- JVM code calls the proxies transparently — every call crosses the FFM boundary into native
// settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}// build.gradle.kts
plugins {
kotlin("multiplatform") version "2.4.20"
id("dev.nucleusframework.nna") version "0.1.0"
}kotlin {
jvmToolchain(25) // FFM is stable since JDK 22 (JEP 454), recommended JDK 25
linuxX64() // use the real platform name (KMP convention)
// macosArm64() // on macOS
// mingwX64() // on Windows
jvm()
sourceSets {
jvmMain {
dependencies {
// your JVM dependencies (Compose, Ktor, etc.)
}
}
}
}
kotlinNativeExport {
nativeLibName = "mylib" // output: libmylib.so (release ~700KB)
// nativePackage is auto-detected from your Kotlin source package declarations
// nativePackage = "com.example" // override only if needed
buildType = "release" // "release" (default, optimized) or "debug"
}// src/nativeMain/kotlin/com/example/Calculator.kt
package com.example
class Calculator(initial: Int = 0) {
private var acc = initial
fun add(value: Int): Int { acc += value; return acc }
fun subtract(value: Int): Int { acc -= value; return acc }
val current: Int get() = acc
fun describe(): String = "Calculator(current=$acc)"
}// src/jvmMain/kotlin/com/example/Main.kt
package com.example
fun main() {
val calc = Calculator(0) // allocates a Kotlin/Native object
calc.add(5) // FFM → native → StableRef → add()
calc.add(3)
println(calc.current) // 8
println(calc.describe()) // "Calculator(current=8)"
calc.close() // releases the native object (also auto-GC'd via Cleaner)
}No JNI. No annotations. No boilerplate. Just write Kotlin/Native and use it from JVM.
./gradlew jvmTest # compiles native + generates bridges + runs JVM tests
./gradlew run # if using Compose Desktop / NucleusEvery test compiles Kotlin/Native → libcalculator.so (470+ exported symbols) → loads via FFM MethodHandle → verifies on JVM. Zero mocks — all 1700+ tests cross the real native boundary. Includes load tests (500K+ FFM calls), concurrent stress tests, 110+ suspend function tests with cancellation, 50+ Flow tests, and 300+ inheritance/interface/extension tests.
| Feature | As param | As return | As property | CB param | CB return | Notes |
|---|---|---|---|---|---|---|
Int |
✅ 5t | ✅ 5t | ✅ 2t | ✅ 3t | ✅ 2t | direct pass-through |
Long |
✅ 2t | ✅ 2t | — | ✅ 2t | ✅ 2t | direct pass-through |
Double |
✅ 2t | ✅ 2t | ✅ 1t | ✅ 2t | ✅ 2t | direct pass-through |
Float |
✅ 1t | ✅ 1t | — | ✅ 1t | ✅ 1t | direct pass-through |
Boolean |
✅ 3t | ✅ 2t | ✅ 1t | ✅ 3t | ✅ 1t | 0/1 convention over FFM |
Byte |
✅ 1t | ✅ 1t | — | ✅ 1t | ✅ 1t | direct pass-through |
Short |
✅ 1t | ✅ 1t | — | ✅ 1t | ✅ 1t | direct pass-through |
String |
✅ 4t | ✅ 4t | ✅ 3t | ✅ 4t | ✅ 3t | output-buffer pattern |
Unit |
— | ✅ 1t | — | — | ✅ 3t | FunctionDescriptor.ofVoid(...) |
enum class |
✅ 3t | ✅ 2t | ✅ 2t | ✅ 2t | ✅ 3t | ordinal mapping |
| Classes | ✅ 3t | ✅ 4t | — | ✅ 19t | ✅ 2t | opaque handle via StableRef (incl. Object in callbacks) |
| Nested classes | ✅ | ✅ | ✅ | — | — | exported as Outer_Inner, supports 3+ nesting levels |
T? (nullable) |
✅ 3t | ✅ 8t | ✅ 3t | ❌ | — | sentinel-based null encoding (incl. DataClass?) |
data class |
✅ 4t | ✅ 6t | — | ✅ 5t | ✅ 3t | all field types: primitive, String, Enum, Object, nested DC, List, Set, Map |
ByteArray |
✅ 2t | ✅ 2t | — | ❌ | — | pointer + size pattern, suspend ✅ |
List<T> |
✅ 26t | ✅ 17t | — | ✅ 12t | ✅ 5t | Int, Long, Double, Float, Short, Byte, Boolean, String, Enum, Object |
List<DC> |
— | ✅ 15t | — | — | — | opaque handle + size/get/dispose bridges (Point, NamedValue, TaggedPoint) |
List<T>? |
✅ 7t | ✅ 8t | — | — | — | -1 count = null sentinel |
Set<T> |
✅ 9t | ✅ 13t | — | — | — | Int, String, Enum + intersect/empty edge cases |
Set<T>? |
— | ✅ 5t | — | — | — | -1 count = null sentinel |
Map<K, V> |
✅ 12t | ✅ 12t | — | ✅ 2t | ✅ 2t | String→Int, Int→String, Int→Int, String→String + merge/empty |
Map<K, V>? |
— | ✅ 4t | — | — | — | -1 count = null sentinel |
(T) -> R (lambda) |
✅ 15t | — | — | — | — | per-call upcall arena, exceptions propagate with their original type |
Flow<T> |
— | ✅ 50t+ | — | — | — | static onNext/onError/onComplete stubs + credit-based backpressure, incl. Flow<DataClass> |
| Feature | Supported | Notes |
|---|---|---|
| Top-level classes | ✅ | StableRef lifecycle, AutoCloseable on JVM |
| Open / abstract classes | ✅ | open class Shape → JVM open class Shape, hierarchy mirrored |
| Inheritance | ✅ | class Circle : Shape → JVM class Circle : Shape(handle), multi-level (3+) |
| Interfaces | ✅ | interface Measurable → JVM interface Measurable, multi-interface impl |
| Sealed classes | ✅ | sealed class AppResult → JVM sealed class, subclass ordinal bridges |
| Extension functions | ✅ | fun Shape.displayName() → real Kotlin extension on JVM proxy |
| Nested classes | ✅ | exported as Outer_Inner, qualified bridge symbols |
| Methods (fun) | ✅ | instance methods with any supported param/return types, override preserved |
| Properties (val/var) | ✅ | getters + setters, all supported types, including constructor val/var params |
| Constructors | ✅ | primary constructor with supported param types |
| Constructor default params | ✅ | generates overloads for trailing default parameters |
| Companion objects | ✅ | static methods and properties on JVM proxy |
| Top-level functions | ✅ | grouped into a singleton object on JVM |
| Enum classes | ✅ | auto-generated JVM enum with ordinal mapping |
| Data classes (nativeMain) | ✅ | auto-generates JVM data class + field marshalling |
| Data classes (commonMain) | ✅ | reuses existing JVM type, no proxy generated |
| Suspend functions | ✅ | suspendCancellableCoroutine + bidirectional cancellation (110+ tests) |
| Flow<T> return | ✅ | onNext/onError/onComplete callbacks with backpressure, no element dropped (50+ tests) |
| Exception propagation | ✅ | try/catch wrapping, KotlinNativeException on JVM (thread-local, safe under concurrency) |
| Object lifecycle | ✅ | Cleaner for GC + close() for explicit release; calls after close() throw IllegalStateException |
Kotlin/Native suspend fun is transparently mapped to JVM suspend fun. The developer writes coroutines on both sides — no callbacks, no CompletableFuture.
// Kotlin/Native
suspend fun fetchData(query: String): String {
delay(100)
return "result for $query"
}
// JVM — transparent, just a suspend fun
val result = calc.fetchData("test") // suspends the coroutineHow it works: the native bridge launches a CoroutineScope with a Job and reports completion through two static FFM upcall stubs, dispatched by a per-call id. The JVM proxy uses suspendCancellableCoroutine to suspend until the native coroutine completes.
Cancellation: JVM coroutine cancel → Job.cancel() on native side. Native CancellationException → JVM CancellationException. Bidirectional, automatic.
Supported return types: Int, Long, Double, Float, Boolean, Byte, Short, String, ByteArray, Unit, enum class, Object, data class, List<T>, Set<T>, Map<K,V>, nullable variants.
Kotlin/Native Flow<T> is transparently mapped to JVM Flow<T> via channelFlow. Event streams, tickers, and reactive patterns work naturally.
// Kotlin/Native
fun countUp(max: Int): Flow<Int> = flow {
for (i in 1..max) { delay(10); emit(i) }
}
// JVM — transparent Flow collection
calc.countUp(5).collect { println(it) } // 1, 2, 3, 4, 5
calc.countUp(100).toList() // [1, 2, ..., 100]
calc.infiniteFlow().take(3).toList() // [0, 1, 2] — auto-cancelledHow it works: 3 static FFM upcall stubs (onNext, onError, onComplete) are dispatched by a per-call id. The native side collects the Flow in a CoroutineScope and only emits against credits granted as the JVM collector consumes elements, so a slow collector applies backpressure instead of losing elements.
Cancellation: collecting only N elements (via take, first) automatically cancels the native Flow collection. Manual Job.cancel() also propagates.
Supported element types: Int, Long, Double, Float, Boolean, Byte, Short, String, enum class, Object, data class (including nested data classes).
Data class in Flow: data classes are serialized element-by-element via StableRef + per-type reader bridges. Nested data classes (e.g. Flow<Rect> where Rect contains two Point) are fully supported.
// Kotlin/Native
data class MemoryInfo(val totalMB: Long, val availableMB: Long)
fun memoryFlow(intervalMs: Long = 1000L): Flow<MemoryInfo> = flow {
while (true) { emit(MemoryInfo(getTotalMemoryMB(), getAvailableMemoryMB())); delay(intervalMs) }
}
// JVM — transparent Flow<DataClass> collection
desktop.memoryFlow(2000L).collect { info ->
println("${info.availableMB} MB / ${info.totalMB} MB")
}JVM lambdas cross the FFM boundary via upcall stubs. The plugin generates all the FFM infrastructure automatically.
Lifecycle: upcall stubs are allocated in a per-call arena and released when the call returns, so a callback is valid for the duration of the call only (as with JNI). Native code must not store a callback and invoke it later; expose event streams as Flow<T> instead.
Exceptions: an exception thrown by a JVM callback unwinds the native caller and is rethrown on the JVM with its original type and message.
Supported callback signatures:
- Params:
Int,Long,Double,Float,Boolean,Byte,Short,String,enum class,data class - Returns:
Int,Long,Double,Float,Boolean,Byte,Short,String,Unit,enum class,data class - Multi-param:
(T, U) -> Rwith any supported types - Data class params are decomposed into individual fields at C ABI level
// Kotlin/Native
fun onValueChanged(callback: (Int) -> Unit) { callback(accumulator) }
fun transform(fn: (Int) -> Int): Int { accumulator = fn(accumulator); return accumulator }
fun formatWith(formatter: (Int) -> String): String = formatter(accumulator)
// JVM — transparent
calc.onValueChanged { value -> println("Value: $value") }
calc.transform { it * 2 }
calc.formatWith { "Result: $it" }
// Event streams: use Flow rather than stored callbacks
desktop.trayClicks().collect { index -> println("Clicked: $index") }List<T>, Set<T>, and Map<K, V> cross the FFM boundary using flat arrays (pointer + size), inspired by swift-java's [UInt8] lowering.
There is no size limit: results that do not fit the initial transfer buffers (4096 elements / 8 KB of packed strings) are parked natively and read back with a larger buffer, without calling the function twice. The same applies to large String, ByteArray and data class String fields.
Supported element types: Int, Long, Double, Float, Short, Byte, Boolean, String, enum class
// Kotlin/Native
fun getScores(): List<Int> = listOf(accumulator, accumulator * 2, accumulator * 3)
fun sumAll(values: List<Int>): Int { accumulator = values.sum(); return accumulator }
fun getMetadata(): Map<String, Int> = mapOf("current" to accumulator, "scale" to scale.toInt())
// JVM — transparent
val scores = calc.getScores() // [10, 20, 30]
calc.sumAll(listOf(1, 2, 3, 4, 5)) // 15
val meta = calc.getMetadata() // {current=42, scale=3}| Collection | C ABI encoding |
|---|---|
List<primitive> |
CPointer<XxxVar> + size: Int |
List<String> |
packed null-terminated buffer + count |
List<Enum> |
ordinal array + count |
Set<T> |
same as List<T> (converted at boundary) |
Map<K, V> |
parallel key + value arrays + count |
Data classes are marshalled by value (field decomposition) — each field becomes a separate C ABI argument. Supported field types: all primitives + String.
The JVM data class (and enum class) is a verbatim copy of the native declaration: default values, body members, companion objects, KDoc and annotations are preserved. Imports of native-only packages (kotlinx.cinterop, platform.*, kotlin.native) are dropped, so bodies must only use APIs available on the JVM.
// Can be in commonMain or nativeMain
data class Point(val x: Int, val y: Int)
// nativeMain
fun getPoint(): Point = Point(accumulator, accumulator * 2)
fun addPoint(p: Point): Int { accumulator += p.x + p.y; return accumulator }
// JVM — uses the real data class (not an opaque handle)
val p = calc.getPoint() // Point(x=5, y=10)
calc.addPoint(Point(3, 7)) // 10- commonMain data classes: the JVM already has the type — no proxy generated, field marshalling only
- nativeMain data classes: the plugin generates the JVM
data classfile automatically
All native bridge functions are wrapped in try/catch. When an exception occurs:
- The native side captures the error message in a
@ThreadLocalvariable - The JVM proxy calls
kne_hasError()after every downcall - If an error is detected,
kne_getLastError()retrieves the message - A
KotlinNativeException(message)is thrown on the JVM side
try { calc.divide(0) } catch (e: KotlinNativeException) { println(e.message) }
calc.add(5) // works normally after exception| Nullable type | Wire type | Null sentinel |
|---|---|---|
String? |
output-buffer Int |
-1 = null |
Object? |
JAVA_LONG |
0L = null |
Enum? |
JAVA_INT |
-1 = null |
Boolean? |
JAVA_INT |
-1 = null, 0 = false, 1 = true |
Int? |
JAVA_LONG |
Long.MIN_VALUE = null |
Long? |
JAVA_LONG |
Long.MIN_VALUE = null |
Short? |
JAVA_INT |
Int.MIN_VALUE = null |
Byte? |
JAVA_INT |
Int.MIN_VALUE = null |
Float? |
JAVA_LONG (raw bits) |
Long.MIN_VALUE = null |
Double? |
JAVA_LONG (raw bits) |
Long.MIN_VALUE = null |
Measured on Intel Core i5-14600 (20 cores), 45 GB RAM, Ubuntu 25.10, JDK 25 (GraalVM), Kotlin 2.4.20.
Methodology: each benchmark runs the operation in a tight loop. 3 warmup iterations are discarded, then 5 measured iterations are averaged. "Native" creates a proxy object via FFM and calls into the Kotlin/Native shared library (.so). "JVM" runs the equivalent Kotlin/JVM code directly. Ratio = native/jvm (>1 = native slower due to FFM overhead). Memory is measured via Runtime.totalMemory() - freeMemory() before/after with explicit GC.
| Benchmark | Native | JVM | Ratio | Analysis |
|---|---|---|---|---|
| Fibonacci recursive (n=35) | 18.07 ms | 23.85 ms | 0.76x | Native faster (no JIT warmup needed) |
| Fibonacci iterative (n=1M) | 0.30 ms | 0.21 ms | 1.43x | Near-equal, JVM JIT slightly ahead |
| Pi Leibniz series (10M iter) | 8.60 ms | 8.56 ms | 1.01x | Identical performance |
| String concat loop (10K) | 21.65 ms | 17.62 ms | 1.23x | Near-equal |
| Bubble sort (5K elements) | 13.13 ms | 4.78 ms | 2.75x | JVM JIT optimizes array access better |
| Benchmark | Native | JVM | Ratio | Analysis |
|---|---|---|---|---|
| 100K trivial calls | 4.94 ms | 0.31 ms | 16x | ~49 ns/call FFM overhead |
| 10K create+close cycles | 4.12 ms | 0.11 ms | 36x | StableRef alloc+dispose cost |
| 10K data class returns | 4.32 ms | 0.15 ms | 29x | Out-param marshaling cost |
| 10K string returns | 6.45 ms | 0.59 ms | 11x | Output-buffer + UTF-8 copy |
| 10K data class params | 0.96 ms | 0.01 ms | 65x | Field expansion overhead |
| 5K list params (100 elems) | 7.78 ms | 2.89 ms | 2.70x | Arena alloc + memcpy |
| Benchmark | Native | JVM | Ratio |
|---|---|---|---|
| 10t × 1K fib(100) | 3.83 ms | 0.49 ms | 7.87x |
| 10t × 1K string reverse | 2.91 ms | 0.69 ms | 4.24x |
| 10t × 1K create+close | 2.25 ms | 0.44 ms | 5.07x |
| 10t × 1K DC roundtrip | 2.11 ms | 0.92 ms | 2.28x |
| Benchmark | Native | JVM | Analysis |
|---|---|---|---|
| 100K point allocations | 0 KB | 3,071 KB | Native: no JVM heap pressure |
| 10t × 10K points (concurrent) | 1,151 KB | 5,124 KB | Native uses 4.5x less JVM memory |
| String concat (10K) | 0 KB | 131,680 KB | Native: strings stay on native heap |
Key takeaways:
- Compute-bound workloads (fibonacci, pi, sorting) run at near-native speed — the FFM boundary is crossed once, then all work happens in Kotlin/Native
- FFM call overhead is ~49 ns/call — negligible for methods that do real work, visible only in micro-benchmarks with 100K+ trivial calls
- Memory advantage: native allocations don't touch the JVM heap, reducing GC pressure significantly (0 KB vs 131 MB for string-heavy workloads)
- Thread-safe: all concurrent benchmarks pass with zero crashes (AtomicReference error state, idempotent dispose)
| Feature | Notes |
|---|---|
Generics (class Box<T>) |
Complex type erasure at FFM boundary — use concrete types |
| Interface / sealed class as return type | Methods must return the concrete type, not the interface/sealed parent |
Operator overloading (operator fun plus) |
Use named methods (fun add()) |
| Infix functions | Use regular method syntax |
| Extension functions on stdlib types | Only extensions on project classes are bridged |
| Feature | Reason | Alternative |
|---|---|---|
| Private/internal/protected members | Only public API is exported | Use public modifier |
| Expect/actual declarations | KMP's responsibility | Use platform-specific source sets |
| Storing a JVM callback on the native side | Upcall stubs are released when the call returns | Expose events as Flow<T> |
NUL characters in String arguments |
Native strings are NUL-terminated; rejected with IllegalArgumentException |
Encode the data (e.g. ByteArray) |
ByteArray in collections |
Buffer lifecycle complexity across FFM | Use List<Int> or Base64 String |
ByteArray as data class field |
Out-param buffer not wired for DC fields | Use separate method or String |
ByteArray as callback param |
Buffer lifecycle across callback boundary | Use String (Base64) |
| Lambda as callback return type | Callbacks supported as parameters only | Return object with methods |
CInterop types in public API (CPointer, COpaque) |
Kotlin/Native-only types, not marshallable | Wrap behind a Kotlin API |
| Subclassing from JVM | JVM proxy classes are handles, not real native objects | Subclass on native side |
The bridge is designed for Kotlin-level APIs — clean classes, interfaces, data classes, functions. It is not a C FFI wrapper. Projects that expose raw C types in their public API (like GTK bindings with CPointer<GtkWidget>) are not compatible. Wrap them behind a clean Kotlin API first.
kotlinNativeExport {
// Name of the shared library (required)
// Produces: libmylib.so (Linux), libmylib.dylib (macOS), mylib.dll (Windows)
nativeLibName = "mylib"
// Package for JVM proxies — auto-detected from your Kotlin source package declarations
// Only set this if you have multiple packages and want to override the auto-detection
// nativePackage = "com.example"
// Build type: "release" (default, ~700KB .so) or "debug" (~6MB .so)
buildType = "release"
}The generated FFM proxies require:
- JDK 22+ (FFM API finalized in JDK 22 via JEP 454, recommended JDK 25)
--enable-native-access=ALL-UNNAMEDJVM arg (auto-configured for tests by the plugin)
The native library is automatically bundled in the JAR and extracted at runtime — no manual java.library.path configuration needed.
The generated KneRuntime uses a three-tier loading strategy:
java.library.path— for development, packaged apps, or manual override- JAR extraction — extracts from
kne/native/{os}-{arch}/in the classpath to a persistent cache (~/.cache/kne/) - Loader lookup — fallback for GraalVM native-image (native lib beside the executable)
generateKneNativeBridges is a @CacheableTask and works incrementally between executions:
- Build cache: outputs depend only on relative source paths/contents, the PSI classpath and the extension configuration, so they are restored
FROM-CACHEacross checkouts and machines (enable withorg.gradle.caching=true). Sources are merged in a deterministic order, so the generated code is reproducible. - Source file → IR mapping: each source file maps to a cached prescan (its type declarations) and IR fragment, kept in
build/kne/incremental(Gradle local state). A file is re-parsed only if its content changed, or if a symbol it looked up now resolves differently (e.g. a referenced data class gained a field). The PSI environment is not even started when nothing needs parsing. - IR → outputs mapping: when the merged API is unchanged (e.g. only function bodies were edited), code generation is skipped. Otherwise only generated files whose content changed are rewritten and stale ones are deleted, so the downstream incremental Kotlin compilation only recompiles what actually changed.
The plugin auto-generates GraalVM reachability metadata under META-INF/native-image/kne/{libName}/:
reflect-config.json— all generated proxy classesresource-config.json— bundled native library resourcesreachability-metadata.json— FFM foreign downcall + upcall descriptors, reflection, and resources
For GraalVM native-image builds, the native .so/.dylib must be placed next to the executable (the plugin bundles it in the JAR for JVM, but native-image can't extract at runtime).
The Compose compiler plugin and Nucleus Native Access both add Kotlin/Native targets, but Compose doesn't support arbitrary native compilations (e.g. linuxX64, mingwX64 for FFM bridges). The recommended approach is to put your native code in a separate Gradle module without the Compose compiler plugin:
my-app/
├── native/ ← Kotlin/Native + nna (no Compose)
│ └── build.gradle.kts
├── app/ ← Compose Desktop + depends on :native
│ └── build.gradle.kts
└── settings.gradle.kts
:native/build.gradle.kts — native bridge module:
plugins {
kotlin("multiplatform") version "2.4.20"
id("dev.nucleusframework.nna")
}
kotlin {
jvmToolchain(25)
linuxX64() // or macosArm64(), mingwX64()
jvm()
}
kotlinNativeExport {
nativeLibName = "mylib"
// nativePackage auto-detected from source package declarations
}:app/build.gradle.kts — Compose Desktop module:
plugins {
kotlin("multiplatform") version "2.4.20"
id("org.jetbrains.compose") version "1.12.1"
id("org.jetbrains.kotlin.plugin.compose") version "2.4.20"
id("io.github.kdroidfilter.nucleus") version "1.7.2"
}
kotlin {
jvmToolchain(25)
jvm()
sourceSets {
jvmMain {
dependencies {
implementation(compose.desktop.currentOs)
implementation(project(":native"))
}
}
}
}
nucleus.application {
mainClass = "com.example.MainKt"
jvmArgs += listOf("--enable-native-access=ALL-UNNAMED")
}This avoids any conflict between the Compose compiler and Kotlin/Native targets used for FFM bridges.
You can combine the plugin with Kotlin/Native cinterop to wrap native C libraries and expose them to JVM:
// build.gradle.kts
kotlin {
linuxX64().compilations["main"].cinterops {
create("libnotify") {
defFile(project.file("src/nativeInterop/cinterop/libnotify.def"))
}
}
}// src/nativeMain/kotlin/LinuxDesktop.kt
class LinuxDesktop {
fun sendNotification(title: String, body: String, icon: String): Boolean {
// calls libnotify via cinterop — impossible from JVM without JNI
notify_init("MyApp")
val n = notify_notification_new(title, body, icon) ?: return false
return notify_notification_show(n, null) != 0
}
fun getHostname(): String = memScoped {
val buf = allocArray<ByteVar>(256)
gethostname(buf, 256u)
buf.toKString()
}
}// src/jvmMain/kotlin/Main.kt — transparent usage
val desktop = LinuxDesktop()
desktop.sendNotification("Hello", "From Kotlin/Native via FFM!", "dialog-information")
println(desktop.getHostname())
desktop.close()The repository includes two complete examples in examples/:
| Example | Description |
|---|---|
calculator/ |
Stateful Calculator class with 1700+ end-to-end tests: all types, callbacks, collections, suspend, Flow, nested classes, inheritance hierarchies, interfaces, sealed classes, extension functions, concurrency |
systeminfo/ |
Linux system info (/proc, POSIX, gethostname) + native notifications via libnotify cinterop, with Compose Desktop UI |
benchmark/ |
Performance benchmarks: native vs JVM (fibonacci, pi, sort, string, allocation, concurrent) |
Run them:
./gradlew :examples:calculator:run
./gradlew :examples:systeminfo:run
./gradlew :examples:calculator:jvmTest # 1700+ end-to-end FFM tests
./gradlew :examples:benchmark:jvmTest # Performance benchmarks (native vs JVM)The plugin is inspired by two projects:
-
swift-export-standalone (JetBrains) — how Kotlin exports its API to Swift via C bridges. We adapted the approach: scan Kotlin sources, generate
@CNamebridge functions withStableReffor object lifecycle. -
swift-java (Apple) — how Swift code is made callable from Java via FFM
MethodHandledowncalls. We adapted the FFM binding generation: each method gets aFunctionDescriptor+MethodHandle, classes useCleanerfor GC safety.
plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/
├── ir/ # Intermediate representation (inspired by SirModule)
│ └── KneIR.kt # KneModule, KneClass, KneFunction, KneType...
├── analysis/
│ ├── PsiSourceParser.kt # Kotlin PSI-based source parser (kotlin-compiler-embeddable), incremental per file
│ ├── SymbolTable.kt # PSI-free symbol table + type resolution with lookup tracking
│ ├── ParseCache.kt # Persistent source file → IR / IR → outputs cache
│ └── PsiParseWorkAction.kt # Gradle Worker for isolated PSI classloader
├── codegen/
│ ├── NativeBridgeGenerator.kt # @CName + StableRef bridges (inspired by @_cdecl thunks)
│ ├── FfmProxyGenerator.kt # JVM proxy classes with FFM (inspired by FFMSwift2JavaGenerator)
│ └── GeneratedOutputs.kt # Write-if-changed output sync + stale file removal
├── tasks/
│ └── GenerateNativeBridgesTask.kt # Single task: PSI parse + native bridges + JVM proxies + GraalVM metadata
├── KotlinNativeExportExtension.kt
└── KotlinNativeExportPlugin.kt # Task wiring, native lib JAR bundling, test configuration
Source analysis: the plugin uses Kotlin PSI (kotlin-compiler-embeddable) for proper AST-based parsing, running in an isolated Gradle Worker classloader. This handles nested generics, function types, default parameters, multi-line constructors, and suspend/Flow detection natively — no regex.
This plugin bridges Kotlin/Native code to the JVM transparently — platform-specific APIs (cinterop, POSIX, C libraries), performance-critical native code, and types that cannot exist in common Kotlin.
The bridge supports the full Kotlin OOP model: inheritance hierarchies are mirrored on the JVM side (open class Shape → class Circle : Shape), interfaces generate JVM interfaces with val handle: Long, and override modifiers are preserved. Extension functions become real Kotlin extensions on the JVM proxy. Sealed classes generate sealed JVM classes with subclass dispatch.
Data classes are supported as value types (field marshalling), and commonMain data classes are reused directly without generating duplicates.
- Kotlin 2.4.20+
- Gradle 9.1+ (for JDK 25 support)
- JDK 22+ (FFM stable since JDK 22 / JEP 454), recommended 25
- Kotlin/Native toolchain (bundled with KMP plugin)
This project is licensed under the MIT License - see the LICENSE file for details.