Ivan FB 028dbb56e6
feat(codegen): add C and Go (cgo) binding generators
0.2.0 carries each request as a single CBOR buffer over the exported ABI,
which is awkward for hand-written host bindings (every consumer would have
to encode CBOR and decode responses by hand). These two generators emit
ergonomic, ready-to-use bindings from the same {.ffi.} registry the C++/Rust
generators already consume.

- c.nim (targetLang=c): a self-contained <lib>.h with a small CBOR encoder,
  ffi_decode_text(), and `static inline <lib>_<proc>(ctx, cb, ud, args...)`
  wrappers that CBOR-encode and forward to the real export. The wrapper keeps
  the export's source name but is given a distinct symbol via an __asm__ label
  so the raw export's asm alias doesn't bind back to the wrapper (which would
  recurse). Scalar/string params only; others fall back to the raw CBOR decl.

- go.nim (targetLang=go): a single <lib>.go cgo package that #includes the
  generated <lib>.h and adds a condvar-backed response capture. This is the
  key bit: 0.2.0 removed the synchronous fast-path, so a caller can no longer
  read a result right after the call — the generated bridges block on the
  callback, turning each async export into a blocking Go method. Also emits a
  go.mod for importability.

Wired both into genBindings dispatch (targetLang "c"/"go") and added
genbindings_c / genbindings_go tasks. Both verified end-to-end against a
scalar-param test lib (build + run) and the real libwaku surface.

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

419 lines
14 KiB
Nim

## C binding generator for the nim-ffi framework.
##
## Emits a single self-contained header `<lib>.h` that hides the CBOR transport
## behind ergonomic, typed C functions. nim-ffi 0.2.0's exported ABI takes the
## request as one CBOR buffer; the generated header restores a natural call
## shape — `<lib>_<proc>(ctx, callback, userData, <typed args...>)` — by
## CBOR-encoding the arguments and forwarding to the real export.
##
## The real export and the ergonomic wrapper share the same C name, so the raw
## symbol is reached through an `__asm__` label (with a platform underscore
## shim) and the inline wrapper keeps the public name. This keeps existing C /
## cgo / JNI / Swift consumers calling the same symbols they always did, just
## with typed arguments instead of a hand-built CBOR blob.
##
## Requests/responses travel as CBOR (RFC 8949), matching the Nim-side
## cbor_serial codec: each request is a map keyed by the Nim parameter names;
## each success response is the CBOR-encoded return value (a text string for
## the common `Result[string, string]` shape).
import std/[os, strutils]
import ./meta, ./string_helpers
## Fixed 64-bit wire type for any Nim `ptr T` / `pointer`, matching the C++/Rust
## generators so the CBOR payload size is architecture-stable.
const CPtrType = "uint64_t"
proc nimTypeToC(typeName: string): string =
## Maps a Nim parameter/field type to the C type used in the ergonomic
## wrapper signature.
let t = typeName.strip()
if t.startsWith("ptr ") or t == "pointer":
return "void*"
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"
of "float", "float32": "float"
of "float64": "double"
else: t
type CborKind = enum
ckText
ckUint
ckInt
ckBool
ckUnsupported
proc cborKindOf(typeName: string): CborKind =
## How a parameter of `typeName` is encoded into the request map. Only the
## scalar/string shapes the FFI boundary actually carries are handled; nested
## {.ffi.} struct params would need recursive map emission (not used by the
## libwaku / liblogosdelivery surface and intentionally rejected here).
let t = typeName.strip()
case t
of "string", "cstring":
ckText
of "uint", "uint8", "uint16", "uint32", "uint64", "cuint", "csize_t", "byte":
ckUint
of "int", "int8", "int16", "int32", "int64", "cint", "clong":
ckInt
of "bool":
ckBool
else:
ckUnsupported
const HeaderPrelude = """
// Generated by nim-ffi C codegen. Do not edit by hand.
//
// Each function below CBOR-encodes its arguments and forwards to the matching
// nim-ffi export. The asynchronous result is delivered to `callback`:
// - RET_OK : `msg`/`len` is the CBOR-encoded return value. For functions that
// return a string this is a CBOR text string; decode it with
// <LIB>_decode_text(). An empty success encodes as 0xF6 (null) or
// an empty text string.
// - RET_ERR: `msg`/`len` is the raw error text (not CBOR-encoded).
//
// Library-initiated events delivered to a listener registered via
// <LIB>_add_event_listener are raw JSON strings (not CBOR).
#ifndef NIM_FFI_GEN_<GUARD>_H
#define NIM_FFI_GEN_<GUARD>_H
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifndef NIM_FFI_RET_CODES
#define NIM_FFI_RET_CODES
#define RET_OK 0
#define RET_ERR 1
#define RET_MISSING_CALLBACK 2
#endif
typedef void (*FFICallBack)(int callerRet, const char *msg, size_t len, void *userData);
// --- minimal growable CBOR encoder ----------------------------------------
typedef struct {
uint8_t *buf;
size_t cap;
size_t len;
} FfiCbor;
static inline FfiCbor ffi_cbor_new(void) {
FfiCbor c;
c.cap = 256;
c.len = 0;
c.buf = (uint8_t *)malloc(c.cap);
return c;
}
static inline void ffi_cbor_free(FfiCbor *c) {
free(c->buf);
c->buf = NULL;
}
static inline void ffi_cbor_put(FfiCbor *c, uint8_t b) {
if (c->len >= c->cap) {
c->cap *= 2;
c->buf = (uint8_t *)realloc(c->buf, c->cap);
}
c->buf[c->len++] = b;
}
// CBOR head: (major << 5) | smallest-fitting length encoding of `arg`.
static inline void ffi_cbor_head(FfiCbor *c, uint8_t major, uint64_t arg) {
uint8_t mt = (uint8_t)(major << 5);
if (arg < 24) {
ffi_cbor_put(c, mt | (uint8_t)arg);
} else if (arg <= 0xff) {
ffi_cbor_put(c, mt | 24);
ffi_cbor_put(c, (uint8_t)arg);
} else if (arg <= 0xffff) {
ffi_cbor_put(c, mt | 25);
ffi_cbor_put(c, (uint8_t)(arg >> 8));
ffi_cbor_put(c, (uint8_t)arg);
} else if (arg <= 0xffffffffULL) {
ffi_cbor_put(c, mt | 26);
ffi_cbor_put(c, (uint8_t)(arg >> 24));
ffi_cbor_put(c, (uint8_t)(arg >> 16));
ffi_cbor_put(c, (uint8_t)(arg >> 8));
ffi_cbor_put(c, (uint8_t)arg);
} else {
ffi_cbor_put(c, mt | 27);
for (int s = 56; s >= 0; s -= 8) ffi_cbor_put(c, (uint8_t)(arg >> s));
}
}
static inline void ffi_cbor_map(FfiCbor *c, size_t n) { ffi_cbor_head(c, 5, n); }
static inline void ffi_cbor_text(FfiCbor *c, const char *s) {
size_t n = s ? strlen(s) : 0;
ffi_cbor_head(c, 3, n);
for (size_t i = 0; i < n; i++) ffi_cbor_put(c, (uint8_t)s[i]);
}
static inline void ffi_cbor_kv_text(FfiCbor *c, const char *k, const char *v) {
ffi_cbor_text(c, k);
ffi_cbor_text(c, v);
}
static inline void ffi_cbor_kv_uint(FfiCbor *c, const char *k, uint64_t v) {
ffi_cbor_text(c, k);
ffi_cbor_head(c, 0, v);
}
static inline void ffi_cbor_kv_int(FfiCbor *c, const char *k, int64_t v) {
ffi_cbor_text(c, k);
if (v >= 0)
ffi_cbor_head(c, 0, (uint64_t)v);
else
ffi_cbor_head(c, 1, (uint64_t)(-(v + 1)));
}
static inline void ffi_cbor_kv_bool(FfiCbor *c, const char *k, int v) {
ffi_cbor_text(c, k);
ffi_cbor_put(c, v ? 0xf5 : 0xf4);
}
// --- response decoding -----------------------------------------------------
// Zero-copy view of a top-level CBOR text string (the RET_OK payload of a
// string-returning function). On success sets *out/*outLen to point INTO `data`
// (no allocation) and returns 1; returns 0 for a non-text-string payload (e.g.
// the 0xF6 empty-success sentinel). The view borrows `data`, so it is only valid
// for as long as `data` is (i.e. the duration of the callback) and is NOT
// NUL-terminated — print it with "%.*s", (int)outLen, out.
static inline int ffi_text_view(const uint8_t *data, size_t len,
const uint8_t **out, size_t *outLen) {
if (len < 1 || (data[0] >> 5) != 3) return 0;
uint8_t info = data[0] & 0x1f;
size_t p = 1;
uint64_t slen = 0;
if (info < 24) {
slen = info;
} else if (info == 24) {
if (len < p + 1) return 0;
slen = data[p++];
} else if (info == 25) {
if (len < p + 2) return 0;
slen = ((uint64_t)data[p] << 8) | data[p + 1];
p += 2;
} else if (info == 26) {
if (len < p + 4) return 0;
slen = ((uint64_t)data[p] << 24) | ((uint64_t)data[p + 1] << 16) |
((uint64_t)data[p + 2] << 8) | data[p + 3];
p += 4;
} else {
return 0;
}
if (len < p + slen) return 0;
*out = data + p;
*outLen = (size_t)slen;
return 1;
}
// Owning variant: decode a top-level CBOR text string into a freshly malloc'd,
// NUL-terminated C string. Returns NULL for a non-text-string payload. The
// caller owns the returned pointer and must free() it. Use this when the value
// must outlive the callback; otherwise prefer the zero-copy ffi_text_view above.
static inline char *ffi_decode_text(const uint8_t *data, size_t len) {
const uint8_t *view;
size_t slen;
if (!ffi_text_view(data, len, &view, &slen)) return NULL;
char *out = (char *)malloc(slen + 1);
if (!out) return NULL;
memcpy(out, view, slen);
out[slen] = '\0';
return out;
}
// Reach the real exported symbol (which shares the wrapper's name) through an
// assembler label. macOS prefixes C symbols with an underscore; Linux does not.
#if defined(__APPLE__)
#define NIM_FFI_SYM(name) "_" name
#else
#define NIM_FFI_SYM(name) name
#endif
"""
proc emitRequestBuild(lines: var seq[string], p: FFIProcMeta, indent: string): bool =
## Emits the CBOR request-map construction for proc `p` into `ffi_c_`.
## Returns false if any parameter type is unsupported by the C generator.
lines.add(indent & "FfiCbor ffi_c_ = ffi_cbor_new();")
lines.add(indent & "ffi_cbor_map(&ffi_c_, " & $p.extraParams.len & ");")
for ep in p.extraParams:
if ep.isPtr:
return false
case cborKindOf(ep.typeName)
of ckText:
lines.add(
indent & "ffi_cbor_kv_text(&ffi_c_, \"" & ep.name & "\", " & ep.name & ");"
)
of ckUint:
lines.add(
indent & "ffi_cbor_kv_uint(&ffi_c_, \"" & ep.name & "\", (uint64_t)" & ep.name &
");"
)
of ckInt:
lines.add(
indent & "ffi_cbor_kv_int(&ffi_c_, \"" & ep.name & "\", (int64_t)" & ep.name &
");"
)
of ckBool:
lines.add(
indent & "ffi_cbor_kv_bool(&ffi_c_, \"" & ep.name & "\", " & ep.name & ");"
)
of ckUnsupported:
return false
return true
proc paramSig(p: FFIProcMeta): string =
## Comma-separated typed parameter list (no leading comma) for the wrapper.
var parts: seq[string] = @[]
for ep in p.extraParams:
let cType =
if ep.isPtr:
CPtrType
else:
nimTypeToC(ep.typeName)
parts.add(cType & " " & ep.name)
return parts.join(", ")
proc allParamsSupported(p: FFIProcMeta): bool =
## The C generator only produces ergonomic wrappers when every parameter is a
## scalar or string. Struct / seq / Option params (which C cannot express
## naturally) fall back to the raw CBOR-buffer ABI declaration instead.
for ep in p.extraParams:
if ep.isPtr or cborKindOf(ep.typeName) == ckUnsupported:
return false
return true
proc generateCHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
let guard = libName.toUpper()
let upperLib = libName.toUpper()
var lines: seq[string] = @[]
lines.add(HeaderPrelude.replace("<GUARD>", guard).replace("<LIB>", upperLib))
lines.add("")
for p in procs:
case p.kind
of FFIKind.DTOR:
# No request payload — declare the real export directly.
lines.add("int " & p.procName & "(void *ctx);")
lines.add("")
of FFIKind.CTOR:
if allParamsSupported(p):
let sig = paramSig(p)
# void* <name>(<typed args...>, FFICallBack callback, void *userData)
lines.add(
"extern void *" & p.procName &
"__ffi(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData) __asm__(NIM_FFI_SYM(\"" &
p.procName & "\"));"
)
let ctorHead =
"void *" & p.procName & "(" & sig & (if sig.len > 0: ", " else: "") &
"FFICallBack callback, void *userData)"
# Rename the wrapper's own symbol so the raw export's asm alias above
# (which targets the unmangled export name) does not bind to this local
# static definition — otherwise the wrapper would call itself.
lines.add(
"static inline " & ctorHead & " __asm__(\"" & p.procName & "__wrap\");"
)
lines.add("static inline " & ctorHead & " {")
discard emitRequestBuild(lines, p, " ")
lines.add(
" void *ffi_r_ = " & p.procName &
"__ffi(ffi_c_.buf, ffi_c_.len, callback, userData);"
)
lines.add(" ffi_cbor_free(&ffi_c_);")
lines.add(" return ffi_r_;")
lines.add("}")
else:
# Non-scalar params: expose the raw CBOR-buffer ABI; the caller builds
# the request map itself (the FfiCbor helpers above are still usable).
lines.add(
"// " & p.procName &
": has struct/seq/Option params; raw CBOR ABI only (build the request map yourself)."
)
lines.add(
"void *" & p.procName &
"(const uint8_t *reqCbor, size_t reqCborLen, FFICallBack callback, void *userData);"
)
lines.add("")
of FFIKind.FFI:
if allParamsSupported(p):
let sig = paramSig(p)
let sigComma =
if sig.len > 0:
", " & sig
else:
""
lines.add(
"extern int " & p.procName &
"__ffi(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen) __asm__(NIM_FFI_SYM(\"" &
p.procName & "\"));"
)
let ffiHead =
"int " & p.procName & "(void *ctx, FFICallBack callback, void *userData" &
sigComma & ")"
# Rename the wrapper's own symbol so the raw export's asm alias above
# (which targets the unmangled export name) does not bind to this local
# static definition — otherwise the wrapper would call itself.
lines.add(
"static inline " & ffiHead & " __asm__(\"" & p.procName & "__wrap\");"
)
lines.add("static inline " & ffiHead & " {")
discard emitRequestBuild(lines, p, " ")
lines.add(
" int ffi_r_ = " & p.procName &
"__ffi(ctx, callback, userData, ffi_c_.buf, ffi_c_.len);"
)
lines.add(" ffi_cbor_free(&ffi_c_);")
lines.add(" return ffi_r_;")
lines.add("}")
else:
lines.add(
"// " & p.procName &
": has struct/seq/Option params; raw CBOR ABI only (build the request map yourself)."
)
lines.add(
"int " & p.procName &
"(void *ctx, FFICallBack callback, void *userData, const uint8_t *reqCbor, size_t reqCborLen);"
)
lines.add("")
# `declareLibrary` always exports the listener-registration ABI.
lines.add(
"uint64_t " & libName &
"_add_event_listener(void *ctx, const char *eventName, FFICallBack callback, void *userData);"
)
lines.add(
"int " & libName & "_remove_event_listener(void *ctx, uint64_t listenerId);"
)
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} // extern \"C\"")
lines.add("#endif")
lines.add("")
lines.add("#endif /* NIM_FFI_GEN_" & guard & "_H */")
return lines.join("\n")
proc generateCBindings*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
writeFile(
outputDir / (libName & ".h"), generateCHeader(procs, types, libName, events)
)