From fdc68ea76dbbfc640846e59415bbd3c4edb2ab0e Mon Sep 17 00:00:00 2001 From: Gabriel Cruz <8129788+gmelodie@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:31:49 -0300 Subject: [PATCH] feat(devx): derive ffiEvent wire name, guard genBindings ordering, real README (#111) --- CHANGELOG.md | 8 ++ README.md | 151 ++++++++++++++++++++++++++++++++++++- ffi/codegen/meta.nim | 5 ++ ffi/internal/ffi_macro.nim | 67 +++++++++++----- 4 files changed, 209 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 405b201..837f733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### Added +- `{.ffiEvent.}` no longer requires an explicit wire-name string: when omitted + it is derived from the proc name via `camelToSnakeCase` + (`onPeerConnected` → `on_peer_connected`), matching how `{.ffi.}` derives its + C export symbol. Pass a string literal only to override it. +- FFI annotations (`{.ffi.}`, `{.ffiCtor.}`, `{.ffiDtor.}`, `{.ffiEvent.}`, + `{.ffiHandle.}`, `{.ffiRaw.}`) that expand after `genBindings()` now produce a + loud compile error instead of being silently dropped from the generated + bindings. - **C binding generator** (`-d:targetLang=c`): emits a header-only C binding (`.h`) plus a `CMakeLists.txt`, alongside the existing Rust / C++ / CDDL backends. Requests/responses travel as CBOR using the same vendored TinyCBOR diff --git a/README.md b/README.md index ede82ed..1a7b46c 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,150 @@ # nim-ffi -Allows exposing Nim projects to other languages -## Example +Expose a Nim library to C, C++ and Rust by annotating ordinary Nim procs. -`examples/timer` is now a self-contained Nimble project that imports `nim-ffi` directly. -Use `cd examples/timer && nimble install -y ../.. && nimble build` to compile the example. +You write async Nim; `nim-ffi` provides the whole FFI runtime — a dedicated +worker thread, a request channel, CBOR (de)serialization, an event queue and a +context/handle registry — and generates the foreign-language bindings for you. +No hand-written `.h` files, no manual request enums, no shared-memory plumbing. + +## Mental model + +- You **declare a library** once with `declareLibrary(name, LibType)`. +- You **annotate procs and types** with pragmas (`{.ffi.}`, `{.ffiCtor.}`, + `{.ffiDtor.}`, `{.ffiEvent.}`). +- You **call `genBindings()` last**, which emits the foreign bindings. + +By default, every request/response crosses the boundary as a single CBOR blob +(the wire format is configurable per library or per annotation — see +[ABI format](#abi-format)); the ctx handle returned by the constructor is the +only pointer that crosses. Each `{.ffi.}` proc runs on the library's own chronos +event loop, so bodies can `await` freely. + +## Minimal example + +```nim +import ffi, chronos + +# 1. The library's main state. The FFI context owns one instance. +type Counter = object + value: int + +declareLibrary("counter", Counter) + +# 2. Request/response shapes. Any {.ffi.} object type becomes a first-class +# struct/class in the generated bindings and rides the wire in the library's +# ABI format (CBOR by default). +type BumpRequest {.ffi.} = object + by: int + +type BumpResponse {.ffi.} = object + newValue: int + +# 3. Constructor: returns Future[Result[LibType, string]]. +proc counterNew*(): Future[Result[Counter, string]] {.ffiCtor.} = + return ok(Counter(value: 0)) + +# 4. A method: first param is the library value, then any typed params. +# Return type is always Future[Result[T, string]]. +proc counterBump*( + c: Counter, req: BumpRequest +): Future[Result[BumpResponse, string]] {.ffi.} = + await sleepAsync(1.milliseconds) + return ok(BumpResponse(newValue: c.value + req.by)) + +# 5. Destructor: exactly one param (the library value). +proc counterDestroy*(c: Counter) {.ffiDtor.} = + discard + +# 6. genBindings() must be the LAST FFI call in the file (see below). +genBindings() +``` + +The generated C export names are the snake_case form of the proc names, e.g. +`counterBump` → `counter_bump`. + +## Pragma reference + +| Pragma | Applies to | Purpose | +| --- | --- | --- | +| `declareLibrary(name, LibType[, defaultABIFormat])` | call | Registers the library, its state type, and the default wire format. Must run before any annotation. | +| `{.ffi.}` on a `type` | `object` | Registers the type for binding generation; it serializes via the library's ABI format (CBOR by default). | +| `{.ffi.}` on a `proc` | proc | Exposes a method. First param is the library value, then typed params; returns `Future[Result[T, string]]`. | +| `{.ffiCtor.}` | proc | The constructor. Returns `Future[Result[LibType, string]]`; creates the FFI context. | +| `{.ffiDtor.}` | proc | The destructor. Exactly one param `(x: LibType)`; tears the context down. | +| `{.ffiEvent[: "wire_name"].}` | proc (empty body) | A library-initiated callback. Call the proc from any `{.ffi.}` handler to fire it. The wire name is optional — see below. | +| `{.ffiHandle.}` | `ref object` | Marks a type as an opaque handle: it stays server-side and crosses the wire as a `uint64` id. | +| `genBindings()` | call | Emits the bindings. Must be the **last** FFI call in the compilation root. | + +### The return-type contract + +Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit +`Future[Result[T, string]]` return type — even for synchronous logic (just +`return ok(...)` without awaiting). The `Result`'s error string is delivered to +the foreign caller as the failure message. + +### Events + +An event is a proc with an empty body annotated `{.ffiEvent.}`. You fire it by +calling it with a typed payload from inside any `{.ffi.}` handler; the foreign +side receives it through a registered callback. + +```nim +type PeerConnected {.ffi.} = object + id: string + +proc onPeerConnected*(peer: PeerConnected) {.ffiEvent.} # wire name: "on_peer_connected" + +proc counterBump*(c: Counter, req: BumpRequest): Future[Result[BumpResponse, string]] {.ffi.} = + onPeerConnected(PeerConnected(id: "p-1")) + return ok(BumpResponse(newValue: c.value + req.by)) +``` + +The wire name is **optional**: when omitted it is derived from the proc name +(`onPeerConnected` → `on_peer_connected`), matching how `{.ffi.}` derives its C +export symbol. Pass a string literal (`{.ffiEvent: "custom_name".}`) only when +you need a name that differs from the proc. + +### ABI format + +The default wire format is `cbor`. Override the library default with +`declareLibrary("lib", Lib, defaultABIFormat = "c")`, or per annotation with an +`"abi = ..."` spec, e.g. `{.ffi: "abi = c".}`. + +## Placement of `genBindings()` + +`genBindings()` reads the compile-time registries that the pragmas populate as +the compiler expands them, so **it must come after every annotation**. Since +Nim resolves `import`s before running the importing module's body, a multi-file +library keeps its annotations in imported sub-modules and calls `genBindings()` +once at the bottom of the top-level root file. + +An annotation that expands *after* `genBindings()` is now a **compile error** +(previously it was silently dropped from the bindings). + +## Generating bindings + +Binding emission is gated behind `-d:ffiGenBindings`; without it, `genBindings()` +is a no-op and the library just builds normally. + +```sh +nim c -d:ffiGenBindings -d:targetLang=c \ + -d:ffiOutputDir=path/to/output -d:ffiSrcPath=../mylib.nim mylib.nim +``` + +- `-d:targetLang` — `rust` (default), `cpp`, `c`, or `cddl`. +- `-d:ffiOutputDir` — where the generated files land. +- `-d:ffiSrcPath` — the Nim source path embedded in the generated build files. + +## Examples + +- `examples/timer` — a self-contained Nimble project covering the ctor, sync + and async methods, multi-param methods, events, and C / C++ / Rust / CDDL + bindings with runnable clients. Start here: + + ```sh + cd examples/timer && nimble install -y ../.. && nimble build + ``` + +- `examples/echo` — a second minimal library, loaded alongside `timer` in the + C++ end-to-end test to prove two libraries coexist in one process. diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 7c2122a..f20e298 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -64,6 +64,11 @@ var currentLibName* {.compileTime.}: string # Set by `declareLibrary`; the FFI annotations require it (name/type/default ABI). var libraryDeclared* {.compileTime.}: bool = false +# Set by `genBindings()`. Any FFI annotation expanded after it registers into the +# codegen registries too late to be emitted, so the annotation macros check this +# and fail loudly instead of silently dropping the proc/type from the bindings. +var genBindingsEmitted* {.compileTime.}: bool = false + # Library-wide default ABI, inherited by each annotation unless it overrides. var currentDefaultABIFormat* {.compileTime.}: ABIFormat = ABIFormat.Cbor diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index e61caea..3644ce4 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -20,6 +20,28 @@ proc requireLibraryDeclared(where: string) {.compileTime.} = ": declareLibrary(name, LibType[, defaultABIFormat]) must be called before any FFI annotation" ) +proc resolveEventWireName( + leading: seq[NimNode], userProcName: NimNode +): tuple[wireName: string, abiSpecStart: int] {.compileTime.} = + ## A leading string that doesn't parse as an `"abi = ..."` spec is the explicit + ## wire name; anything else means derive the name from the proc. Returns the + ## resolved name and the index where the trailing ABI specs begin. + if leading.len > 0 and leading[0].kind in {nnkStrLit, nnkRStrLit, nnkTripleStrLit} and + ($leading[0]).len > 0 and not parseAbiSpec($leading[0]).ok: + ($leading[0], 1) + else: + (camelToSnakeCase($userProcName), 0) + +proc requireBeforeGenBindings(where: string) {.compileTime.} = + ## Enforce that this annotation expands before `genBindings()`. Anything + ## registered afterwards never reaches the generator, so turn what used to be + ## a silent drop into a loud error pointing at the fix. + if genBindingsEmitted: + error( + where & + " appears after genBindings(); genBindings() must be the LAST FFI call in the compilation root, after every {.ffi.}/{.ffiCtor.}/{.ffiDtor.}/{.ffiEvent.} annotation" + ) + proc resolveABIFormat(abiSpecs: seq[NimNode]): ABIFormat {.compileTime.} = ## Resolve one annotation's ABI from its optional `"abi = ..."` string specs ## (last wins), inheriting the library default when absent. @@ -650,6 +672,7 @@ macro ffiRaw*(args: varargs[untyped]): untyped = ## ) {.ffiRaw.} = ## return ok(WakuNodeVersionString) + requireBeforeGenBindings("`.ffiRaw.`") requireLibraryDeclared("`.ffiRaw.`") let prc = args[^1] let (rawAbiFormat, rawTimeoutMs) = resolveFFISpecs(args[0 ..^ 2]) @@ -742,6 +765,7 @@ macro ffiHandle*(args: varargs[untyped]): untyped = ## ## An optional `"abi = ..."` spec is accepted for surface parity but only ## validated — a handle always rides as an abi-agnostic `uint64` id. + requireBeforeGenBindings("`.ffiHandle.`") requireLibraryDeclared("`.ffiHandle.`") let prc = args[^1] discard resolveABIFormat(args[0 ..^ 2]) @@ -797,7 +821,8 @@ macro ffi*(args: varargs[untyped]): untyped = ## proc mylib_send*(w: MyLib, cfg: SendConfig): Future[Result[string, string]] {.ffi.} = ## return ok("done") - # Annotated node is the last vararg; leading args are override specs. + requireBeforeGenBindings("`.ffi.`") + # Annotated node is the last vararg; leading args are `"abi = ..."` specs. let prc = args[^1] let (abiFormat, timeoutMs) = resolveFFISpecs(args[0 ..^ 2]) @@ -1338,6 +1363,7 @@ macro ffiCtor*(args: varargs[untyped]): untyped = ## a decimal string on success. The caller should hold the returned pointer ## and pass it to subsequent .ffi. calls. + requireBeforeGenBindings("`.ffiCtor.`") requireLibraryDeclared("`.ffiCtor.`") let prc = args[^1] let (abiFormat, timeoutMs) = resolveFFISpecs(args[0 ..^ 2]) @@ -1546,6 +1572,7 @@ macro ffiDtor*(args: varargs[untyped]): untyped = ## Returns RET_OK on success, RET_ERR on failure (null/invalid ctx, or ## destroyFFIContext failure). + requireBeforeGenBindings("`.ffiDtor.`") requireLibraryDeclared("`.ffiDtor.`") let prc = args[^1] let abiFormat = resolveABIFormat(args[0 ..^ 2]) @@ -1652,12 +1679,15 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ## payload, and the per-target codegens emit a typed handler dispatcher ## on the foreign side. ## - ## The first pragma argument is the wire-format event name, taken verbatim - ## (no case conversion). That string appears in the CBOR `eventType` field - ## and is the single source of truth across Nim / C++ / Rust bindings. + ## The wire-format event name is optional: when omitted it is derived from + ## the proc name via `camelToSnakeCase` (matching how {.ffi.} derives its C + ## export symbol), so `proc onPeerConnected(...)` becomes `on_peer_connected`. + ## Pass a string literal to override it verbatim (no case conversion). That + ## name appears in the CBOR `eventType` field and is the single source of + ## truth across Nim / C++ / Rust bindings. ## ## The wire format follows the library default and can be overridden by - ## passing an `"abi = ..."` spec after the event name, e.g. + ## passing an `"abi = ..."` spec (after the optional event name), e.g. ## `{.ffiEvent("on_peer_connected", "abi = cbor").}`. ## ## Example: @@ -1665,7 +1695,7 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ## id: string ## address: string ## - ## proc onPeerConnected*(peer: PeerInfo) {.ffiEvent: "on_peer_connected".} + ## proc onPeerConnected*(peer: PeerInfo) {.ffiEvent.} # -> "on_peer_connected" ## ## # ... then from inside any {.ffi.} handler: ## onPeerConnected(PeerInfo(id: "p-1", address: "127.0.0.1")) @@ -1673,22 +1703,25 @@ macro ffiEvent*(args: varargs[untyped]): untyped = ## Restriction (first pass): exactly one parameter. Multi-param events ## need a synthesised envelope struct; planned for a follow-up. + requireBeforeGenBindings("`.ffiEvent.`") requireLibraryDeclared("`.ffiEvent.`") - if args.len < 2: - error("ffiEvent requires a wire-name string and a proc declaration") + if args.len < 1: + error("ffiEvent must be applied to a proc declaration") let prc = args[^1] if prc.kind notin {nnkProcDef, nnkFuncDef}: error("ffiEvent must be applied to a proc declaration") - if args[0].kind notin {nnkStrLit, nnkRStrLit, nnkTripleStrLit}: - error("ffiEvent: the first argument must be the wire-name string literal") - let wireName = $args[0] - # Args between the wire name and the proc are ABI override specs. - let abiFormat = resolveABIFormat(args[1 ..^ 2]) + let procName = prc[0] + var userProcName = procName + if procName.kind == nnkPostfix: + userProcName = procName[1] + + let leading = args[0 ..^ 2] + let (wireName, abiSpecStart) = resolveEventWireName(leading, userProcName) + let abiFormat = resolveABIFormat(leading[abiSpecStart ..^ 1]) gateABIFormat(abiFormat, "`.ffiEvent.` proc") - let procName = prc[0] let formalParams = prc[3] if formalParams.len != 2: @@ -1708,10 +1741,6 @@ macro ffiEvent*(args: varargs[untyped]): untyped = else: payloadTypeNode.repr - var userProcName = procName - if procName.kind == nnkPostfix: - userProcName = procName[1] - # The generated body: dispatchFFIEventCbor("wire_name", payload). let wireNameLit = newStrLitNode(wireName) let dispatchBody = @@ -1778,6 +1807,8 @@ macro genBindings*( ## # -d:ffiOutputDir=examples/timer/rust_bindings \ ## # -d:ffiSrcPath=../timer.nim mylib.nim + genBindingsEmitted = true + when defined(ffiGenBindings): if outputDir.len == 0: error(