mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-22 01:10:03 +00:00
Merge d5544431210d9ca54fb5e1e030f5b43b09c35e39 into c000a8467dfc81af043bbb1f11d1da03570e5128
This commit is contained in:
commit
1eb5ad9b06
5
examples/timer/android/.gitignore
vendored
Normal file
5
examples/timer/android/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/src/main/jniLibs/
|
||||
/.nimcache/
|
||||
/build/
|
||||
/.gradle/
|
||||
*.so
|
||||
72
examples/timer/android/README.md
Normal file
72
examples/timer/android/README.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Android example — Kotlin over the native C ABI (JNI)
|
||||
|
||||
An Android library module that wraps the timer library's **native**
|
||||
(zero-serialization) C ABI behind an idiomatic Kotlin class, `TimerNode`, via a
|
||||
small JNI shim. Struct returns come back as typed Kotlin values.
|
||||
|
||||
```kotlin
|
||||
TimerNode("my-app").use { node ->
|
||||
println(node.version()) // "nim-timer v0.1.0"
|
||||
val r = node.echo("hello", delayMs = 5)
|
||||
println("${r.echoed} / ${r.timerName}")
|
||||
}
|
||||
```
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
Kotlin TimerNode ──external fun──▶ libmy_timer_jni.so ──C ABI──▶ libmy_timer.so
|
||||
(src/main/kotlin) (jni/my_timer_jni.c) (Nim, generated)
|
||||
```
|
||||
|
||||
- `libmy_timer.so` — the Nim library, exporting the native C ABI.
|
||||
- `libmy_timer_jni.so` — a JNI shim that turns each `native` Kotlin method into a
|
||||
blocking call into that ABI, reading the typed `EchoResponse` struct out of the
|
||||
result callback. It `NEEDS` `libmy_timer.so` at load time.
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `build-libs.sh` | Cross-compiles both `.so`s per ABI into `src/main/jniLibs/<abi>/`. |
|
||||
| `jni/my_timer_jni.c` | The JNI bridge. |
|
||||
| `src/main/kotlin/.../TimerNode.kt` | The Kotlin wrapper + `EchoResult`. |
|
||||
| `build.gradle.kts`, `settings.gradle.kts` | Library module build files. |
|
||||
| `src/androidTest/...` | Instrumented test (runs on a device/emulator). |
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd examples/timer/android
|
||||
export ANDROID_NDK_ROOT=/path/to/android-ndk # or rely on the default
|
||||
./build-libs.sh # arm64-v8a + x86_64 by default
|
||||
```
|
||||
|
||||
This stages `libmy_timer.so` + `libmy_timer_jni.so` under `src/main/jniLibs/`.
|
||||
The Android Gradle plugin packages everything under `jniLibs/` into the AAR/APK
|
||||
automatically — no extra Gradle config needed.
|
||||
|
||||
Add ABIs by extending the table at the bottom of `build-libs.sh` and the
|
||||
`abiFilters` in `build.gradle.kts`.
|
||||
|
||||
## Use it
|
||||
|
||||
- **As a module**: drop `android/` into your project, `include(":mytimer")` in
|
||||
your `settings.gradle`, and `implementation(project(":mytimer"))`.
|
||||
- **By hand**: copy `src/main/jniLibs/<abi>/*.so` into your app's `jniLibs/` and
|
||||
`TimerNode.kt` into your sources.
|
||||
|
||||
Then `org.logos.mytimer.TimerNode` is ready to use.
|
||||
|
||||
## Run the test
|
||||
|
||||
```sh
|
||||
./gradlew connectedAndroidTest # needs a connected device or emulator
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- This is the **native, same-process** path — the app loads the library directly.
|
||||
The CBOR ABI is for inter-process communication only (see [`../ipc`](../ipc)).
|
||||
- Each call blocks on a condvar in the JNI shim until the library's FFI-thread
|
||||
callback fires, so the Kotlin API is simple and synchronous.
|
||||
- Regenerate `../c_bindings/my_timer.h` with `nimble genbindings_c` (from the
|
||||
repo root) if the library's API changes.
|
||||
50
examples/timer/android/build-libs.sh
Executable file
50
examples/timer/android/build-libs.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-compile the native libraries for Android and stage them under
|
||||
# src/main/jniLibs/<abi>/, so Gradle packages them into the AAR/APK:
|
||||
# - libmy_timer.so the Nim timer library (native C ABI)
|
||||
# - libmy_timer_jni.so the JNI shim that bridges it to Kotlin
|
||||
#
|
||||
# Requires the Android NDK (set ANDROID_NDK_ROOT, or it falls back to the
|
||||
# Homebrew location) and Nim. Builds arm64-v8a + x86_64 by default (real devices
|
||||
# and the emulator); add rows to the table below for more ABIs.
|
||||
#
|
||||
# ./build-libs.sh
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$HERE/../../.." && pwd)"
|
||||
NIM_SRC="$REPO_ROOT/examples/timer/timer.nim"
|
||||
HDR_DIR="$REPO_ROOT/examples/timer/c_bindings"
|
||||
JNILIBS="$HERE/src/main/jniLibs"
|
||||
|
||||
NDK="${ANDROID_NDK_ROOT:-/opt/homebrew/share/android-ndk}"
|
||||
API="${ANDROID_API:-21}"
|
||||
HOST_TAG="$(ls "$NDK/toolchains/llvm/prebuilt" | head -1)"
|
||||
TC="$NDK/toolchains/llvm/prebuilt/$HOST_TAG"
|
||||
|
||||
build_abi() { # <abi> <nim-cpu> <ndk-triple>
|
||||
local abi="$1" cpu="$2" triple="$3"
|
||||
local cc="$TC/bin/${triple}${API}-clang"
|
||||
local out="$JNILIBS/$abi"
|
||||
echo ">> $abi"
|
||||
mkdir -p "$out"
|
||||
# 1) the Nim library (native C ABI)
|
||||
( cd "$REPO_ROOT" && nim c --mm:orc -d:release -d:chronicles_log_level=WARN \
|
||||
--threads:on --os:android --cpu:"$cpu" --cc:clang \
|
||||
--clang.exe:"$cc" --clang.linkerexe:"$cc" \
|
||||
--app:lib --noMain --nimMainPrefix:libmy_timer \
|
||||
--nimcache:"$HERE/.nimcache/$abi" \
|
||||
-o:"$out/libmy_timer.so" "$NIM_SRC" >/dev/null )
|
||||
# 2) the JNI shim, linked against the Nim library
|
||||
"$cc" -shared -fPIC -O2 -I"$HDR_DIR" "$HERE/jni/my_timer_jni.c" \
|
||||
-L"$out" -lmy_timer -o "$out/libmy_timer_jni.so"
|
||||
echo " $(cd "$out" && echo *.so)"
|
||||
}
|
||||
|
||||
test -x "$TC/bin/aarch64-linux-android${API}-clang" \
|
||||
|| { echo "NDK not found at $NDK (set ANDROID_NDK_ROOT)"; exit 1; }
|
||||
|
||||
rm -rf "$JNILIBS"
|
||||
build_abi arm64-v8a arm64 aarch64-linux-android
|
||||
build_abi x86_64 amd64 x86_64-linux-android
|
||||
echo ">> done -> src/main/jniLibs/"
|
||||
37
examples/timer/android/build.gradle.kts
Normal file
37
examples/timer/android/build.gradle.kts
Normal file
@ -0,0 +1,37 @@
|
||||
// Android library module wrapping the timer library. The native .so files are
|
||||
// produced by ./build-libs.sh into src/main/jniLibs/<abi>/ and packaged
|
||||
// automatically by the Android Gradle plugin.
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.logos.mytimer"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// Limit to the ABIs build-libs.sh produces. Add more rows there + here
|
||||
// (armeabi-v7a, x86) if you need them.
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets["main"].kotlin.srcDir("src/main/kotlin")
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test:runner:1.5.2")
|
||||
}
|
||||
128
examples/timer/android/jni/my_timer_jni.c
Normal file
128
examples/timer/android/jni/my_timer_jni.c
Normal file
@ -0,0 +1,128 @@
|
||||
// Generated by nim-ffi Kotlin/JNI codegen. Do not edit by hand.
|
||||
//
|
||||
// JNI bridge exposing the library's native (zero-serialization) C ABI to Kotlin.
|
||||
// The library calls back on its own FFI thread; each bridge function blocks on a
|
||||
// condvar until the callback fires, then returns a plain Java value — so the
|
||||
// Kotlin side sees a simple synchronous API. A struct return is read out of the
|
||||
// typed C-POD inside the callback (valid only there).
|
||||
#include "my_timer.h"
|
||||
|
||||
#include <jni.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
int ret, done;
|
||||
char text[1024]; // string return / error text
|
||||
char fields[2][256]; // string fields of a struct return
|
||||
pthread_mutex_t mu;
|
||||
pthread_cond_t cv;
|
||||
} Resp;
|
||||
|
||||
static void resp_init(Resp *r) {
|
||||
memset(r, 0, sizeof(*r));
|
||||
pthread_mutex_init(&r->mu, NULL);
|
||||
pthread_cond_init(&r->cv, NULL);
|
||||
}
|
||||
static void resp_destroy(Resp *r) {
|
||||
pthread_mutex_destroy(&r->mu);
|
||||
pthread_cond_destroy(&r->cv);
|
||||
}
|
||||
static void resp_wait(Resp *r) {
|
||||
pthread_mutex_lock(&r->mu);
|
||||
while (!r->done) pthread_cond_wait(&r->cv, &r->mu);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
|
||||
static void copy_raw(char *dst, size_t cap, const char *msg, size_t len) {
|
||||
size_t n = len < cap - 1 ? len : cap - 1;
|
||||
if (msg && n) memcpy(dst, msg, n);
|
||||
dst[n] = '\0';
|
||||
}
|
||||
|
||||
static void ack_cb(int ret, const char *msg, size_t len, void *ud) {
|
||||
Resp *r = ud;
|
||||
pthread_mutex_lock(&r->mu);
|
||||
r->ret = ret;
|
||||
if (ret == RET_ERR) copy_raw(r->text, sizeof(r->text), msg, len);
|
||||
r->done = 1;
|
||||
pthread_cond_signal(&r->cv);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
static void string_cb(int ret, const char *msg, size_t len, void *ud) {
|
||||
Resp *r = ud;
|
||||
pthread_mutex_lock(&r->mu);
|
||||
r->ret = ret;
|
||||
copy_raw(r->text, sizeof(r->text), msg, len);
|
||||
r->done = 1;
|
||||
pthread_cond_signal(&r->cv);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
|
||||
static void echo_cb(int ret, const char *msg, size_t len, void *ud) {
|
||||
Resp *r = ud;
|
||||
pthread_mutex_lock(&r->mu);
|
||||
r->ret = ret;
|
||||
if (ret == RET_OK) {
|
||||
const EchoResponse *e = (const EchoResponse *)msg;
|
||||
strncpy(r->fields[0], e->echoed, sizeof(r->fields[0]) - 1);
|
||||
strncpy(r->fields[1], e->timerName, sizeof(r->fields[1]) - 1);
|
||||
} else {
|
||||
copy_raw(r->text, sizeof(r->text), msg, len);
|
||||
}
|
||||
r->done = 1;
|
||||
pthread_cond_signal(&r->cv);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_org_logos_mytimer_MyTimerNode_nativeCreate(JNIEnv *env, jobject thiz, jstring j_name) {
|
||||
const char *name = (*env)->GetStringUTFChars(env, j_name, NULL);
|
||||
TimerConfig config = {.name = name};
|
||||
Resp r;
|
||||
resp_init(&r);
|
||||
void *ctx = my_timer_create(config, ack_cb, &r);
|
||||
resp_wait(&r);
|
||||
(*env)->ReleaseStringUTFChars(env, j_name, name);
|
||||
resp_destroy(&r);
|
||||
(void)thiz;
|
||||
return (jlong)(intptr_t)ctx;
|
||||
}
|
||||
|
||||
JNIEXPORT jobjectArray JNICALL
|
||||
Java_org_logos_mytimer_MyTimerNode_nativeEcho(JNIEnv *env, jobject thiz, jlong ctx, jstring j_message, jlong delayMs) {
|
||||
const char *message = (*env)->GetStringUTFChars(env, j_message, NULL);
|
||||
EchoRequest req = {.message = message, .delayMs = (int64_t)delayMs};
|
||||
Resp r;
|
||||
resp_init(&r);
|
||||
if (my_timer_echo((void *)(intptr_t)ctx, echo_cb, &r, req) == RET_OK)
|
||||
resp_wait(&r);
|
||||
(*env)->ReleaseStringUTFChars(env, j_message, message);
|
||||
jclass strClass = (*env)->FindClass(env, "java/lang/String");
|
||||
jobjectArray arr = (*env)->NewObjectArray(env, 2, strClass, NULL);
|
||||
(*env)->SetObjectArrayElement(env, arr, 0, (*env)->NewStringUTF(env, r.fields[0]));
|
||||
(*env)->SetObjectArrayElement(env, arr, 1, (*env)->NewStringUTF(env, r.fields[1]));
|
||||
resp_destroy(&r);
|
||||
(void)thiz;
|
||||
return arr;
|
||||
}
|
||||
|
||||
JNIEXPORT jstring JNICALL
|
||||
Java_org_logos_mytimer_MyTimerNode_nativeVersion(JNIEnv *env, jobject thiz, jlong ctx) {
|
||||
Resp r;
|
||||
resp_init(&r);
|
||||
if (my_timer_version((void *)(intptr_t)ctx, string_cb, &r) == RET_OK)
|
||||
resp_wait(&r);
|
||||
jstring out = (*env)->NewStringUTF(env, r.text);
|
||||
resp_destroy(&r);
|
||||
(void)thiz;
|
||||
return out;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_org_logos_mytimer_MyTimerNode_nativeDestroy(JNIEnv *env, jobject thiz, jlong ctx) {
|
||||
my_timer_destroy((void *)(intptr_t)ctx);
|
||||
(void)env;
|
||||
(void)thiz;
|
||||
}
|
||||
23
examples/timer/android/settings.gradle.kts
Normal file
23
examples/timer/android/settings.gradle.kts
Normal file
@ -0,0 +1,23 @@
|
||||
// Standalone settings so this directory builds as its own library module.
|
||||
// To use it from an existing app instead, drop the `android/` directory in as a
|
||||
// module and add `include(":mytimer")` to your project's settings.
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
plugins {
|
||||
id("com.android.library") version "8.5.2"
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24"
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "mytimer"
|
||||
@ -0,0 +1,21 @@
|
||||
package org.logos.mytimer
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
// Instrumented test — runs on a device/emulator (the .so files are device code).
|
||||
// ./gradlew connectedAndroidTest
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TimerNodeTest {
|
||||
@Test
|
||||
fun createVersionEcho() {
|
||||
MyTimerNode("android-demo").use { node ->
|
||||
assertEquals("nim-timer v0.1.0", node.version())
|
||||
val r = node.echo("hello from Kotlin", delayMs = 2)
|
||||
assertEquals("hello from Kotlin", r.echoed)
|
||||
assertEquals("android-demo", r.timerName) // lib state round-tripped
|
||||
}
|
||||
}
|
||||
}
|
||||
2
examples/timer/android/src/main/AndroidManifest.xml
Normal file
2
examples/timer/android/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@ -0,0 +1,28 @@
|
||||
// Generated by nim-ffi Kotlin codegen. Do not edit by hand.
|
||||
package org.logos.mytimer
|
||||
|
||||
/** Typed result mirroring the library's EchoResponse. */
|
||||
data class EchoResponse(val echoed: String, val timerName: String)
|
||||
|
||||
class MyTimerNode(name: String) : AutoCloseable {
|
||||
private val ctx: Long = nativeCreate(name)
|
||||
|
||||
fun echo(message: String, delayMs: Long = 0): EchoResponse {
|
||||
val a = nativeEcho(ctx, message, delayMs)
|
||||
return EchoResponse(a[0], a[1])
|
||||
}
|
||||
fun version(): String = nativeVersion(ctx)
|
||||
override fun close() = nativeDestroy(ctx)
|
||||
|
||||
private external fun nativeCreate(name: String): Long
|
||||
private external fun nativeEcho(ctx: Long, message: String, delayMs: Long): Array<String>
|
||||
private external fun nativeVersion(ctx: Long): String
|
||||
private external fun nativeDestroy(ctx: Long)
|
||||
|
||||
companion object {
|
||||
init {
|
||||
System.loadLibrary("my_timer")
|
||||
System.loadLibrary("my_timer_jni")
|
||||
}
|
||||
}
|
||||
}
|
||||
11
ffi.nimble
11
ffi.nimble
@ -172,6 +172,17 @@ task genbindings_go, "Generate Go (cgo) bindings for the timer example":
|
||||
if findExe("gofmt").len > 0:
|
||||
exec "gofmt -w examples/timer/go_bindings/my_timer.go"
|
||||
|
||||
task genbindings_kotlin, "Generate the Kotlin/JNI wrapper for the Android timer example":
|
||||
# Emits src/main/kotlin/.../MyTimerNode.kt + jni/my_timer_jni.c over the native
|
||||
# C ABI. The C headers the shim includes come from `nimble genbindings_c`; run
|
||||
# that too if the library's types or procs changed.
|
||||
exec "nim c " & nimFlagsOrc &
|
||||
" --app:lib --noMain --nimMainPrefix:libmy_timer" &
|
||||
" -d:ffiGenBindings -d:targetLang=kotlin" &
|
||||
" -d:ffiOutputDir=examples/timer/android" &
|
||||
" -d:ffiSrcPath=../timer.nim" &
|
||||
" -o:/dev/null examples/timer/timer.nim"
|
||||
|
||||
task genbindings_cddl, "Generate CDDL schema for the timer example":
|
||||
exec "nim c " & nimFlagsOrc &
|
||||
" --app:lib --noMain --nimMainPrefix:libtimer" &
|
||||
|
||||
573
ffi/codegen/kotlin.nim
Normal file
573
ffi/codegen/kotlin.nim
Normal file
@ -0,0 +1,573 @@
|
||||
## Kotlin/JNI binding generator for the nim-ffi framework.
|
||||
##
|
||||
## Emits the two artifacts an Android consumer needs over the *native*
|
||||
## (zero-serialization) C ABI from `c.nim`:
|
||||
## - `<Lib>Node.kt` — an idiomatic Kotlin `AutoCloseable` wrapper. Each call is
|
||||
## a blocking `external fun` into the JNI shim; a struct return surfaces as a
|
||||
## Kotlin `data class`.
|
||||
## - `<lib>_jni.c` — the JNI shim. The library calls back on its own FFI
|
||||
## thread; each bridge function blocks on a condvar until the callback fires,
|
||||
## then returns a plain Java value, so the Kotlin side stays synchronous. A
|
||||
## struct return is read out of the typed C-POD inside the callback (valid
|
||||
## only there).
|
||||
##
|
||||
## Callback-shape selection is per proc, from its metadata: ctor/void -> `ack_cb`
|
||||
## (ret + error text), `string` return -> `string_cb`, struct return -> a
|
||||
## per-proc `<proc>_cb` that copies fields out inside the callback.
|
||||
##
|
||||
## Scope (first increment): procs whose params are scalars / strings (or a single
|
||||
## `{.ffi.}` struct of those), and — for struct returns — structs whose fields
|
||||
## are all strings (surfaced as a `String[]` across JNI). Procs needing
|
||||
## seq/Option params, multiple struct params, or a non-string struct return are
|
||||
## skipped with a logged notice so the generated code always compiles. That
|
||||
## marshaling, events, and async are the next increments.
|
||||
|
||||
import std/[os, strutils]
|
||||
import ./meta, ./string_helpers
|
||||
|
||||
const OrgPrefix = "org.logos"
|
||||
|
||||
proc pkgName(libName: string): string =
|
||||
## "my_timer" -> "org.logos.mytimer". Underscores are dropped from the leaf so
|
||||
## the JNI symbol needs no `_1` mangling.
|
||||
return OrgPrefix & "." & libName.replace("_", "")
|
||||
|
||||
proc className(libName: string): string =
|
||||
return snakeToPascalCase(libName) & "Node"
|
||||
|
||||
proc jniPrefix(libName: string): string =
|
||||
return "Java_" & pkgName(libName).replace(".", "_") & "_" & className(libName)
|
||||
|
||||
# --- shared metadata helpers --------------------------------------------------
|
||||
|
||||
type FieldPlan = object
|
||||
name: string
|
||||
typeName: string
|
||||
|
||||
proc isSimpleScalar(typeName: string): bool =
|
||||
let t = typeName.strip()
|
||||
case t
|
||||
of "string", "cstring", "bool", "float", "float32", "float64", "int", "int64",
|
||||
"int32", "int16", "int8", "clong", "cint", "uint", "uint64", "uint32",
|
||||
"uint16", "uint8", "byte", "csize_t":
|
||||
return true
|
||||
else:
|
||||
return false
|
||||
|
||||
proc isStringy(typeName: string): bool =
|
||||
return typeName.strip() in ["string", "cstring"]
|
||||
|
||||
proc structFields(types: seq[FFITypeMeta], typeName: string): seq[FieldPlan] =
|
||||
var plan: seq[FieldPlan] = @[]
|
||||
for t in types:
|
||||
if t.name == typeName:
|
||||
for f in t.fields:
|
||||
plan.add(FieldPlan(name: f.name, typeName: f.typeName))
|
||||
return plan
|
||||
|
||||
proc isKnownStruct(types: seq[FFITypeMeta], typeName: string): bool =
|
||||
for t in types:
|
||||
if t.name == typeName:
|
||||
return true
|
||||
return false
|
||||
|
||||
proc allFieldsSimple(types: seq[FFITypeMeta], typeName: string): bool =
|
||||
let fields = structFields(types, typeName)
|
||||
if fields.len == 0:
|
||||
return false
|
||||
for f in fields:
|
||||
if not isSimpleScalar(f.typeName):
|
||||
return false
|
||||
return true
|
||||
|
||||
proc allFieldsString(types: seq[FFITypeMeta], typeName: string): bool =
|
||||
let fields = structFields(types, typeName)
|
||||
if fields.len == 0:
|
||||
return false
|
||||
for f in fields:
|
||||
if not isStringy(f.typeName):
|
||||
return false
|
||||
return true
|
||||
|
||||
proc returnsString(p: FFIProcMeta): bool =
|
||||
return p.returnTypeName.strip() == "string"
|
||||
|
||||
proc returnsStruct(p: FFIProcMeta, types: seq[FFITypeMeta]): bool =
|
||||
return isKnownStruct(types, p.returnTypeName.strip())
|
||||
|
||||
proc canEmit(p: FFIProcMeta, types: seq[FFITypeMeta]): bool =
|
||||
## Covered by this increment: simple params (with at most one struct param),
|
||||
## and — when the return is a struct — an all-string struct.
|
||||
var structParams = 0
|
||||
for ep in p.extraParams:
|
||||
if isSimpleScalar(ep.typeName):
|
||||
continue
|
||||
if isKnownStruct(types, ep.typeName):
|
||||
inc structParams
|
||||
if not allFieldsSimple(types, ep.typeName):
|
||||
return false
|
||||
else:
|
||||
return false
|
||||
if structParams > 1:
|
||||
return false
|
||||
if returnsStruct(p, types) and not allFieldsString(types, p.returnTypeName.strip()):
|
||||
return false
|
||||
return true
|
||||
|
||||
proc methodBase(p: FFIProcMeta): string =
|
||||
## "my_timer_echo" -> "echo"; snake_case tail collapsed to camelCase.
|
||||
var base = p.procName
|
||||
if base.startsWith(p.libName & "_"):
|
||||
base = base[p.libName.len + 1 .. ^1]
|
||||
let parts = base.split('_')
|
||||
var s = parts[0]
|
||||
for i in 1 ..< parts.len:
|
||||
s &= capitalizeFirstLetter(parts[i])
|
||||
return s
|
||||
|
||||
proc nativeName(p: FFIProcMeta): string =
|
||||
return "native" & capitalizeFirstLetter(methodBase(p))
|
||||
|
||||
proc cbName(p: FFIProcMeta): string =
|
||||
return methodBase(p) & "_cb"
|
||||
|
||||
# --- flattened parameters (a single struct param's fields flatten in) ---------
|
||||
|
||||
proc flatParams(p: FFIProcMeta, types: seq[FFITypeMeta]): seq[FieldPlan] =
|
||||
var params: seq[FieldPlan] = @[]
|
||||
for ep in p.extraParams:
|
||||
if isSimpleScalar(ep.typeName):
|
||||
params.add(FieldPlan(name: ep.name, typeName: ep.typeName))
|
||||
else:
|
||||
params.add(structFields(types, ep.typeName))
|
||||
return params
|
||||
|
||||
# === Kotlin side ==============================================================
|
||||
|
||||
proc ktType(typeName: string): string =
|
||||
let t = typeName.strip()
|
||||
if isStringy(t):
|
||||
return "String"
|
||||
case t
|
||||
of "bool": "Boolean"
|
||||
of "float", "float32": "Float"
|
||||
of "float64": "Double"
|
||||
else: "Long" # all integer aliases cross as int64 in the native ABI
|
||||
|
||||
proc ktDefault(typeName: string): string =
|
||||
let t = typeName.strip()
|
||||
if t == "bool":
|
||||
return "false"
|
||||
return "0"
|
||||
|
||||
proc ktParamList(params: seq[FieldPlan], withDefaults: bool): string =
|
||||
var parts: seq[string] = @[]
|
||||
for fp in params:
|
||||
var decl = fp.name & ": " & ktType(fp.typeName)
|
||||
if withDefaults and not isStringy(fp.typeName):
|
||||
decl &= " = " & ktDefault(fp.typeName)
|
||||
parts.add(decl)
|
||||
return parts.join(", ")
|
||||
|
||||
proc ktArgs(params: seq[FieldPlan]): string =
|
||||
var parts: seq[string] = @[]
|
||||
for fp in params:
|
||||
parts.add(fp.name)
|
||||
return parts.join(", ")
|
||||
|
||||
proc generateKotlin(
|
||||
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string
|
||||
): string =
|
||||
let cls = className(libName)
|
||||
var lines: seq[string] = @[]
|
||||
lines.add("// Generated by nim-ffi Kotlin codegen. Do not edit by hand.")
|
||||
lines.add("package " & pkgName(libName))
|
||||
lines.add("")
|
||||
|
||||
# data class per all-string struct return type
|
||||
var emitted: seq[string] = @[]
|
||||
for p in procs:
|
||||
if not canEmit(p, types):
|
||||
continue
|
||||
let rt = p.returnTypeName.strip()
|
||||
if returnsStruct(p, types) and rt notin emitted:
|
||||
emitted.add(rt)
|
||||
var fieldDecls: seq[string] = @[]
|
||||
for f in structFields(types, rt):
|
||||
fieldDecls.add("val " & f.name & ": " & ktType(f.typeName))
|
||||
lines.add("/** Typed result mirroring the library's " & rt & ". */")
|
||||
lines.add("data class " & rt & "(" & fieldDecls.join(", ") & ")")
|
||||
lines.add("")
|
||||
|
||||
# find the ctor to drive the primary constructor
|
||||
var ctor: FFIProcMeta
|
||||
var haveCtor = false
|
||||
for p in procs:
|
||||
if p.kind == FFIKind.CTOR and canEmit(p, types):
|
||||
ctor = p
|
||||
haveCtor = true
|
||||
|
||||
let ctorParams = if haveCtor: flatParams(ctor, types) else: @[]
|
||||
lines.add(
|
||||
"class " & cls & "(" & ktParamList(ctorParams, withDefaults = false) &
|
||||
") : AutoCloseable {"
|
||||
)
|
||||
if haveCtor:
|
||||
lines.add(
|
||||
" private val ctx: Long = " & nativeName(ctor) & "(" & ktArgs(ctorParams) & ")"
|
||||
)
|
||||
lines.add("")
|
||||
|
||||
# public methods
|
||||
for p in procs:
|
||||
if not canEmit(p, types) or p.kind != FFIKind.FFI:
|
||||
continue
|
||||
let params = flatParams(p, types)
|
||||
let pl = ktParamList(params, withDefaults = true)
|
||||
let callArgs =
|
||||
if params.len > 0: "ctx, " & ktArgs(params) else: "ctx"
|
||||
if returnsStruct(p, types):
|
||||
let rt = p.returnTypeName.strip()
|
||||
let fields = structFields(types, rt)
|
||||
lines.add(" fun " & methodBase(p) & "(" & pl & "): " & rt & " {")
|
||||
lines.add(" val a = " & nativeName(p) & "(" & callArgs & ")")
|
||||
var ctorArgs: seq[string] = @[]
|
||||
for i in 0 ..< fields.len:
|
||||
ctorArgs.add("a[" & $i & "]")
|
||||
lines.add(" return " & rt & "(" & ctorArgs.join(", ") & ")")
|
||||
lines.add(" }")
|
||||
elif returnsString(p):
|
||||
lines.add(
|
||||
" fun " & methodBase(p) & "(" & pl & "): String = " & nativeName(p) & "(" &
|
||||
callArgs & ")"
|
||||
)
|
||||
else:
|
||||
lines.add(
|
||||
" fun " & methodBase(p) & "(" & pl & ") = " & nativeName(p) & "(" & callArgs &
|
||||
")"
|
||||
)
|
||||
|
||||
# close()
|
||||
for p in procs:
|
||||
if p.kind == FFIKind.DTOR and canEmit(p, types):
|
||||
lines.add(" override fun close() = " & nativeName(p) & "(ctx)")
|
||||
lines.add("")
|
||||
|
||||
# external declarations
|
||||
for p in procs:
|
||||
if not canEmit(p, types):
|
||||
continue
|
||||
let params = flatParams(p, types)
|
||||
case p.kind
|
||||
of FFIKind.CTOR:
|
||||
lines.add(
|
||||
" private external fun " & nativeName(p) & "(" &
|
||||
ktParamList(params, withDefaults = false) & "): Long"
|
||||
)
|
||||
of FFIKind.DTOR:
|
||||
lines.add(" private external fun " & nativeName(p) & "(ctx: Long)")
|
||||
of FFIKind.FFI:
|
||||
let lead = if params.len > 0: "ctx: Long, " else: "ctx: Long"
|
||||
let ret =
|
||||
if returnsStruct(p, types): "): Array<String>"
|
||||
elif returnsString(p): "): String"
|
||||
else: ")"
|
||||
lines.add(
|
||||
" private external fun " & nativeName(p) & "(" & lead &
|
||||
ktParamList(params, withDefaults = false) & ret
|
||||
)
|
||||
|
||||
lines.add("")
|
||||
lines.add(" companion object {")
|
||||
lines.add(" init {")
|
||||
lines.add(" System.loadLibrary(\"" & libName & "\")")
|
||||
lines.add(" System.loadLibrary(\"" & libName & "_jni\")")
|
||||
lines.add(" }")
|
||||
lines.add(" }")
|
||||
lines.add("}")
|
||||
return lines.join("\n")
|
||||
|
||||
# === JNI C shim ===============================================================
|
||||
|
||||
proc cScalarType(typeName: string): string =
|
||||
let t = typeName.strip()
|
||||
if t in ["float", "float32"]:
|
||||
return "float"
|
||||
if t == "float64":
|
||||
return "double"
|
||||
if t == "bool":
|
||||
return "int"
|
||||
return "int64_t"
|
||||
|
||||
proc jniScalarType(typeName: string): string =
|
||||
let t = typeName.strip()
|
||||
if t in ["float", "float32"]:
|
||||
return "jfloat"
|
||||
if t == "float64":
|
||||
return "jdouble"
|
||||
if t == "bool":
|
||||
return "jboolean"
|
||||
return "jlong"
|
||||
|
||||
proc maxReturnFields(procs: seq[FFIProcMeta], types: seq[FFITypeMeta]): int =
|
||||
var m = 1
|
||||
for p in procs:
|
||||
if canEmit(p, types) and returnsStruct(p, types):
|
||||
m = max(m, structFields(types, p.returnTypeName.strip()).len)
|
||||
return m
|
||||
|
||||
proc emitJniMarshal(
|
||||
p: FFIProcMeta, types: seq[FFITypeMeta]
|
||||
): tuple[params, pre, post, args: seq[string]] =
|
||||
## Builds the JNI signature params, the prologue (GetStringUTFChars + struct
|
||||
## literals), the epilogue (ReleaseStringUTFChars), and the C call arguments.
|
||||
var params, pre, post, args: seq[string] = @[]
|
||||
for ep in p.extraParams:
|
||||
if isSimpleScalar(ep.typeName):
|
||||
params.add(jniScalarType(ep.typeName) & " " & ep.name)
|
||||
args.add("(" & cScalarType(ep.typeName) & ")" & ep.name)
|
||||
else:
|
||||
var inits: seq[string] = @[]
|
||||
for f in structFields(types, ep.typeName):
|
||||
if isStringy(f.typeName):
|
||||
params.add("jstring j_" & f.name)
|
||||
pre.add(
|
||||
" const char *" & f.name & " = (*env)->GetStringUTFChars(env, j_" &
|
||||
f.name & ", NULL);"
|
||||
)
|
||||
post.add(
|
||||
" (*env)->ReleaseStringUTFChars(env, j_" & f.name & ", " & f.name & ");"
|
||||
)
|
||||
inits.add("." & f.name & " = " & f.name)
|
||||
else:
|
||||
params.add(jniScalarType(f.typeName) & " " & f.name)
|
||||
inits.add("." & f.name & " = (" & cScalarType(f.typeName) & ")" & f.name)
|
||||
pre.add(" " & ep.typeName & " " & ep.name & " = {" & inits.join(", ") & "};")
|
||||
args.add(ep.name)
|
||||
return (params, pre, post, args)
|
||||
|
||||
proc emitStructCb(
|
||||
lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta]
|
||||
) =
|
||||
let rt = p.returnTypeName.strip()
|
||||
let fields = structFields(types, rt)
|
||||
lines.add("static void " & cbName(p) & "(int ret, const char *msg, size_t len, void *ud) {")
|
||||
lines.add(" Resp *r = ud;")
|
||||
lines.add(" pthread_mutex_lock(&r->mu);")
|
||||
lines.add(" r->ret = ret;")
|
||||
lines.add(" if (ret == RET_OK) {")
|
||||
lines.add(" const " & rt & " *e = (const " & rt & " *)msg;")
|
||||
for i, f in fields:
|
||||
lines.add(
|
||||
" strncpy(r->fields[" & $i & "], e->" & f.name & ", sizeof(r->fields[" & $i &
|
||||
"]) - 1);"
|
||||
)
|
||||
lines.add(" } else {")
|
||||
lines.add(" copy_raw(r->text, sizeof(r->text), msg, len);")
|
||||
lines.add(" }")
|
||||
lines.add(" r->done = 1;")
|
||||
lines.add(" pthread_cond_signal(&r->cv);")
|
||||
lines.add(" pthread_mutex_unlock(&r->mu);")
|
||||
lines.add("}")
|
||||
|
||||
proc emitJniFunc(
|
||||
lines: var seq[string], p: FFIProcMeta, types: seq[FFITypeMeta], libName: string
|
||||
) =
|
||||
let (params, pre, post, args) = emitJniMarshal(p, types)
|
||||
let sig = if params.len > 0: ", " & params.join(", ") else: ""
|
||||
let callArgs = if args.len > 0: ", " & args.join(", ") else: ""
|
||||
let fn = jniPrefix(libName) & "_" & nativeName(p)
|
||||
case p.kind
|
||||
of FFIKind.CTOR:
|
||||
# Native ctor ABI: void *<name>(<typed args...>, cb, ud).
|
||||
let lead = if args.len > 0: args.join(", ") & ", " else: ""
|
||||
lines.add("JNIEXPORT jlong JNICALL")
|
||||
lines.add(fn & "(JNIEnv *env, jobject thiz" & sig & ") {")
|
||||
lines.add(pre)
|
||||
lines.add(" Resp r;")
|
||||
lines.add(" resp_init(&r);")
|
||||
lines.add(" void *ctx = " & p.procName & "(" & lead & "ack_cb, &r);")
|
||||
lines.add(" resp_wait(&r);")
|
||||
lines.add(post)
|
||||
lines.add(" resp_destroy(&r);")
|
||||
lines.add(" (void)thiz;")
|
||||
lines.add(" return (jlong)(intptr_t)ctx;")
|
||||
lines.add("}")
|
||||
of FFIKind.DTOR:
|
||||
lines.add("JNIEXPORT void JNICALL")
|
||||
lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx) {")
|
||||
lines.add(" " & p.procName & "((void *)(intptr_t)ctx);")
|
||||
lines.add(" (void)env;")
|
||||
lines.add(" (void)thiz;")
|
||||
lines.add("}")
|
||||
of FFIKind.FFI:
|
||||
if returnsStruct(p, types):
|
||||
let n = structFields(types, p.returnTypeName.strip()).len
|
||||
lines.add("JNIEXPORT jobjectArray JNICALL")
|
||||
lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {")
|
||||
lines.add(pre)
|
||||
lines.add(" Resp r;")
|
||||
lines.add(" resp_init(&r);")
|
||||
lines.add(
|
||||
" if (" & p.procName & "((void *)(intptr_t)ctx, " & cbName(p) & ", &r" &
|
||||
callArgs & ") == RET_OK)"
|
||||
)
|
||||
lines.add(" resp_wait(&r);")
|
||||
lines.add(post)
|
||||
lines.add(" jclass strClass = (*env)->FindClass(env, \"java/lang/String\");")
|
||||
lines.add(
|
||||
" jobjectArray arr = (*env)->NewObjectArray(env, " & $n & ", strClass, NULL);"
|
||||
)
|
||||
for i in 0 ..< n:
|
||||
lines.add(
|
||||
" (*env)->SetObjectArrayElement(env, arr, " & $i &
|
||||
", (*env)->NewStringUTF(env, r.fields[" & $i & "]));"
|
||||
)
|
||||
lines.add(" resp_destroy(&r);")
|
||||
lines.add(" (void)thiz;")
|
||||
lines.add(" return arr;")
|
||||
lines.add("}")
|
||||
elif returnsString(p):
|
||||
lines.add("JNIEXPORT jstring JNICALL")
|
||||
lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {")
|
||||
lines.add(pre)
|
||||
lines.add(" Resp r;")
|
||||
lines.add(" resp_init(&r);")
|
||||
lines.add(
|
||||
" if (" & p.procName & "((void *)(intptr_t)ctx, string_cb, &r" & callArgs &
|
||||
") == RET_OK)"
|
||||
)
|
||||
lines.add(" resp_wait(&r);")
|
||||
lines.add(post)
|
||||
lines.add(" jstring out = (*env)->NewStringUTF(env, r.text);")
|
||||
lines.add(" resp_destroy(&r);")
|
||||
lines.add(" (void)thiz;")
|
||||
lines.add(" return out;")
|
||||
lines.add("}")
|
||||
else:
|
||||
lines.add("JNIEXPORT void JNICALL")
|
||||
lines.add(fn & "(JNIEnv *env, jobject thiz, jlong ctx" & sig & ") {")
|
||||
lines.add(pre)
|
||||
lines.add(" Resp r;")
|
||||
lines.add(" resp_init(&r);")
|
||||
lines.add(
|
||||
" if (" & p.procName & "((void *)(intptr_t)ctx, ack_cb, &r" & callArgs &
|
||||
") == RET_OK)"
|
||||
)
|
||||
lines.add(" resp_wait(&r);")
|
||||
lines.add(post)
|
||||
lines.add(" resp_destroy(&r);")
|
||||
lines.add(" (void)env;")
|
||||
lines.add(" (void)thiz;")
|
||||
lines.add("}")
|
||||
lines.add("")
|
||||
|
||||
proc jniPreamble(libName: string, maxFields: int): string =
|
||||
return (
|
||||
"""
|
||||
// Generated by nim-ffi Kotlin/JNI codegen. Do not edit by hand.
|
||||
//
|
||||
// JNI bridge exposing the library's native (zero-serialization) C ABI to Kotlin.
|
||||
// The library calls back on its own FFI thread; each bridge function blocks on a
|
||||
// condvar until the callback fires, then returns a plain Java value — so the
|
||||
// Kotlin side sees a simple synchronous API. A struct return is read out of the
|
||||
// typed C-POD inside the callback (valid only there).
|
||||
#include """" & libName &
|
||||
""".h"
|
||||
|
||||
#include <jni.h>
|
||||
#include <pthread.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct {
|
||||
int ret, done;
|
||||
char text[1024]; // string return / error text
|
||||
char fields[""" & $maxFields &
|
||||
"""][256]; // string fields of a struct return
|
||||
pthread_mutex_t mu;
|
||||
pthread_cond_t cv;
|
||||
} Resp;
|
||||
|
||||
static void resp_init(Resp *r) {
|
||||
memset(r, 0, sizeof(*r));
|
||||
pthread_mutex_init(&r->mu, NULL);
|
||||
pthread_cond_init(&r->cv, NULL);
|
||||
}
|
||||
static void resp_destroy(Resp *r) {
|
||||
pthread_mutex_destroy(&r->mu);
|
||||
pthread_cond_destroy(&r->cv);
|
||||
}
|
||||
static void resp_wait(Resp *r) {
|
||||
pthread_mutex_lock(&r->mu);
|
||||
while (!r->done) pthread_cond_wait(&r->cv, &r->mu);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
|
||||
static void copy_raw(char *dst, size_t cap, const char *msg, size_t len) {
|
||||
size_t n = len < cap - 1 ? len : cap - 1;
|
||||
if (msg && n) memcpy(dst, msg, n);
|
||||
dst[n] = '\0';
|
||||
}
|
||||
|
||||
static void ack_cb(int ret, const char *msg, size_t len, void *ud) {
|
||||
Resp *r = ud;
|
||||
pthread_mutex_lock(&r->mu);
|
||||
r->ret = ret;
|
||||
if (ret == RET_ERR) copy_raw(r->text, sizeof(r->text), msg, len);
|
||||
r->done = 1;
|
||||
pthread_cond_signal(&r->cv);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
static void string_cb(int ret, const char *msg, size_t len, void *ud) {
|
||||
Resp *r = ud;
|
||||
pthread_mutex_lock(&r->mu);
|
||||
r->ret = ret;
|
||||
copy_raw(r->text, sizeof(r->text), msg, len);
|
||||
r->done = 1;
|
||||
pthread_cond_signal(&r->cv);
|
||||
pthread_mutex_unlock(&r->mu);
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
proc generateJni(
|
||||
procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string
|
||||
): string =
|
||||
var lines: seq[string] = @[]
|
||||
lines.add(jniPreamble(libName, maxReturnFields(procs, types)))
|
||||
for p in procs:
|
||||
if canEmit(p, types) and returnsStruct(p, types):
|
||||
emitStructCb(lines, p, types)
|
||||
lines.add("")
|
||||
for p in procs:
|
||||
if canEmit(p, types):
|
||||
emitJniFunc(lines, p, types, libName)
|
||||
return lines.join("\n")
|
||||
|
||||
# === entry point ==============================================================
|
||||
|
||||
proc generateKotlinBindings*(
|
||||
procs: seq[FFIProcMeta],
|
||||
types: seq[FFITypeMeta],
|
||||
libName: string,
|
||||
outputDir: string,
|
||||
nimSrcRelPath: string,
|
||||
events: seq[FFIEventMeta] = @[],
|
||||
) =
|
||||
## `outputDir` is the Android module root. The Kotlin wrapper lands under its
|
||||
## package path; the JNI shim under `jni/`. The C headers it includes come from
|
||||
## the C generator (`genbindings_c`).
|
||||
for p in procs:
|
||||
if not canEmit(p, types):
|
||||
echo "kotlin codegen: skipping '" & p.procName &
|
||||
"' (needs seq/Option, multi-struct params, or a non-string struct return" &
|
||||
" — not yet supported)"
|
||||
|
||||
let ktDir = outputDir / "src/main/kotlin" / pkgName(libName).replace(".", "/")
|
||||
createDir(ktDir)
|
||||
writeFile(ktDir / (className(libName) & ".kt"), generateKotlin(procs, types, libName))
|
||||
|
||||
let jniDir = outputDir / "jni"
|
||||
createDir(jniDir)
|
||||
writeFile(jniDir / (libName & "_jni.c"), generateJni(procs, types, libName))
|
||||
@ -9,6 +9,7 @@ when defined(ffiGenBindings):
|
||||
import ../codegen/cddl
|
||||
import ../codegen/c
|
||||
import ../codegen/go
|
||||
import ../codegen/kotlin
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# String helpers used by multiple macros
|
||||
@ -1988,10 +1989,15 @@ macro genBindings*(
|
||||
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
|
||||
ffiEventRegistry,
|
||||
)
|
||||
of "kotlin":
|
||||
generateKotlinBindings(
|
||||
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath,
|
||||
ffiEventRegistry,
|
||||
)
|
||||
else:
|
||||
error(
|
||||
"genBindings: unknown targetLang '" & lang &
|
||||
"'. Use 'c', 'go', 'rust', 'cpp', or 'cddl'."
|
||||
"'. Use 'c', 'go', 'kotlin', 'rust', 'cpp', or 'cddl'."
|
||||
)
|
||||
|
||||
return newEmptyNode()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user