diff --git a/ffi.nimble b/ffi.nimble index d6b3215..5bc95f8 100644 --- a/ffi.nimble +++ b/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" & diff --git a/ffi/codegen/kotlin.nim b/ffi/codegen/kotlin.nim new file mode 100644 index 0000000..b74a4ea --- /dev/null +++ b/ffi/codegen/kotlin.nim @@ -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`: +## - `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`. +## - `_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 `_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" + 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 *(, 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 +#include +#include +#include + +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)) diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5179bf7..a3b6c95 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -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()