feat(devx): derive ffiEvent wire name, guard genBindings ordering, real README (#111)

This commit is contained in:
Gabriel Cruz 2026-07-06 12:31:49 -03:00 committed by GitHub
parent 8dcdcdc76a
commit fdc68ea76d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 209 additions and 22 deletions

View File

@ -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
(`<lib>.h`) plus a `CMakeLists.txt`, alongside the existing Rust / C++ / CDDL
backends. Requests/responses travel as CBOR using the same vendored TinyCBOR

151
README.md
View File

@ -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.

View File

@ -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

View File

@ -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(