feat(ffi): add {.ffiExport.} for simple synchronous C exports

{.ffi.}/{.ffiCtor.} cover the async, context-handle, CBOR-marshaled path.
{.ffiExport.} covers the complementary case: a no-argument lifecycle/health
entry point a host loads with dlopen+dlsym and calls synchronously — no
context, no callback, no CBOR, the return value crossing the ABI directly.

Native Nim return types are bridged to the C ABI (int/bool -> int,
uint64 -> unsigned long long, string -> const char* kept alive in shared
memory; no-return -> void), and initializeLibrary() is injected so the Nim
runtime is up on first call — the host never invokes NimMain itself.

Fills the no-argument scalar case of the declared-but-unimplemented abi=c path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138fdSv6Xhv2KjhiyYR4SAu
This commit is contained in:
Ivan FB 2026-07-12 11:09:58 +02:00
parent 8f15afce5c
commit 40cd6d2d26
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
2 changed files with 93 additions and 2 deletions

View File

@ -1,7 +1,7 @@
import std/[atomics, tables]
import chronos, chronicles
import
ffi/internal/[ffi_library, ffi_macro, c_wire],
ffi/internal/[ffi_library, ffi_macro, ffi_export, c_wire],
ffi/[
alloc, ffi_types, ffi_events, ffi_handles, ffi_context, ffi_context_pool,
ffi_thread_request, cbor_serial,
@ -10,5 +10,5 @@ import
export atomics, tables
export chronos, chronicles
export
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_events, ffi_handles,
atomics, alloc, ffi_library, ffi_macro, ffi_export, ffi_types, ffi_events, ffi_handles,
ffi_context, ffi_context_pool, ffi_thread_request, cbor_serial, c_wire

View File

@ -0,0 +1,91 @@
## Simple synchronous C export for a nim-ffi library.
##
## `{.ffi.}` / `{.ffiCtor.}` are the async, context-handle, CBOR-marshaled path —
## the right tool for stateful, multi-call libraries. `{.ffiExport.}` covers the
## other common case: a handful of DEAD-SIMPLE lifecycle/health entry points a host
## loads with plain `dlopen` + `dlsym` and calls synchronously — no context, no
## callback, no CBOR. The function's own return value crosses the ABI directly.
##
## You write NATIVE Nim types; `ffiExport` bridges to the C ABI for you:
## int / bool -> C int
## uint64 -> C unsigned long long
## string -> C const char* (kept alive in shared memory until the next call)
## (no return) -> C void
## and it injects the library's `initializeLibrary()` bootstrap so the Nim runtime
## is up on first call — the host never invokes NimMain itself.
##
## declareLibraryBase("myLib") # emits initializeLibrary()
## proc my_start(): int {.ffiExport.} = 0 # -> int my_start(void)
## proc my_alive(): uint64 {.ffiExport.} = beats # -> unsigned long long my_alive(void)
## proc my_error(): string {.ffiExport.} = lastErr # -> const char* my_error(void)
##
## Build the shared library with `--noMain --nimMainPrefix:libmyLib`. Arguments are
## not supported (these are no-arg lifecycle calls); use `{.ffi.}` for calls that
## take arguments.
import std/macros
proc cReturnType(t: NimNode): NimNode =
## Native Nim return type -> the C-ABI type that actually crosses the boundary.
if t.kind == nnkEmpty:
return t # void
if t.kind == nnkIdent:
case $t
of "int", "int32", "bool": return ident("cint")
of "uint", "uint64": return ident("culonglong")
of "string": return ident("cstring")
else: discard
return t # already a C-compatible type
macro ffiExport*(prc: untyped): untyped =
## Mark a no-argument proc as a simple synchronous C export (see module doc):
## native Nim return type, bridged to the C ABI, with the runtime bootstrapped.
prc.expectKind({nnkProcDef, nnkFuncDef})
let nameNode = if prc.name.kind == nnkPostfix: prc.name[1] else: prc.name
let exportName = $nameNode
let params = prc.params
if params.len > 1:
error("ffiExport supports no-argument procs; use {.ffi.} for calls with arguments")
let nativeRet = params[0]
let cRet = cReturnType(nativeRet)
# The user's body becomes a private impl proc; the exported wrapper converts.
let implName = genSym(nskProc, exportName & "Impl")
var impl = copyNimTree(prc)
impl[0] = implName # rename
impl[4] = newEmptyNode() # drop pragmas (internal, not exported)
let wrapName = ident(exportName)
let boot = quote do:
when declared(initializeLibrary):
initializeLibrary()
var res = newStmtList(impl)
if nativeRet.kind == nnkIdent and $nativeRet == "string":
# string -> const char*: keep the bytes alive in shared memory across the call.
let buf = genSym(nskVar, exportName & "Buf")
res.add quote do:
var `buf` {.global.}: pointer = nil
proc `wrapName`(): cstring {.exportc: `exportName`, cdecl, dynlib.} =
`boot`
let s = `implName`()
if `buf` != nil: deallocShared(`buf`)
`buf` = allocShared(s.len + 1)
if s.len > 0: copyMem(`buf`, unsafeAddr s[0], s.len)
cast[ptr char](cast[uint](`buf`) + uint(s.len))[] = '\0'
return cast[cstring](`buf`)
elif nativeRet.kind == nnkEmpty:
res.add quote do:
proc `wrapName`() {.exportc: `exportName`, cdecl, dynlib.} =
`boot`
`implName`()
else:
# scalar: convert the native result to the C return type (cint / culonglong / …).
res.add quote do:
proc `wrapName`(): `cRet` {.exportc: `exportName`, cdecl, dynlib.} =
`boot`
return `cRet`(`implName`())
return res