From 3af62cd17f710215109e9a21fb86903438f1cd42 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Tue, 18 Aug 2026 16:20:48 +0200 Subject: [PATCH 1/3] Marshal const void* buffers, pass JNI includes, and cross-compile ABI builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps found binding pdfium — a C++ library shipped as a prebuilt binary — for JVM, Android and Kotlin/Native from one .def. cinterop writes a const void* parameter as CValuesRef<*>, which fell through the element-type match and stayed a raw address, so a JVM caller had no way to hand FPDF_LoadMemDocument a ByteArray without finding off-heap memory. Const says the callee only reads, so it is data; a non-const void* is a COpaquePointer and still crosses as an address. The generated stub includes jni.h and the documented CMakeLists had no way to find it, so that example fails for anyone whose compiler lacks the JDK headers on its default path. The plugin already picks a JDK for the bindings and now passes its include roots as KONAN_JNI_INCLUDE_DIRS; find_package(JNI) would be free to choose a different one. An ABI build used the host compiler. abi() means Android, which means cross-compiling, so the NDK toolchain is now supplied — found through ANDROID_NDK_HOME, ndk.dir or sdk.dir in local.properties, or ANDROID_HOME, and skipped when the build names a toolchain itself. Without it the failure is a linker reporting an unknown file type, which says nothing about Android. abi { platform.set(26) } chooses the minimum API. Version bumped to 1.2.0-alpha06. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 11 +++++- build.gradle.kts | 2 +- .../lemcoder/interop/ExternalNativeBuild.kt | 3 ++ .../lemcoder/interop/JvmInteropRegistry.kt | 34 +++++++++++++++++++ .../github/lemcoder/jvm/JvmInteropSupport.kt | 24 +++++++++++++ .../io/github/lemcoder/util/JniMarshalling.kt | 6 ++++ .../io/github/lemcoder/JniMarshallingTest.kt | 16 +++++++++ 7 files changed, 94 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3d63da6..29c4f0c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ externalNativeBuild { ``` Each ABI gets its own configure/build pair, the libraries land in `jniLibs//`, and the plugin -wires that directory into the Android variants so they are packaged into the AAR. The plugin sets +wires that directory into the Android variants so they are packaged into the AAR. The Android +toolchain comes from the NDK the plugin finds — `ANDROID_NDK_HOME`, then `ndk.dir` or `sdk.dir` in +`local.properties`, then `ANDROID_HOME` — unless the build passes `-DCMAKE_TOOLCHAIN_FILE` itself. +`platform.set(26)` inside an `abi` block chooses the minimum API; it is `android-21` otherwise. The plugin sets `CMAKE_LIBRARY_OUTPUT_DIRECTORY`, so the CMakeLists needs no knowledge of the layout. The plugin supplies what only it knows, as cache entries your `CMakeLists.txt` reads: @@ -154,6 +157,7 @@ The plugin supplies what only it knows, as cache entries your `CMakeLists.txt` r |---|---| | `KONAN_JNI_STUB_DIR` | directory holding the generated `.c` stub | | `KONAN_JNI_LIB_NAME` | the name the generated bindings will `System.loadLibrary` | +| `KONAN_JNI_INCLUDE_DIRS` | include roots for `jni.h` and its platform header, `;`-separated | It also points `JAVA_HOME` at a JDK that ships `include/jni.h` — the one running Gradle often does not, since IDE-bundled JBRs strip the headers — and locates `cmake` itself, because the Gradle daemon @@ -162,13 +166,18 @@ does not inherit a login shell's PATH. ```cmake set(KONAN_JNI_STUB_DIR "" CACHE PATH "") set(KONAN_JNI_LIB_NAME "" CACHE STRING "") +set(KONAN_JNI_INCLUDE_DIRS "" CACHE STRING "") file(GLOB JNI_SOURCES "${KONAN_JNI_STUB_DIR}/*.c") add_library(mylib-jni SHARED ${JNI_SOURCES}) set_target_properties(mylib-jni PROPERTIES OUTPUT_NAME "${KONAN_JNI_LIB_NAME}") +target_include_directories(mylib-jni PRIVATE ${KONAN_JNI_INCLUDE_DIRS}) target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arrive transitively ``` +`find_package(JNI)` would work too, but it is free to pick a different JDK from the one the plugin +chose for the bindings; the cache variable is the JDK that generated them. + ### Generated bindings One `external fun kniBridgeN(...)` per C function, in header order, each carrying its C-derived signature as a doc comment. Parameters are marshalled by shape: diff --git a/build.gradle.kts b/build.gradle.kts index 936890a..b80598b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { `kotlin-dsl` } -version = "1.2.0-alpha05" +version = "1.2.0-alpha06" group = "io.github.lemcoder" java { diff --git a/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt b/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt index 06c568d..f93f532 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/ExternalNativeBuild.kt @@ -89,6 +89,9 @@ abstract class CMakeAbiSettings @Inject constructor(private val abiName: String) override fun getName(): String = abiName + /** Minimum Android API the stub is built against; `android-21` when unset. */ + abstract val platform: Property + /** Configure preset for this ABI. */ abstract val preset: Property diff --git a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt index f369a14..c26f602 100644 --- a/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt +++ b/src/main/kotlin/io/github/lemcoder/interop/JvmInteropRegistry.kt @@ -45,6 +45,7 @@ abstract class JvmInteropRegistry @Inject constructor( */ const val STUB_DIR_VARIABLE = "KONAN_JNI_STUB_DIR" const val LIB_NAME_VARIABLE = "KONAN_JNI_LIB_NAME" + const val JNI_INCLUDE_VARIABLE = "KONAN_JNI_INCLUDE_DIRS" } /** How a declaration site takes the generated Kotlin; keeps KGP types out of this class. */ @@ -283,6 +284,39 @@ abstract class JvmInteropRegistry @Inject constructor( generate.flatMap { it.stubSourceDirectory }.map { it.asFile.absolutePath }, ) cacheEntries.put(LIB_NAME_VARIABLE, generate.flatMap { it.stubLibraryBaseName }) + // The stub includes jni.h, and the JDK running Gradle often has no headers — an + // IDE-bundled JBR strips them. JAVA_HOME alone does not reach the compiler, so the + // include roots are passed too; find_package(JNI) would be free to pick another JDK. + cacheEntries.put( + JNI_INCLUDE_VARIABLE, + project.provider { + val home = settings.jniHome.orNull?.let { java.io.File(it) } ?: JvmInteropSupport.detectJniHome() + val include = home.resolve("include") + val platform = include.listFiles()?.firstOrNull { it.isDirectory } + listOfNotNull(include, platform).joinToString(";") { it.absolutePath } + }, + ) + // An ABI is an Android ABI, and building for one means cross-compiling: without the + // NDK toolchain CMake uses the host compiler, and the failure is a linker complaining + // about an unknown file type rather than anything mentioning Android. Skipped when the + // build names a toolchain itself. + if (abi != null) { + val named = cmake.arguments.get() + abi.arguments.get() + if (named.none { it.startsWith("-DCMAKE_TOOLCHAIN_FILE") }) { + val ndk = JvmInteropSupport.findNdk(project.projectDir) + checkNotNull(ndk) { + "No Android NDK found for ABI '${abi.name}'. Set ANDROID_NDK_HOME, or ndk.dir " + + "in local.properties, or pass -DCMAKE_TOOLCHAIN_FILE yourself." + } + cacheEntries.put( + "CMAKE_TOOLCHAIN_FILE", + ndk.resolve("build/cmake/android.toolchain.cmake").absolutePath, + ) + cacheEntries.put("ANDROID_ABI", abi.name) + cacheEntries.put("ANDROID_PLATFORM", abi.platform.map { "android-$it" }.orElse("android-21")) + } + } + // Land the library where the plugin says, so a consumer never guesses the build // layout and Android gets the jniLibs// shape AGP packages. cacheEntries.put( diff --git a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt b/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt index e9b8231..913c643 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/JvmInteropSupport.kt @@ -60,6 +60,30 @@ internal object JvmInteropSupport { ?: error("No JDK with include/jni.h found. Set the interop's jniHome.") /** The home of the JDK [detectJniIncludeDirs] would use. */ + /** + * The Android NDK, for cross-compiling a stub per ABI. + * + * Checked in the order a developer would expect to win: an explicit environment variable, then + * `ndk.dir` or an SDK in `local.properties`, then `ANDROID_HOME`. The newest installed NDK is + * taken when a directory holds several. + */ + fun findNdk(projectDir: File): File? { + System.getenv("ANDROID_NDK_HOME")?.let { return File(it).takeIf { home -> home.isDirectory } } + System.getenv("ANDROID_NDK_ROOT")?.let { return File(it).takeIf { root -> root.isDirectory } } + + val properties = generateSequence(projectDir) { it.parentFile } + .map { it.resolve("local.properties") } + .firstOrNull { it.isFile } + ?.let { file -> file.readLines().mapNotNull { line -> line.split("=", limit = 2).takeIf { it.size == 2 } } } + ?.associate { (key, value) -> key.trim() to value.trim() } + .orEmpty() + + properties["ndk.dir"]?.let { return File(it).takeIf { dir -> dir.isDirectory } } + + val sdk = properties["sdk.dir"]?.let(::File) ?: System.getenv("ANDROID_HOME")?.let(::File) + return sdk?.resolve("ndk")?.listFiles()?.filter { it.isDirectory }?.maxByOrNull { it.name } + } + fun detectJniHome(): File = jdkCandidates() .flatMap { listOf(it, it.resolve("Contents/Home")) } .firstOrNull { it.resolve("include/jni.h").isFile } diff --git a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt b/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt index de14ba7..1c74d99 100644 --- a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt +++ b/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt @@ -49,6 +49,7 @@ private val CSTR_ARG = Regex("""^(\w+)\??\.cstr\??\.getPointer\(memScope\)\.rawV private val PTR_ARG = Regex("""^(\w+)\??\.getPointer\(memScope\)\.rawValue$""") private val STRUCT_RETURN_ARG = Regex("""^kniRetVal\.rawPtr$""") private val VAR_ELEMENT = Regex("""^CValuesRef<(\w+)Var>\??$""") +private val VOID_BUFFER = Regex("""^CValuesRef<\*>\??$""") private val STRUCT_VALUE = Regex("""^CValue<\w+>$""") /** @@ -94,6 +95,11 @@ private fun classify(arg: String, paramTypes: Map): ParamKind { val pointee = PTR_ARG.find(arg)?.groupValues?.get(1) ?: return ParamKind.RAW val type = paramTypes[pointee] ?: return ParamKind.RAW if (STRUCT_VALUE.matches(type)) return ParamKind.BYTE_ARRAY + // `const void*` arrives as CValuesRef<*>: bytes the callee reads, which on the JVM is a + // ByteArray. A non-const `void*` is a COpaquePointer and stays an address — that is a handle, + // not data. Without this, an API shaped like load(const void* data, int size) forces the caller + // to find off-heap memory, which is the thing these bindings exist to avoid. + if (VOID_BUFFER.matches(type)) return ParamKind.BYTE_ARRAY val element = VAR_ELEMENT.find(type)?.groupValues?.get(1) ?: return ParamKind.RAW return when (element) { "Byte" -> ParamKind.BYTE_ARRAY diff --git a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt b/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt index f2efc30..672e316 100644 --- a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt +++ b/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt @@ -28,6 +28,13 @@ class JniMarshallingTest { return kniBridge0() } + @ExperimentalForeignApi + fun koi_model_load_mem(data: CValuesRef<*>?, size: Int): CPointer? { + memScoped { + return interpretCPointer(kniBridge9(data?.getPointer(memScope).rawValue, size)) + } + } + @ExperimentalForeignApi fun koi_system_info(): CPointer? { return interpretCPointer(kniBridge1()) @@ -105,6 +112,15 @@ class JniMarshallingTest { assertEquals(listOf(ParamKind.RAW, ParamKind.STRING, ParamKind.FLOAT_ARRAY, ParamKind.RAW), kinds[5]) } + @Test + fun `a const void buffer crosses as a byte array`() { + // load(const void* data, int size) is a common shape; cinterop writes it CValuesRef<*>. + // Leaving it as an address would make the caller find off-heap memory for a ByteArray. + val kinds = parseBridgeKinds(rawKotlin) + + assertEquals(listOf(ParamKind.BYTE_ARRAY, ParamKind.RAW), kinds[9]) + } + @Test fun `bridges with nothing to marshal are left alone`() { assertFalse(kinds.containsKey(0)) From fa7bac9ee21709f94e57f912cfb2f6353af0fce3 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 20 Aug 2026 22:33:40 +0200 Subject: [PATCH 2/3] Name the bridges after the C functions they call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cinterop numbers them, so a binding read `kniBridge54(handle)` where the C API says `FPDFText_CountChars(text_page)`. The number is an artefact of the generator and carries nothing: it makes the hand-written expect/actual on top unreadable, and a linker error or stack frame names something you cannot search for. The name was already there. cinterop generates a friendly wrapper beside each bridge, and the formatter was reading it to build the doc comment; `parseBridgeNames` now takes the name from the same place and both `stripCinterop` and `marshalStub` rename their side together. JNI escapes `_` in a method name as `_1`, because `_` is the separator in `Java___`. Without that, `FPDFText_CountChars` resolves as method `CountChars` on class `FPDFText`, and it compiles, links and dies on the first call — the same failure a missing `@JvmName` gives. Almost every C API worth binding has underscores in it, so the mangling is covered by a test rather than left to a future reader. A bridge keeps its number wherever the rename would be unsafe: two bridges that would share a name, one bridge reached from wrappers that disagree, a name the generated file already uses, or anything that is not a plain identifier. Passing no names reproduces the old output exactly. Verified on the jvm example, which now resolves `my_add`/`my_scale` and prints 5 and 40.0, and against pdfium in MikroMarkdown, whose 176 bridges rename with no numbers left and whose tests pass with real symbols. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 12 ++++ README.md | 18 +++++- build.gradle.kts | 2 +- examples/README.md | 7 ++- .../android/src/main/kotlin/example/Api.kt | 8 +-- examples/jvm/src/main/kotlin/Main.kt | 8 +-- .../lemcoder/jvm/GenerateJvmInteropTask.kt | 7 ++- .../github/lemcoder/util/CInteropFormatter.kt | 40 ++++++++++++- .../io/github/lemcoder/util/JniMarshalling.kt | 21 ++++++- .../io/github/lemcoder/JniMarshallingTest.kt | 57 +++++++++++++++++++ 10 files changed, 158 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6c2cf52..13c4797 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,18 @@ Bindings are `internal` by default and each carries **`@JvmName`**. Without it K internal functions (`kniBridge0$module`) while JNI resolves the C symbol from the unmangled method name: it compiles, links, and dies on the first call. +**The bridges are renamed to the C function they call.** cinterop numbers them; `parseBridgeNames` +recovers the name from the wrapper cinterop generated beside each one — the same place the doc comment +comes from — and `stripCinterop`/`marshalStub` rename both sides together. The rename is skipped, and +the number kept, when two bridges would share a name, when one bridge is reached from wrappers that +disagree, or when the name collides with `kniCString`/`nativeLibrary`. + +**JNI escapes `_` in a method name as `_1`.** The symbol is `Java___` with +`_` as the separator, so `FPDFText_CountChars` has to emit `Java_pdfium_pdfium_FPDFText_1CountChars`. +Get this wrong and the symbol reads as method `CountChars` on class `FPDFText`: it compiles, links, +and dies on the first call, exactly like a missing `@JvmName`. Almost every C API worth binding has +underscores in it, so this is the failure mode to check first after touching the naming. + ## Kotlin DSL gotchas - The `kotlin-dsl` plugin adds a `T.() -> Unit` overload of `whenObjectAdded`, making the SAM form diff --git a/README.md b/README.md index 29c4f0c..ae453e8 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,20 @@ target_link_libraries(mylib-jni PRIVATE mylib) # frameworks, libc++ etc. arriv chose for the bindings; the cache variable is the JDK that generated them. ### Generated bindings -One `external fun kniBridgeN(...)` per C function, in header order, each carrying its C-derived -signature as a doc comment. Parameters are marshalled by shape: +One `external fun` per C function, in header order, named for the function it calls and carrying its +C-derived signature as a doc comment: + +```kotlin +/** C: FPDFText_CountChars(text_page: FPDF_TEXTPAGE?): Int */ +@JvmName("FPDFText_CountChars") +internal external fun FPDFText_CountChars(p0: Long): Int +``` + +cinterop numbers the bridges `kniBridge0…N`; the plugin renames them, so a binding reads as the C API +does and a linker error or stack frame names something searchable. A bridge keeps its number when the +rename would be ambiguous — two bridges sharing a name, or a name the generated file already uses. + +Parameters are marshalled by shape: | C parameter | Kotlin parameter | Crosses as | |------------------------------------|------------------|------------------------------------------------| @@ -190,7 +202,7 @@ signature as a doc comment. Parameters are marshalled by shape: | opaque handle, pointer to struct | `Long` | raw address | The bridges are `internal` by default — they are an implementation detail of the module that writes -the idiomatic API over them, and this keeps `kniBridge0…N` out of its published surface. Each carries +the idiomatic API over them, and this keeps them out of its published surface. Each carries `@JvmName`, because Kotlin mangles internal functions on the JVM and JNI resolves the symbol from the unmangled method name. Set `visibility.set(BindingVisibility.PUBLIC)` if the wrapper is in a different Gradle module. diff --git a/build.gradle.kts b/build.gradle.kts index b80598b..7a2bb91 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { `kotlin-dsl` } -version = "1.2.0-alpha06" +version = "1.2.0-alpha07" group = "io.github.lemcoder" java { diff --git a/examples/README.md b/examples/README.md index 502bebc..7a1ac73 100644 --- a/examples/README.md +++ b/examples/README.md @@ -42,8 +42,8 @@ expect fun add(a: Int, b: Int): Int // nativeMain — over the cinterop binding actual fun add(a: Int, b: Int): Int = mymath.my_add(a, b) -// jvmMain / androidMain — over the generated JNI bridge -actual fun add(a: Int, b: Int): Int = example.kniBridge0(a, b) +// jvmMain / androidMain — over the generated JNI bridge, which carries the same name +actual fun add(a: Int, b: Int): Int = example.my_add(a, b) ``` Pointer/string params arrive at the JNI bridge as a raw address (`Long`); convert in your `actual`. @@ -51,5 +51,6 @@ Pointer/string params arrive at the JNI bridge as a raw address (`Long`); conver ## What's verified here vs. needs a device - `jvm` and `native` run to completion and print `5` / `40.0`. -- `android` produces and verifies the `.so` (ELF + exported `Java_..._kniBridgeN` symbols). Running it +- `android` produces and verifies the `.so` (ELF + exported `Java_..._my_1add` symbols, JNI escaping + each `_` in the name as `_1`). Running it on Android needs an emulator/device and an AGP module — see [`android/README.md`](android/README.md). diff --git a/examples/android/src/main/kotlin/example/Api.kt b/examples/android/src/main/kotlin/example/Api.kt index 3b16370..6d1678e 100644 --- a/examples/android/src/main/kotlin/example/Api.kt +++ b/examples/android/src/main/kotlin/example/Api.kt @@ -1,6 +1,6 @@ package example -// Idiomatic API the user writes on top of the generated low-level JNI bridges -// (build/generated/jvmInterop/kotlin/example/example.kt). -fun add(a: Int, b: Int): Int = kniBridge0(a, b) // C: my_add(a: Int, b: Int): Int -fun scale(x: Double): Double = kniBridge1(x) // C: my_scale(x: Double): Double +// Idiomatic API the user writes on top of the generated low-level JNI bridges, which carry the names +// of the C functions they call (build/generated/jvmInterop/kotlin/example/example.kt). +fun add(a: Int, b: Int): Int = my_add(a, b) +fun scale(x: Double): Double = my_scale(x) diff --git a/examples/jvm/src/main/kotlin/Main.kt b/examples/jvm/src/main/kotlin/Main.kt index 6c4f177..1c7c972 100644 --- a/examples/jvm/src/main/kotlin/Main.kt +++ b/examples/jvm/src/main/kotlin/Main.kt @@ -1,9 +1,9 @@ package example -// Idiomatic API the *user* writes on top of the generated low-level JNI bridges. -// (See build/generated/jvmInterop/kotlin/.../example.kt for the generated `kniBridgeN` + their C signatures.) -fun add(a: Int, b: Int): Int = kniBridge0(a, b) // C: my_add(a: Int, b: Int): Int -fun scale(x: Double): Double = kniBridge1(x) // C: my_scale(x: Double): Double +// Idiomatic API the *user* writes on top of the generated low-level JNI bridges, which carry the +// names of the C functions they call. (See build/generated/jvmInterop/kotlin/.../example.kt.) +fun add(a: Int, b: Int): Int = my_add(a, b) +fun scale(x: Double): Double = my_scale(x) fun main() { println("add(2, 3) = ${add(2, 3)}") // expect 5 diff --git a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt index dd9c9d6..60691cb 100644 --- a/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt +++ b/src/main/kotlin/io/github/lemcoder/jvm/GenerateJvmInteropTask.kt @@ -6,6 +6,7 @@ import io.github.lemcoder.util.ParamKind import io.github.lemcoder.util.execCapture import io.github.lemcoder.util.marshalStub import io.github.lemcoder.util.parseBridgeKinds +import io.github.lemcoder.util.parseBridgeNames import io.github.lemcoder.util.stripCinterop import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection @@ -138,10 +139,12 @@ abstract class GenerateJvmInteropTask @Inject constructor( val kinds = kotlinFiles.fold(emptyMap>()) { acc, kt -> acc + parseBridgeKinds(kt.readText()) } + // cinterop numbers the bridges; the wrappers beside them name the C function each one calls. + val names = kotlinFiles.fold(emptyMap()) { acc, kt -> acc + parseBridgeNames(kt.readText()) } val internal = internalBindings.getOrElse(true) - kotlinFiles.forEach { kt -> kt.writeText(stripCinterop(kt.readText(), kinds, internal)) } + kotlinFiles.forEach { kt -> kt.writeText(stripCinterop(kt.readText(), kinds, internal, names)) } out.resolve(C_DIR).walkTopDown().filter { it.extension == "c" } - .forEach { c -> c.writeText(marshalStub(c.readText(), kinds)) } + .forEach { c -> c.writeText(marshalStub(c.readText(), kinds, names)) } } private companion object { diff --git a/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt b/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt index f221ea3..a086018 100644 --- a/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt +++ b/src/main/kotlin/io/github/lemcoder/util/CInteropFormatter.kt @@ -6,6 +6,40 @@ private val LOAD_LIB = Regex("""loadKonanLibrary\("([^"]+)"\)""") // Captures each friendly wrapper's name/params/return and the kniBridge it delegates to, so we can keep // the original C-derived signature as documentation next to the otherwise opaque bridge. private val WRAPPER = Regex("""(?s)fun\s+(\w+)\(([^)]*)\)\s*:\s*([^{]+?)\s*\{[^}]*?kniBridge(\d+)""") +private val IDENTIFIER = Regex("""[A-Za-z_]\w*""") + +/** Names the generated file already uses, which a bridge must not take. */ +private val RESERVED = setOf(C_STRING_HELPER, "nativeLibrary") + +/** + * Bridge index -> the name of the C function it calls, taken from the wrapper cinterop generated + * beside it. + * + * cinterop numbers the bridges, so a binding reads `kniBridge54(handle)` where the C API says + * `FPDFText_CountChars(text_page)`. The number is an implementation detail of the generator and + * carries nothing for a reader: renaming the bridge to the function it calls is what makes a + * hand-written `expect`/`actual` on top legible, and it keeps stack traces and linker errors + * pointing at something searchable. + * + * A bridge is left numbered whenever the rename would be unsafe: two bridges sharing a name, one + * bridge reached from wrappers that disagree, a name the generated file already uses, or anything + * that is not a plain identifier. + */ +fun parseBridgeNames(rawKotlin: String): Map { + val pairs = + WRAPPER.findAll(rawKotlin).mapNotNull { m -> m.groupValues[4].toIntOrNull()?.to(m.groupValues[1]) }.toList() + val timesUsed = pairs.groupingBy { it.second }.eachCount() + + return pairs + .groupBy({ it.first }, { it.second }) + .mapNotNull { (index, names) -> + val name = names.distinct().singleOrNull() ?: return@mapNotNull null + if (timesUsed.getValue(name) != names.size) return@mapNotNull null + if (name in RESERVED || !IDENTIFIER.matches(name)) return@mapNotNull null + index to name + } + .toMap() +} /** * Rewrites a cinterop-generated JVM `.kt` into a runtime-free form: keeps `@file:JvmName` + package + @@ -21,6 +55,7 @@ fun stripCinterop( src: String, kinds: Map> = emptyMap(), internalBindings: Boolean = false, + names: Map = emptyMap(), ): String { val jvmName = JVM_NAME.find(src)?.groupValues?.get(1) val pkg = src.lineSequence().firstOrNull { it.trimStart().startsWith("package ") }?.trim() @@ -47,10 +82,11 @@ fun stripCinterop( }.joinToString(", ") val ret = m.groupValues[3].replace("NativePtr", "Long") val doc = origByBridge[idx]?.let { "/** C: $it */\n" } ?: "" + val name = names[idx.toIntOrNull()] ?: bridge // @JvmName because Kotlin mangles internal functions on the JVM ("kniBridge0${'$'}module"), // and JNI resolves the C symbol from the unmangled method name. - val jvmNameAnnotation = if (internalBindings) "@JvmName(\"$bridge\")\n" else "" - "$doc$jvmNameAnnotation${modifier}external fun $bridge($params): $ret" + val jvmNameAnnotation = if (internalBindings) "@JvmName(\"$name\")\n" else "" + "$doc$jvmNameAnnotation${modifier}external fun $name($params): $ret" }.toList() return buildString { diff --git a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt b/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt index 1c74d99..498791e 100644 --- a/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt +++ b/src/main/kotlin/io/github/lemcoder/util/JniMarshalling.kt @@ -125,10 +125,16 @@ private val C_RETURN = Regex("""(?s)^(.*?)\breturn\s+(.*?);\s*$""") * The incoming object is renamed to `j` and the marshalled pointer keeps the original `p` name, * so the generated call expression — casts and all — is reused verbatim. */ -fun marshalStub(cSource: String, kinds: Map>): String { +fun marshalStub(cSource: String, kinds: Map>, names: Map = emptyMap()): String { val rewritten = C_FUNCTION.replace(cSource) { m -> - val (returnType, jniName, indexText, paramText, body) = m.destructured - val bridgeKinds = kinds[indexText.toInt()] ?: return@replace m.value + val (returnType, numberedName, indexText, paramText, body) = m.destructured + val jniName = names[indexText.toInt()]?.let { "${numberedName.substringBeforeLast("_kniBridge")}_${jniMangle(it)}" } + ?: numberedName + val bridgeKinds = kinds[indexText.toInt()] + // A bridge with nothing to marshal still gets renamed, so the symbol matches the @JvmName. + if (bridgeKinds == null) { + return@replace if (jniName == numberedName) m.value else m.value.replace(numberedName, jniName) + } val params = splitTopLevel(paramText).map { it.trim() } val fixed = params.take(2) // JNIEnv*, jclass @@ -194,6 +200,15 @@ fun marshalStub(cSource: String, kinds: Map>): String { } } +/** + * JNI's own mangling of a method name into a C symbol. + * + * The symbol is `Java___` with `_` as the separator, so an underscore inside + * the method name has to be escaped as `_1` — without it `FPDFText_CountChars` resolves as a method + * `CountChars` in a class `FPDFText`, and the runtime reports the binding as unimplemented. + */ +private fun jniMangle(name: String): String = name.replace("_", "_1") + /** Index of the `)` matching the `(` at [open], or -1. */ private fun matchingParen(text: String, open: Int): Int { var depth = 0 diff --git a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt b/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt index 672e316..0a60160 100644 --- a/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt +++ b/src/test/kotlin/io/github/lemcoder/JniMarshallingTest.kt @@ -3,6 +3,7 @@ package io.github.lemcoder import io.github.lemcoder.util.ParamKind import io.github.lemcoder.util.marshalStub import io.github.lemcoder.util.parseBridgeKinds +import io.github.lemcoder.util.parseBridgeNames import io.github.lemcoder.util.stripCinterop import kotlin.test.Test import kotlin.test.assertContains @@ -157,6 +158,62 @@ class JniMarshallingTest { assertFalse(kotlin.contains("@JvmName(\"kniBridge")) } + // ---- naming ---------------------------------------------------------------------------- + + @Test + fun `each bridge is named for the C function its wrapper calls`() { + val names = parseBridgeNames(rawKotlin) + + assertEquals("koi_backend_init", names[0]) + assertEquals("koi_system_info", names[1]) + assertEquals("koi_model_load", names[2]) + assertEquals("koi_default_session_params", names[3]) + assertEquals("koi_session_create", names[4]) + assertEquals("koi_embed", names[5]) + assertEquals("koi_model_load_mem", names[9]) + } + + @Test + fun `named declarations carry the name as their JvmName too`() { + val kotlin = stripCinterop(rawKotlin, kinds, internalBindings = true, names = parseBridgeNames(rawKotlin)) + + assertContains(kotlin, "@JvmName(\"koi_model_load\")\ninternal external fun koi_model_load(p0: String?): Long") + assertContains(kotlin, "/** C: koi_model_load(path: String?): CPointer? */") + assertFalse(kotlin.contains("external fun kniBridge"), "no bridge should be left numbered here") + } + + @Test + fun `the JNI symbol escapes the underscores in the name`() { + val c = marshalStub(rawC, kinds, parseBridgeNames(rawKotlin)) + + // Java___, so an underscore inside the method name has to become _1 or the + // symbol reads as a different class entirely and the binding resolves as unimplemented. + assertContains(c, "Java_probe_probe_koi_1model_1load ") + assertContains(c, "Java_probe_probe_koi_1embed ") + // A bridge with nothing to marshal is renamed as well, or its symbol stops matching @JvmName. + assertContains(c, "Java_probe_probe_koi_1backend_1init ") + assertFalse(c.contains("kniBridge"), "every symbol should have been renamed") + } + + @Test + fun `a name two bridges share is left numbered`() { + val clashing = rawKotlin.replace("fun koi_system_info(", "fun koi_model_load(") + val names = parseBridgeNames(clashing) + + assertFalse(names.containsKey(1), "koi_model_load now names two bridges, so neither may take it") + assertFalse(names.containsKey(2)) + assertEquals("koi_embed", names[5], "unrelated bridges keep their names") + } + + @Test + fun `without names the bridges stay numbered`() { + val kotlin = stripCinterop(rawKotlin, kinds, internalBindings = true) + val c = marshalStub(rawC, kinds) + + assertContains(kotlin, "internal external fun kniBridge2(p0: String?): Long") + assertContains(c, "Java_probe_probe_kniBridge2 ") + } + @Test fun `without kinds every parameter stays an address`() { val kotlin = stripCinterop(rawKotlin) From 369810becb429ed4a07f243adfb579431c71f144 Mon Sep 17 00:00:00 2001 From: mikolaj Date: Thu, 20 Aug 2026 22:33:40 +0200 Subject: [PATCH 3/3] Point the jvm example at lib/, where its stub is actually built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `java.library.path` named `jvmInterop/mymath/jniLibs/`, but a host build writes to `jvmInterop/mymath/lib/` — only ABI-named directories may sit under jniLibs, which is AGP's contract and the reason for the split. A clean build therefore failed with "no examplestubs in java.library.path", and the example only appeared to work because an older plugin version had left a stub in jniLibs that later runs kept loading. That stale copy is also why the rename looked broken at first: the run picked up a dylib still exporting kniBridge0. Co-Authored-By: Claude Opus 5 --- examples/jvm/build.gradle.kts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/jvm/build.gradle.kts b/examples/jvm/build.gradle.kts index f1f9ed3..e0eaa91 100644 --- a/examples/jvm/build.gradle.kts +++ b/examples/jvm/build.gradle.kts @@ -37,9 +37,10 @@ application { mainClass.set("example.MainKt") } tasks.named("run") { dependsOn("linkJvmInteropMymath") - // loadLibrary() resolves the stub from java.library.path. + // loadLibrary() resolves the stub from java.library.path. Host libraries live under lib/, not + // jniLibs/ — only ABI-named directories may sit under the latter, which is AGP's contract. jvmArgs( "-Djava.library.path=" + - layout.buildDirectory.dir("jvmInterop/mymath/jniLibs/${host.abiDir}").get().asFile.absolutePath + layout.buildDirectory.dir("jvmInterop/mymath/lib/${host.abiDir}").get().asFile.absolutePath ) }