Ivan FB f3206c30b8
feat(ffi): emit a native (zero-serialization) C ABI alongside CBOR
A single {.ffi.} definition now produces BOTH interfaces, chosen by the
caller at link time rather than by a global compile flag:

- `<name>`      — native typed-arg C export. Args travel to the FFI thread in
                  a c_malloc'd C-POD struct passed by pointer (no CBOR), and the
                  result is delivered to the callback as raw bytes. This is the
                  preferred path for same-process callers: no serialization on
                  either side.
- `<name>_cbor` — the existing CBOR-buffer dispatcher, kept for generic /
                  cross-language callers.

Both share the user's helper proc; they register distinct handlers keyed by
"<Camel>Req" (CBOR) and "<Camel>ReqNative". FFIThreadRequest gains a `cborMode`
flag and a `payloadFree` hook so the native C-POD payload (which owns duplicated
cstring fields) is released correctly and an empty native result is delivered as
a zero-length buffer instead of the CBOR null sentinel. alloc.nim gains
ffiCMalloc/ffiCFree (prefixed to avoid Nim's style-insensitive clash with
ansi_c.c_malloc/c_free).

Verified end-to-end on a scalar-param lib: native calls return raw strings
("calc v1", "sum=42"); the _cbor variant still returns CBOR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 02:02:39 +02:00

400 lines
13 KiB
Nim

## Go (cgo) binding generator for the nim-ffi framework.
##
## Emits a single `<lib>.go` file that wraps the native C ABI behind idiomatic
## Go. It includes the C codegen's `<lib>.h` (the native typed-arg declarations)
## and adds the piece every async-only consumer needs: a per-call response
## capture that BLOCKS until the FFI callback fires, then copies the raw result.
##
## nim-ffi 0.2.0 dispatches every `{.ffi.}` call through the FFI thread (no sync
## fast-path), so a caller cannot read the result immediately after the call —
## the generated `<lib>Call_*` C bridges register a condvar-backed callback and
## wait on it, turning each async export into a blocking Go method.
##
## Generated surface (libName = "waku" → package waku):
## - type WakuNode struct { ctx unsafe.Pointer }
## - func NewWaku(<ctor args...>) (*WakuNode, error)
## - func (n *WakuNode) <Method>(<args...>) (string, error) per {.ffi.} proc
## - func (n *WakuNode) Destroy() error
## - func (n *WakuNode) SetEventHandler(h func(string)) raw-JSON events
##
## Only scalar/string params are supported (matching the C codegen); a proc with
## struct/seq/Option params is skipped with a // SKIPPED comment.
import std/[os, strutils]
import ./meta, ./string_helpers
proc nimTypeToGo(typeName: string): string =
let t = typeName.strip()
case t
of "string", "cstring": "string"
of "int", "int64", "clong": "int64"
of "int32", "cint": "int32"
of "int16": "int16"
of "int8": "int8"
of "uint", "uint64", "csize_t": "uint64"
of "uint32", "cuint": "uint32"
of "uint16": "uint16"
of "uint8", "byte": "uint8"
of "bool": "bool"
of "float", "float32": "float32"
of "float64": "float64"
else: t
proc nimTypeToCParam(typeName: string): string =
## C type used in the generated cgo bridge signature.
let t = typeName.strip()
case t
of "string", "cstring": "const char*"
of "int", "int64", "clong": "int64_t"
of "int32", "cint": "int32_t"
of "int16": "int16_t"
of "int8": "int8_t"
of "uint", "uint64", "csize_t": "uint64_t"
of "uint32", "cuint": "uint32_t"
of "uint16": "uint16_t"
of "uint8", "byte": "uint8_t"
of "bool": "int"
else: t
proc cgoArgType(typeName: string): string =
## The `C.<type>` a Go value is converted to before calling a bridge.
let t = typeName.strip()
case t
of "string", "cstring": "*C.char"
of "int", "int64", "clong": "C.int64_t"
of "int32", "cint": "C.int32_t"
of "int16": "C.int16_t"
of "int8": "C.int8_t"
of "uint", "uint64", "csize_t": "C.uint64_t"
of "uint32", "cuint": "C.uint32_t"
of "uint16": "C.uint16_t"
of "uint8", "byte": "C.uint8_t"
of "bool": "C.int"
else: ""
proc supported(typeName: string): bool =
cgoArgType(typeName).len > 0 or typeName.strip() in ["string", "cstring"]
proc allSupported(p: FFIProcMeta): bool =
for ep in p.extraParams:
if ep.isPtr or not supported(ep.typeName):
return false
return true
proc methodName(procName, libName: string): string =
let prefix = libName & "_"
let bare =
if procName.startsWith(prefix):
procName[prefix.len .. ^1]
else:
procName
return snakeToPascalCase(bare)
proc generateGoFile*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
let nodeType = capitalizeFirstLetter(libName) & "Node"
let respT = capitalizeFirstLetter(libName) & "Resp"
var L: seq[string] = @[]
# Locate ctor / dtor.
var ctor, dtor: FFIProcMeta
var haveCtor, haveDtor = false
for p in procs:
if p.kind == FFIKind.CTOR:
ctor = p
haveCtor = true
elif p.kind == FFIKind.DTOR:
dtor = p
haveDtor = true
# ---- file + cgo preamble -------------------------------------------------
L.add("// Code generated by nim-ffi Go codegen. DO NOT EDIT.")
L.add("package " & libName)
L.add("")
L.add("/*")
L.add("#cgo LDFLAGS: -l" & libName)
L.add("#include \"" & libName & ".h\"")
L.add("#include <stdlib.h>")
L.add("#include <string.h>")
L.add("#include <pthread.h>")
L.add("")
# cgo emits the exported callback with `char*` (it drops const); the forward
# declaration must match exactly or the C compiler reports conflicting types.
L.add(
"extern void " & libName & "GoEvent(int ret, char* msg, size_t len, void* userData);"
)
L.add("")
L.add("typedef struct {")
L.add(" int ret; char* msg; size_t len; int done;")
L.add(" pthread_mutex_t mu; pthread_cond_t cv;")
L.add("} " & respT & ";")
L.add("")
L.add("static " & respT & "* " & libName & "RespNew() {")
L.add(" " & respT & "* r = (" & respT & "*)calloc(1, sizeof(" & respT & "));")
L.add(" pthread_mutex_init(&r->mu, NULL); pthread_cond_init(&r->cv, NULL);")
L.add(" return r;")
L.add("}")
L.add("static void " & libName & "RespFree(" & respT & "* r) {")
L.add(" if (!r) return;")
L.add(" if (r->msg) free(r->msg);")
L.add(" pthread_mutex_destroy(&r->mu); pthread_cond_destroy(&r->cv); free(r);")
L.add("}")
L.add("static int " & libName & "RespRet(" & respT & "* r) { return r->ret; }")
L.add("static char* " & libName & "RespMsg(" & respT & "* r) { return r->msg; }")
L.add("static size_t " & libName & "RespLen(" & respT & "* r) { return r->len; }")
L.add("")
L.add(
"static void " & libName & "RespCb(int ret, const char* msg, size_t len, void* ud) {"
)
L.add(" " & respT & "* r = (" & respT & "*)ud;")
L.add(" pthread_mutex_lock(&r->mu);")
L.add(" r->ret = ret;")
L.add(" // Native ABI: (msg, len) is the raw result (RET_OK) or error (RET_ERR).")
L.add(" // Copy it so it survives past the callback.")
L.add(
" char* e = (char*)malloc(len + 1); if (e) { memcpy(e, msg, len); e[len] = 0; }"
)
L.add(" r->msg = e; r->len = len;")
L.add(" r->done = 1; pthread_cond_signal(&r->cv); pthread_mutex_unlock(&r->mu);")
L.add("}")
L.add("static void " & libName & "RespWait(" & respT & "* r) {")
L.add(" pthread_mutex_lock(&r->mu);")
L.add(" while (!r->done) pthread_cond_wait(&r->cv, &r->mu);")
L.add(" pthread_mutex_unlock(&r->mu);")
L.add("}")
L.add("")
# ctor bridge
if haveCtor:
var cparams: seq[string] = @[]
for ep in ctor.extraParams:
cparams.add(nimTypeToCParam(ep.typeName) & " " & ep.name)
let cparamsStr =
if cparams.len > 0:
cparams.join(", ") & ", "
else:
""
var callArgs: seq[string] = @[]
for ep in ctor.extraParams:
callArgs.add(ep.name)
let callArgsStr =
if callArgs.len > 0:
callArgs.join(", ") & ", "
else:
""
L.add(
"static void* " & libName & "Call_" & ctor.procName & "(" & cparamsStr & respT &
"* r) {"
)
L.add(
" void* ctx = " & ctor.procName & "(" & callArgsStr & libName & "RespCb, r);"
)
L.add(" " & libName & "RespWait(r);")
L.add(" return ctx;")
L.add("}")
# per-proc bridges
for p in procs:
if p.kind != FFIKind.FFI or not allSupported(p):
continue
var cparams: seq[string] = @[]
var callArgs: seq[string] = @[]
for ep in p.extraParams:
cparams.add(nimTypeToCParam(ep.typeName) & " " & ep.name)
callArgs.add(ep.name)
let cparamsStr =
if cparams.len > 0:
", " & cparams.join(", ")
else:
""
let callArgsStr =
if callArgs.len > 0:
", " & callArgs.join(", ")
else:
""
L.add(
"static int " & libName & "Call_" & p.procName & "(void* ctx" & cparamsStr & ", " &
respT & "* r) {"
)
L.add(
" int rc = " & p.procName & "(ctx, " & libName & "RespCb, r" & callArgsStr & ");"
)
L.add(" if (rc == RET_OK) " & libName & "RespWait(r);")
L.add(" return rc;")
L.add("}")
# dtor + event registration bridges
if haveDtor:
L.add(
"static int " & libName & "Call_" & dtor.procName & "(void* ctx) { return " &
dtor.procName & "(ctx); }"
)
L.add(
"static uint64_t " & libName & "RegisterEvents(void* ctx) { return " & libName &
"_add_event_listener(ctx, \"\", (FFICallBack)" & libName & "GoEvent, ctx); }"
)
L.add("*/")
L.add("import \"C\"")
L.add("")
L.add("import (")
L.add("\t\"errors\"")
L.add("\t\"sync\"")
L.add("\t\"unsafe\"")
L.add(")")
L.add("")
# ---- Go types + helpers --------------------------------------------------
L.add("type " & nodeType & " struct {")
L.add("\tctx unsafe.Pointer")
L.add("}")
L.add("")
L.add("// goStr extracts and frees the captured response string.")
L.add("func respStr(r *C." & respT & ") string {")
L.add(
"\treturn C.GoStringN(C." & libName & "RespMsg(r), C.int(C." & libName &
"RespLen(r)))"
)
L.add("}")
L.add("")
# event handler registry (raw JSON events)
L.add("var (")
L.add("\teventMu sync.Mutex")
L.add("\teventHandler func(string)")
L.add(")")
L.add("")
L.add("// SetEventHandler installs the catch-all handler for library-initiated")
L.add("// events (delivered as raw JSON strings).")
L.add("func (n *" & nodeType & ") SetEventHandler(h func(string)) {")
L.add("\teventMu.Lock()")
L.add("\teventHandler = h")
L.add("\teventMu.Unlock()")
L.add("\tC." & libName & "RegisterEvents(n.ctx)")
L.add("}")
L.add("")
L.add("//export " & libName & "GoEvent")
L.add(
"func " & libName &
"GoEvent(ret C.int, msg *C.char, length C.size_t, userData unsafe.Pointer) {"
)
L.add("\teventMu.Lock()")
L.add("\th := eventHandler")
L.add("\teventMu.Unlock()")
L.add("\tif h != nil && ret == C.RET_OK {")
L.add("\t\th(C.GoStringN(msg, C.int(length)))")
L.add("\t}")
L.add("}")
L.add("")
# ---- constructor ---------------------------------------------------------
if haveCtor:
var goParams: seq[string] = @[]
var conv: seq[string] = @[]
var callArgs: seq[string] = @[]
for ep in ctor.extraParams:
goParams.add(ep.name & " " & nimTypeToGo(ep.typeName))
if nimTypeToGo(ep.typeName) == "string":
conv.add("\tc_" & ep.name & " := C.CString(" & ep.name & ")")
conv.add("\tdefer C.free(unsafe.Pointer(c_" & ep.name & "))")
callArgs.add("c_" & ep.name)
else:
callArgs.add(cgoArgType(ep.typeName) & "(" & ep.name & ")")
let callArgsStr =
if callArgs.len > 0:
callArgs.join(", ") & ", "
else:
""
L.add(
"func New" & capitalizeFirstLetter(libName) & "(" & goParams.join(", ") & ") (*" &
nodeType & ", error) {"
)
for c in conv:
L.add(c)
L.add("\tr := C." & libName & "RespNew()")
L.add("\tdefer C." & libName & "RespFree(r)")
L.add("\tctx := C." & libName & "Call_" & ctor.procName & "(" & callArgsStr & "r)")
L.add("\tif C." & libName & "RespRet(r) != C.RET_OK {")
L.add("\t\treturn nil, errors.New(respStr(r))")
L.add("\t}")
L.add("\treturn &" & nodeType & "{ctx: ctx}, nil")
L.add("}")
L.add("")
# ---- methods -------------------------------------------------------------
for p in procs:
if p.kind != FFIKind.FFI:
continue
if not allSupported(p):
L.add(
"// SKIPPED " & p.procName &
": struct/seq/Option params unsupported by Go codegen"
)
L.add("")
continue
let mName = methodName(p.procName, libName)
var goParams: seq[string] = @[]
var conv: seq[string] = @[]
var callArgs: seq[string] = @[]
for ep in p.extraParams:
goParams.add(ep.name & " " & nimTypeToGo(ep.typeName))
if nimTypeToGo(ep.typeName) == "string":
conv.add("\tc_" & ep.name & " := C.CString(" & ep.name & ")")
conv.add("\tdefer C.free(unsafe.Pointer(c_" & ep.name & "))")
callArgs.add("c_" & ep.name)
else:
callArgs.add(cgoArgType(ep.typeName) & "(" & ep.name & ")")
let callArgsStr =
if callArgs.len > 0:
", " & callArgs.join(", ")
else:
""
L.add(
"func (n *" & nodeType & ") " & mName & "(" & goParams.join(", ") &
") (string, error) {"
)
for c in conv:
L.add(c)
L.add("\tr := C." & libName & "RespNew()")
L.add("\tdefer C." & libName & "RespFree(r)")
L.add("\tC." & libName & "Call_" & p.procName & "(n.ctx" & callArgsStr & ", r)")
L.add("\tif C." & libName & "RespRet(r) != C.RET_OK {")
L.add("\t\treturn \"\", errors.New(respStr(r))")
L.add("\t}")
L.add("\treturn respStr(r), nil")
L.add("}")
L.add("")
# ---- destructor ----------------------------------------------------------
if haveDtor:
L.add("func (n *" & nodeType & ") Destroy() error {")
L.add("\tif C." & libName & "Call_" & dtor.procName & "(n.ctx) != C.RET_OK {")
L.add("\t\treturn errors.New(\"" & libName & " destroy failed\")")
L.add("\t}")
L.add("\treturn nil")
L.add("}")
L.add("")
return L.join("\n")
proc generateGoBindings*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
writeFile(
outputDir / (libName & ".go"), generateGoFile(procs, types, libName, events)
)
# Emit a go.mod so the generated package is an importable module (parity with
# the Rust/C++ generators that emit Cargo.toml / CMakeLists.txt). Consumers can
# `replace <libName> => <path to this dir>`.
let goMod = outputDir / "go.mod"
if not fileExists(goMod):
writeFile(goMod, "module " & libName & "\n\ngo 1.21\n")