simplify ffi generation and add simple Rust example

This commit is contained in:
Ivan FB 2026-05-03 10:01:38 +02:00
parent e3eca63236
commit 26bf173e2c
No known key found for this signature in database
GPG Key ID: DF0C67A04C543270
15 changed files with 2083 additions and 38 deletions

17
.gitignore vendored
View File

@ -1,3 +1,20 @@
nimble.develop
nimble.paths
nimbledeps
# Nim compiler output
*.dylib
*.so
*.dll
tests/test_alloc
tests/test_ffi_context
tests/test_serial
# Generated binding crates (regenerated by `nimble genbindings_*`)
examples/**/nim_bindings/
# Cargo build artifacts
examples/**/rust_client/target/
# Development plan (local only)
PLAN.md

View File

@ -0,0 +1,50 @@
import ffi, chronos
declareLibrary("nimtimer")
# The library's main state type. The FFI context owns one instance.
type NimTimer = object
name: string # set at creation time, read back in each response
ffiType:
type TimerConfig = object
name: string
ffiType:
type EchoRequest = object
message: string
delayMs: int # how long chronos sleeps before replying
ffiType:
type EchoResponse = object
echoed: string
timerName: string # proves that the timer's own state is accessible
# --- Constructor -----------------------------------------------------------
# Called once from Rust. Creates the FFIContext + NimTimer.
# Uses chronos (await sleepAsync) so the body is async.
proc nimtimer_create*(
config: TimerConfig
): Future[Result[NimTimer, string]] {.ffiCtor.} =
await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread
return ok(NimTimer(name: config.name))
# --- Async method ----------------------------------------------------------
# Waits `delayMs` milliseconds (non-blocking, on the chronos event loop)
# then echoes the message back with a request counter.
proc nimtimer_echo*(
timer: NimTimer, req: EchoRequest
): Future[Result[EchoResponse, string]] {.ffi.} =
await sleepAsync(req.delayMs.milliseconds)
return ok(EchoResponse(echoed: req.message, timerName: timer.name))
# --- Sync method -----------------------------------------------------------
# No await — the macro detects this and fires the callback inline,
# without going through the request channel.
proc nimtimer_version*(
timer: NimTimer
): Future[Result[string, string]] {.ffi.} =
return ok("nim-timer v0.1.0")
when defined(ffiGenBindings):
genBindings("rust", "examples/nim_timer/nim_bindings", "../nim_timer.nim")

115
examples/nim_timer/rust_client/Cargo.lock generated Normal file
View File

@ -0,0 +1,115 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nimtimer"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rust_client"
version = "0.1.0"
dependencies = [
"nimtimer",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -0,0 +1,8 @@
[package]
name = "rust_client"
version = "0.1.0"
edition = "2021"
[dependencies]
nimtimer = { path = "../nim_bindings" }
serde_json = "1"

View File

@ -0,0 +1,47 @@
// Rust client for the nim_timer shared library built with nim-ffi + chronos.
//
// This file uses the generated `nimtimer` crate, which wraps all the raw FFI
// boilerplate (extern "C" declarations, callback machinery, JSON encode/decode).
//
// To regenerate the `nim_bindings` crate:
// nim c --mm:orc -d:chronicles_log_level=WARN --nimMainPrefix:libnimtimer \
// -d:ffiGenBindings examples/nim_timer/nim_timer.nim
use nimtimer::{EchoRequest, NimTimerCtx, TimerConfig};
use std::time::Duration;
fn main() {
let timeout = Duration::from_secs(5);
// ── 1. Create the timer service ────────────────────────────────────────
let ctx = NimTimerCtx::create(TimerConfig { name: "demo".into() }, timeout)
.expect("nimtimer_create failed");
println!("[1] Context created");
// ── 2. Sync call: version ──────────────────────────────────────────────
let version = ctx.version().expect("nimtimer_version failed");
println!("[2] Version (sync call, callback fired inline): {version}");
// ── 3. Async call: echo (200 ms delay) ────────────────────────────────
let echo = ctx
.echo(EchoRequest {
message: "hello from Rust".into(),
delay_ms: 200,
})
.expect("nimtimer_echo failed");
println!(
"[3] Echo (async, 200 ms chronos delay): echoed={}, timerName={}",
echo.echoed, echo.timer_name
);
// ── 4. A second echo ──────────────────────────────────────────────────
let echo2 = ctx
.echo(EchoRequest {
message: "second request".into(),
delay_ms: 50,
})
.expect("second nimtimer_echo failed");
println!("[4] Echo: echoed={}, timerName={}", echo2.echoed, echo2.timer_name);
println!("\nDone. The Nim FFI thread and watchdog are still running.");
println!("(In a real app, call nimtimer_destroy to join them gracefully.)");
}

View File

@ -2,9 +2,9 @@ import std/[atomics, tables]
import chronos, chronicles
import
ffi/internal/[ffi_library, ffi_macro],
ffi/[alloc, ffi_types, ffi_context, ffi_thread_request]
ffi/[alloc, ffi_types, ffi_context, ffi_thread_request, serial]
export atomics, tables
export chronos, chronicles
export
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_context, ffi_thread_request
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_context, ffi_thread_request, serial

View File

@ -19,6 +19,7 @@ task buildffi, "Compile the library":
task test, "Run all tests":
exec "nim c -r " & nimFlags & " tests/test_alloc.nim"
exec "nim c -r " & nimFlags & " tests/test_serial.nim"
exec "nim c -r " & nimFlags & " tests/test_ffi_context.nim"
task test_alloc, "Run alloc unit tests":
@ -26,3 +27,9 @@ task test_alloc, "Run alloc unit tests":
task test_ffi, "Run FFI context integration tests":
exec "nim c -r " & nimFlags & " tests/test_ffi_context.nim"
task test_serial, "Run serial unit tests":
exec "nim c -r " & nimFlags & " tests/test_serial.nim"
task genbindings_example, "Generate Rust bindings for the nim_timer example":
exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -o:/dev/null examples/nim_timer/nim_timer.nim"

35
ffi/codegen/meta.nim Normal file
View File

@ -0,0 +1,35 @@
## Compile-time metadata types for FFI binding generation.
## Populated by the {.ffiCtor.} and {.ffi.} macros and consumed by codegen.
type
FFIParamMeta* = object
name*: string # Nim param name, e.g. "req"
typeName*: string # Nim type name, e.g. "EchoRequest"
isPtr*: bool # true if the type is `ptr T`
FFIProcKind* = enum
ffiCtorKind
ffiFfiKind
FFIProcMeta* = object
procName*: string # e.g. "nimtimer_echo"
libName*: string # library name, e.g. "nimtimer"
kind*: FFIProcKind
libTypeName*: string # e.g. "NimTimer"
extraParams*: seq[FFIParamMeta] # all params except the lib param
returnTypeName*: string # e.g. "EchoResponse", "string", "pointer"
returnIsPtr*: bool # true if return type is ptr T
isAsync*: bool
FFIFieldMeta* = object
name*: string # e.g. "delayMs"
typeName*: string # e.g. "int"
FFITypeMeta* = object
name*: string
fields*: seq[FFIFieldMeta]
# Compile-time registries populated by the macros
var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta]
var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta]
var currentLibName* {.compileTime.}: string

417
ffi/codegen/rust.nim Normal file
View File

@ -0,0 +1,417 @@
## Rust binding generator for the nim-ffi framework.
## Generates a complete Rust crate from compile-time FFI metadata.
import std/[os, strutils]
import ./meta
# ---------------------------------------------------------------------------
# Name conversion helpers
# ---------------------------------------------------------------------------
proc toSnakeCase*(s: string): string =
## Converts camelCase to snake_case.
## e.g. "delayMs" → "delay_ms", "timerName" → "timer_name"
result = ""
for i, c in s:
if c.isUpperAscii() and i > 0:
result.add('_')
result.add(c.toLowerAscii())
proc toPascalCase*(s: string): string =
## Converts the first letter to uppercase.
if s.len == 0: return s
result = s
result[0] = s[0].toUpperAscii()
proc nimTypeToRust*(typeName: string): string =
## Maps Nim type names to Rust type names.
case typeName
of "string", "cstring": "String"
of "int", "int64": "i64"
of "int32": "i32"
of "bool": "bool"
of "float", "float64": "f64"
of "pointer": "usize"
else: toPascalCase(typeName)
proc deriveLibName*(procs: seq[FFIProcMeta]): string =
## Extracts the common prefix before the first `_` from proc names.
## e.g. ["nimtimer_create", "nimtimer_echo"] → "nimtimer"
if currentLibName.len > 0:
return currentLibName
if procs.len == 0:
return "unknown"
let first = procs[0].procName
let parts = first.split('_')
if parts.len > 0:
return parts[0]
return "unknown"
proc stripLibPrefix*(procName: string, libName: string): string =
## Strips the library prefix from a proc name.
## e.g. "nimtimer_echo", "nimtimer" → "echo"
let prefix = libName & "_"
if procName.startsWith(prefix):
return procName[prefix.len .. ^1]
return procName
# ---------------------------------------------------------------------------
# File generators
# ---------------------------------------------------------------------------
proc generateCargoToml*(libName: string): string =
result = """[package]
name = "$1"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
""" % [libName]
proc generateBuildRs*(libName: string, nimSrcRelPath: string): string =
## Generates build.rs that compiles the Nim library.
## nimSrcRelPath is relative to the output (crate) directory.
let escapedSrc = nimSrcRelPath.replace("\\", "\\\\")
result = """use std::path::PathBuf;
use std::process::Command;
fn main() {
let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let nim_src = manifest.join("$1");
let nim_src = nim_src.canonicalize().unwrap_or(manifest.join("$1"));
// Walk up to find the nim-ffi repo root (directory containing nim_src's library)
// The repo root is where nim c should be run from (contains config.nims).
// We assume nim_src lives somewhere under repo_root.
// Derive repo_root as the ancestor that contains the .nimble file or config.nims.
let mut repo_root = nim_src.clone();
loop {
repo_root = match repo_root.parent() {
Some(p) => p.to_path_buf(),
None => break,
};
if repo_root.join("config.nims").exists() || repo_root.join("ffi.nimble").exists() {
break;
}
}
#[cfg(target_os = "macos")]
let lib_ext = "dylib";
#[cfg(target_os = "linux")]
let lib_ext = "so";
let out_lib = repo_root.join(format!("lib$2.{lib_ext}"));
let mut cmd = Command::new("nim");
cmd.arg("c")
.arg("--mm:orc")
.arg("-d:chronicles_log_level=WARN")
.arg("--app:lib")
.arg("--noMain")
.arg(format!("--nimMainPrefix:lib$2"))
.arg(format!("-o:{}", out_lib.display()));
cmd.arg(&nim_src).current_dir(&repo_root);
let status = cmd.status().expect("failed to run nim compiler");
assert!(status.success(), "Nim compilation failed");
println!("cargo:rustc-link-search={}", repo_root.display());
println!("cargo:rustc-link-lib=$2");
println!("cargo:rerun-if-changed={}", nim_src.display());
}
""" % [escapedSrc, libName]
proc generateLibRs*(): string =
result = """mod ffi;
mod types;
mod api;
pub use types::*;
pub use api::*;
"""
proc generateFfiRs*(procs: seq[FFIProcMeta]): string =
## Generates ffi.rs with extern "C" declarations for all procs.
var lines: seq[string] = @[]
lines.add("use std::os::raw::{c_char, c_int, c_void};")
lines.add("")
lines.add("pub type FfiCallback = unsafe extern \"C\" fn(")
lines.add(" ret: c_int,")
lines.add(" msg: *const c_char,")
lines.add(" len: usize,")
lines.add(" user_data: *mut c_void,")
lines.add(");")
lines.add("")
# Collect unique lib names for #[link(...)]
var libNames: seq[string] = @[]
for p in procs:
if p.libName notin libNames:
libNames.add(p.libName)
# Derive lib name from proc names if not set
var linkLibName = ""
if libNames.len > 0 and libNames[0].len > 0:
linkLibName = libNames[0]
else:
# derive from first proc name
if procs.len > 0:
let parts = procs[0].procName.split('_')
if parts.len > 0:
linkLibName = parts[0]
lines.add("#[link(name = \"$1\")]" % [linkLibName])
lines.add("extern \"C\" {")
for p in procs:
var params: seq[string] = @[]
if p.kind == ffiFfiKind:
# Method: ctx comes first
params.add("ctx: *mut c_void")
params.add("callback: FfiCallback")
params.add("user_data: *mut c_void")
for ep in p.extraParams:
params.add("$1_json: *const c_char" % [toSnakeCase(ep.name)])
else:
# Constructor: no ctx
for ep in p.extraParams:
params.add("$1_json: *const c_char" % [toSnakeCase(ep.name)])
params.add("callback: FfiCallback")
params.add("user_data: *mut c_void")
let paramStr = params.join(", ")
lines.add(" pub fn $1($2) -> c_int;" % [p.procName, paramStr])
lines.add("}")
result = lines.join("\n") & "\n"
proc generateTypesRs*(types: seq[FFITypeMeta]): string =
## Generates types.rs with Rust structs for all FFI types.
var lines: seq[string] = @[]
lines.add("use serde::{Deserialize, Serialize};")
lines.add("")
for t in types:
lines.add("#[derive(Debug, Clone, Serialize, Deserialize)]")
lines.add("pub struct $1 {" % [t.name])
for f in t.fields:
let snakeName = toSnakeCase(f.name)
let rustType = nimTypeToRust(f.typeName)
# Add serde rename if camelCase name differs from snake_case
if snakeName != f.name:
lines.add(" #[serde(rename = \"$1\")]" % [f.name])
lines.add(" pub $1: $2," % [snakeName, rustType])
lines.add("}")
lines.add("")
result = lines.join("\n")
proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string =
## Generates api.rs with the high-level Rust API.
var lines: seq[string] = @[]
# Find ctor and method procs
var ctors: seq[FFIProcMeta] = @[]
var methods: seq[FFIProcMeta] = @[]
for p in procs:
if p.kind == ffiCtorKind:
ctors.add(p)
else:
methods.add(p)
# Derive the lib type name from ctor or from libName
var libTypeName = ""
if ctors.len > 0:
libTypeName = ctors[0].libTypeName
else:
# Fallback: PascalCase of libName
libTypeName = toPascalCase(libName)
let ctxTypeName = libTypeName & "Ctx"
# Imports
lines.add("use std::ffi::{CStr, CString};")
lines.add("use std::os::raw::{c_char, c_int, c_void};")
lines.add("use std::sync::{Arc, Condvar, Mutex};")
lines.add("use std::time::Duration;")
lines.add("use super::ffi;")
lines.add("use super::types::*;")
lines.add("")
# FfiCallbackResult struct
lines.add("#[derive(Default)]")
lines.add("struct FfiCallbackResult {")
lines.add(" payload: Option<Result<String, String>>,")
lines.add("}")
lines.add("")
lines.add("type Pair = Arc<(Mutex<FfiCallbackResult>, Condvar)>;")
lines.add("")
# on_result callback
lines.add("unsafe extern \"C\" fn on_result(")
lines.add(" ret: c_int,")
lines.add(" msg: *const c_char,")
lines.add(" _len: usize,")
lines.add(" user_data: *mut c_void,")
lines.add(") {")
lines.add(" let pair = Arc::from_raw(user_data as *const (Mutex<FfiCallbackResult>, Condvar));")
lines.add(" {")
lines.add(" let (lock, cvar) = &*pair;")
lines.add(" let mut state = lock.lock().unwrap();")
lines.add(" state.payload = Some(if ret == 0 {")
lines.add(" Ok(CStr::from_ptr(msg).to_string_lossy().into_owned())")
lines.add(" } else {")
lines.add(" Err(CStr::from_ptr(msg).to_string_lossy().into_owned())")
lines.add(" });")
lines.add(" cvar.notify_one();")
lines.add(" }")
lines.add(" std::mem::forget(pair);")
lines.add("}")
lines.add("")
# ffi_call helper
lines.add("fn ffi_call<F>(timeout: Duration, f: F) -> Result<String, String>")
lines.add("where")
lines.add(" F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,")
lines.add("{")
lines.add(" let pair: Pair = Arc::new((Mutex::new(FfiCallbackResult::default()), Condvar::new()));")
lines.add(" let raw = Arc::into_raw(pair.clone()) as *mut c_void;")
lines.add(" let ret = f(on_result, raw);")
lines.add(" if ret == 2 {")
lines.add(" return Err(\"RET_MISSING_CALLBACK (internal error)\".into());")
lines.add(" }")
lines.add(" let (lock, cvar) = &*pair;")
lines.add(" let guard = lock.lock().unwrap();")
lines.add(" let (guard, timed_out) = cvar")
lines.add(" .wait_timeout_while(guard, timeout, |s| s.payload.is_none())")
lines.add(" .unwrap();")
lines.add(" if timed_out.timed_out() {")
lines.add(" return Err(format!(\"timed out after {:?}\", timeout));")
lines.add(" }")
lines.add(" guard.payload.clone().unwrap()")
lines.add("}")
lines.add("")
# Ctx struct
lines.add("/// High-level context for `$1`." % [libTypeName])
lines.add("pub struct $1 {" % [ctxTypeName])
lines.add(" ptr: *mut c_void,")
lines.add(" timeout: Duration,")
lines.add("}")
lines.add("")
lines.add("unsafe impl Send for $1 {}" % [ctxTypeName])
lines.add("unsafe impl Sync for $1 {}" % [ctxTypeName])
lines.add("")
# impl block
lines.add("impl $1 {" % [ctxTypeName])
# Constructor method(s)
for ctor in ctors:
var paramsList: seq[string] = @[]
for ep in ctor.extraParams:
let rustType = nimTypeToRust(ep.typeName)
let snakeName = toSnakeCase(ep.name)
paramsList.add("$1: $2" % [snakeName, rustType])
paramsList.add("timeout: Duration")
let paramsStr = paramsList.join(", ")
lines.add(" pub fn create($1) -> Result<Self, String> {" % [paramsStr])
# Serialize extra params
for ep in ctor.extraParams:
let snakeName = toSnakeCase(ep.name)
let rustType = nimTypeToRust(ep.typeName)
if rustType == "String":
# Primitive string — wrap it in JSON
lines.add(" let $1_json_str = serde_json::to_string(&$1).map_err(|e| e.to_string())?;" % [snakeName])
lines.add(" let $1_c = CString::new($1_json_str).unwrap();" % [snakeName])
else:
lines.add(" let $1_json = serde_json::to_string(&$1).map_err(|e| e.to_string())?;" % [snakeName])
lines.add(" let $1_c = CString::new($1_json).unwrap();" % [snakeName])
# Build the ffi_call closure
var ffiCallArgs: seq[string] = @[]
for ep in ctor.extraParams:
let snakeName = toSnakeCase(ep.name)
ffiCallArgs.add("$1_c.as_ptr()" % [snakeName])
ffiCallArgs.add("cb")
ffiCallArgs.add("ud")
let ffiCallArgsStr = ffiCallArgs.join(", ")
lines.add(" let raw = ffi_call(timeout, |cb, ud| unsafe {")
lines.add(" ffi::$1($2)" % [ctor.procName, ffiCallArgsStr])
lines.add(" })?;")
lines.add(" // ctor returns the context address as a plain decimal string")
lines.add(" let addr: usize = raw.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;")
lines.add(" Ok(Self { ptr: addr as *mut c_void, timeout })")
lines.add(" }")
lines.add("")
# Method implementations
for m in methods:
let methodName = stripLibPrefix(m.procName, libName)
let retRustType = nimTypeToRust(m.returnTypeName)
var paramsList: seq[string] = @[]
for ep in m.extraParams:
let rustType = nimTypeToRust(ep.typeName)
let snakeName = toSnakeCase(ep.name)
paramsList.add("$1: $2" % [snakeName, rustType])
let paramsStr = if paramsList.len > 0: ", " & paramsList.join(", ") else: ""
lines.add(" pub fn $1(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retRustType])
# Serialize extra params
for ep in m.extraParams:
let snakeName = toSnakeCase(ep.name)
let rustType = nimTypeToRust(ep.typeName)
if rustType == "String":
lines.add(" let $1_json_str = serde_json::to_string(&$1).map_err(|e| e.to_string())?;" % [snakeName])
lines.add(" let $1_c = CString::new($1_json_str).unwrap();" % [snakeName])
else:
lines.add(" let $1_json = serde_json::to_string(&$1).map_err(|e| e.to_string())?;" % [snakeName])
lines.add(" let $1_c = CString::new($1_json).unwrap();" % [snakeName])
# Build ffi call args: ctx first, then callback/ud, then json args
var ffiArgs: seq[string] = @["self.ptr", "cb", "ud"]
for ep in m.extraParams:
let snakeName = toSnakeCase(ep.name)
ffiArgs.add("$1_c.as_ptr()" % [snakeName])
let ffiArgsStr = ffiArgs.join(", ")
lines.add(" let raw = ffi_call(self.timeout, |cb, ud| unsafe {")
lines.add(" ffi::$1($2)" % [m.procName, ffiArgsStr])
lines.add(" })?;")
# Deserialize return value
if retRustType == "String":
lines.add(" serde_json::from_str::<String>(&raw).map_err(|e| e.to_string())")
elif retRustType == "usize":
lines.add(" raw.parse::<usize>().map_err(|e| e.to_string())")
else:
lines.add(" serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % [retRustType])
lines.add(" }")
lines.add("")
lines.add("}")
result = lines.join("\n") & "\n"
proc generateRustCrate*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
) =
## Generates a complete Rust crate in outputDir.
createDir(outputDir)
createDir(outputDir / "src")
writeFile(outputDir / "Cargo.toml", generateCargoToml(libName))
writeFile(outputDir / "build.rs", generateBuildRs(libName, nimSrcRelPath))
writeFile(outputDir / "src" / "lib.rs", generateLibRs())
writeFile(outputDir / "src" / "ffi.rs", generateFfiRs(procs))
writeFile(outputDir / "src" / "types.rs", generateTypesRs(types))
writeFile(outputDir / "src" / "api.rs", generateApiRs(procs, libName))

View File

@ -197,7 +197,8 @@ proc ffiThreadBody[T](ctx: ptr FFIContext[T]) {.thread.} =
chronicles.error "ffi thread could not receive a request"
continue
ctx.myLib = addr ffiReqHandler
if ctx.myLib.isNil():
ctx.myLib = addr ffiReqHandler
## Handle the request
asyncSpawn processRequest(request, ctx)

View File

@ -1,6 +1,10 @@
import std/[macros, atomics], strformat, chronicles, chronos
import ../codegen/meta
macro declareLibrary*(libraryName: static[string]): untyped =
# Record the library name for binding generation
currentLibName = libraryName
var res = newStmtList()
## Generate {.pragma: exported, exportc, cdecl, raises: [].}

View File

@ -1,6 +1,39 @@
import std/[macros, tables]
import std/[macros, tables, strutils]
import chronos
import ../ffi_types
import ../codegen/meta
when defined(ffiGenBindings):
import ../codegen/rust
# ---------------------------------------------------------------------------
# String helpers used by multiple macros
# ---------------------------------------------------------------------------
proc capitalizeFirstLetter(s: string): string =
## Returns `s` with the first character uppercased.
if s.len == 0:
return s
result = s
result[0] = s[0].toUpperAscii()
proc toCamelCase(s: string): string =
## Converts snake_case or mixed identifiers to CamelCase for type names.
## e.g. "testlib_create" -> "TestlibCreate"
var parts = s.split('_')
result = ""
for p in parts:
result.add capitalizeFirstLetter(p)
proc bodyHasAwait(n: NimNode): bool =
## Returns true if the AST node `n` contains any `await` or `waitFor` call.
if n.kind in {nnkCall, nnkCommand}:
let callee = n[0]
if callee.kind == nnkIdent and callee.strVal in ["await", "waitFor"]:
return true
for child in n:
if bodyHasAwait(child):
return true
false
proc extractFieldsFromLambda(body: NimNode): seq[NimNode] =
## Extracts the fields (params) from the given lambda body, when using the registerReqFFI macro.
@ -254,7 +287,7 @@ proc buildProcessFFIRequestProc(reqTypeName, reqHandler, body: NimNode): NimNode
newStmtList(procNode.body)
let newBody = newStmtList()
let reqIdent = ident("req")
let reqIdent = genSym(nskLet, "req")
newBody.add quote do:
let `reqIdent`: ptr `reqTypeName` = cast[ptr `reqTypeName`](request)
@ -423,55 +456,35 @@ macro processReq*(
when defined(ffiDumpMacros):
echo result.repr
macro ffi*(prc: untyped): untyped =
macro ffiRaw*(prc: untyped): untyped =
## Defines an FFI-exported proc that registers a request handler to be executed
## asynchronously in the FFI thread.
##
## {.ffi.} implicitly implies: ...Return[Future[Result[string, string]] {.async.}
##
## When using {.ffi.}, the first three parameters must be:
##
## This is the "raw" / legacy form of the macro where the developer writes
## the ctx, callback, and userData parameters explicitly.
##
## {.ffiRaw.} implicitly implies: ...Return[Future[Result[string, string]] {.async.}
##
## When using {.ffiRaw.}, the first three parameters must be:
## - ctx: ptr FFIContext[T] <-- T is the type that handles the FFI requests
## - callback: FFICallBack
## - userData: pointer
## Then, additional parameters may be defined as needed, after these first three, always
## considering that only no-GC'ed (or C-like) types are allowed.
##
##
## e.g.:
## proc waku_version(
## ctx: ptr FFIContext[Waku], callback: FFICallBack, userData: pointer
## ) {.ffi.} =
## ) {.ffiRaw.} =
## return ok(WakuNodeVersionString)
##
## e.g2.:
## proc waku_start(
## ctx: ptr FFIContext[Waku], callback: FFICallBack, userData: pointer
## ) {.ffi.} =
## (await startWaku(ctx[].myLib)).isOkOr:
## error "START_NODE failed", error = error
## return err("failed to start: " & $error)
## return ok("")
##
## e.g3.:
## proc waku_peer_exchange_request(
## ctx: ptr FFIContext[Waku],
## callback: FFICallBack,
## userData: pointer,
## numPeers: uint64,
## ) {.ffi.} =
## let numValidPeers = (await performPeerExchangeRequestTo(numPeers, ctx.myLib[])).valueOr:
## error "waku_peer_exchange_request failed", error = error
## return err("failed peer exchange: " & $error)
## return ok($numValidPeers)
##
## In these examples, notice that ctx.myLib is of type "ptr Waku", being Waku main library type.
##
##
let procName = prc[0]
let formalParams = prc[3]
let bodyNode = prc[^1]
if formalParams.len < 2:
error("`.ffi.` procs require at least 1 parameter")
error("`.ffiRaw.` procs require at least 1 parameter")
let firstParam = formalParams[1]
let paramIdent = firstParam[0]
@ -544,3 +557,847 @@ macro ffi*(prc: untyped): untyped =
when defined(ffiDumpMacros):
echo result.repr
macro ffi*(prc: untyped): untyped =
## Simplified FFI macro — developer writes a clean Nim-idiomatic signature.
##
## The annotated proc must:
## - Have a first parameter of the library type (e.g. w: Waku)
## - Optionally have additional Nim-typed parameters
## - Return Future[Result[RetType, string]]
## - NOT include ctx, callback, or userData in its signature
##
## Example:
## proc mylib_send*(w: MyLib, cfg: SendConfig): Future[Result[string, string]] {.ffi.} =
## return ok("done")
##
## The macro generates:
## 1. A named async helper proc (MyLibSendBody) containing the user body
## 2. A registerReqFFI call that deserializes cstring args and calls the helper
## 3. A C-exported proc with ctx/callback/userData + cstring params
let procName = prc[0]
let formalParams = prc[3]
let bodyNode = prc[^1]
# Need at least the library param
if formalParams.len < 2:
error("`.ffi.` procs require at least 1 parameter (the library type)")
# Extract LibType from the first parameter
let firstParam = formalParams[1]
let libParamName = firstParam[0] # e.g. `w`
let libTypeName = firstParam[1] # e.g. `Waku`
# Extract the return type: Future[Result[RetType, string]]
# RetType is used in the body helper proc signature
let retTypeNode = formalParams[0]
if retTypeNode.kind == nnkEmpty:
error("`.ffi.` proc must have an explicit return type Future[Result[RetType, string]]")
if retTypeNode.kind != nnkBracketExpr or $retTypeNode[0] != "Future":
error("`.ffi.` return type must be Future[Result[RetType, string]], got: " & retTypeNode.repr)
let resultInner = retTypeNode[1] # Result[RetType, string]
if resultInner.kind != nnkBracketExpr or $resultInner[0] != "Result":
error("`.ffi.` return type must be Future[Result[RetType, string]], got: " & retTypeNode.repr)
# Collect additional param names and types (everything after the first param)
var extraParamNames: seq[string] = @[]
var extraParamTypes: seq[NimNode] = @[]
for i in 2 ..< formalParams.len:
let p = formalParams[i]
for j in 0 ..< p.len - 2:
extraParamNames.add($p[j])
extraParamTypes.add(p[^2])
# Generate type/proc names from proc name
let procNameStr = block:
let raw = $procName
if raw.endsWith("*"): raw[0 ..^ 2] else: raw
let camelName = toCamelCase(procNameStr)
# Names of generated things
let reqTypeName = ident(camelName & "Req")
let helperProcName = ident(camelName & "Body")
# Determine whether the body uses async operations
let isAsync = bodyHasAwait(bodyNode)
# Strip the * from the exported proc name (needed for both branches)
let exportedProcName =
if procName.kind == nnkPostfix:
procName[1]
else:
procName
# Common exported params (needed for both branches)
let ctxType = nnkPtrTy.newTree(
nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)
)
if isAsync:
# -------------------------------------------------------------------------
# ASYNC PATH — existing behavior
# -------------------------------------------------------------------------
# -------------------------------------------------------------------------
# 1. Named async helper proc containing the user body
# -------------------------------------------------------------------------
# proc MyLibSendBody*(w: Waku, cfg: SendConfig): Future[Result[RetType, string]] {.async.} =
# <user body>
var helperParams = newSeq[NimNode]()
helperParams.add(retTypeNode)
# First param: w: LibType (by value, not pointer)
helperParams.add(newIdentDefs(libParamName, libTypeName))
for i in 0 ..< extraParamNames.len:
helperParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i]))
let helperProc = newProc(
name = postfix(helperProcName, "*"),
params = helperParams,
body = newStmtList(bodyNode),
pragmas = newTree(nnkPragma, ident("async")),
)
# -------------------------------------------------------------------------
# 2. registerReqFFI call
# -------------------------------------------------------------------------
let futStrStr = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(ident("Result"), ident("string"), ident("string")),
)
let ctxHandlerName = ident("ffiCtxHandler")
let ptrFfiCtx = nnkPtrTy.newTree(
nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)
)
var lambdaParams = newSeq[NimNode]()
lambdaParams.add(futStrStr)
for name in extraParamNames:
lambdaParams.add(newIdentDefs(ident(name & "Json"), ident("cstring")))
let lambdaBody = newStmtList()
for i in 0 ..< extraParamNames.len:
let jsonIdent = ident(extraParamNames[i] & "Json")
let paramIdent = ident(extraParamNames[i])
let ptype = extraParamTypes[i]
lambdaBody.add quote do:
let `paramIdent` = ffiDeserialize(`jsonIdent`, `ptype`).valueOr:
return err($error)
let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib"))
let libValDeref = newTree(nnkDerefExpr, ctxMyLib)
let helperCall = newTree(nnkCall, helperProcName, libValDeref)
for name in extraParamNames:
helperCall.add(ident(name))
let retValIdent = ident("retVal")
lambdaBody.add quote do:
let `retValIdent` = (await `helperCall`).valueOr:
return err($error)
lambdaBody.add quote do:
return ok(ffiSerialize(`retValIdent`))
let lambdaNode = newProc(
name = newEmptyNode(),
params = lambdaParams,
body = lambdaBody,
pragmas = newTree(nnkPragma, ident("async")),
)
let registerReq = quote:
registerReqFFI(`reqTypeName`, `ctxHandlerName`: `ptrFfiCtx`):
`lambdaNode`
# -------------------------------------------------------------------------
# 3. C-exported proc (async path)
# -------------------------------------------------------------------------
var exportedParams = newSeq[NimNode]()
exportedParams.add(ident("cint"))
exportedParams.add(newIdentDefs(ident("ctx"), ctxType))
exportedParams.add(newIdentDefs(ident("callback"), ident("FFICallBack")))
exportedParams.add(newIdentDefs(ident("userData"), ident("pointer")))
for name in extraParamNames:
exportedParams.add(newIdentDefs(ident(name & "Json"), ident("cstring")))
let ffiBody = newStmtList()
ffiBody.add quote do:
if callback.isNil:
return RET_MISSING_CALLBACK
ffiBody.add quote do:
if ctx.isNil or ctx[].myLib.isNil:
let errStr = "context not initialized"
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
let newReqCall = newTree(nnkCall, ident("ffiNewReq"))
newReqCall.add(reqTypeName)
newReqCall.add(ident("callback"))
newReqCall.add(ident("userData"))
for name in extraParamNames:
newReqCall.add(ident(name & "Json"))
let sendCall = newCall(
newDotExpr(ident("ffi_context"), ident("sendRequestToFFIThread")),
ident("ctx"),
newReqCall,
)
let sendResIdent = genSym(nskLet, "sendRes")
ffiBody.add quote do:
let `sendResIdent` =
try:
`sendCall`
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `sendResIdent`.isErr():
let errStr = "error in sendRequestToFFIThread: " & `sendResIdent`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
return RET_OK
let ffiProc = newProc(
name = exportedProcName,
params = exportedParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
ident("exportc"),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
# Register proc metadata for binding generation
block:
var ffiExtraParams: seq[FFIParamMeta] = @[]
for i in 0 ..< extraParamNames.len:
let ptype = extraParamTypes[i]
var isPtr = false
var tn = ""
if ptype.kind == nnkPtrTy:
isPtr = true
tn = $ptype[0]
else:
tn = $ptype
ffiExtraParams.add(FFIParamMeta(name: extraParamNames[i], typeName: tn, isPtr: isPtr))
let retTypeInner = resultInner[1] # RetType from Result[RetType, string]
var retIsPtr = false
var retTn = ""
if retTypeInner.kind == nnkPtrTy:
retIsPtr = true
retTn = $retTypeInner[0]
else:
retTn = $retTypeInner
ffiProcRegistry.add(FFIProcMeta(
procName: procNameStr,
libName: currentLibName,
kind: ffiFfiKind,
libTypeName: $libTypeName,
extraParams: ffiExtraParams,
returnTypeName: retTn,
returnIsPtr: retIsPtr,
isAsync: true,
))
result = newStmtList(helperProc, registerReq, ffiProc)
else:
# -------------------------------------------------------------------------
# SYNC PATH — no await/waitFor in body; bypass thread-channel machinery
# -------------------------------------------------------------------------
# -------------------------------------------------------------------------
# 1. Named sync helper proc (no {.async.}) with Result[RetType, string] return
# -------------------------------------------------------------------------
# proc MyLibVersionBody*(w: LibType): Result[RetType, string] =
# <user body>
let syncRetType = resultInner # Result[RetType, string]
var syncHelperParams = newSeq[NimNode]()
syncHelperParams.add(syncRetType)
syncHelperParams.add(newIdentDefs(libParamName, libTypeName))
for i in 0 ..< extraParamNames.len:
syncHelperParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i]))
let syncHelperProc = newProc(
name = postfix(helperProcName, "*"),
params = syncHelperParams,
body = newStmtList(bodyNode),
pragmas = newEmptyNode(),
)
# -------------------------------------------------------------------------
# 2. C-exported proc (sync path) — calls helper inline, fires callback inline
# -------------------------------------------------------------------------
var syncExportedParams = newSeq[NimNode]()
syncExportedParams.add(ident("cint"))
syncExportedParams.add(newIdentDefs(ident("ctx"), ctxType))
syncExportedParams.add(newIdentDefs(ident("callback"), ident("FFICallBack")))
syncExportedParams.add(newIdentDefs(ident("userData"), ident("pointer")))
for name in extraParamNames:
syncExportedParams.add(newIdentDefs(ident(name & "Json"), ident("cstring")))
let syncFfiBody = newStmtList()
syncFfiBody.add quote do:
if callback.isNil:
return RET_MISSING_CALLBACK
syncFfiBody.add quote do:
if ctx.isNil or ctx[].myLib.isNil:
let errStr = "context not initialized"
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
# Inline deserialization of each extra param
for i in 0 ..< extraParamNames.len:
let jsonIdent = ident(extraParamNames[i] & "Json")
let paramIdent = ident(extraParamNames[i])
let ptype = extraParamTypes[i]
syncFfiBody.add quote do:
let `paramIdent` = ffiDeserialize(`jsonIdent`, `ptype`).valueOr:
let errStr = "deserialization failed: " & $error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
# Build the call to the sync helper: helperProcName(ctx[].myLib[], extraParam, ...)
let syncCtxMyLib = newDotExpr(newTree(nnkDerefExpr, ident("ctx")), ident("myLib"))
let syncLibValDeref = newTree(nnkDerefExpr, syncCtxMyLib)
let syncHelperCall = newTree(nnkCall, helperProcName, syncLibValDeref)
for name in extraParamNames:
syncHelperCall.add(ident(name))
let retValOrErrIdent = ident("retValOrErr")
syncFfiBody.add quote do:
let `retValOrErrIdent` = `syncHelperCall`
if `retValOrErrIdent`.isErr():
let errStr = `retValOrErrIdent`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
let serialized = ffiSerialize(`retValOrErrIdent`.value)
callback(RET_OK, unsafeAddr serialized[0], cast[csize_t](serialized.len), userData)
return RET_OK
let syncFfiProc = newProc(
name = exportedProcName,
params = syncExportedParams,
body = syncFfiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
ident("exportc"),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
# Register proc metadata for binding generation (sync path)
block:
var ffiExtraParamsSync: seq[FFIParamMeta] = @[]
for i in 0 ..< extraParamNames.len:
let ptype = extraParamTypes[i]
var isPtr = false
var tn = ""
if ptype.kind == nnkPtrTy:
isPtr = true
tn = $ptype[0]
else:
tn = $ptype
ffiExtraParamsSync.add(FFIParamMeta(name: extraParamNames[i], typeName: tn, isPtr: isPtr))
let retTypeInnerSync = resultInner[1]
var retIsPtrSync = false
var retTnSync = ""
if retTypeInnerSync.kind == nnkPtrTy:
retIsPtrSync = true
retTnSync = $retTypeInnerSync[0]
else:
retTnSync = $retTypeInnerSync
ffiProcRegistry.add(FFIProcMeta(
procName: procNameStr,
libName: currentLibName,
kind: ffiFfiKind,
libTypeName: $libTypeName,
extraParams: ffiExtraParamsSync,
returnTypeName: retTnSync,
returnIsPtr: retIsPtrSync,
isAsync: false,
))
result = newStmtList(syncHelperProc, syncFfiProc)
when defined(ffiDumpMacros):
echo result.repr
# ---------------------------------------------------------------------------
# ffiCtor — constructor macro
# ---------------------------------------------------------------------------
proc buildCtorRequestType(reqTypeName: NimNode, paramNames: seq[string]): NimNode =
## Builds the request object type for a ctor request.
## Each original Nim-typed param becomes a cstring field named <paramName>Json.
##
## e.g. type TestlibCreateCtorReq* = object
## configJson: cstring
var fields: seq[NimNode] = @[]
for name in paramNames:
let fieldName = ident(name & "Json")
fields.add newTree(nnkIdentDefs, fieldName, ident("cstring"), newEmptyNode())
let recList =
if fields.len > 0:
newTree(nnkRecList, fields)
else:
newTree(nnkRecList, newTree(nnkIdentDefs, ident("_placeholder"), ident("pointer"), newEmptyNode()))
let objTy = newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), recList)
let typeName = postfix(reqTypeName, "*")
result = newNimNode(nnkTypeSection).add(newTree(nnkTypeDef, typeName, newEmptyNode(), objTy))
when defined(ffiDumpMacros):
echo result.repr
proc buildCtorDeleteReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimNode =
## Generates ffiDeleteReq for the ctor request type.
var body = newStmtList()
for name in paramNames:
let fieldName = ident(name & "Json")
body.add newCall(
ident("deallocShared"),
newDotExpr(newTree(nnkDerefExpr, ident("self")), fieldName),
)
body.add newCall(ident("deallocShared"), ident("self"))
let selfParam = newIdentDefs(ident("self"), newTree(nnkPtrTy, reqTypeName))
result = newProc(
name = postfix(ident("ffiDeleteReq"), "*"),
params = @[newEmptyNode()] & @[selfParam],
body = body,
)
when defined(ffiDumpMacros):
echo result.repr
proc buildCtorFfiNewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimNode =
## Generates ffiNewReq for the ctor request type.
## Params: T: typedesc[CtorReq], callback: FFICallBack, userData: pointer,
## <paramName>Json: cstring, ...
var formalParams = newSeq[NimNode]()
let typedescParam = newIdentDefs(
ident("T"), nnkBracketExpr.newTree(ident("typedesc"), reqTypeName)
)
formalParams.add(typedescParam)
formalParams.add(newIdentDefs(ident("callback"), ident("FFICallBack")))
formalParams.add(newIdentDefs(ident("userData"), ident("pointer")))
for name in paramNames:
formalParams.add(newIdentDefs(ident(name & "Json"), ident("cstring")))
let retType = newTree(nnkPtrTy, ident("FFIThreadRequest"))
formalParams = @[retType] & formalParams
let reqObjIdent = ident("reqObj")
var newBody = newStmtList()
newBody.add quote do:
var `reqObjIdent` = createShared(T)
for name in paramNames:
let fieldName = ident(name & "Json")
newBody.add quote do:
`reqObjIdent`[].`fieldName` = `fieldName`.alloc()
newBody.add quote do:
let typeStr = $T
var ret = FFIThreadRequest.init(callback, userData, typeStr.cstring, `reqObjIdent`)
proc destroyContent(content: pointer) {.nimcall.} =
ffiDeleteReq(cast[ptr `reqTypeName`](content))
ret[].deleteReqContent = destroyContent
return ret
result = newProc(
name = postfix(ident("ffiNewReq"), "*"),
params = formalParams,
body = newBody,
pragmas = newEmptyNode(),
)
when defined(ffiDumpMacros):
echo result.repr
proc buildCtorBodyProc(
helperName: NimNode,
paramNames: seq[string],
paramTypes: seq[NimNode],
libTypeName: NimNode,
userBody: NimNode,
): NimNode =
## Generates a named top-level async helper proc that contains the user body.
## e.g.:
## proc TestlibCreateCtorBody*(config: SimpleConfig): Future[Result[SimpleLib, string]] {.async.} =
## return ok(SimpleLib(value: config.initialValue))
let innerRetType = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(ident("Result"), libTypeName, ident("string")),
)
var innerParams = newSeq[NimNode]()
innerParams.add(innerRetType)
for i in 0 ..< paramNames.len:
innerParams.add(newIdentDefs(ident(paramNames[i]), paramTypes[i]))
result = newProc(
name = postfix(helperName, "*"),
params = innerParams,
body = newStmtList(userBody),
pragmas = newTree(nnkPragma, ident("async")),
)
when defined(ffiDumpMacros):
echo result.repr
proc buildCtorProcessFFIRequestProc(
reqTypeName: NimNode,
helperName: NimNode,
paramNames: seq[string],
paramTypes: seq[NimNode],
libTypeName: NimNode,
): NimNode =
## Generates the processFFIRequest proc for the ctor.
## The handler:
## 1. Unpacks cstring fields from the request
## 2. Deserializes each cstring to the Nim type
## 3. Calls the helper async proc to get Result[LibType, string]
## 4. Stores the result in ctx.myLib via createShared
## 5. Returns ok($cast[ByteAddress](ctx))
# Build Future[Result[string, string]] return type
let returnType = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(ident("Result"), ident("string"), ident("string")),
)
# The ctx param type: ptr FFIContext[LibType]
let ctxType = nnkPtrTy.newTree(
nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)
)
let typedescParam =
newIdentDefs(ident("T"), nnkBracketExpr.newTree(ident("typedesc"), reqTypeName))
var formalParams: seq[NimNode] = @[]
formalParams.add(returnType)
formalParams.add(typedescParam)
formalParams.add(newIdentDefs(ident("request"), ident("pointer")))
formalParams.add(newIdentDefs(ident("ctx"), ctxType))
# Build the proc body
let newBody = newStmtList()
let reqIdent = ident("req")
let ctxIdent = ident("ctx")
# Cast the request
newBody.add quote do:
let `reqIdent`: ptr `reqTypeName` = cast[ptr `reqTypeName`](request)
# Unpack fields and deserialize each param
for i in 0 ..< paramNames.len:
let fieldName = ident(paramNames[i] & "Json")
let paramName = ident(paramNames[i])
let ptype = paramTypes[i]
newBody.add quote do:
let `fieldName` = `reqIdent`[].`fieldName`
newBody.add quote do:
let `paramName` = ffiDeserialize(`fieldName`, `ptype`).valueOr:
return err($error)
# Call the helper proc with deserialized params
let helperCallNode = newTree(nnkCall, helperName)
for name in paramNames:
helperCallNode.add(ident(name))
let libValIdent = ident("libVal")
newBody.add quote do:
let `libValIdent` = (await `helperCallNode`).valueOr:
return err($error)
# Store in ctx.myLib
let myLibIdent = newDotExpr(newTree(nnkDerefExpr, ctxIdent), ident("myLib"))
newBody.add quote do:
`myLibIdent` = createShared(`libTypeName`)
`myLibIdent`[] = `libValIdent`
# Return context address as decimal string
newBody.add quote do:
return ok($cast[uint](`ctxIdent`))
result = newProc(
name = postfix(ident("processFFIRequest"), "*"),
params = formalParams,
body = newBody,
procType = nnkProcDef,
pragmas = newTree(nnkPragma, ident("async")),
)
when defined(ffiDumpMacros):
echo result.repr
proc addCtorRequestToRegistry(reqTypeName, libTypeName: NimNode): NimNode =
## Registers the ctor request in the registeredRequests table.
## The handler casts reqHandler to ptr FFIContext[LibType] and calls processFFIRequest.
let ctxType = nnkPtrTy.newTree(
nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)
)
let returnType = nnkBracketExpr.newTree(
ident("Future"),
nnkBracketExpr.newTree(ident("Result"), ident("string"), ident("string")),
)
let callExpr = newCall(
newDotExpr(reqTypeName, ident("processFFIRequest")),
ident("request"),
newTree(nnkCast, ctxType, ident("reqHandler")),
)
var newBody = newStmtList()
newBody.add quote do:
return await `callExpr`
let asyncProc = newProc(
name = newEmptyNode(),
params =
@[
returnType,
newIdentDefs(ident("request"), ident("pointer")),
newIdentDefs(ident("reqHandler"), ident("pointer")),
],
body = newBody,
pragmas = nnkPragma.newTree(ident("async")),
)
let key = newLit($reqTypeName)
result = newAssignment(
newTree(nnkBracketExpr, ident("registeredRequests"), key), asyncProc
)
when defined(ffiDumpMacros):
echo result.repr
macro ffiCtor*(prc: untyped): untyped =
## Defines a C-exported constructor that creates an FFIContext and populates
## ctx.myLib asynchronously in the FFI thread.
##
## The annotated proc must:
## - Have Nim-typed parameters (they are automatically serialized to/from JSON)
## - Return Future[Result[LibType, string]]
## - NOT include ctx, callback, or userData in its signature
##
## Example:
## proc mylib_create*(config: SimpleConfig): Future[Result[SimpleLib, string]] {.ffiCtor.} =
## return ok(SimpleLib(value: config.initialValue))
##
## The generated C-exported proc will have the signature:
## proc mylib_create(configJson: cstring, callback: FFICallBack,
## userData: pointer): cint {.exportc, cdecl, raises: [].}
##
## On success the callback receives the ctx address as a decimal string.
## The caller should hold this pointer and pass it to subsequent .ffi. calls.
let procName = prc[0]
let formalParams = prc[3]
let bodyNode = prc[^1]
# Extract LibType from return type: Future[Result[LibType, string]]
let retTypeNode = formalParams[0]
# retTypeNode should be Future[Result[LibType, string]]
if retTypeNode.kind == nnkEmpty:
error("ffiCtor: proc must have an explicit return type Future[Result[LibType, string]]")
# retTypeNode: BracketExpr(Future, BracketExpr(Result, LibType, string))
if retTypeNode.kind != nnkBracketExpr or $retTypeNode[0] != "Future":
error("ffiCtor: return type must be Future[Result[LibType, string]], got: " & retTypeNode.repr)
let resultInner = retTypeNode[1] # Result[LibType, string]
if resultInner.kind != nnkBracketExpr or $resultInner[0] != "Result":
error("ffiCtor: return type must be Future[Result[LibType, string]], got: " & retTypeNode.repr)
let libTypeName = resultInner[1] # LibType
# Collect param names and types (skip return type at index 0)
var paramNames: seq[string] = @[]
var paramTypes: seq[NimNode] = @[]
for i in 1 ..< formalParams.len:
let p = formalParams[i]
# p is IdentDefs: [name, type, default]
for j in 0 ..< p.len - 2: # handle multi-name identdefs
paramNames.add($p[j])
paramTypes.add(p[^2])
# Generate ctor request type name: <ProcNameCamelCase>CtorReq
let procNameStr = $procName
# Strip trailing * if exported
let cleanName =
if procNameStr.endsWith("*"):
procNameStr[0 ..^ 2]
else:
procNameStr
let reqTypeNameStr = toCamelCase(cleanName) & "CtorReq"
let reqTypeName = ident(reqTypeNameStr)
# Build constituent parts
let typeDef = buildCtorRequestType(reqTypeName, paramNames)
let deleteProc = buildCtorDeleteReqProc(reqTypeName, paramNames)
let ffiNewReqProc = buildCtorFfiNewReqProc(reqTypeName, paramNames)
# Helper proc name: e.g., TestlibCreateCtorReq -> TestlibCreateCtorBody
let helperProcNameStr = reqTypeNameStr[0 ..^ ("CtorReq".len + 1)] & "CtorBody"
let helperProcName = ident(helperProcNameStr)
let helperProc = buildCtorBodyProc(helperProcName, paramNames, paramTypes, libTypeName, bodyNode)
let processProc =
buildCtorProcessFFIRequestProc(reqTypeName, helperProcName, paramNames, paramTypes, libTypeName)
let addToReg = addCtorRequestToRegistry(reqTypeName, libTypeName)
# Build the C-exported proc params:
# (<paramName>Json: cstring, ..., callback: FFICallBack, userData: pointer): cint
var exportedParams = newSeq[NimNode]()
exportedParams.add(ident("cint")) # return type
for name in paramNames:
exportedParams.add(newIdentDefs(ident(name & "Json"), ident("cstring")))
exportedParams.add(newIdentDefs(ident("callback"), ident("FFICallBack")))
exportedParams.add(newIdentDefs(ident("userData"), ident("pointer")))
# Build the C-exported proc body
let ffiBody = newStmtList()
# initializeLibrary() — only if declared
ffiBody.add quote do:
when declared(initializeLibrary):
initializeLibrary()
# if callback.isNil: return RET_MISSING_CALLBACK
ffiBody.add quote do:
if callback.isNil:
return RET_MISSING_CALLBACK
# Deserialize each param for early validation
for i in 0 ..< paramNames.len:
let jsonIdent = ident(paramNames[i] & "Json")
let ptype = paramTypes[i]
ffiBody.add quote do:
block:
let validateRes = ffiDeserialize(`jsonIdent`, `ptype`)
if validateRes.isErr():
let errStr = "ffiCtor: failed to deserialize param: " & $validateRes.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
# Build the ffiNewReq call with all cstring params
var newReqArgs: seq[NimNode] = @[reqTypeName, ident("callback"), ident("userData")]
for name in paramNames:
newReqArgs.add(ident(name & "Json"))
let newReqCall = newCall(ident("ffiNewReq"), newReqArgs)
# Use a gensym'd ctx identifier so both the let binding and usage match
let ctxSym = genSym(nskLet, "ctx")
ffiBody.add quote do:
let `ctxSym` = createFFIContext[`libTypeName`]().valueOr:
let errStr = "ffiCtor: failed to create FFIContext: " & $error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
# sendRequestToFFIThread using the gensym'd ctx
let sendCall = newCall(
newDotExpr(ctxSym, ident("sendRequestToFFIThread")), newReqCall
)
let sendResIdent = genSym(nskLet, "sendRes")
ffiBody.add quote do:
let `sendResIdent` =
try:
`sendCall`
except Exception as exc:
Result[void, string].err("sendRequestToFFIThread exception: " & exc.msg)
if `sendResIdent`.isErr():
let errStr = "ffiCtor: failed to send request: " & $`sendResIdent`.error
callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData)
return RET_ERR
ffiBody.add quote do:
return RET_OK
# Strip the * from proc name for the C exported version
let exportedProcName =
if procName.kind == nnkPostfix:
procName[1] # the bare ident without *
else:
procName
let ffiProc = newProc(
name = exportedProcName,
params = exportedParams,
body = ffiBody,
pragmas = newTree(
nnkPragma,
ident("dynlib"),
ident("exportc"),
ident("cdecl"),
newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)),
),
)
# Register metadata for binding generation (we're inside a macro = compile-time context)
block:
var ctorExtraParams: seq[FFIParamMeta] = @[]
for i in 0 ..< paramNames.len:
let ptype = paramTypes[i]
var isPtr = false
var tn = ""
if ptype.kind == nnkPtrTy:
isPtr = true
tn = $ptype[0]
else:
tn = $ptype
ctorExtraParams.add(FFIParamMeta(name: paramNames[i], typeName: tn, isPtr: isPtr))
ffiProcRegistry.add(FFIProcMeta(
procName: cleanName,
libName: currentLibName,
kind: ffiCtorKind,
libTypeName: $libTypeName,
extraParams: ctorExtraParams,
returnTypeName: $libTypeName,
returnIsPtr: false,
isAsync: true,
))
result = newStmtList(typeDef, deleteProc, ffiNewReqProc, helperProc, processProc, addToReg, ffiProc)
when defined(ffiDumpMacros):
echo result.repr
# ---------------------------------------------------------------------------
# genBindings — Rust crate generator
# ---------------------------------------------------------------------------
macro genBindings*(
lang: static[string],
outputDir: static[string],
nimSrcRelPath: static[string] = "",
): untyped =
## Generates binding files for the specified target language.
## Call at the END of your library file, after all {.ffiCtor.} and {.ffi.} procs.
##
## Example:
## genBindings("rust", "examples/nim_timer/nim_bindings", "examples/nim_timer/nim_timer.nim")
##
## Activate with: nim c -d:ffiGenBindings mylib.nim
when defined(ffiGenBindings):
if lang == "rust":
let libName = deriveLibName(ffiProcRegistry)
generateRustCrate(ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)
result = newEmptyNode()

130
ffi/serial.nim Normal file
View File

@ -0,0 +1,130 @@
import std/[json, macros]
import results
import ./codegen/meta
proc ffiSerialize*(x: string): string =
$(%* x)
proc ffiSerialize*(x: cstring): string =
if x.isNil: "null"
else: ffiSerialize($x)
proc ffiSerialize*(x: int): string =
$x
proc ffiSerialize*(x: int32): string =
$x
proc ffiSerialize*(x: bool): string =
if x: "true" else: "false"
proc ffiSerialize*(x: float): string =
$(%* x)
proc ffiSerialize*(x: pointer): string =
$cast[uint](x)
proc ffiDeserialize*(s: cstring, _: typedesc[string]): Result[string, string] =
try:
let node = parseJson($s)
if node.kind != JString:
return err("expected JSON string")
ok(node.getStr())
except Exception as e:
err(e.msg)
proc ffiDeserialize*(s: cstring, _: typedesc[int]): Result[int, string] =
try:
ok(int(parseJson($s).getBiggestInt()))
except Exception as e:
err(e.msg)
proc ffiDeserialize*(s: cstring, _: typedesc[int32]): Result[int32, string] =
try:
ok(int32(parseJson($s).getBiggestInt()))
except Exception as e:
err(e.msg)
proc ffiDeserialize*(s: cstring, _: typedesc[bool]): Result[bool, string] =
try:
ok(parseJson($s).getBool())
except Exception as e:
err(e.msg)
proc ffiDeserialize*(s: cstring, _: typedesc[float]): Result[float, string] =
try:
ok(parseJson($s).getFloat())
except Exception as e:
err(e.msg)
proc ffiDeserialize*(s: cstring, _: typedesc[pointer]): Result[pointer, string] =
try:
let address = cast[pointer](uint(parseJson($s).getBiggestInt()))
ok(address)
except Exception as e:
err(e.msg)
proc ffiSerialize*[T](x: ptr T): string =
$cast[uint](x)
proc ffiDeserialize*[T](s: cstring, _: typedesc[ptr T]): Result[ptr T, string] =
try:
let address = cast[ptr T](uint(parseJson($s).getBiggestInt()))
ok(address)
except Exception as e:
err(e.msg)
macro ffiType*(body: untyped): untyped =
## Statement macro applied to a type declaration block.
## Generates ffiSerialize and ffiDeserialize overloads for each type,
## and registers the type in ffiTypeRegistry for binding generation.
## Usage:
## ffiType:
## type Foo = object
## field: int
let typeSection = body[0]
let typeDef = typeSection[0]
let typeName =
if typeDef[0].kind == nnkPostfix:
typeDef[0][1]
else:
typeDef[0]
# Collect field metadata for the codegen registry
let typeNameStr = $typeName
var fieldMetas: seq[FFIFieldMeta] = @[]
# typeDef layout: TypDef[name, genericParams, objectTy]
# objectTy layout: ObjectTy[empty, empty, recList]
let objTy = typeDef[2]
if objTy.kind == nnkObjectTy and objTy.len >= 3:
let recList = objTy[2]
if recList.kind == nnkRecList:
for identDef in recList:
if identDef.kind == nnkIdentDefs:
# identDef: [name1, ..., type, default]
let fieldType = identDef[^2]
var fieldTypeName: string
if fieldType.kind == nnkIdent:
fieldTypeName = $fieldType
elif fieldType.kind == nnkPtrTy:
fieldTypeName = "ptr " & $fieldType[0]
else:
fieldTypeName = fieldType.repr
for i in 0 ..< identDef.len - 2:
let fname = $identDef[i]
fieldMetas.add(FFIFieldMeta(name: fname, typeName: fieldTypeName))
ffiTypeRegistry.add(FFITypeMeta(name: typeNameStr, fields: fieldMetas))
let serializeProc = quote do:
proc ffiSerialize*(x: `typeName`): string =
$(%* x)
let deserializeProc = quote do:
proc ffiDeserialize*(s: cstring, _: typedesc[`typeName`]): Result[`typeName`, string] =
try:
ok(parseJson($s).to(`typeName`))
except Exception as e:
err(e.msg)
result = newStmtList(body, serializeProc, deserializeProc)

View File

@ -1,4 +1,4 @@
import std/locks
import std/[locks, strutils, os]
import unittest2
import results
import ../ffi
@ -135,3 +135,247 @@ suite "sendRequestToFFIThread":
deinitCallbackData(d)
check d.retCode == RET_OK
check callbackMsg(d) == "pong:" & msg
# ---------------------------------------------------------------------------
# ffiCtor macro integration test
# ---------------------------------------------------------------------------
type SimpleLib = object
value: int
ffiType:
type SimpleConfig = object
initialValue: int
proc testlib_create*(
config: SimpleConfig
): Future[Result[SimpleLib, string]] {.ffiCtor.} =
return ok(SimpleLib(value: config.initialValue))
suite "ffiCtor macro":
test "creates context and returns pointer via callback":
var d: CallbackData
initCallbackData(d)
defer: deinitCallbackData(d)
let configJson = ffiSerialize(SimpleConfig(initialValue: 42))
let ret = testlib_create(configJson.cstring, testCallback, addr d)
check ret == RET_OK
waitCallback(d)
check d.retCode == RET_OK
# The callback message is the ctx address as a decimal string
let addrStr = callbackMsg(d)
check addrStr.len > 0
let ctxAddr = cast[uint](parseBiggestUInt(addrStr))
check ctxAddr != 0
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
# Verify the library was properly initialized
check not ctx[].myLib.isNil
check ctx[].myLib[].value == 42
check destroyFFIContext(ctx).isOk()
# ---------------------------------------------------------------------------
# Simplified .ffi. macro integration test
# ---------------------------------------------------------------------------
ffiType:
type SendConfig = object
message: string
proc testlib_send*(
lib: SimpleLib, cfg: SendConfig
): Future[Result[string, string]] {.ffi.} =
return ok("echo:" & cfg.message & ":" & $lib.value)
suite "simplified .ffi. macro":
test "sends request and gets serialized response via callback":
# First create a context using ffiCtor
var ctorD: CallbackData
initCallbackData(ctorD)
defer: deinitCallbackData(ctorD)
let configJson = ffiSerialize(SimpleConfig(initialValue: 7))
let ctorRet = testlib_create(configJson.cstring, testCallback, addr ctorD)
check ctorRet == RET_OK
waitCallback(ctorD)
check ctorD.retCode == RET_OK
let addrStr = callbackMsg(ctorD)
check addrStr.len > 0
let ctxAddr = cast[uint](parseBiggestUInt(addrStr))
check ctxAddr != 0
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
defer: check destroyFFIContext(ctx).isOk()
# Now call the .ffi. proc
var d: CallbackData
initCallbackData(d)
defer: deinitCallbackData(d)
let cfgJson = ffiSerialize(SendConfig(message: "hello"))
let ret = testlib_send(ctx, testCallback, addr d, cfgJson.cstring)
check ret == RET_OK
waitCallback(d)
check d.retCode == RET_OK
let receivedMsg = callbackMsg(d)
let decoded = ffiDeserialize(receivedMsg.cstring, string).valueOr:
check false
""
check decoded == "echo:hello:7"
# ---------------------------------------------------------------------------
# async/sync detection in .ffi. macro integration test
# ---------------------------------------------------------------------------
# Sync proc (no await in body) — macro detects this and bypasses thread machinery
proc testlib_version*(
lib: SimpleLib
): Future[Result[string, string]] {.ffi.} =
return ok("v" & $lib.value)
suite "async/sync detection in .ffi.":
test "sync proc invokes callback without thread hop":
# Create a context using ffiCtor
var ctorD: CallbackData
initCallbackData(ctorD)
defer: deinitCallbackData(ctorD)
let configJson = ffiSerialize(SimpleConfig(initialValue: 3))
let ctorRet = testlib_create(configJson.cstring, testCallback, addr ctorD)
check ctorRet == RET_OK
waitCallback(ctorD)
check ctorD.retCode == RET_OK
let addrStr = callbackMsg(ctorD)
check addrStr.len > 0
let ctxAddr = cast[uint](parseBiggestUInt(addrStr))
check ctxAddr != 0
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
defer: check destroyFFIContext(ctx).isOk()
var d2: CallbackData
initCallbackData(d2)
defer: deinitCallbackData(d2)
# Call sync proc — callback should fire before the proc returns (no thread hop)
let ret = testlib_version(ctx, testCallback, addr d2)
# No sleep needed: sync path fires callback inline before returning
check ret == RET_OK
check d2.called # fires synchronously — no waitCallback needed
check d2.retCode == RET_OK
let receivedMsg = callbackMsg(d2)
let decoded = ffiDeserialize(receivedMsg.cstring, string).valueOr:
check false
""
check decoded == "v3"
# ---------------------------------------------------------------------------
# ptr T return type in .ffi. macro integration test
# ---------------------------------------------------------------------------
type Handle = object
data: string
ffiType:
type NameParam = object
name: string
proc testlib_alloc_handle*(
lib: SimpleLib, np: NameParam
): Future[Result[ptr Handle, string]] {.ffi.} =
let h = createShared(Handle)
h[] = Handle(data: np.name & ":" & $lib.value)
return ok(h)
proc testlib_read_handle*(
lib: SimpleLib, handle: pointer
): Future[Result[string, string]] {.ffi.} =
let h = cast[ptr Handle](handle)
return ok(h[].data)
proc testlib_free_handle*(
lib: SimpleLib, handle: pointer
): Future[Result[string, string]] {.ffi.} =
let h = cast[ptr Handle](handle)
deallocShared(h)
return ok("freed")
suite "ptr return type in .ffi.":
test "returns a heap-allocated handle and reads it back":
# Create context via ffiCtor
var ctorD: CallbackData
initCallbackData(ctorD)
defer: deinitCallbackData(ctorD)
let configJson = ffiSerialize(SimpleConfig(initialValue: 5))
let ctorRet = testlib_create(configJson.cstring, testCallback, addr ctorD)
check ctorRet == RET_OK
waitCallback(ctorD)
check ctorD.retCode == RET_OK
let ctxAddrStr = callbackMsg(ctorD)
check ctxAddrStr.len > 0
let ctxAddr = cast[uint](parseBiggestUInt(ctxAddrStr))
check ctxAddr != 0
let ctx = cast[ptr FFIContext[SimpleLib]](ctxAddr)
defer: check destroyFFIContext(ctx).isOk()
# Alloc a handle
var allocD: CallbackData
initCallbackData(allocD)
defer: deinitCallbackData(allocD)
let npJson = ffiSerialize(NameParam(name: "test"))
let allocRet = testlib_alloc_handle(ctx, testCallback, addr allocD, npJson.cstring)
check allocRet == RET_OK
waitCallback(allocD)
check allocD.retCode == RET_OK
let handleAddrStr = callbackMsg(allocD)
check handleAddrStr.len > 0
let handleAddr = parseBiggestUInt(handleAddrStr)
check handleAddr != 0
# Read the handle back
var readD: CallbackData
initCallbackData(readD)
defer: deinitCallbackData(readD)
let handleJson = ffiSerialize(cast[pointer](handleAddr))
let readRet = testlib_read_handle(ctx, testCallback, addr readD, handleJson.cstring)
check readRet == RET_OK
waitCallback(readD)
check readD.retCode == RET_OK
let readMsg = callbackMsg(readD)
let decodedStr = ffiDeserialize(readMsg.cstring, string).valueOr:
check false
""
check decodedStr == "test:5"
# Free the handle
var freeD: CallbackData
initCallbackData(freeD)
defer: deinitCallbackData(freeD)
let freeRet = testlib_free_handle(ctx, testCallback, addr freeD, handleJson.cstring)
check freeRet == RET_OK
waitCallback(freeD)
check freeD.retCode == RET_OK

113
tests/test_serial.nim Normal file
View File

@ -0,0 +1,113 @@
import unittest
import results
import ../ffi/serial
ffiType:
type Point = object
x: int
y: int
ffiType:
type Nested = object
label: string
point: Point
suite "ffiSerialize / ffiDeserialize primitives":
test "string round-trip":
let s = "hello world"
let serialized = ffiSerialize(s)
let back = ffiDeserialize(serialized.cstring, string)
check back.isOk()
check back.value == s
test "string with special chars":
let s = "tab\there"
let serialized = ffiSerialize(s)
let back = ffiDeserialize(serialized.cstring, string)
check back.isOk()
check back.value == s
test "int round-trip":
let v = 42
let serialized = ffiSerialize(v)
let back = ffiDeserialize(serialized.cstring, int)
check back.isOk()
check back.value == v
test "int negative round-trip":
let v = -100
let serialized = ffiSerialize(v)
let back = ffiDeserialize(serialized.cstring, int)
check back.isOk()
check back.value == v
test "bool true round-trip":
let serialized = ffiSerialize(true)
let back = ffiDeserialize(serialized.cstring, bool)
check back.isOk()
check back.value == true
test "bool false round-trip":
let serialized = ffiSerialize(false)
let back = ffiDeserialize(serialized.cstring, bool)
check back.isOk()
check back.value == false
test "float round-trip":
let v = 3.14
let serialized = ffiSerialize(v)
let back = ffiDeserialize(serialized.cstring, float)
check back.isOk()
check abs(back.value - v) < 1e-9
test "float negative round-trip":
let v = -2.718
let serialized = ffiSerialize(v)
let back = ffiDeserialize(serialized.cstring, float)
check back.isOk()
check abs(back.value - v) < 1e-9
suite "pointer serialization":
test "pointer serialize and recover address":
var x = 12345
let p = addr x
let serialized = ffiSerialize(cast[pointer](p))
let back = ffiDeserialize(serialized.cstring, pointer)
check back.isOk()
check back.value == cast[pointer](p)
test "nil pointer serializes as 0":
let p: pointer = nil
let serialized = ffiSerialize(p)
check serialized == "0"
suite "ffiType macro — object round-trip":
test "Point round-trip":
let pt = Point(x: 10, y: 20)
let serialized = ffiSerialize(pt)
let back = ffiDeserialize(serialized.cstring, Point)
check back.isOk()
check back.value.x == 10
check back.value.y == 20
test "Nested object round-trip":
let n = Nested(label: "origin", point: Point(x: 0, y: 0))
let serialized = ffiSerialize(n)
let back = ffiDeserialize(serialized.cstring, Nested)
check back.isOk()
check back.value.label == "origin"
check back.value.point.x == 0
check back.value.point.y == 0
suite "ffiDeserialize error handling":
test "malformed JSON returns err":
let back = ffiDeserialize("not json at all".cstring, int)
check back.isErr()
test "wrong JSON type returns err for string":
let back = ffiDeserialize("42".cstring, string)
check back.isErr()
test "malformed JSON for object returns err":
let back = ffiDeserialize("{bad json".cstring, Point)
check back.isErr()