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 3d63da6..ae453e8 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,16 +166,33 @@ 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: +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 | |------------------------------------|------------------|------------------------------------------------| @@ -181,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 936890a..7a2bb91 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-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/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 ) } 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/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/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/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/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 de14ba7..498791e 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 @@ -119,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 @@ -188,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 f2efc30..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 @@ -28,6 +29,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 +113,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)) @@ -141,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)