From 26bf173e2c8000387f8a90ae8ec9304d91c41a65 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 10:01:38 +0200 Subject: [PATCH 01/10] simplify ffi generation and add simple Rust example --- .gitignore | 17 + examples/nim_timer/nim_timer.nim | 50 ++ examples/nim_timer/rust_client/Cargo.lock | 115 +++ examples/nim_timer/rust_client/Cargo.toml | 8 + examples/nim_timer/rust_client/src/main.rs | 47 ++ ffi.nim | 4 +- ffi.nimble | 7 + ffi/codegen/meta.nim | 35 + ffi/codegen/rust.nim | 417 ++++++++++ ffi/ffi_context.nim | 3 +- ffi/internal/ffi_library.nim | 4 + ffi/internal/ffi_macro.nim | 925 ++++++++++++++++++++- ffi/serial.nim | 130 +++ tests/test_ffi_context.nim | 246 +++++- tests/test_serial.nim | 113 +++ 15 files changed, 2083 insertions(+), 38 deletions(-) create mode 100644 examples/nim_timer/nim_timer.nim create mode 100644 examples/nim_timer/rust_client/Cargo.lock create mode 100644 examples/nim_timer/rust_client/Cargo.toml create mode 100644 examples/nim_timer/rust_client/src/main.rs create mode 100644 ffi/codegen/meta.nim create mode 100644 ffi/codegen/rust.nim create mode 100644 ffi/serial.nim create mode 100644 tests/test_serial.nim diff --git a/.gitignore b/.gitignore index f3685e2..e20ab27 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim new file mode 100644 index 0000000..ca645a4 --- /dev/null +++ b/examples/nim_timer/nim_timer.nim @@ -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") diff --git a/examples/nim_timer/rust_client/Cargo.lock b/examples/nim_timer/rust_client/Cargo.lock new file mode 100644 index 0000000..51c3923 --- /dev/null +++ b/examples/nim_timer/rust_client/Cargo.lock @@ -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" diff --git a/examples/nim_timer/rust_client/Cargo.toml b/examples/nim_timer/rust_client/Cargo.toml new file mode 100644 index 0000000..0189b2f --- /dev/null +++ b/examples/nim_timer/rust_client/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "rust_client" +version = "0.1.0" +edition = "2021" + +[dependencies] +nimtimer = { path = "../nim_bindings" } +serde_json = "1" diff --git a/examples/nim_timer/rust_client/src/main.rs b/examples/nim_timer/rust_client/src/main.rs new file mode 100644 index 0000000..cd7f5c2 --- /dev/null +++ b/examples/nim_timer/rust_client/src/main.rs @@ -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.)"); +} diff --git a/ffi.nim b/ffi.nim index 0ef64ac..8ad9285 100644 --- a/ffi.nim +++ b/ffi.nim @@ -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 diff --git a/ffi.nimble b/ffi.nimble index 3086130..ba84437 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -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" diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim new file mode 100644 index 0000000..2f3d2dc --- /dev/null +++ b/ffi/codegen/meta.nim @@ -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 diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim new file mode 100644 index 0000000..444c1bd --- /dev/null +++ b/ffi/codegen/rust.nim @@ -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>,") + lines.add("}") + lines.add("") + lines.add("type Pair = Arc<(Mutex, 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, 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(timeout: Duration, f: F) -> Result") + 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 {" % [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::(&raw).map_err(|e| e.to_string())") + elif retRustType == "usize": + lines.add(" raw.parse::().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)) diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 545d2d0..5083a83 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -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) diff --git a/ffi/internal/ffi_library.nim b/ffi/internal/ffi_library.nim index 158e03e..1dbfd0f 100644 --- a/ffi/internal/ffi_library.nim +++ b/ffi/internal/ffi_library.nim @@ -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: [].} diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5797045..1cb8cea 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -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.} = + # + 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] = + # + 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 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, + ## 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: 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: + # (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() diff --git a/ffi/serial.nim b/ffi/serial.nim new file mode 100644 index 0000000..7a042d4 --- /dev/null +++ b/ffi/serial.nim @@ -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) diff --git a/tests/test_ffi_context.nim b/tests/test_ffi_context.nim index 9cb9542..ca57097 100644 --- a/tests/test_ffi_context.nim +++ b/tests/test_ffi_context.nim @@ -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 diff --git a/tests/test_serial.nim b/tests/test_serial.nim new file mode 100644 index 0000000..24b4f51 --- /dev/null +++ b/tests/test_serial.nim @@ -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() From 0c516f53aaecba537bc411c1abe5782ed256aee9 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 11:11:06 +0200 Subject: [PATCH 02/10] allow auto-generate cpp code and add example --- examples/nim_timer/nim_timer.nim | 2 +- ffi.nimble | 7 +- ffi/codegen/cpp.nim | 310 +++++++++++++++++++++++++++++++ ffi/codegen/meta.nim | 3 + ffi/internal/ffi_macro.nim | 20 +- 5 files changed, 333 insertions(+), 9 deletions(-) create mode 100644 ffi/codegen/cpp.nim diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index ca645a4..b9e25a9 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -47,4 +47,4 @@ proc nimtimer_version*( return ok("nim-timer v0.1.0") when defined(ffiGenBindings): - genBindings("rust", "examples/nim_timer/nim_bindings", "../nim_timer.nim") + genBindings("examples/nim_timer/nim_bindings", "../nim_timer.nim") diff --git a/ffi.nimble b/ffi.nimble index ba84437..c94c3dc 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -31,5 +31,8 @@ task test_ffi, "Run FFI context integration tests": 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" +task genbindings_rust, "Generate Rust bindings for the nim_timer example": + exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:ffiTargetLang=rust -o:/dev/null examples/nim_timer/nim_timer.nim" + +task genbindings_cpp, "Generate C++ bindings for the nim_timer example": + exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:ffiTargetLang=cpp -o:/dev/null examples/nim_timer/nim_timer.nim" diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim new file mode 100644 index 0000000..1cdd708 --- /dev/null +++ b/ffi/codegen/cpp.nim @@ -0,0 +1,310 @@ +## C++ binding generator for the nim-ffi framework. +## Generates a header-only C++ binding and CMakeLists.txt from compile-time FFI metadata. + +import std/[os, strutils] +import ./meta + +proc nimTypeToCpp*(typeName: string): string = + case typeName + of "string", "cstring": "std::string" + of "int", "int64": "int64_t" + of "int32": "int32_t" + of "bool": "bool" + of "float", "float64": "double" + of "pointer": "void*" + else: typeName + +proc stripLibPrefixCpp(procName, libName: string): string = + let prefix = libName & "_" + if procName.startsWith(prefix): + return procName[prefix.len .. ^1] + return procName + +proc generateCppHeader*( + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, +): string = + var lines: seq[string] = @[] + + lines.add("#pragma once") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("") + + # Types + if types.len > 0: + lines.add("// ============================================================") + lines.add("// Types") + lines.add("// ============================================================") + lines.add("") + for t in types: + lines.add("struct $1 {" % [t.name]) + for f in t.fields: + let cppType = nimTypeToCpp(f.typeName) + lines.add(" $1 $2;" % [cppType, f.name]) + lines.add("};") + var fieldNames: seq[string] = @[] + for f in t.fields: + fieldNames.add(f.name) + lines.add("NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE($1, $2)" % [t.name, fieldNames.join(", ")]) + lines.add("") + + # C extern declarations + lines.add("// ============================================================") + lines.add("// C FFI declarations") + lines.add("// ============================================================") + lines.add("") + lines.add("extern \"C\" {") + lines.add("typedef void (*FfiCallback)(int ret, const char* msg, size_t len, void* user_data);") + lines.add("") + + for p in procs: + var params: seq[string] = @[] + if p.kind == ffiFfiKind: + params.add("void* ctx") + params.add("FfiCallback callback") + params.add("void* user_data") + for ep in p.extraParams: + params.add("const char* $1_json" % [ep.name]) + else: # ffiCtorKind + for ep in p.extraParams: + params.add("const char* $1_json" % [ep.name]) + params.add("FfiCallback callback") + params.add("void* user_data") + lines.add("int $1($2);" % [p.procName, params.join(", ")]) + + lines.add("} // extern \"C\"") + lines.add("") + + # Anonymous namespace with synchronous call helper + lines.add("// ============================================================") + lines.add("// Synchronous call helper (anonymous namespace, header-only)") + lines.add("// ============================================================") + lines.add("") + lines.add("namespace {") + lines.add("") + lines.add("struct FfiCallState_ {") + lines.add(" std::mutex mtx;") + lines.add(" std::condition_variable cv;") + lines.add(" bool done{false};") + lines.add(" bool ok{false};") + lines.add(" std::string msg;") + lines.add("};") + lines.add("") + lines.add("inline void ffi_cb_(int ret, const char* msg, size_t /*len*/, void* ud) {") + lines.add(" auto* s = static_cast(ud);") + lines.add(" std::lock_guard lock(s->mtx);") + lines.add(" s->ok = (ret == 0);") + lines.add(" s->msg = msg ? std::string(msg) : std::string{};") + lines.add(" s->done = true;") + lines.add(" s->cv.notify_one();") + lines.add("}") + lines.add("") + lines.add("inline std::string ffi_call_(std::function f) {") + lines.add(" FfiCallState_ state;") + lines.add(" const int ret = f(ffi_cb_, &state);") + lines.add(" if (ret == 2)") + lines.add(" throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");") + lines.add(" std::unique_lock lock(state.mtx);") + lines.add(" state.cv.wait(lock, [&state]{ return state.done; });") + lines.add(" if (!state.ok)") + lines.add(" throw std::runtime_error(state.msg);") + lines.add(" return state.msg;") + lines.add("}") + lines.add("") + lines.add("} // anonymous namespace") + lines.add("") + + # Derive context type name and separate ctors / methods + var ctors: seq[FFIProcMeta] = @[] + var methods: seq[FFIProcMeta] = @[] + for p in procs: + if p.kind == ffiCtorKind: ctors.add(p) + else: methods.add(p) + + let libTypeName = + if ctors.len > 0: ctors[0].libTypeName + else: libName[0 .. 0].toUpperAscii() & libName[1 .. ^1] + + let ctxTypeName = libTypeName & "Ctx" + + lines.add("// ============================================================") + lines.add("// High-level C++ context class") + lines.add("// ============================================================") + lines.add("") + lines.add("class $1 {" % [ctxTypeName]) + lines.add("public:") + + # Static create() factory + for ctor in ctors: + var ctorParams: seq[string] = @[] + for ep in ctor.extraParams: + let cppType = nimTypeToCpp(ep.typeName) + ctorParams.add("const $1& $2" % [cppType, ep.name]) + + lines.add(" static $1 create($2) {" % [ctxTypeName, ctorParams.join(", ")]) + for ep in ctor.extraParams: + lines.add(" const auto $1_json = nlohmann::json($1).dump();" % [ep.name]) + + var callArgs: seq[string] = @[] + for ep in ctor.extraParams: + callArgs.add("$1_json.c_str()" % [ep.name]) + callArgs.add("cb") + callArgs.add("ud") + + lines.add(" const auto raw = ffi_call_([&](FfiCallback cb, void* ud) {") + lines.add(" return $1($2);" % [ctor.procName, callArgs.join(", ")]) + lines.add(" });") + lines.add(" // ctor returns the context address as a plain decimal string") + lines.add(" const auto addr = std::stoull(raw);") + lines.add(" return $1(reinterpret_cast(static_cast(addr)));" % [ctxTypeName]) + lines.add(" }") + lines.add("") + + # Instance methods + for m in methods: + let methodName = stripLibPrefixCpp(m.procName, libName) + let retCppType = nimTypeToCpp(m.returnTypeName) + + var methParams: seq[string] = @[] + for ep in m.extraParams: + let cppType = nimTypeToCpp(ep.typeName) + methParams.add("const $1& $2" % [cppType, ep.name]) + let methParamsStr = methParams.join(", ") + + lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) + for ep in m.extraParams: + lines.add(" const auto $1_json = nlohmann::json($1).dump();" % [ep.name]) + + var callArgs = @["ptr_", "cb", "ud"] + for ep in m.extraParams: + callArgs.add("$1_json.c_str()" % [ep.name]) + + lines.add(" const auto raw = ffi_call_([&](FfiCallback cb, void* ud) {") + lines.add(" return $1($2);" % [m.procName, callArgs.join(", ")]) + lines.add(" });") + + if retCppType == "std::string": + lines.add(" return nlohmann::json::parse(raw).get();") + else: + lines.add(" return nlohmann::json::parse(raw).get<$1>();" % [retCppType]) + lines.add(" }") + lines.add("") + + lines.add("private:") + lines.add(" void* ptr_;") + lines.add(" explicit $1(void* p) : ptr_(p) {}" % [ctxTypeName]) + lines.add("};") + lines.add("") + + result = lines.join("\n") + +proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = + ## Generates CMakeLists.txt for the C++ bindings directory. + ## CMake uses ${...} which would clash with Nim's % format operator, + ## so we build the file line by line using string concatenation. + let src = nimSrcRelPath.replace("\\", "/") + let cv = "${CMAKE_CURRENT_SOURCE_DIR}" # CMake variable shorthand + let rv = "${REPO_ROOT}" + let lf = "${NIM_LIB_FILE}" + let nm = "${NIM_EXECUTABLE}" + let ns = "${NIM_SRC}" + let sd = "${_search_dir}" + var L: seq[string] = @[] + L.add("cmake_minimum_required(VERSION 3.14)") + L.add("project(" & libName & "_cpp_bindings CXX)") + L.add("") + L.add("set(CMAKE_CXX_STANDARD 17)") + L.add("set(CMAKE_CXX_STANDARD_REQUIRED ON)") + L.add("") + L.add("# ── nlohmann/json ─────────────────────────────────────────────────────────────") + L.add("include(FetchContent)") + L.add("FetchContent_Declare(") + L.add(" nlohmann_json") + L.add(" GIT_REPOSITORY https://github.com/nlohmann/json.git") + L.add(" GIT_TAG v3.11.3") + L.add(" GIT_SHALLOW TRUE") + L.add(")") + L.add("FetchContent_MakeAvailable(nlohmann_json)") + L.add("") + L.add("# ── Locate the repository root (contains ffi.nimble) ─────────────────────────") + L.add("set(_search_dir \"" & cv & "\")") + L.add("set(REPO_ROOT \"\")") + L.add("foreach(_i RANGE 10)") + L.add(" if(EXISTS \"" & sd & "/ffi.nimble\")") + L.add(" set(REPO_ROOT \"" & sd & "\")") + L.add(" break()") + L.add(" endif()") + L.add(" get_filename_component(_search_dir \"" & sd & "\" DIRECTORY)") + L.add("endforeach()") + L.add("if(\"${REPO_ROOT}\" STREQUAL \"\")") + L.add(" message(FATAL_ERROR \"Cannot find repo root (no ffi.nimble in any ancestor)\")") + L.add("endif()") + L.add("") + L.add("# ── Nim source path ───────────────────────────────────────────────────────────") + L.add("get_filename_component(NIM_SRC") + L.add(" \"" & cv & "/" & src & "\"") + L.add(" ABSOLUTE)") + L.add("") + L.add("# ── Compile the Nim shared library ───────────────────────────────────────────") + L.add("find_program(NIM_EXECUTABLE nim REQUIRED)") + L.add("") + L.add("if(CMAKE_SYSTEM_NAME STREQUAL \"Darwin\")") + L.add(" set(NIM_LIB_FILE \"" & rv & "/lib" & libName & ".dylib\")") + L.add("elseif(CMAKE_SYSTEM_NAME STREQUAL \"Windows\")") + L.add(" set(NIM_LIB_FILE \"" & rv & "/" & libName & ".dll\")") + L.add("else()") + L.add(" set(NIM_LIB_FILE \"" & rv & "/lib" & libName & ".so\")") + L.add("endif()") + L.add("") + L.add("add_custom_command(") + L.add(" OUTPUT \"" & lf & "\"") + L.add(" COMMAND \"" & nm & "\" c") + L.add(" --mm:orc") + L.add(" -d:chronicles_log_level=WARN") + L.add(" --app:lib") + L.add(" --noMain") + L.add(" \"--nimMainPrefix:lib" & libName & "\"") + L.add(" \"-o:" & lf & "\"") + L.add(" \"" & ns & "\"") + L.add(" WORKING_DIRECTORY \"" & rv & "\"") + L.add(" DEPENDS \"" & ns & "\"") + L.add(" COMMENT \"Compiling Nim library lib" & libName & "\"") + L.add(" VERBATIM") + L.add(")") + L.add("add_custom_target(nim_lib ALL DEPENDS \"" & lf & "\")") + L.add("") + L.add("add_library(" & libName & " SHARED IMPORTED GLOBAL)") + L.add("set_target_properties(" & libName & " PROPERTIES IMPORTED_LOCATION \"" & lf & "\")") + L.add("add_dependencies(" & libName & " nim_lib)") + L.add("") + L.add("# ── Interface target exposing the generated header ────────────────────────────") + L.add("add_library(" & libName & "_headers INTERFACE)") + L.add("target_include_directories(" & libName & "_headers INTERFACE \"" & cv & "\")") + L.add("target_link_libraries(" & libName & "_headers INTERFACE " & libName & " nlohmann_json::nlohmann_json)") + L.add("") + L.add("# ── Optional example executable ───────────────────────────────────────────────") + L.add("if(EXISTS \"" & cv & "/main.cpp\")") + L.add(" add_executable(example main.cpp)") + L.add(" target_link_libraries(example PRIVATE " & libName & "_headers)") + L.add(" add_dependencies(example nim_lib)") + L.add("endif()") + L.add("") + result = L.join("\n") + +proc generateCppBindings*( + procs: seq[FFIProcMeta], + types: seq[FFITypeMeta], + libName: string, + outputDir: string, + nimSrcRelPath: string, +) = + createDir(outputDir) + writeFile(outputDir / (libName & ".hpp"), generateCppHeader(procs, types, libName)) + writeFile(outputDir / "CMakeLists.txt", generateCppCMakeLists(libName, nimSrcRelPath)) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 2f3d2dc..39bf1b9 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -33,3 +33,6 @@ type var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta] var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] var currentLibName* {.compileTime.}: string + +# Target language for binding generation; override with -d:ffiTargetLang=cpp +const ffiTargetLang* {.strdefine.} = "rust" diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 1cb8cea..3f3ad6d 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -4,6 +4,7 @@ import ../ffi_types import ../codegen/meta when defined(ffiGenBindings): import ../codegen/rust + import ../codegen/cpp # --------------------------------------------------------------------------- # String helpers used by multiple macros @@ -1383,21 +1384,28 @@ macro ffiCtor*(prc: untyped): untyped = # --------------------------------------------------------------------------- macro genBindings*( - lang: static[string], outputDir: static[string], nimSrcRelPath: static[string] = "", ): untyped = - ## Generates binding files for the specified target language. + ## Generates binding files for the target language set by -d:ffiTargetLang=. + ## Supported values: "rust" (default), "cpp" (case-insensitive). ## 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") + ## genBindings("examples/nim_timer/nim_bindings", "../nim_timer.nim") ## - ## Activate with: nim c -d:ffiGenBindings mylib.nim + ## Activate with: nim c -d:ffiGenBindings -d:ffiTargetLang=rust mylib.nim + ## or: nim c -d:ffiGenBindings -d:ffiTargetLang=cpp mylib.nim when defined(ffiGenBindings): - if lang == "rust": - let libName = deriveLibName(ffiProcRegistry) + let lang = ffiTargetLang.toLowerAscii() + let libName = deriveLibName(ffiProcRegistry) + case lang + of "rust": generateRustCrate(ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) + of "cpp", "c++": + generateCppBindings(ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) + else: + error("genBindings: unknown ffiTargetLang '" & lang & "'. Use 'rust' or 'cpp'.") result = newEmptyNode() From 95241084744204e036597d780bb7dff200dd9edf Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 15:48:47 +0200 Subject: [PATCH 03/10] enhance cpp and rust tokio examples Co-authored-by: Copilot --- .gitignore | 5 +- README.md | 5 + examples/nim_timer/README.md | 54 ++++ .../nim_timer/cpp_bindings/CMakeLists.txt | 78 ++++++ examples/nim_timer/cpp_bindings/README.md | 36 +++ examples/nim_timer/cpp_bindings/main.cpp | 37 +++ examples/nim_timer/cpp_bindings/nimtimer.hpp | 174 +++++++++++++ examples/nim_timer/nim_timer.nim | 43 +++- examples/nim_timer/nim_timer.nimble | 21 ++ examples/nim_timer/rust_bindings/Cargo.toml | 8 + examples/nim_timer/rust_bindings/README.md | 39 +++ examples/nim_timer/rust_bindings/build.rs | 47 ++++ examples/nim_timer/rust_bindings/src/api.rs | 102 ++++++++ examples/nim_timer/rust_bindings/src/ffi.rs | 16 ++ examples/nim_timer/rust_bindings/src/lib.rs | 5 + examples/nim_timer/rust_bindings/src/types.rs | 37 +++ examples/nim_timer/rust_client/Cargo.lock | 28 +++ examples/nim_timer/rust_client/Cargo.toml | 11 +- examples/nim_timer/rust_client/README.md | 43 ++++ examples/nim_timer/rust_client/src/main.rs | 2 +- .../nim_timer/rust_client/src/tokio_main.rs | 71 ++++++ ffi.nimble | 16 +- ffi/codegen/cpp.nim | 159 +++++++++--- ffi/codegen/meta.nim | 33 ++- ffi/codegen/rust.nim | 86 +++++-- ffi/internal/ffi_macro.nim | 234 +++++++++++------- ffi/serial.nim | 81 +++++- 27 files changed, 1280 insertions(+), 191 deletions(-) create mode 100644 examples/nim_timer/README.md create mode 100644 examples/nim_timer/cpp_bindings/CMakeLists.txt create mode 100644 examples/nim_timer/cpp_bindings/README.md create mode 100644 examples/nim_timer/cpp_bindings/main.cpp create mode 100644 examples/nim_timer/cpp_bindings/nimtimer.hpp create mode 100644 examples/nim_timer/nim_timer.nimble create mode 100644 examples/nim_timer/rust_bindings/Cargo.toml create mode 100644 examples/nim_timer/rust_bindings/README.md create mode 100644 examples/nim_timer/rust_bindings/build.rs create mode 100644 examples/nim_timer/rust_bindings/src/api.rs create mode 100644 examples/nim_timer/rust_bindings/src/ffi.rs create mode 100644 examples/nim_timer/rust_bindings/src/lib.rs create mode 100644 examples/nim_timer/rust_bindings/src/types.rs create mode 100644 examples/nim_timer/rust_client/README.md create mode 100644 examples/nim_timer/rust_client/src/tokio_main.rs diff --git a/.gitignore b/.gitignore index e20ab27..0d37f33 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,10 @@ tests/test_ffi_context tests/test_serial # Generated binding crates (regenerated by `nimble genbindings_*`) -examples/**/nim_bindings/ +examples/**/rust_bindings/target/ + +# Example build artifacts +examples/**/cpp_bindings/build/ # Cargo build artifacts examples/**/rust_client/target/ diff --git a/README.md b/README.md index 9ef2548..4c9a0f6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # nim-ffi Allows exposing Nim projects to other languages + +## Example + +`examples/nim_timer` is now a self-contained Nimble project that imports `nim-ffi` directly. +Use `cd examples/nim_timer && nimble install -y ../.. && nimble build` to compile the example. diff --git a/examples/nim_timer/README.md b/examples/nim_timer/README.md new file mode 100644 index 0000000..b0d59f6 --- /dev/null +++ b/examples/nim_timer/README.md @@ -0,0 +1,54 @@ +# nim_timer example + +This example is a self-contained Nimble project demonstrating how to import `nim-ffi` and use the `.ffiCtor.` / `.ffi.` abstraction. + +## Usage + +1. Change into the example directory: + ```sh + cd examples/nim_timer + ``` + +2. Install the local `ffi` dependency: + ```sh + nimble install -y ../.. + ``` + +3. Build the example library: + ```sh + nimble build + ``` + +4. Generate bindings: + ```sh + nimble genbindings_rust + nimble genbindings_cpp + ``` + +## Rust example clients + +The Rust client lives in `examples/nim_timer/rust_client`. + +- Run the sync example: + ```sh + cd examples/nim_timer/rust_client + cargo run --bin rust_client + ``` + +- Run the Tokio example: + ```sh + cd examples/nim_timer/rust_client + cargo run --bin tokio_client + ``` + +## C++ example + +The generated C++ example lives in `examples/nim_timer/cpp_bindings`. + +Build and run it with: +```sh +cd examples/nim_timer/cpp_bindings +cmake -S . -B build +cmake --build build +./build/example +``` diff --git a/examples/nim_timer/cpp_bindings/CMakeLists.txt b/examples/nim_timer/cpp_bindings/CMakeLists.txt new file mode 100644 index 0000000..eabfd9a --- /dev/null +++ b/examples/nim_timer/cpp_bindings/CMakeLists.txt @@ -0,0 +1,78 @@ +cmake_minimum_required(VERSION 3.14) +project(nimtimer_cpp_bindings CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ── nlohmann/json ───────────────────────────────────────────────────────────── +include(FetchContent) +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(nlohmann_json) + +# ── Locate the repository root (contains ffi.nimble) ───────────────────────── +set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") +set(REPO_ROOT "") +foreach(_i RANGE 10) + if(EXISTS "${_search_dir}/ffi.nimble") + set(REPO_ROOT "${_search_dir}") + break() + endif() + get_filename_component(_search_dir "${_search_dir}" DIRECTORY) +endforeach() +if("${REPO_ROOT}" STREQUAL "") + message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)") +endif() + +# ── Nim source path ─────────────────────────────────────────────────────────── +get_filename_component(NIM_SRC + "${CMAKE_CURRENT_SOURCE_DIR}/../nim_timer.nim" + ABSOLUTE) + +# ── Compile the Nim shared library ─────────────────────────────────────────── +find_program(NIM_EXECUTABLE nim REQUIRED) + +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(NIM_LIB_FILE "${REPO_ROOT}/libnimtimer.dylib") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(NIM_LIB_FILE "${REPO_ROOT}/nimtimer.dll") +else() + set(NIM_LIB_FILE "${REPO_ROOT}/libnimtimer.so") +endif() + +add_custom_command( + OUTPUT "${NIM_LIB_FILE}" + COMMAND "${NIM_EXECUTABLE}" c + --mm:orc + -d:chronicles_log_level=WARN + --app:lib + --noMain + "--nimMainPrefix:libnimtimer" + "-o:${NIM_LIB_FILE}" + "${NIM_SRC}" + WORKING_DIRECTORY "${REPO_ROOT}" + DEPENDS "${NIM_SRC}" + COMMENT "Compiling Nim library libnimtimer" + VERBATIM +) +add_custom_target(nim_lib ALL DEPENDS "${NIM_LIB_FILE}") + +add_library(nimtimer SHARED IMPORTED GLOBAL) +set_target_properties(nimtimer PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}") +add_dependencies(nimtimer nim_lib) + +# ── Interface target exposing the generated header ──────────────────────────── +add_library(nimtimer_headers INTERFACE) +target_include_directories(nimtimer_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(nimtimer_headers INTERFACE nimtimer nlohmann_json::nlohmann_json) + +# ── Optional example executable ─────────────────────────────────────────────── +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp") + add_executable(example main.cpp) + target_link_libraries(example PRIVATE nimtimer_headers) + add_dependencies(example nim_lib) +endif() diff --git a/examples/nim_timer/cpp_bindings/README.md b/examples/nim_timer/cpp_bindings/README.md new file mode 100644 index 0000000..49f94b5 --- /dev/null +++ b/examples/nim_timer/cpp_bindings/README.md @@ -0,0 +1,36 @@ +# C++ Bindings for nim-timer + +## Purpose + +This folder contains **auto-generated C++ bindings** for the `nim_timer` Nim library. It is generated from `../nim_timer.nim` and provides: + +- `nimtimer.hpp`: High-level C++ class (`NimTimerCtx`) wrapping the FFI interface +- `main.cpp`: Example executable demonstrating how to use the bindings +- `CMakeLists.txt`: Build configuration that compiles the Nim library and links the C++ example + +## How It's Generated + +Generate or regenerate these bindings by running from the parent directory: + +```sh +cd examples/nim_timer +nimble genbindings_cpp +``` + +This command: +1. Invokes the Nim compiler with `-d:targetLang:cpp` flag +2. Triggers `genBindings("examples/nim_timer/cpp_bindings", "../nim_timer.nim")` in `nim_timer.nim` +3. Creates/updates the generated binding files + +## Building the Example + +```sh +cd examples/nim_timer/cpp_bindings +cmake -S . -B build +cmake --build build +./build/example +``` + +## Do Not Edit + +The generated files in this folder are overwritten each time `nimble genbindings_cpp` runs. Any manual changes will be lost. diff --git a/examples/nim_timer/cpp_bindings/main.cpp b/examples/nim_timer/cpp_bindings/main.cpp new file mode 100644 index 0000000..68e9658 --- /dev/null +++ b/examples/nim_timer/cpp_bindings/main.cpp @@ -0,0 +1,37 @@ +#include "nimtimer.hpp" +#include + +int main() { + try { + auto ctx = NimTimerCtx::create(TimerConfig{"cpp-demo"}); + std::cout << "[1] Context created\n"; + + auto version = ctx.version(); + std::cout << "[2] Version: " << version << "\n"; + + auto echo = ctx.echo(EchoRequest{"hello from C++", 200}); + std::cout << "[3] Echo 1: echoed=" << echo.echoed + << ", timerName=" << echo.timerName << "\n"; + + auto echo2 = ctx.echo(EchoRequest{"second C++ request", 50}); + std::cout << "[4] Echo 2: echoed=" << echo2.echoed + << ", timerName=" << echo2.timerName << "\n"; + + auto complexReq = ComplexRequest{ + std::vector{EchoRequest{"one", 10}, EchoRequest{"two", 20}}, + std::vector{"fast", "async"}, + std::optional("extra note"), + std::optional(3) + }; + auto complex = ctx.complex(complexReq); + std::cout << "[5] Complex: summary=" << complex.summary + << ", itemCount=" << complex.itemCount + << ", hasNote=" << complex.hasNote << "\n"; + + std::cout << "\nDone.\n"; + } catch (const std::exception& ex) { + std::cerr << "Error: " << ex.what() << "\n"; + return 1; + } + return 0; +} diff --git a/examples/nim_timer/cpp_bindings/nimtimer.hpp b/examples/nim_timer/cpp_bindings/nimtimer.hpp new file mode 100644 index 0000000..a648d84 --- /dev/null +++ b/examples/nim_timer/cpp_bindings/nimtimer.hpp @@ -0,0 +1,174 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace nlohmann { + template + void to_json(json& j, const std::optional& opt) { + if (opt) j = *opt; + else j = nullptr; + } + + template + void from_json(const json& j, std::optional& opt) { + if (j.is_null()) opt = std::nullopt; + else opt = j.get(); + } +} + +// ============================================================ +// Types +// ============================================================ + +struct TimerConfig { + std::string name; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(TimerConfig, name) + +struct EchoRequest { + std::string message; + int64_t delayMs; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(EchoRequest, message, delayMs) + +struct EchoResponse { + std::string echoed; + std::string timerName; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(EchoResponse, echoed, timerName) + +struct ComplexRequest { + std::vector messages; + std::vector tags; + std::optional note; + std::optional retries; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ComplexRequest, messages, tags, note, retries) + +struct ComplexResponse { + std::string summary; + int64_t itemCount; + bool hasNote; +}; +NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ComplexResponse, summary, itemCount, hasNote) + +// ============================================================ +// C FFI declarations +// ============================================================ + +extern "C" { +typedef void (*FfiCallback)(int ret, const char* msg, size_t len, void* user_data); + +int nimtimer_create(const char* config_json, FfiCallback callback, void* user_data); +int nimtimer_echo(void* ctx, FfiCallback callback, void* user_data, const char* req_json); +int nimtimer_version(void* ctx, FfiCallback callback, void* user_data); +int nimtimer_complex(void* ctx, FfiCallback callback, void* user_data, const char* req_json); +} // extern "C" + + +template +inline std::string serializeFfiArg(const T& value) { + return nlohmann::json(value).dump(); +} + +inline std::string serializeFfiArg(void* value) { + return std::to_string(reinterpret_cast(value)); +} + +template +inline T deserializeFfiResult(const std::string& raw) { + return nlohmann::json::parse(raw).get(); +} + +template<> +inline void* deserializeFfiResult(const std::string& raw) { + return reinterpret_cast(static_cast(std::stoull(raw))); +} + +// ============================================================ +// Synchronous call helper (anonymous namespace, header-only) +// ============================================================ + +namespace { + +struct FfiCallState_ { + std::mutex mtx; + std::condition_variable cv; + bool done{false}; + bool ok{false}; + std::string msg; +}; + +inline void ffi_cb_(int ret, const char* msg, size_t /*len*/, void* ud) { + auto* s = static_cast(ud); + std::lock_guard lock(s->mtx); + s->ok = (ret == 0); + s->msg = msg ? std::string(msg) : std::string{}; + s->done = true; + s->cv.notify_one(); +} + +inline std::string ffi_call_(std::function f) { + FfiCallState_ state; + const int ret = f(ffi_cb_, &state); + if (ret == 2) + throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + std::unique_lock lock(state.mtx); + state.cv.wait(lock, [&state]{ return state.done; }); + if (!state.ok) + throw std::runtime_error(state.msg); + return state.msg; +} + +} // anonymous namespace + +// ============================================================ +// High-level C++ context class +// ============================================================ + +class NimTimerCtx { +public: + static NimTimerCtx create(const TimerConfig& config) { + const auto config_json = serializeFfiArg(config); + const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { + return nimtimer_create(config_json.c_str(), cb, ud); + }); + // ctor returns the context address as a plain decimal string + const auto addr = std::stoull(raw); + return NimTimerCtx(reinterpret_cast(static_cast(addr))); + } + + EchoResponse echo(const EchoRequest& req) const { + const auto req_json = serializeFfiArg(req); + const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { + return nimtimer_echo(ptr_, cb, ud, req_json.c_str()); + }); + return deserializeFfiResult(raw); + } + + std::string version() const { + const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { + return nimtimer_version(ptr_, cb, ud); + }); + return deserializeFfiResult(raw); + } + + ComplexResponse complex(const ComplexRequest& req) const { + const auto req_json = serializeFfiArg(req); + const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { + return nimtimer_complex(ptr_, cb, ud, req_json.c_str()); + }); + return deserializeFfiResult(raw); + } + +private: + void* ptr_; + explicit NimTimerCtx(void* p) : ptr_(p) {} +}; diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index b9e25a9..4df4391 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -1,10 +1,12 @@ -import ffi, chronos +import ffi, chronos, options + +type Maybe[T] = Option[T] 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 + name: string # set at creation time, read back in each response ffiType: type TimerConfig = object @@ -13,12 +15,25 @@ ffiType: ffiType: type EchoRequest = object message: string - delayMs: int # how long chronos sleeps before replying + 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 + timerName: string # proves that the timer's own state is accessible + +ffiType: + type ComplexRequest = object + messages: seq[EchoRequest] + tags: seq[string] + note: Option[string] + retries: Maybe[int] + +ffiType: + type ComplexResponse = object + summary: string + itemCount: int + hasNote: bool # --- Constructor ----------------------------------------------------------- # Called once from Rust. Creates the FFIContext + NimTimer. @@ -26,7 +41,7 @@ ffiType: proc nimtimer_create*( config: TimerConfig ): Future[Result[NimTimer, string]] {.ffiCtor.} = - await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread + await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread return ok(NimTimer(name: config.name)) # --- Async method ---------------------------------------------------------- @@ -41,10 +56,18 @@ proc nimtimer_echo*( # --- 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.} = +proc nimtimer_version*(timer: NimTimer): Future[Result[string, string]] {.ffi.} = return ok("nim-timer v0.1.0") -when defined(ffiGenBindings): - genBindings("examples/nim_timer/nim_bindings", "../nim_timer.nim") +proc nimtimer_complex*( + timer: NimTimer, req: ComplexRequest +): Future[Result[ComplexResponse, string]] {.ffi.} = + let note = if req.note.isSome: req.note.get else: "" + let retries = if req.retries.isSome: req.retries.get else: 0 + let count = req.messages.len + let summary = + "received " & $count & " messages, note=" & note & ", retries=" & $retries + return + ok(ComplexResponse(summary: summary, itemCount: count, hasNote: req.note.isSome)) + +genBindings() # reads -d:ffiOutputDir, -d:ffiNimSrcRelPath, -d:targetLang from compile flags diff --git a/examples/nim_timer/nim_timer.nimble b/examples/nim_timer/nim_timer.nimble new file mode 100644 index 0000000..103e922 --- /dev/null +++ b/examples/nim_timer/nim_timer.nimble @@ -0,0 +1,21 @@ +version = "0.1.0" +packageName = "nimtimer" +author = "Institute of Free Technology" +description = "Example Nim timer library using nim-ffi" +license = "MIT or Apache License 2.0" + +requires "nim >= 2.2.4" +requires "chronos" +requires "chronicles" +requires "taskpools" +requires "ffi >= 0.1.3" + +# Build the example library and optionally generate bindings. +task build, "Compile the nimtimer library": + exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=rust nim_timer.nim" + +task genbindings_rust, "Generate Rust bindings for the nimtimer example": + exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=rust nim_timer.nim" + +task genbindings_cpp, "Generate C++ bindings for the nimtimer example": + exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=cpp nim_timer.nim" diff --git a/examples/nim_timer/rust_bindings/Cargo.toml b/examples/nim_timer/rust_bindings/Cargo.toml new file mode 100644 index 0000000..68ef056 --- /dev/null +++ b/examples/nim_timer/rust_bindings/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "nimtimer" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/examples/nim_timer/rust_bindings/README.md b/examples/nim_timer/rust_bindings/README.md new file mode 100644 index 0000000..2c7deca --- /dev/null +++ b/examples/nim_timer/rust_bindings/README.md @@ -0,0 +1,39 @@ +# Rust Bindings for nim-timer + +## Purpose + +This folder contains **auto-generated Rust bindings** (the `nimtimer` crate) for the `nim_timer` Nim library. It is generated from `../nim_timer.nim` and provides: + +- `src/lib.rs`: Main library exposing high-level Rust types and the `NimTimerCtx` API +- `src/api.rs`: High-level async/sync wrapper around the FFI +- `src/ffi.rs`: Raw `extern "C"` declarations for the Nim library +- `src/types.rs`: Serializable Rust types matching the Nim FFI types +- `build.rs`: Build script that compiles the Nim library to `libnimtimer.dylib` (or `.so`/`.dll`) +- `Cargo.toml`: Package manifest with serde and serde_json dependencies + +## How It's Generated + +Generate or regenerate these bindings by running from the parent directory: + +```sh +cd examples/nim_timer +nimble genbindings_rust +``` + +This command: +1. Invokes the Nim compiler with `-d:targetLang:rust` flag +2. Triggers `genBindings("examples/nim_timer/rust_bindings", "../nim_timer.nim")` in `nim_timer.nim` +3. Creates/updates the generated binding files + +## Using as a Dependency + +The `rust_client` example consumes this crate: + +```toml +[dependencies] +nimtimer = { path = "../rust_bindings" } +``` + +## Do Not Edit + +The generated files in this folder are overwritten each time `nimble genbindings_rust` runs. Any manual changes will be lost. diff --git a/examples/nim_timer/rust_bindings/build.rs b/examples/nim_timer/rust_bindings/build.rs new file mode 100644 index 0000000..b5b12a8 --- /dev/null +++ b/examples/nim_timer/rust_bindings/build.rs @@ -0,0 +1,47 @@ +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("../nim_timer.nim"); + let nim_src = nim_src.canonicalize().unwrap_or(manifest.join("../nim_timer.nim")); + + // 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!("libnimtimer.{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:libnimtimer")) + .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=nimtimer"); + println!("cargo:rerun-if-changed={}", nim_src.display()); +} diff --git a/examples/nim_timer/rust_bindings/src/api.rs b/examples/nim_timer/rust_bindings/src/api.rs new file mode 100644 index 0000000..7aa056f --- /dev/null +++ b/examples/nim_timer/rust_bindings/src/api.rs @@ -0,0 +1,102 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::Duration; +use super::ffi; +use super::types::*; + +#[derive(Default)] +struct FfiCallbackResult { + payload: Option>, +} + +type Pair = Arc<(Mutex, Condvar)>; + +unsafe extern "C" fn on_result( + ret: c_int, + msg: *const c_char, + _len: usize, + user_data: *mut c_void, +) { + let pair = Arc::from_raw(user_data as *const (Mutex, Condvar)); + { + let (lock, cvar) = &*pair; + let mut state = lock.lock().unwrap(); + state.payload = Some(if ret == 0 { + Ok(CStr::from_ptr(msg).to_string_lossy().into_owned()) + } else { + Err(CStr::from_ptr(msg).to_string_lossy().into_owned()) + }); + cvar.notify_one(); + } + std::mem::forget(pair); +} + +fn ffi_call(timeout: Duration, f: F) -> Result +where + F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int, +{ + let pair: Pair = Arc::new((Mutex::new(FfiCallbackResult::default()), Condvar::new())); + let raw = Arc::into_raw(pair.clone()) as *mut c_void; + let ret = f(on_result, raw); + if ret == 2 { + return Err("RET_MISSING_CALLBACK (internal error)".into()); + } + let (lock, cvar) = &*pair; + let guard = lock.lock().unwrap(); + let (guard, timed_out) = cvar + .wait_timeout_while(guard, timeout, |s| s.payload.is_none()) + .unwrap(); + if timed_out.timed_out() { + return Err(format!("timed out after {:?}", timeout)); + } + guard.payload.clone().unwrap() +} + +/// High-level context for `NimTimer`. +pub struct NimTimerCtx { + ptr: *mut c_void, + timeout: Duration, +} + +unsafe impl Send for NimTimerCtx {} +unsafe impl Sync for NimTimerCtx {} + +impl NimTimerCtx { + pub fn create(config: TimerConfig, timeout: Duration) -> Result { + let config_json = serde_json::to_string(&config).map_err(|e| e.to_string())?; + let config_c = CString::new(config_json).unwrap(); + let raw = ffi_call(timeout, |cb, ud| unsafe { + ffi::nimtimer_create(config_c.as_ptr(), cb, ud) + })?; + // ctor returns the context address as a plain decimal string + let addr: usize = raw.parse().map_err(|e: std::num::ParseIntError| e.to_string())?; + Ok(Self { ptr: addr as *mut c_void, timeout }) + } + + pub fn echo(&self, req: EchoRequest) -> Result { + let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; + let req_c = CString::new(req_json).unwrap(); + let raw = ffi_call(self.timeout, |cb, ud| unsafe { + ffi::nimtimer_echo(self.ptr, cb, ud, req_c.as_ptr()) + })?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) + } + + pub fn version(&self) -> Result { + let raw = ffi_call(self.timeout, |cb, ud| unsafe { + ffi::nimtimer_version(self.ptr, cb, ud) + })?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) + } + + pub fn complex(&self, req: ComplexRequest) -> Result { + let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; + let req_c = CString::new(req_json).unwrap(); + let raw = ffi_call(self.timeout, |cb, ud| unsafe { + ffi::nimtimer_complex(self.ptr, cb, ud, req_c.as_ptr()) + })?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) + } + +} diff --git a/examples/nim_timer/rust_bindings/src/ffi.rs b/examples/nim_timer/rust_bindings/src/ffi.rs new file mode 100644 index 0000000..2cc1172 --- /dev/null +++ b/examples/nim_timer/rust_bindings/src/ffi.rs @@ -0,0 +1,16 @@ +use std::os::raw::{c_char, c_int, c_void}; + +pub type FfiCallback = unsafe extern "C" fn( + ret: c_int, + msg: *const c_char, + len: usize, + user_data: *mut c_void, +); + +#[link(name = "nimtimer")] +extern "C" { + pub fn nimtimer_create(config_json: *const c_char, callback: FfiCallback, user_data: *mut c_void) -> c_int; + pub fn nimtimer_echo(ctx: *mut c_void, callback: FfiCallback, user_data: *mut c_void, req_json: *const c_char) -> c_int; + pub fn nimtimer_version(ctx: *mut c_void, callback: FfiCallback, user_data: *mut c_void) -> c_int; + pub fn nimtimer_complex(ctx: *mut c_void, callback: FfiCallback, user_data: *mut c_void, req_json: *const c_char) -> c_int; +} diff --git a/examples/nim_timer/rust_bindings/src/lib.rs b/examples/nim_timer/rust_bindings/src/lib.rs new file mode 100644 index 0000000..29c439a --- /dev/null +++ b/examples/nim_timer/rust_bindings/src/lib.rs @@ -0,0 +1,5 @@ +mod ffi; +mod types; +mod api; +pub use types::*; +pub use api::*; diff --git a/examples/nim_timer/rust_bindings/src/types.rs b/examples/nim_timer/rust_bindings/src/types.rs new file mode 100644 index 0000000..9037805 --- /dev/null +++ b/examples/nim_timer/rust_bindings/src/types.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimerConfig { + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoRequest { + pub message: String, + #[serde(rename = "delayMs")] + pub delay_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EchoResponse { + pub echoed: String, + #[serde(rename = "timerName")] + pub timer_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplexRequest { + pub messages: Vec, + pub tags: Vec, + pub note: Option, + pub retries: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplexResponse { + pub summary: String, + #[serde(rename = "itemCount")] + pub item_count: i64, + #[serde(rename = "hasNote")] + pub has_note: bool, +} diff --git a/examples/nim_timer/rust_client/Cargo.lock b/examples/nim_timer/rust_client/Cargo.lock index 51c3923..5c3459e 100644 --- a/examples/nim_timer/rust_client/Cargo.lock +++ b/examples/nim_timer/rust_client/Cargo.lock @@ -22,6 +22,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -46,6 +52,7 @@ version = "0.1.0" dependencies = [ "nimtimer", "serde_json", + "tokio", ] [[package]] @@ -102,6 +109,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/examples/nim_timer/rust_client/Cargo.toml b/examples/nim_timer/rust_client/Cargo.toml index 0189b2f..cd21745 100644 --- a/examples/nim_timer/rust_client/Cargo.toml +++ b/examples/nim_timer/rust_client/Cargo.toml @@ -4,5 +4,14 @@ version = "0.1.0" edition = "2021" [dependencies] -nimtimer = { path = "../nim_bindings" } +nimtimer = { path = "../rust_bindings" } serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[[bin]] +name = "rust_client" +path = "src/main.rs" + +[[bin]] +name = "tokio_client" +path = "src/tokio_main.rs" diff --git a/examples/nim_timer/rust_client/README.md b/examples/nim_timer/rust_client/README.md new file mode 100644 index 0000000..bf818af --- /dev/null +++ b/examples/nim_timer/rust_client/README.md @@ -0,0 +1,43 @@ +# Rust Client Examples + +## Purpose + +This folder contains **example Rust applications** that demonstrate how to use the auto-generated `nimtimer` crate (from `../rust_bindings`). + +## What's Included + +Two executable examples: + +- **`rust_client`** — Synchronous example + - Shows basic synchronous calls to the Nim timer API + - Uses blocking wait with condition variables + - Source: `src/main.rs` + +- **`tokio_client`** — Asynchronous example with Tokio runtime + - Demonstrates the Tokio async runtime integration + - Uses `spawn_blocking` to handle the blocking FFI callbacks on a separate thread pool + - Source: `src/tokio_main.rs` + +## Building + +```sh +cd examples/nim_timer/rust_client +cargo build +``` + +## Running + +```sh +# Sync example +cargo run --bin rust_client + +# Tokio async example +cargo run --bin tokio_client +``` + +## Important Notes + +- The `nimtimer` crate is a **local dependency** (`path = "../rust_bindings"`) +- It is **auto-generated** — do not manually edit it +- These examples are **not** part of the generated output; they are hand-written to show usage patterns +- To regenerate the `nimtimer` crate, run `nimble genbindings_rust` from the parent directory diff --git a/examples/nim_timer/rust_client/src/main.rs b/examples/nim_timer/rust_client/src/main.rs index cd7f5c2..33594cf 100644 --- a/examples/nim_timer/rust_client/src/main.rs +++ b/examples/nim_timer/rust_client/src/main.rs @@ -3,7 +3,7 @@ // 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: +// To regenerate the `rust_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}; diff --git a/examples/nim_timer/rust_client/src/tokio_main.rs b/examples/nim_timer/rust_client/src/tokio_main.rs new file mode 100644 index 0000000..abb8934 --- /dev/null +++ b/examples/nim_timer/rust_client/src/tokio_main.rs @@ -0,0 +1,71 @@ +use nimtimer::{EchoRequest, NimTimerCtx, TimerConfig}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::task; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + let timeout = Duration::from_secs(5); + + let ctx = task::spawn_blocking(move || { + NimTimerCtx::create(TimerConfig { name: "tokio-demo".into() }, timeout) + }) + .await + .expect("failed to join create task") + .expect("nimtimer_create failed"); + + let ctx = Arc::new(Mutex::new(ctx)); + + let version = task::spawn_blocking({ + let ctx = Arc::clone(&ctx); + move || { + let ctx = ctx.lock().unwrap(); + ctx.version() + } + }) + .await + .expect("failed to join version task") + .expect("nimtimer_version failed"); + + println!("[1] Tokio runtime started"); + println!("[2] Version: {version}"); + + let req1 = EchoRequest { + message: "hello from tokio".into(), + delay_ms: 200, + }; + let req2 = EchoRequest { + message: "second tokio request".into(), + delay_ms: 50, + }; + + let fut1 = task::spawn_blocking({ + let ctx = Arc::clone(&ctx); + move || { + let ctx = ctx.lock().unwrap(); + ctx.echo(req1) + } + }); + + let fut2 = task::spawn_blocking({ + let ctx = Arc::clone(&ctx); + move || { + let ctx = ctx.lock().unwrap(); + ctx.echo(req2) + } + }); + + let echo1 = fut1 + .await + .expect("failed to join tokio blocking task") + .expect("nimtimer_echo failed"); + let echo2 = fut2 + .await + .expect("failed to join tokio blocking task") + .expect("nimtimer_echo failed"); + + println!("[3] Echo 1: echoed={}, timerName={}", echo1.echoed, echo1.timer_name); + println!("[4] Echo 2: echoed={}, timerName={}", echo2.echoed, echo2.timer_name); + + println!("\nDone. Tokio runtime shut down."); +} diff --git a/ffi.nimble b/ffi.nimble index c94c3dc..6a635bb 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -5,7 +5,7 @@ author = "Institute of Free Technology" description = "FFI framework with custom header generation" license = "MIT or Apache License 2.0" -packageName = "ffi" +packageName = "ffi" requires "nim >= 2.2.4" requires "chronos" @@ -32,7 +32,17 @@ task test_serial, "Run serial unit tests": exec "nim c -r " & nimFlags & " tests/test_serial.nim" task genbindings_rust, "Generate Rust bindings for the nim_timer example": - exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:ffiTargetLang=rust -o:/dev/null examples/nim_timer/nim_timer.nim" + exec "nim c " & nimFlags & + " --app:lib --noMain --nimMainPrefix:libnimtimer" & + " -d:ffiGenBindings -d:targetLang=rust" & + " -d:ffiOutputDir=examples/nim_timer/rust_bindings" & + " -d:ffiNimSrcRelPath=../nim_timer.nim" & + " -o:/dev/null examples/nim_timer/nim_timer.nim" task genbindings_cpp, "Generate C++ bindings for the nim_timer example": - exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:ffiTargetLang=cpp -o:/dev/null examples/nim_timer/nim_timer.nim" + exec "nim c " & nimFlags & + " --app:lib --noMain --nimMainPrefix:libnimtimer" & + " -d:ffiGenBindings -d:targetLang=cpp" & + " -d:ffiOutputDir=examples/nim_timer/cpp_bindings" & + " -d:ffiNimSrcRelPath=../nim_timer.nim" & + " -o:/dev/null examples/nim_timer/nim_timer.nim" diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 1cdd708..43b088c 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -4,15 +4,36 @@ import std/[os, strutils] import ./meta +proc genericInnerType(typeName, prefix: string): string = + if typeName.startsWith(prefix) and typeName.endsWith("]"): + let start = prefix.len + let lastIndex = typeName.len - 2 + return typeName[start .. lastIndex] + return "" + proc nimTypeToCpp*(typeName: string): string = - case typeName + let trimmed = typeName.strip() + if trimmed.startsWith("ptr "): + return "void*" + else: + let seqInner = genericInnerType(trimmed, "seq[") + if seqInner.len > 0: + return "std::vector<" & nimTypeToCpp(seqInner) & ">" + let optionInner = genericInnerType(trimmed, "Option[") + if optionInner.len > 0: + return "std::optional<" & nimTypeToCpp(optionInner) & ">" + let maybeInner = genericInnerType(trimmed, "Maybe[") + if maybeInner.len > 0: + return "std::optional<" & nimTypeToCpp(maybeInner) & ">" + case trimmed of "string", "cstring": "std::string" of "int", "int64": "int64_t" of "int32": "int32_t" of "bool": "bool" - of "float", "float64": "double" + of "float": "float" + of "float64": "double" of "pointer": "void*" - else: typeName + else: trimmed proc stripLibPrefixCpp(procName, libName: string): string = let prefix = libName & "_" @@ -21,9 +42,7 @@ proc stripLibPrefixCpp(procName, libName: string): string = return procName proc generateCppHeader*( - procs: seq[FFIProcMeta], - types: seq[FFITypeMeta], - libName: string, + procs: seq[FFIProcMeta], types: seq[FFITypeMeta], libName: string ): string = var lines: seq[string] = @[] @@ -34,8 +53,24 @@ proc generateCppHeader*( lines.add("#include ") lines.add("#include ") lines.add("#include ") + lines.add("#include ") + lines.add("#include ") lines.add("#include ") lines.add("") + lines.add("namespace nlohmann {") + lines.add(" template") + lines.add(" void to_json(json& j, const std::optional& opt) {") + lines.add(" if (opt) j = *opt;") + lines.add(" else j = nullptr;") + lines.add(" }") + lines.add("") + lines.add(" template") + lines.add(" void from_json(const json& j, std::optional& opt) {") + lines.add(" if (j.is_null()) opt = std::nullopt;") + lines.add(" else opt = j.get();") + lines.add(" }") + lines.add("}") + lines.add("") # Types if types.len > 0: @@ -52,7 +87,9 @@ proc generateCppHeader*( var fieldNames: seq[string] = @[] for f in t.fields: fieldNames.add(f.name) - lines.add("NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE($1, $2)" % [t.name, fieldNames.join(", ")]) + lines.add( + "NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE($1, $2)" % [t.name, fieldNames.join(", ")] + ) lines.add("") # C extern declarations @@ -61,7 +98,9 @@ proc generateCppHeader*( lines.add("// ============================================================") lines.add("") lines.add("extern \"C\" {") - lines.add("typedef void (*FfiCallback)(int ret, const char* msg, size_t len, void* user_data);") + lines.add( + "typedef void (*FfiCallback)(int ret, const char* msg, size_t len, void* user_data);" + ) lines.add("") for p in procs: @@ -82,6 +121,30 @@ proc generateCppHeader*( lines.add("} // extern \"C\"") lines.add("") + # Transport serialization helpers + lines.add("") + lines.add("template") + lines.add("inline std::string serializeFfiArg(const T& value) {") + lines.add(" return nlohmann::json(value).dump();") + lines.add("}") + lines.add("") + lines.add("inline std::string serializeFfiArg(void* value) {") + lines.add(" return std::to_string(reinterpret_cast(value));") + lines.add("}") + lines.add("") + lines.add("template") + lines.add("inline T deserializeFfiResult(const std::string& raw) {") + lines.add(" return nlohmann::json::parse(raw).get();") + lines.add("}") + lines.add("") + lines.add("template<>") + lines.add("inline void* deserializeFfiResult(const std::string& raw) {") + lines.add( + " return reinterpret_cast(static_cast(std::stoull(raw)));" + ) + lines.add("}") + lines.add("") + # Anonymous namespace with synchronous call helper lines.add("// ============================================================") lines.add("// Synchronous call helper (anonymous namespace, header-only)") @@ -110,7 +173,9 @@ proc generateCppHeader*( lines.add(" FfiCallState_ state;") lines.add(" const int ret = f(ffi_cb_, &state);") lines.add(" if (ret == 2)") - lines.add(" throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");") + lines.add( + " throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");" + ) lines.add(" std::unique_lock lock(state.mtx);") lines.add(" state.cv.wait(lock, [&state]{ return state.done; });") lines.add(" if (!state.ok)") @@ -125,12 +190,16 @@ proc generateCppHeader*( var ctors: seq[FFIProcMeta] = @[] var methods: seq[FFIProcMeta] = @[] for p in procs: - if p.kind == ffiCtorKind: ctors.add(p) - else: methods.add(p) + if p.kind == ffiCtorKind: + ctors.add(p) + else: + methods.add(p) let libTypeName = - if ctors.len > 0: ctors[0].libTypeName - else: libName[0 .. 0].toUpperAscii() & libName[1 .. ^1] + if ctors.len > 0: + ctors[0].libTypeName + else: + libName[0 .. 0].toUpperAscii() & libName[1 .. ^1] let ctxTypeName = libTypeName & "Ctx" @@ -150,7 +219,7 @@ proc generateCppHeader*( lines.add(" static $1 create($2) {" % [ctxTypeName, ctorParams.join(", ")]) for ep in ctor.extraParams: - lines.add(" const auto $1_json = nlohmann::json($1).dump();" % [ep.name]) + lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) var callArgs: seq[string] = @[] for ep in ctor.extraParams: @@ -163,7 +232,10 @@ proc generateCppHeader*( lines.add(" });") lines.add(" // ctor returns the context address as a plain decimal string") lines.add(" const auto addr = std::stoull(raw);") - lines.add(" return $1(reinterpret_cast(static_cast(addr)));" % [ctxTypeName]) + lines.add( + " return $1(reinterpret_cast(static_cast(addr)));" % + [ctxTypeName] + ) lines.add(" }") lines.add("") @@ -180,7 +252,7 @@ proc generateCppHeader*( lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) for ep in m.extraParams: - lines.add(" const auto $1_json = nlohmann::json($1).dump();" % [ep.name]) + lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) var callArgs = @["ptr_", "cb", "ud"] for ep in m.extraParams: @@ -190,10 +262,10 @@ proc generateCppHeader*( lines.add(" return $1($2);" % [m.procName, callArgs.join(", ")]) lines.add(" });") - if retCppType == "std::string": - lines.add(" return nlohmann::json::parse(raw).get();") + if retCppType == "void*": + lines.add(" return deserializeFfiResult(raw);") else: - lines.add(" return nlohmann::json::parse(raw).get<$1>();" % [retCppType]) + lines.add(" return deserializeFfiResult<$1>(raw);" % [retCppType]) lines.add(" }") lines.add("") @@ -210,12 +282,12 @@ proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = ## CMake uses ${...} which would clash with Nim's % format operator, ## so we build the file line by line using string concatenation. let src = nimSrcRelPath.replace("\\", "/") - let cv = "${CMAKE_CURRENT_SOURCE_DIR}" # CMake variable shorthand - let rv = "${REPO_ROOT}" - let lf = "${NIM_LIB_FILE}" - let nm = "${NIM_EXECUTABLE}" - let ns = "${NIM_SRC}" - let sd = "${_search_dir}" + let cv = "${CMAKE_CURRENT_SOURCE_DIR}" # CMake variable shorthand + let rv = "${REPO_ROOT}" + let lf = "${NIM_LIB_FILE}" + let nm = "${NIM_EXECUTABLE}" + let ns = "${NIM_SRC}" + let sd = "${_search_dir}" var L: seq[string] = @[] L.add("cmake_minimum_required(VERSION 3.14)") L.add("project(" & libName & "_cpp_bindings CXX)") @@ -223,7 +295,9 @@ proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = L.add("set(CMAKE_CXX_STANDARD 17)") L.add("set(CMAKE_CXX_STANDARD_REQUIRED ON)") L.add("") - L.add("# ── nlohmann/json ─────────────────────────────────────────────────────────────") + L.add( + "# ── nlohmann/json ─────────────────────────────────────────────────────────────" + ) L.add("include(FetchContent)") L.add("FetchContent_Declare(") L.add(" nlohmann_json") @@ -233,7 +307,9 @@ proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = L.add(")") L.add("FetchContent_MakeAvailable(nlohmann_json)") L.add("") - L.add("# ── Locate the repository root (contains ffi.nimble) ─────────────────────────") + L.add( + "# ── Locate the repository root (contains ffi.nimble) ─────────────────────────" + ) L.add("set(_search_dir \"" & cv & "\")") L.add("set(REPO_ROOT \"\")") L.add("foreach(_i RANGE 10)") @@ -244,15 +320,21 @@ proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = L.add(" get_filename_component(_search_dir \"" & sd & "\" DIRECTORY)") L.add("endforeach()") L.add("if(\"${REPO_ROOT}\" STREQUAL \"\")") - L.add(" message(FATAL_ERROR \"Cannot find repo root (no ffi.nimble in any ancestor)\")") + L.add( + " message(FATAL_ERROR \"Cannot find repo root (no ffi.nimble in any ancestor)\")" + ) L.add("endif()") L.add("") - L.add("# ── Nim source path ───────────────────────────────────────────────────────────") + L.add( + "# ── Nim source path ───────────────────────────────────────────────────────────" + ) L.add("get_filename_component(NIM_SRC") L.add(" \"" & cv & "/" & src & "\"") L.add(" ABSOLUTE)") L.add("") - L.add("# ── Compile the Nim shared library ───────────────────────────────────────────") + L.add( + "# ── Compile the Nim shared library ───────────────────────────────────────────" + ) L.add("find_program(NIM_EXECUTABLE nim REQUIRED)") L.add("") L.add("if(CMAKE_SYSTEM_NAME STREQUAL \"Darwin\")") @@ -281,15 +363,24 @@ proc generateCppCMakeLists*(libName: string, nimSrcRelPath: string): string = L.add("add_custom_target(nim_lib ALL DEPENDS \"" & lf & "\")") L.add("") L.add("add_library(" & libName & " SHARED IMPORTED GLOBAL)") - L.add("set_target_properties(" & libName & " PROPERTIES IMPORTED_LOCATION \"" & lf & "\")") + L.add( + "set_target_properties(" & libName & " PROPERTIES IMPORTED_LOCATION \"" & lf & "\")" + ) L.add("add_dependencies(" & libName & " nim_lib)") L.add("") - L.add("# ── Interface target exposing the generated header ────────────────────────────") + L.add( + "# ── Interface target exposing the generated header ────────────────────────────" + ) L.add("add_library(" & libName & "_headers INTERFACE)") L.add("target_include_directories(" & libName & "_headers INTERFACE \"" & cv & "\")") - L.add("target_link_libraries(" & libName & "_headers INTERFACE " & libName & " nlohmann_json::nlohmann_json)") + L.add( + "target_link_libraries(" & libName & "_headers INTERFACE " & libName & + " nlohmann_json::nlohmann_json)" + ) L.add("") - L.add("# ── Optional example executable ───────────────────────────────────────────────") + L.add( + "# ── Optional example executable ───────────────────────────────────────────────" + ) L.add("if(EXISTS \"" & cv & "/main.cpp\")") L.add(" add_executable(example main.cpp)") L.add(" target_link_libraries(example PRIVATE " & libName & "_headers)") diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 39bf1b9..cc11a72 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -3,27 +3,27 @@ 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` + 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" + 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 + 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" + name*: string # e.g. "delayMs" + typeName*: string # e.g. "int" FFITypeMeta* = object name*: string @@ -34,5 +34,12 @@ var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta] var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] var currentLibName* {.compileTime.}: string -# Target language for binding generation; override with -d:ffiTargetLang=cpp -const ffiTargetLang* {.strdefine.} = "rust" +# Target language for binding generation; override with -d:targetLang=cpp +const targetLang* {.strdefine.} = "rust" + +# Output directory for generated bindings; set with -d:ffiOutputDir=path/to/dir +const ffiOutputDir* {.strdefine.} = "" + +# Nim source path (relative to outputDir) embedded in generated build files; +# set with -d:ffiNimSrcRelPath=../relative/path.nim +const ffiNimSrcRelPath* {.strdefine.} = "" diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index 444c1bd..da000e3 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -19,20 +19,28 @@ proc toSnakeCase*(s: string): string = proc toPascalCase*(s: string): string = ## Converts the first letter to uppercase. - if s.len == 0: return s + 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 + ## Maps Nim type names to Rust type names, including generics. + let t = typeName.strip() + if t.startsWith("seq[") and t.endsWith("]"): + return "Vec<" & nimTypeToRust(t[4 .. ^2]) & ">" + if t.startsWith("Option[") and t.endsWith("]"): + return "Option<" & nimTypeToRust(t[7 .. ^2]) & ">" + if t.startsWith("Maybe[") and t.endsWith("]"): + return "Option<" & nimTypeToRust(t[6 .. ^2]) & ">" + case t of "string", "cstring": "String" of "int", "int64": "i64" of "int32": "i32" of "bool": "bool" of "float", "float64": "f64" of "pointer": "usize" - else: toPascalCase(typeName) + else: toPascalCase(t) proc deriveLibName*(procs: seq[FFIProcMeta]): string = ## Extracts the common prefix before the first `_` from proc names. @@ -60,7 +68,8 @@ proc stripLibPrefix*(procName: string, libName: string): string = # --------------------------------------------------------------------------- proc generateCargoToml*(libName: string): string = - result = """[package] + result = + """[package] name = "$1" version = "0.1.0" edition = "2021" @@ -68,13 +77,15 @@ edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -""" % [libName] +""" % + [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; + result = + """use std::path::PathBuf; use std::process::Command; fn main() { @@ -121,7 +132,8 @@ fn main() { println!("cargo:rustc-link-lib=$2"); println!("cargo:rerun-if-changed={}", nim_src.display()); } -""" % [escapedSrc, libName] +""" % + [escapedSrc, libName] proc generateLibRs*(): string = result = """mod ffi; @@ -239,7 +251,7 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("use super::types::*;") lines.add("") - # FfiCallbackResult struct + # FfiCallbackResult + Pair lines.add("#[derive(Default)]") lines.add("struct FfiCallbackResult {") lines.add(" payload: Option>,") @@ -248,7 +260,7 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("type Pair = Arc<(Mutex, Condvar)>;") lines.add("") - # on_result callback + # on_result callback (Arc-based, blocking) lines.add("unsafe extern \"C\" fn on_result(") lines.add(" ret: c_int,") lines.add(" msg: *const c_char,") @@ -270,7 +282,7 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("}") lines.add("") - # ffi_call helper + # Blocking ffi_call helper using Condvar::wait_timeout_while lines.add("fn ffi_call(timeout: Duration, f: F) -> Result") lines.add("where") lines.add(" F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,") @@ -325,10 +337,18 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = 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]) + 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_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 @@ -344,7 +364,9 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = 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( + " 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("") @@ -359,19 +381,34 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = 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: "" + let paramsStr = + if paramsList.len > 0: + ", " & paramsList.join(", ") + else: + "" - lines.add(" pub fn $1(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retRustType]) + 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]) + 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_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 @@ -387,11 +424,16 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = # Deserialize return value if retRustType == "String": - lines.add(" serde_json::from_str::(&raw).map_err(|e| e.to_string())") + lines.add( + " serde_json::from_str::(&raw).map_err(|e| e.to_string())" + ) elif retRustType == "usize": lines.add(" raw.parse::().map_err(|e| e.to_string())") else: - lines.add(" serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % [retRustType]) + lines.add( + " serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % + [retRustType] + ) lines.add(" }") lines.add("") diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 3f3ad6d..9be68da 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -200,6 +200,7 @@ proc buildFfiNewReqProc(reqTypeName, body: NimNode): NimNode = FFIThreadRequest.init(callback, userData, typeStr.cstring, `reqObjIdent`) proc destroyContent(content: pointer) {.nimcall.} = ffiDeleteReq(cast[ptr `reqTypeName`](content)) + ret[].deleteReqContent = destroyContent return ret ) @@ -363,12 +364,11 @@ proc addNewRequestToRegistry(reqTypeName, reqHandler: NimNode): NimNode = # CreateNodeRequest.processFFIRequest(request, reqHandler) let asyncProc = newProc( name = newEmptyNode(), # anonymous proc - params = - @[ - returnType, - newIdentDefs(ident("request"), ident("pointer")), - newIdentDefs(ident("reqHandler"), ident("pointer")), - ], + params = @[ + returnType, + newIdentDefs(ident("request"), ident("pointer")), + newIdentDefs(ident("reqHandler"), ident("pointer")), + ], body = newBody, pragmas = nnkPragma.newTree(ident("async")), ) @@ -587,19 +587,27 @@ macro ffi*(prc: untyped): untyped = # Extract LibType from the first parameter let firstParam = formalParams[1] - let libParamName = firstParam[0] # e.g. `w` - let libTypeName = firstParam[1] # e.g. `Waku` + 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]]") + 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) + 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) + 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] = @[] @@ -613,7 +621,10 @@ macro ffi*(prc: untyped): untyped = # Generate type/proc names from proc name let procNameStr = block: let raw = $procName - if raw.endsWith("*"): raw[0 ..^ 2] else: raw + if raw.endsWith("*"): + raw[0 ..^ 2] + else: + raw let camelName = toCamelCase(procNameStr) # Names of generated things @@ -631,9 +642,8 @@ macro ffi*(prc: untyped): untyped = procName # Common exported params (needed for both branches) - let ctxType = nnkPtrTy.newTree( - nnkBracketExpr.newTree(ident("FFIContext"), libTypeName) - ) + let ctxType = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)) if isAsync: # ------------------------------------------------------------------------- @@ -668,9 +678,8 @@ macro ffi*(prc: untyped): untyped = ) let ctxHandlerName = ident("ffiCtxHandler") - let ptrFfiCtx = nnkPtrTy.newTree( - nnkBracketExpr.newTree(ident("FFIContext"), libTypeName) - ) + let ptrFfiCtx = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)) var lambdaParams = newSeq[NimNode]() lambdaParams.add(futStrStr) @@ -785,8 +794,10 @@ macro ffi*(prc: untyped): untyped = 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] + 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: @@ -794,19 +805,20 @@ macro ffi*(prc: untyped): untyped = 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, - )) + 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 @@ -881,7 +893,9 @@ macro ffi*(prc: untyped): untyped = 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) + callback( + RET_OK, unsafeAddr serialized[0], cast[csize_t](serialized.len), userData + ) return RET_OK let syncFfiProc = newProc( @@ -909,7 +923,9 @@ macro ffi*(prc: untyped): untyped = tn = $ptype[0] else: tn = $ptype - ffiExtraParamsSync.add(FFIParamMeta(name: extraParamNames[i], typeName: tn, isPtr: isPtr)) + ffiExtraParamsSync.add( + FFIParamMeta(name: extraParamNames[i], typeName: tn, isPtr: isPtr) + ) let retTypeInnerSync = resultInner[1] var retIsPtrSync = false var retTnSync = "" @@ -918,16 +934,18 @@ macro ffi*(prc: untyped): untyped = 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, - )) + ffiProcRegistry.add( + FFIProcMeta( + procName: procNameStr, + libName: currentLibName, + kind: ffiFfiKind, + libTypeName: $libTypeName, + extraParams: ffiExtraParamsSync, + returnTypeName: retTnSync, + returnIsPtr: retIsPtrSync, + isAsync: false, + ) + ) result = newStmtList(syncHelperProc, syncFfiProc) @@ -953,11 +971,15 @@ proc buildCtorRequestType(reqTypeName: NimNode, paramNames: seq[string]): NimNod if fields.len > 0: newTree(nnkRecList, fields) else: - newTree(nnkRecList, newTree(nnkIdentDefs, ident("_placeholder"), ident("pointer"), newEmptyNode())) + 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)) + result = + newNimNode(nnkTypeSection).add(newTree(nnkTypeDef, typeName, newEmptyNode(), objTy)) when defined(ffiDumpMacros): echo result.repr @@ -990,9 +1012,8 @@ proc buildCtorFfiNewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimN var formalParams = newSeq[NimNode]() - let typedescParam = newIdentDefs( - ident("T"), nnkBracketExpr.newTree(ident("typedesc"), reqTypeName) - ) + 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"))) @@ -1018,6 +1039,7 @@ proc buildCtorFfiNewReqProc(reqTypeName: NimNode, paramNames: seq[string]): NimN var ret = FFIThreadRequest.init(callback, userData, typeStr.cstring, `reqObjIdent`) proc destroyContent(content: pointer) {.nimcall.} = ffiDeleteReq(cast[ptr `reqTypeName`](content)) + ret[].deleteReqContent = destroyContent return ret @@ -1084,9 +1106,8 @@ proc buildCtorProcessFFIRequestProc( ) # The ctx param type: ptr FFIContext[LibType] - let ctxType = nnkPtrTy.newTree( - nnkBracketExpr.newTree(ident("FFIContext"), libTypeName) - ) + let ctxType = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)) let typedescParam = newIdentDefs(ident("T"), nnkBracketExpr.newTree(ident("typedesc"), reqTypeName)) @@ -1152,9 +1173,8 @@ 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 ctxType = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), libTypeName)) let returnType = nnkBracketExpr.newTree( ident("Future"), @@ -1173,20 +1193,18 @@ proc addCtorRequestToRegistry(reqTypeName, libTypeName: NimNode): NimNode = let asyncProc = newProc( name = newEmptyNode(), - params = - @[ - returnType, - newIdentDefs(ident("request"), ident("pointer")), - newIdentDefs(ident("reqHandler"), ident("pointer")), - ], + 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 - ) + result = + newAssignment(newTree(nnkBracketExpr, ident("registeredRequests"), key), asyncProc) when defined(ffiDumpMacros): echo result.repr @@ -1219,13 +1237,21 @@ macro ffiCtor*(prc: untyped): untyped = 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]]") + 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) + 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) + 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) @@ -1256,9 +1282,11 @@ macro ffiCtor*(prc: untyped): untyped = # 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 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: @@ -1311,9 +1339,8 @@ macro ffiCtor*(prc: untyped): untyped = return RET_ERR # sendRequestToFFIThread using the gensym'd ctx - let sendCall = newCall( - newDotExpr(ctxSym, ident("sendRequestToFFIThread")), newReqCall - ) + let sendCall = + newCall(newDotExpr(ctxSym, ident("sendRequestToFFIThread")), newReqCall) let sendResIdent = genSym(nskLet, "sendRes") ffiBody.add quote do: @@ -1363,18 +1390,22 @@ macro ffiCtor*(prc: untyped): untyped = 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, - )) + 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) + result = newStmtList( + typeDef, deleteProc, ffiNewReqProc, helperProc, processProc, addToReg, ffiProc + ) when defined(ffiDumpMacros): echo result.repr @@ -1384,28 +1415,39 @@ macro ffiCtor*(prc: untyped): untyped = # --------------------------------------------------------------------------- macro genBindings*( - outputDir: static[string], - nimSrcRelPath: static[string] = "", + outputDir: static[string] = ffiOutputDir, + nimSrcRelPath: static[string] = ffiNimSrcRelPath, ): untyped = - ## Generates binding files for the target language set by -d:ffiTargetLang=. + ## Generates binding files for the language set by -d:targetLang=. ## Supported values: "rust" (default), "cpp" (case-insensitive). - ## Call at the END of your library file, after all {.ffiCtor.} and {.ffi.} procs. + ## Output path and nim source path default to -d:ffiOutputDir and + ## -d:ffiNimSrcRelPath, or can be passed as explicit arguments. + ## This macro is a no-op unless -d:ffiGenBindings is set. ## - ## Example: - ## genBindings("examples/nim_timer/nim_bindings", "../nim_timer.nim") - ## - ## Activate with: nim c -d:ffiGenBindings -d:ffiTargetLang=rust mylib.nim - ## or: nim c -d:ffiGenBindings -d:ffiTargetLang=cpp mylib.nim + ## Example (all via compile flags): + ## genBindings() + ## # nim c -d:ffiGenBindings -d:targetLang=rust \ + ## # -d:ffiOutputDir=examples/nim_timer/rust_bindings \ + ## # -d:ffiNimSrcRelPath=../nim_timer.nim mylib.nim when defined(ffiGenBindings): - let lang = ffiTargetLang.toLowerAscii() + if outputDir.len == 0: + error( + "genBindings: output directory is empty." & + " Pass it as an argument or set -d:ffiOutputDir=path/to/output" + ) + let lang = targetLang.toLowerAscii() let libName = deriveLibName(ffiProcRegistry) case lang of "rust": - generateRustCrate(ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) + generateRustCrate( + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath + ) of "cpp", "c++": - generateCppBindings(ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) + generateCppBindings( + ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath + ) else: - error("genBindings: unknown ffiTargetLang '" & lang & "'. Use 'rust' or 'cpp'.") + error("genBindings: unknown targetLang '" & lang & "'. Use 'rust' or 'cpp'.") result = newEmptyNode() diff --git a/ffi/serial.nim b/ffi/serial.nim index 7a042d4..935db4b 100644 --- a/ffi/serial.nim +++ b/ffi/serial.nim @@ -1,13 +1,15 @@ -import std/[json, macros] +import std/[json, macros, sequtils, options] import results import ./codegen/meta proc ffiSerialize*(x: string): string = - $(%* x) + $(%*x) proc ffiSerialize*(x: cstring): string = - if x.isNil: "null" - else: ffiSerialize($x) + if x.isNil: + "null" + else: + ffiSerialize($x) proc ffiSerialize*(x: int): string = $x @@ -19,7 +21,7 @@ proc ffiSerialize*(x: bool): string = if x: "true" else: "false" proc ffiSerialize*(x: float): string = - $(%* x) + $(%*x) proc ffiSerialize*(x: pointer): string = $cast[uint](x) @@ -67,6 +69,18 @@ proc ffiDeserialize*(s: cstring, _: typedesc[pointer]): Result[pointer, string] proc ffiSerialize*[T](x: ptr T): string = $cast[uint](x) +proc ffiSerialize*[T](x: seq[T]): string = + var arr = newJArray() + for item in x: + arr.add(parseJson(ffiSerialize(item))) + result = $arr + +proc ffiSerialize*[T](x: Option[T]): string = + if x.isSome: + ffiSerialize(x.get) + else: + "null" + proc ffiDeserialize*[T](s: cstring, _: typedesc[ptr T]): Result[ptr T, string] = try: let address = cast[ptr T](uint(parseJson($s).getBiggestInt())) @@ -74,6 +88,38 @@ proc ffiDeserialize*[T](s: cstring, _: typedesc[ptr T]): Result[ptr T, string] = except Exception as e: err(e.msg) +proc ffiDeserialize*[T](s: cstring, _: typedesc[seq[T]]): Result[seq[T], string] = + try: + let node = parseJson($s) + if node.kind != JArray: + return err("expected JSON array") + var resultSeq: seq[T] = @[] + for item in node: + let itemJson = $item + let parsed = ffiDeserialize(itemJson.cstring, typedesc[T]) + if parsed.isOk: + resultSeq.add(parsed.get) + else: + return err(parsed.error) + ok(resultSeq) + except Exception as e: + err(e.msg) + +proc ffiDeserialize*[T](s: cstring, _: typedesc[Option[T]]): Result[Option[T], string] = + try: + let node = parseJson($s) + if node.kind == JNull: + ok(none(T)) + else: + let itemJson = $node + let parsed = ffiDeserialize(itemJson.cstring, typedesc[T]) + if parsed.isOk: + ok(some(parsed.get)) + else: + err(parsed.error) + 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, @@ -116,15 +162,30 @@ macro ffiType*(body: untyped): untyped = ffiTypeRegistry.add(FFITypeMeta(name: typeNameStr, fields: fieldMetas)) - let serializeProc = quote do: + let serializeProc = quote: proc ffiSerialize*(x: `typeName`): string = - $(%* x) + $(%*x) - let deserializeProc = quote do: - proc ffiDeserialize*(s: cstring, _: typedesc[`typeName`]): Result[`typeName`, string] = + var assignmentText = "" + for field in fieldMetas: + if assignmentText.len > 0: + assignmentText &= "\n" + assignmentText &= + " result[\"" & field.name & "\"] = parseJson(ffiSerialize(x." & field.name & "))" + let jsonProc = parseStmt( + "proc `%`*(x: " & typeNameStr & "): JsonNode =\n var result = newJObject()\n" & + assignmentText & "\n return result\n" + ) + + let importJson = quote: + import json + let deserializeProc = quote: + 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) + result = newStmtList(importJson, body, serializeProc, jsonProc, deserializeProc) From 47f3422057913e883b32136c8b2dbe0e7ad92c9d Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 16:23:00 +0200 Subject: [PATCH 04/10] allow annotate type with ffi too --- examples/nim_timer/nim_timer.nim | 39 +++++++++----------- ffi/internal/ffi_macro.nim | 56 ++++++++++++++++++++++------- ffi/serial.nim | 62 ++++++++++---------------------- 3 files changed, 79 insertions(+), 78 deletions(-) diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index 4df4391..a9f0329 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -8,32 +8,27 @@ declareLibrary("nimtimer") type NimTimer = object name: string # set at creation time, read back in each response -ffiType: - type TimerConfig = object - name: string +type TimerConfig {.ffi.} = object + name: string -ffiType: - type EchoRequest = object - message: string - delayMs: int # how long chronos sleeps before replying +type EchoRequest {.ffi.} = 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 +type EchoResponse {.ffi.} = object + echoed: string + timerName: string # proves that the timer's own state is accessible -ffiType: - type ComplexRequest = object - messages: seq[EchoRequest] - tags: seq[string] - note: Option[string] - retries: Maybe[int] +type ComplexRequest {.ffi.} = object + messages: seq[EchoRequest] + tags: seq[string] + note: Option[string] + retries: Maybe[int] -ffiType: - type ComplexResponse = object - summary: string - itemCount: int - hasNote: bool +type ComplexResponse {.ffi.} = object + summary: string + itemCount: int + hasNote: bool # --- Constructor ----------------------------------------------------------- # Called once from Rust. Creates the FFIContext + NimTimer. diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 9be68da..fadbcee 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -10,6 +10,31 @@ when defined(ffiGenBindings): # String helpers used by multiple macros # --------------------------------------------------------------------------- +proc registerFfiTypeInfo(typeDef: NimNode): NimNode {.compileTime.} = + ## Registers the type in ffiTypeRegistry for binding generation and returns + ## the clean typeDef. Serialization is handled by the generic overloads in serial.nim. + let typeName = + if typeDef[0].kind == nnkPostfix: typeDef[0][1] else: typeDef[0] + let typeNameStr = $typeName + + var fieldMetas: seq[FFIFieldMeta] = @[] + 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: + let fieldType = identDef[^2] + let fieldTypeName = + if fieldType.kind == nnkIdent: $fieldType + elif fieldType.kind == nnkPtrTy: "ptr " & $fieldType[0] + else: fieldType.repr + for i in 0 ..< identDef.len - 2: + fieldMetas.add(FFIFieldMeta(name: $identDef[i], typeName: fieldTypeName)) + + ffiTypeRegistry.add(FFITypeMeta(name: typeNameStr, fields: fieldMetas)) + result = typeDef + proc capitalizeFirstLetter(s: string): string = ## Returns `s` with the first character uppercased. if s.len == 0: @@ -560,22 +585,29 @@ macro ffiRaw*(prc: untyped): untyped = echo result.repr macro ffi*(prc: untyped): untyped = - ## Simplified FFI macro — developer writes a clean Nim-idiomatic signature. + ## Simplified FFI macro — applies to procs or types. ## - ## 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 + ## On a type: `type Foo {.ffi.} = object` registers Foo for binding generation + ## and generates ffiSerialize/ffiDeserialize overloads. ## - ## Example: + ## On a proc: the annotated proc must have a first parameter of the library type, + ## optionally additional Nim-typed parameters, and return Future[Result[RetType, string]]. + ## It must NOT include ctx, callback, or userData in its signature. + ## + ## Example (type): + ## type EchoRequest {.ffi.} = object + ## message: string + ## delayMs: int + ## + ## Example (proc): ## 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 + + if prc.kind == nnkTypeDef: + var cleanTypeDef = prc.copyNimTree() + if cleanTypeDef[0].kind == nnkPragmaExpr: + cleanTypeDef[0] = cleanTypeDef[0][0] + return registerFfiTypeInfo(cleanTypeDef) let procName = prc[0] let formalParams = prc[3] diff --git a/ffi/serial.nim b/ffi/serial.nim index 935db4b..d0f3cf8 100644 --- a/ffi/serial.nim +++ b/ffi/serial.nim @@ -1,4 +1,4 @@ -import std/[json, macros, sequtils, options] +import std/[json, macros, options] import results import ./codegen/meta @@ -81,6 +81,9 @@ proc ffiSerialize*[T](x: Option[T]): string = else: "null" +proc ffiSerialize*[T: object](x: T): string = + $(%*x) + proc ffiDeserialize*[T](s: cstring, _: typedesc[ptr T]): Result[ptr T, string] = try: let address = cast[ptr T](uint(parseJson($s).getBiggestInt())) @@ -120,10 +123,16 @@ proc ffiDeserialize*[T](s: cstring, _: typedesc[Option[T]]): Result[Option[T], s except Exception as e: err(e.msg) +proc ffiDeserialize*[T: object](s: cstring, _: typedesc[T]): Result[T, string] = + try: + ok(parseJson($s).to(T)) + 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. + ## Registers the type in ffiTypeRegistry for binding generation. + ## Serialization is handled by the generic ffiSerialize/ffiDeserialize overloads. ## Usage: ## ffiType: ## type Foo = object @@ -136,56 +145,21 @@ macro ffiType*(body: untyped): untyped = 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 + let fieldTypeName = + if fieldType.kind == nnkIdent: $fieldType + elif fieldType.kind == nnkPtrTy: "ptr " & $fieldType[0] + else: fieldType.repr for i in 0 ..< identDef.len - 2: - let fname = $identDef[i] - fieldMetas.add(FFIFieldMeta(name: fname, typeName: fieldTypeName)) + fieldMetas.add(FFIFieldMeta(name: $identDef[i], typeName: fieldTypeName)) ffiTypeRegistry.add(FFITypeMeta(name: typeNameStr, fields: fieldMetas)) - - let serializeProc = quote: - proc ffiSerialize*(x: `typeName`): string = - $(%*x) - - var assignmentText = "" - for field in fieldMetas: - if assignmentText.len > 0: - assignmentText &= "\n" - assignmentText &= - " result[\"" & field.name & "\"] = parseJson(ffiSerialize(x." & field.name & "))" - let jsonProc = parseStmt( - "proc `%`*(x: " & typeNameStr & "): JsonNode =\n var result = newJObject()\n" & - assignmentText & "\n return result\n" - ) - - let importJson = quote: - import json - let deserializeProc = quote: - proc ffiDeserialize*( - s: cstring, _: typedesc[`typeName`] - ): Result[`typeName`, string] = - try: - ok(parseJson($s).to(`typeName`)) - except Exception as e: - err(e.msg) - - result = newStmtList(importJson, body, serializeProc, jsonProc, deserializeProc) + result = body From a80f042f3503ae78e558028b38175ea3ae8d61c9 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 16:36:37 +0200 Subject: [PATCH 05/10] use proper case --- examples/nim_timer/nim_timer.nim | 8 ++++---- ffi/internal/ffi_macro.nim | 23 +++++++++++++++++------ 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index a9f0329..e595cf6 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -33,7 +33,7 @@ type ComplexResponse {.ffi.} = object # --- Constructor ----------------------------------------------------------- # Called once from Rust. Creates the FFIContext + NimTimer. # Uses chronos (await sleepAsync) so the body is async. -proc nimtimer_create*( +proc nimtimerCreate*( config: TimerConfig ): Future[Result[NimTimer, string]] {.ffiCtor.} = await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread @@ -42,7 +42,7 @@ proc nimtimer_create*( # --- Async method ---------------------------------------------------------- # Waits `delayMs` milliseconds (non-blocking, on the chronos event loop) # then echoes the message back with a request counter. -proc nimtimer_echo*( +proc nimtimerEcho*( timer: NimTimer, req: EchoRequest ): Future[Result[EchoResponse, string]] {.ffi.} = await sleepAsync(req.delayMs.milliseconds) @@ -51,10 +51,10 @@ proc nimtimer_echo*( # --- 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.} = +proc nimtimerVersion*(timer: NimTimer): Future[Result[string, string]] {.ffi.} = return ok("nim-timer v0.1.0") -proc nimtimer_complex*( +proc nimtimerComplex*( timer: NimTimer, req: ComplexRequest ): Future[Result[ComplexResponse, string]] {.ffi.} = let note = if req.note.isSome: req.note.get else: "" diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index fadbcee..6f4203e 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -10,6 +10,15 @@ when defined(ffiGenBindings): # String helpers used by multiple macros # --------------------------------------------------------------------------- +proc nimNameToCExport(s: string): string = + ## Converts a camelCase Nim proc name to a snake_case C export name. + ## Leaves already-snake_case names unchanged. + ## e.g. "nimtimerCreate" → "nimtimer_create", "nimtimer_echo" → "nimtimer_echo" + for i, c in s: + if c.isUpperAscii() and i > 0: + result.add('_') + result.add(c.toLowerAscii()) + proc registerFfiTypeInfo(typeDef: NimNode): NimNode {.compileTime.} = ## Registers the type in ffiTypeRegistry for binding generation and returns ## the clean typeDef. Serialization is handled by the generic overloads in serial.nim. @@ -657,6 +666,7 @@ macro ffi*(prc: untyped): untyped = raw[0 ..^ 2] else: raw + let cExportName = nimNameToCExport(procNameStr) let camelName = toCamelCase(procNameStr) # Names of generated things @@ -808,7 +818,7 @@ macro ffi*(prc: untyped): untyped = pragmas = newTree( nnkPragma, ident("dynlib"), - ident("exportc"), + newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)), ident("cdecl"), newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)), ), @@ -839,7 +849,7 @@ macro ffi*(prc: untyped): untyped = retTn = $retTypeInner ffiProcRegistry.add( FFIProcMeta( - procName: procNameStr, + procName: cExportName, libName: currentLibName, kind: ffiFfiKind, libTypeName: $libTypeName, @@ -937,7 +947,7 @@ macro ffi*(prc: untyped): untyped = pragmas = newTree( nnkPragma, ident("dynlib"), - ident("exportc"), + newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)), ident("cdecl"), newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)), ), @@ -968,7 +978,7 @@ macro ffi*(prc: untyped): untyped = retTnSync = $retTypeInnerSync ffiProcRegistry.add( FFIProcMeta( - procName: procNameStr, + procName: cExportName, libName: currentLibName, kind: ffiFfiKind, libTypeName: $libTypeName, @@ -1304,6 +1314,7 @@ macro ffiCtor*(prc: untyped): untyped = procNameStr[0 ..^ 2] else: procNameStr + let cExportName = nimNameToCExport(cleanName) let reqTypeNameStr = toCamelCase(cleanName) & "CtorReq" let reqTypeName = ident(reqTypeNameStr) @@ -1403,7 +1414,7 @@ macro ffiCtor*(prc: untyped): untyped = pragmas = newTree( nnkPragma, ident("dynlib"), - ident("exportc"), + newTree(nnkExprColonExpr, ident("exportc"), newStrLitNode(cExportName)), ident("cdecl"), newTree(nnkExprColonExpr, ident("raises"), newTree(nnkBracket)), ), @@ -1424,7 +1435,7 @@ macro ffiCtor*(prc: untyped): untyped = ctorExtraParams.add(FFIParamMeta(name: paramNames[i], typeName: tn, isPtr: isPtr)) ffiProcRegistry.add( FFIProcMeta( - procName: cleanName, + procName: cExportName, libName: currentLibName, kind: ffiCtorKind, libTypeName: $libTypeName, From f9d9a5237c25ba17b155ad79856a207da12a5477 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 16:54:53 +0200 Subject: [PATCH 06/10] simplify Rust example Co-authored-by: Copilot --- examples/nim_timer/rust_bindings/Cargo.toml | 5 ++ examples/nim_timer/rust_bindings/src/api.rs | 40 ++++++++++ examples/nim_timer/rust_client/Cargo.lock | 1 + examples/nim_timer/rust_client/Cargo.toml | 2 +- .../nim_timer/rust_client/src/tokio_main.rs | 73 ++++--------------- 5 files changed, 63 insertions(+), 58 deletions(-) diff --git a/examples/nim_timer/rust_bindings/Cargo.toml b/examples/nim_timer/rust_bindings/Cargo.toml index 68ef056..40596c0 100644 --- a/examples/nim_timer/rust_bindings/Cargo.toml +++ b/examples/nim_timer/rust_bindings/Cargo.toml @@ -3,6 +3,11 @@ name = "nimtimer" version = "0.1.0" edition = "2021" +[features] +default = [] +tokio-runtime = ["tokio"] + [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros"] } diff --git a/examples/nim_timer/rust_bindings/src/api.rs b/examples/nim_timer/rust_bindings/src/api.rs index 7aa056f..aaf440c 100644 --- a/examples/nim_timer/rust_bindings/src/api.rs +++ b/examples/nim_timer/rust_bindings/src/api.rs @@ -5,6 +5,8 @@ use std::time::Duration; use super::ffi; use super::types::*; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); + #[derive(Default)] struct FfiCallbackResult { payload: Option>, @@ -74,6 +76,15 @@ impl NimTimerCtx { Ok(Self { ptr: addr as *mut c_void, timeout }) } + pub fn new(config: TimerConfig) -> Result { + Self::create(config, DEFAULT_TIMEOUT) + } + + #[cfg(feature = "tokio")] + pub async fn new_async(config: TimerConfig) -> Result { + tokio::task::block_in_place(move || Self::new(config)) + } + pub fn echo(&self, req: EchoRequest) -> Result { let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; let req_c = CString::new(req_json).unwrap(); @@ -90,6 +101,16 @@ impl NimTimerCtx { serde_json::from_str::(&raw).map_err(|e| e.to_string()) } + #[cfg(feature = "tokio")] + pub async fn version_async(&self) -> Result { + let ptr = self.ptr; + let timeout = self.timeout; + tokio::task::block_in_place(move || { + let ctx = Self { ptr, timeout }; + ctx.version() + }) + } + pub fn complex(&self, req: ComplexRequest) -> Result { let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; let req_c = CString::new(req_json).unwrap(); @@ -99,4 +120,23 @@ impl NimTimerCtx { serde_json::from_str::(&raw).map_err(|e| e.to_string()) } + #[cfg(feature = "tokio")] + pub async fn echo_async(&self, req: EchoRequest) -> Result { + let ptr = self.ptr; + let timeout = self.timeout; + tokio::task::block_in_place(move || { + let ctx = Self { ptr, timeout }; + ctx.echo(req) + }) + } + + #[cfg(feature = "tokio")] + pub async fn complex_async(&self, req: ComplexRequest) -> Result { + let ptr = self.ptr; + let timeout = self.timeout; + tokio::task::block_in_place(move || { + let ctx = Self { ptr, timeout }; + ctx.complex(req) + }) + } } diff --git a/examples/nim_timer/rust_client/Cargo.lock b/examples/nim_timer/rust_client/Cargo.lock index 5c3459e..5bbc31e 100644 --- a/examples/nim_timer/rust_client/Cargo.lock +++ b/examples/nim_timer/rust_client/Cargo.lock @@ -20,6 +20,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "tokio", ] [[package]] diff --git a/examples/nim_timer/rust_client/Cargo.toml b/examples/nim_timer/rust_client/Cargo.toml index cd21745..d28fdeb 100644 --- a/examples/nim_timer/rust_client/Cargo.toml +++ b/examples/nim_timer/rust_client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -nimtimer = { path = "../rust_bindings" } +nimtimer = { path = "../rust_bindings", features = ["tokio-runtime"] } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/examples/nim_timer/rust_client/src/tokio_main.rs b/examples/nim_timer/rust_client/src/tokio_main.rs index abb8934..5303de8 100644 --- a/examples/nim_timer/rust_client/src/tokio_main.rs +++ b/examples/nim_timer/rust_client/src/tokio_main.rs @@ -1,71 +1,30 @@ use nimtimer::{EchoRequest, NimTimerCtx, TimerConfig}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use tokio::task; #[tokio::main(flavor = "multi_thread", worker_threads = 2)] -async fn main() { - let timeout = Duration::from_secs(5); - - let ctx = task::spawn_blocking(move || { - NimTimerCtx::create(TimerConfig { name: "tokio-demo".into() }, timeout) - }) - .await - .expect("failed to join create task") - .expect("nimtimer_create failed"); - - let ctx = Arc::new(Mutex::new(ctx)); - - let version = task::spawn_blocking({ - let ctx = Arc::clone(&ctx); - move || { - let ctx = ctx.lock().unwrap(); - ctx.version() - } - }) - .await - .expect("failed to join version task") - .expect("nimtimer_version failed"); +async fn main() -> Result<(), Box> { + let ctx = NimTimerCtx::new_async(TimerConfig { name: "tokio-demo".into() }).await?; + let version = ctx.version_async().await?; println!("[1] Tokio runtime started"); println!("[2] Version: {version}"); - let req1 = EchoRequest { - message: "hello from tokio".into(), - delay_ms: 200, - }; - let req2 = EchoRequest { - message: "second tokio request".into(), - delay_ms: 50, - }; + let echo1 = ctx + .echo_async(EchoRequest { + message: "hello from tokio".into(), + delay_ms: 200, + }) + .await?; - let fut1 = task::spawn_blocking({ - let ctx = Arc::clone(&ctx); - move || { - let ctx = ctx.lock().unwrap(); - ctx.echo(req1) - } - }); - - let fut2 = task::spawn_blocking({ - let ctx = Arc::clone(&ctx); - move || { - let ctx = ctx.lock().unwrap(); - ctx.echo(req2) - } - }); - - let echo1 = fut1 - .await - .expect("failed to join tokio blocking task") - .expect("nimtimer_echo failed"); - let echo2 = fut2 - .await - .expect("failed to join tokio blocking task") - .expect("nimtimer_echo failed"); + let echo2 = ctx + .echo_async(EchoRequest { + message: "second tokio request".into(), + delay_ms: 50, + }) + .await?; println!("[3] Echo 1: echoed={}, timerName={}", echo1.echoed, echo1.timer_name); println!("[4] Echo 2: echoed={}, timerName={}", echo2.echoed, echo2.timer_name); println!("\nDone. Tokio runtime shut down."); + Ok(()) } From 8b75ae8b039754b5d6443b1ccd16214fbb8f6344 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 17:33:06 +0200 Subject: [PATCH 07/10] enhance async cpp Co-authored-by: Copilot --- examples/nim_timer/cpp_bindings/main.cpp | 15 +++++++--- examples/nim_timer/cpp_bindings/nimtimer.hpp | 17 +++++++++++ ffi/codegen/cpp.nim | 31 ++++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/examples/nim_timer/cpp_bindings/main.cpp b/examples/nim_timer/cpp_bindings/main.cpp index 68e9658..0fd57c0 100644 --- a/examples/nim_timer/cpp_bindings/main.cpp +++ b/examples/nim_timer/cpp_bindings/main.cpp @@ -1,19 +1,24 @@ #include "nimtimer.hpp" #include +#include int main() { try { auto ctx = NimTimerCtx::create(TimerConfig{"cpp-demo"}); std::cout << "[1] Context created\n"; - auto version = ctx.version(); + auto versionFuture = ctx.versionAsync(); + auto echo1Future = ctx.echoAsync(EchoRequest{"hello from C++", 200}); + auto echo2Future = ctx.echoAsync(EchoRequest{"second C++ request", 50}); + + auto version = versionFuture.get(); std::cout << "[2] Version: " << version << "\n"; - auto echo = ctx.echo(EchoRequest{"hello from C++", 200}); + auto echo = echo1Future.get(); std::cout << "[3] Echo 1: echoed=" << echo.echoed << ", timerName=" << echo.timerName << "\n"; - auto echo2 = ctx.echo(EchoRequest{"second C++ request", 50}); + auto echo2 = echo2Future.get(); std::cout << "[4] Echo 2: echoed=" << echo2.echoed << ", timerName=" << echo2.timerName << "\n"; @@ -23,7 +28,9 @@ int main() { std::optional("extra note"), std::optional(3) }; - auto complex = ctx.complex(complexReq); + + auto complexFuture = ctx.complexAsync(complexReq); + auto complex = complexFuture.get(); std::cout << "[5] Complex: summary=" << complex.summary << ", itemCount=" << complex.itemCount << ", hasNote=" << complex.hasNote << "\n"; diff --git a/examples/nim_timer/cpp_bindings/nimtimer.hpp b/examples/nim_timer/cpp_bindings/nimtimer.hpp index a648d84..e38f621 100644 --- a/examples/nim_timer/cpp_bindings/nimtimer.hpp +++ b/examples/nim_timer/cpp_bindings/nimtimer.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -145,6 +146,10 @@ public: return NimTimerCtx(reinterpret_cast(static_cast(addr))); } + static std::future createAsync(const TimerConfig& config) { + return std::async(std::launch::async, [config]() { return create(config); }); + } + EchoResponse echo(const EchoRequest& req) const { const auto req_json = serializeFfiArg(req); const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { @@ -153,6 +158,10 @@ public: return deserializeFfiResult(raw); } + std::future echoAsync(const EchoRequest& req) const { + return std::async(std::launch::async, [this, req]() { return echo(req); }); + } + std::string version() const { const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { return nimtimer_version(ptr_, cb, ud); @@ -160,6 +169,10 @@ public: return deserializeFfiResult(raw); } + std::future versionAsync() const { + return std::async(std::launch::async, [this]() { return version(); }); + } + ComplexResponse complex(const ComplexRequest& req) const { const auto req_json = serializeFfiArg(req); const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { @@ -168,6 +181,10 @@ public: return deserializeFfiResult(raw); } + std::future complexAsync(const ComplexRequest& req) const { + return std::async(std::launch::async, [this, req]() { return complex(req); }); + } + private: void* ptr_; explicit NimTimerCtx(void* p) : ptr_(p) {} diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 43b088c..cc23652 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -53,6 +53,7 @@ proc generateCppHeader*( lines.add("#include ") lines.add("#include ") lines.add("#include ") + lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") @@ -238,6 +239,15 @@ proc generateCppHeader*( ) lines.add(" }") lines.add("") + lines.add( + " static std::future<$1> createAsync(const TimerConfig& config) {" % + [ctxTypeName] + ) + lines.add( + " return std::async(std::launch::async, [config]() { return create(config); });" + ) + lines.add(" }") + lines.add("") # Instance methods for m in methods: @@ -245,10 +255,13 @@ proc generateCppHeader*( let retCppType = nimTypeToCpp(m.returnTypeName) var methParams: seq[string] = @[] + var methParamNames: seq[string] = @[] for ep in m.extraParams: let cppType = nimTypeToCpp(ep.typeName) methParams.add("const $1& $2" % [cppType, ep.name]) + methParamNames.add(ep.name) let methParamsStr = methParams.join(", ") + let methParamNamesStr = methParamNames.join(", ") lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) for ep in m.extraParams: @@ -268,6 +281,24 @@ proc generateCppHeader*( lines.add(" return deserializeFfiResult<$1>(raw);" % [retCppType]) lines.add(" }") lines.add("") + if methParamsStr.len > 0: + lines.add( + " std::future<$1> $2Async($3) const {" % + [retCppType, methodName, methParamsStr] + ) + lines.add( + " return std::async(std::launch::async, [this, $1]() { return $2($3); });" % + [methParamNamesStr, methodName, methParamNamesStr] + ) + lines.add(" }") + else: + lines.add(" std::future<$1> $2Async() const {" % [retCppType, methodName]) + lines.add( + " return std::async(std::launch::async, [this]() { return $2(); });" % + [methodName] + ) + lines.add(" }") + lines.add("") lines.add("private:") lines.add(" void* ptr_;") From 5305c20c22b04375b35fbe3510adb5fe4bb9f040 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 23:45:49 +0200 Subject: [PATCH 08/10] simplify auto-generate cpp and rust --- examples/nim_timer/nim_timer.nimble | 16 +- examples/nim_timer/rust_bindings/Cargo.toml | 6 +- examples/nim_timer/rust_bindings/src/api.rs | 101 +++++++--- examples/nim_timer/rust_client/Cargo.toml | 2 +- ffi/codegen/cpp.nim | 2 +- ffi/codegen/rust.nim | 212 ++++++++++++-------- 6 files changed, 212 insertions(+), 127 deletions(-) diff --git a/examples/nim_timer/nim_timer.nimble b/examples/nim_timer/nim_timer.nimble index 103e922..a21b837 100644 --- a/examples/nim_timer/nim_timer.nimble +++ b/examples/nim_timer/nim_timer.nimble @@ -8,14 +8,20 @@ requires "nim >= 2.2.4" requires "chronos" requires "chronicles" requires "taskpools" -requires "ffi >= 0.1.3" +requires "https://github.com/logos-messaging/nim-ffi >= 0.1.3" + +const nimFlags = "--mm:orc -d:chronicles_log_level=WARN" -# Build the example library and optionally generate bindings. task build, "Compile the nimtimer library": - exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=rust nim_timer.nim" + exec "nim c " & nimFlags & + " --app:lib --noMain --nimMainPrefix:libnimtimer nim_timer.nim" task genbindings_rust, "Generate Rust bindings for the nimtimer example": - exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=rust nim_timer.nim" + exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer" & + " -d:ffiGenBindings -d:targetLang=rust" & " -d:ffiOutputDir=rust_bindings" & + " -d:ffiNimSrcRelPath=nim_timer.nim" & " -o:/dev/null nim_timer.nim" task genbindings_cpp, "Generate C++ bindings for the nimtimer example": - exec "nim c --app:lib --noMain --nimMainPrefix:libnimtimer -d:ffiGenBindings -d:targetLang=cpp nim_timer.nim" + exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libnimtimer" & + " -d:ffiGenBindings -d:targetLang=cpp" & " -d:ffiOutputDir=cpp_bindings" & + " -d:ffiNimSrcRelPath=nim_timer.nim" & " -o:/dev/null nim_timer.nim" diff --git a/examples/nim_timer/rust_bindings/Cargo.toml b/examples/nim_timer/rust_bindings/Cargo.toml index 40596c0..838af9a 100644 --- a/examples/nim_timer/rust_bindings/Cargo.toml +++ b/examples/nim_timer/rust_bindings/Cargo.toml @@ -3,11 +3,7 @@ name = "nimtimer" version = "0.1.0" edition = "2021" -[features] -default = [] -tokio-runtime = ["tokio"] - [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", optional = true, features = ["rt-multi-thread", "macros"] } +tokio = { version = "1", features = ["sync"] } diff --git a/examples/nim_timer/rust_bindings/src/api.rs b/examples/nim_timer/rust_bindings/src/api.rs index aaf440c..a9e88e8 100644 --- a/examples/nim_timer/rust_bindings/src/api.rs +++ b/examples/nim_timer/rust_bindings/src/api.rs @@ -5,8 +5,6 @@ use std::time::Duration; use super::ffi; use super::types::*; -const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); - #[derive(Default)] struct FfiCallbackResult { payload: Option>, @@ -55,6 +53,44 @@ where guard.payload.clone().unwrap() } +unsafe extern "C" fn on_result_async( + ret: c_int, + msg: *const c_char, + _len: usize, + user_data: *mut c_void, +) { + let tx = Box::from_raw( + user_data as *mut tokio::sync::oneshot::Sender>, + ); + let value = if ret == 0 { + Ok(CStr::from_ptr(msg).to_string_lossy().into_owned()) + } else { + Err(CStr::from_ptr(msg).to_string_lossy().into_owned()) + }; + let _ = tx.send(value); +} + +async fn ffi_call_async(f: F) -> Result +where + F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int, +{ + let rx = { + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let raw = Box::into_raw(Box::new(tx)) as *mut c_void; + let ret = f(on_result_async, raw); + if ret == 2 { + drop(unsafe { + Box::from_raw( + raw as *mut tokio::sync::oneshot::Sender>, + ) + }); + return Err("RET_MISSING_CALLBACK (internal error)".into()); + } + rx + }; + rx.await.map_err(|_| "channel closed before callback fired".to_string())? +} + /// High-level context for `NimTimer`. pub struct NimTimerCtx { ptr: *mut c_void, @@ -71,18 +107,18 @@ impl NimTimerCtx { let raw = ffi_call(timeout, |cb, ud| unsafe { ffi::nimtimer_create(config_c.as_ptr(), cb, ud) })?; - // ctor returns the context address as a plain decimal string let addr: usize = raw.parse().map_err(|e: std::num::ParseIntError| e.to_string())?; Ok(Self { ptr: addr as *mut c_void, timeout }) } - pub fn new(config: TimerConfig) -> Result { - Self::create(config, DEFAULT_TIMEOUT) - } - - #[cfg(feature = "tokio")] pub async fn new_async(config: TimerConfig) -> Result { - tokio::task::block_in_place(move || Self::new(config)) + let config_json = serde_json::to_string(&config).map_err(|e| e.to_string())?; + let config_c = CString::new(config_json).unwrap(); + let raw = ffi_call_async(move |cb, ud| unsafe { + ffi::nimtimer_create(config_c.as_ptr(), cb, ud) + }).await?; + let addr: usize = raw.parse().map_err(|e: std::num::ParseIntError| e.to_string())?; + Ok(Self { ptr: addr as *mut c_void, timeout: Duration::from_secs(30) }) } pub fn echo(&self, req: EchoRequest) -> Result { @@ -94,6 +130,16 @@ impl NimTimerCtx { serde_json::from_str::(&raw).map_err(|e| e.to_string()) } + pub async fn echo_async(&self, req: EchoRequest) -> Result { + let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; + let req_c = CString::new(req_json).unwrap(); + let ptr = self.ptr as usize; + let raw = ffi_call_async(move |cb, ud| unsafe { + ffi::nimtimer_echo(ptr as *mut c_void, cb, ud, req_c.as_ptr()) + }).await?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) + } + pub fn version(&self) -> Result { let raw = ffi_call(self.timeout, |cb, ud| unsafe { ffi::nimtimer_version(self.ptr, cb, ud) @@ -101,14 +147,12 @@ impl NimTimerCtx { serde_json::from_str::(&raw).map_err(|e| e.to_string()) } - #[cfg(feature = "tokio")] pub async fn version_async(&self) -> Result { - let ptr = self.ptr; - let timeout = self.timeout; - tokio::task::block_in_place(move || { - let ctx = Self { ptr, timeout }; - ctx.version() - }) + let ptr = self.ptr as usize; + let raw = ffi_call_async(move |cb, ud| unsafe { + ffi::nimtimer_version(ptr as *mut c_void, cb, ud) + }).await?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) } pub fn complex(&self, req: ComplexRequest) -> Result { @@ -120,23 +164,14 @@ impl NimTimerCtx { serde_json::from_str::(&raw).map_err(|e| e.to_string()) } - #[cfg(feature = "tokio")] - pub async fn echo_async(&self, req: EchoRequest) -> Result { - let ptr = self.ptr; - let timeout = self.timeout; - tokio::task::block_in_place(move || { - let ctx = Self { ptr, timeout }; - ctx.echo(req) - }) + pub async fn complex_async(&self, req: ComplexRequest) -> Result { + let req_json = serde_json::to_string(&req).map_err(|e| e.to_string())?; + let req_c = CString::new(req_json).unwrap(); + let ptr = self.ptr as usize; + let raw = ffi_call_async(move |cb, ud| unsafe { + ffi::nimtimer_complex(ptr as *mut c_void, cb, ud, req_c.as_ptr()) + }).await?; + serde_json::from_str::(&raw).map_err(|e| e.to_string()) } - #[cfg(feature = "tokio")] - pub async fn complex_async(&self, req: ComplexRequest) -> Result { - let ptr = self.ptr; - let timeout = self.timeout; - tokio::task::block_in_place(move || { - let ctx = Self { ptr, timeout }; - ctx.complex(req) - }) - } } diff --git a/examples/nim_timer/rust_client/Cargo.toml b/examples/nim_timer/rust_client/Cargo.toml index d28fdeb..cd21745 100644 --- a/examples/nim_timer/rust_client/Cargo.toml +++ b/examples/nim_timer/rust_client/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -nimtimer = { path = "../rust_bindings", features = ["tokio-runtime"] } +nimtimer = { path = "../rust_bindings" } serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index cc23652..fea7dc0 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -294,7 +294,7 @@ proc generateCppHeader*( else: lines.add(" std::future<$1> $2Async() const {" % [retCppType, methodName]) lines.add( - " return std::async(std::launch::async, [this]() { return $2(); });" % + " return std::async(std::launch::async, [this]() { return $1(); });" % [methodName] ) lines.add(" }") diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index da000e3..6291a5e 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -77,6 +77,7 @@ edition = "2021" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["sync"] } """ % [libName] @@ -220,29 +221,27 @@ proc generateTypesRs*(types: seq[FFITypeMeta]): string = result = lines.join("\n") proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = - ## Generates api.rs with the high-level Rust API. + ## Generates api.rs with both a blocking and a tokio-async high-level API. + ## + ## Blocking: ctx.echo(req) — thread-blocks via Condvar + ## Async: ctx.echo_async(req).await — non-blocking via oneshot channel; + ## the FFI callback fires from the Nim/chronos thread and wakes + ## the awaiting task without ever blocking a thread. 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) + 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) + if ctors.len > 0: libTypeName = ctors[0].libTypeName + else: libTypeName = toPascalCase(libName) let ctxTypeName = libTypeName & "Ctx" - # Imports + # ── 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};") @@ -251,7 +250,7 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("use super::types::*;") lines.add("") - # FfiCallbackResult + Pair + # ── Blocking trampoline ──────────────────────────────────────────────────── lines.add("#[derive(Default)]") lines.add("struct FfiCallbackResult {") lines.add(" payload: Option>,") @@ -259,8 +258,6 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("") lines.add("type Pair = Arc<(Mutex, Condvar)>;") lines.add("") - - # on_result callback (Arc-based, blocking) lines.add("unsafe extern \"C\" fn on_result(") lines.add(" ret: c_int,") lines.add(" msg: *const c_char,") @@ -281,8 +278,6 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add(" std::mem::forget(pair);") lines.add("}") lines.add("") - - # Blocking ffi_call helper using Condvar::wait_timeout_while lines.add("fn ffi_call(timeout: Duration, f: F) -> Result") lines.add("where") lines.add(" F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,") @@ -305,7 +300,52 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = lines.add("}") lines.add("") - # Ctx struct + # ── Async (tokio oneshot) trampoline ─────────────────────────────────────── + # The callback is invoked from the Nim/chronos thread and sends the result + # through the oneshot channel, waking the awaiting tokio task without + # blocking any thread. + lines.add("unsafe extern \"C\" fn on_result_async(") + 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 tx = Box::from_raw(") + lines.add(" user_data as *mut tokio::sync::oneshot::Sender>,") + lines.add(" );") + lines.add(" let value = 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(" let _ = tx.send(value);") + lines.add("}") + lines.add("") + # Scoped block keeps raw/tx/F dead at the single await point so the + # returned future is Send regardless of whether F itself is Send. + lines.add("async fn ffi_call_async(f: F) -> Result") + lines.add("where") + lines.add(" F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,") + lines.add("{") + lines.add(" let rx = {") + lines.add(" let (tx, rx) = tokio::sync::oneshot::channel::>();") + lines.add(" let raw = Box::into_raw(Box::new(tx)) as *mut c_void;") + lines.add(" let ret = f(on_result_async, raw);") + lines.add(" if ret == 2 {") + lines.add(" drop(unsafe {") + lines.add(" Box::from_raw(") + lines.add(" raw as *mut tokio::sync::oneshot::Sender>,") + lines.add(" )") + lines.add(" });") + lines.add(" return Err(\"RET_MISSING_CALLBACK (internal error)\".into());") + lines.add(" }") + lines.add(" rx") + lines.add(" };") + lines.add(" rx.await.map_err(|_| \"channel closed before callback fired\".to_string())?") + lines.add("}") + lines.add("") + + # ── Context struct ───────────────────────────────────────────────────────── lines.add("/// High-level context for `$1`." % [libTypeName]) lines.add("pub struct $1 {" % [ctxTypeName]) lines.add(" ptr: *mut c_void,") @@ -315,35 +355,28 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = 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) + # ── Constructors ─────────────────────────────────────────────────────────── for ctor in ctors: - var paramsList: seq[string] = @[] + var asyncParamsList: 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(", ") + asyncParamsList.add( + "$1: $2" % [toSnakeCase(ep.name), nimTypeToRust(ep.typeName)] + ) + let asyncParamsStr = asyncParamsList.join(", ") + let blockingParamsStr = + if asyncParamsList.len > 0: asyncParamsList.join(", ") & ", timeout: Duration" + else: "timeout: Duration" - lines.add(" pub fn create($1) -> Result {" % [paramsStr]) - - # Serialize extra params - for ep in ctor.extraParams: - let snakeName = toSnakeCase(ep.name) - let rustType = nimTypeToRust(ep.typeName) + # Helper: emit JSON serialization lines for extra params + template emitSerialize(snakeName, rustType: string) = 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] - ) + 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())?;" % @@ -351,59 +384,56 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = ) lines.add(" let $1_c = CString::new($1_json).unwrap();" % [snakeName]) - # Build the ffi_call closure + # Build the ordered arg list for the raw FFI call (ctor: params, cb, ud) var ffiCallArgs: seq[string] = @[] for ep in ctor.extraParams: - let snakeName = toSnakeCase(ep.name) - ffiCallArgs.add("$1_c.as_ptr()" % [snakeName]) + ffiCallArgs.add("$1_c.as_ptr()" % [toSnakeCase(ep.name)]) ffiCallArgs.add("cb") ffiCallArgs.add("ud") let ffiCallArgsStr = ffiCallArgs.join(", ") + # -- blocking create -- + lines.add(" pub fn create($1) -> Result {" % [blockingParamsStr]) + for ep in ctor.extraParams: + emitSerialize(toSnakeCase(ep.name), nimTypeToRust(ep.typeName)) 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(" 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 + # -- async new_async -- + # move closure: each CString is moved in (Send), no raw ptr escapes the block + lines.add(" pub async fn new_async($1) -> Result {" % [asyncParamsStr]) + for ep in ctor.extraParams: + emitSerialize(toSnakeCase(ep.name), nimTypeToRust(ep.typeName)) + lines.add(" let raw = ffi_call_async(move |cb, ud| unsafe {") + lines.add(" ffi::$1($2)" % [ctor.procName, ffiCallArgsStr]) + lines.add(" }).await?;") + 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: Duration::from_secs(30) })") + lines.add(" }") + lines.add("") + + # ── Methods ──────────────────────────────────────────────────────────────── 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: - "" + paramsList.add("$1: $2" % [toSnakeCase(ep.name), nimTypeToRust(ep.typeName)]) + 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) + template emitSerialize(snakeName, rustType: string) = 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] - ) + 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())?;" % @@ -411,29 +441,47 @@ proc generateApiRs*(procs: seq[FFIProcMeta], libName: string): string = ) lines.add(" let $1_c = CString::new($1_json).unwrap();" % [snakeName]) - # Build ffi call args: ctx first, then callback/ud, then json args + template emitDeserialize(retRustType: string) = + if retRustType == "String": + lines.add(" serde_json::from_str::(&raw).map_err(|e| e.to_string())") + elif retRustType == "usize": + lines.add(" raw.parse::().map_err(|e| e.to_string())") + else: + lines.add( + " serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % [retRustType] + ) + + # -- blocking method -- + lines.add(" pub fn $1(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retRustType]) + for ep in m.extraParams: + emitSerialize(toSnakeCase(ep.name), nimTypeToRust(ep.typeName)) 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]) + ffiArgs.add("$1_c.as_ptr()" % [toSnakeCase(ep.name)]) 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(" })?;") + emitDeserialize(retRustType) + lines.add(" }") + lines.add("") - # Deserialize return value - if retRustType == "String": - lines.add( - " serde_json::from_str::(&raw).map_err(|e| e.to_string())" - ) - elif retRustType == "usize": - lines.add(" raw.parse::().map_err(|e| e.to_string())") - else: - lines.add( - " serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % - [retRustType] - ) + # -- async method -- + # ptr is cast to usize (Copy + Send) so the move closure is Send, + # keeping the returned future Send for multi-threaded tokio runtimes. + lines.add(" pub async fn $1_async(&self$2) -> Result<$3, String> {" % + [methodName, paramsStr, retRustType]) + for ep in m.extraParams: + emitSerialize(toSnakeCase(ep.name), nimTypeToRust(ep.typeName)) + lines.add(" let ptr = self.ptr as usize;") + var asyncFfiArgs: seq[string] = @["ptr as *mut c_void", "cb", "ud"] + for ep in m.extraParams: + asyncFfiArgs.add("$1_c.as_ptr()" % [toSnakeCase(ep.name)]) + let asyncFfiArgsStr = asyncFfiArgs.join(", ") + lines.add(" let raw = ffi_call_async(move |cb, ud| unsafe {") + lines.add(" ffi::$1($2)" % [m.procName, asyncFfiArgsStr]) + lines.add(" }).await?;") + emitDeserialize(retRustType) lines.add(" }") lines.add("") From 0305c1ace7f112dab6917bd6dc59177742b07688 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Mon, 4 May 2026 00:36:52 +0200 Subject: [PATCH 09/10] Fix cpp vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. No timeout → wait_for + 30 s default (ffi/codegen/cpp.nim) ffi_call_ now takes std::chrono::milliseconds timeout and uses cv.wait_for. All factory/method signatures carry a timeout parameter (default std::chrono::seconds{30}), mirroring the Rust blocking API. 2. Stack-allocated state → shared_ptr ownership (ffi/codegen/cpp.nim) ffi_cb_ now receives a heap-allocated std::shared_ptr* as user_data. The refcount is 2 going in (one for ffi_call_, one for the callback). If ffi_call_ times out and returns, its copy drops — but the state stays alive (refcount 1) until Nim eventually calls back and delete sptr in ffi_cb_ drops the last reference. No more stack UAF. 3. Destructor + Rule of 5 (ffi/codegen/cpp.nim, examples/nim_timer/nim_timer.nim) Added nimtimer_destroy to nim_timer.nim with {.dynlib, exportc, cdecl, raises: [].} — joins the FFI and watchdog threads, frees the context Codegen now always emits void {libName}_destroy(void* ctx) in extern "C" and generates a destructor, deleted copy ctor/assignment, and move ctor/assignment for the context class timeout_ stored in the class; move transfers it, destructor uses it 4. Hardcoded TimerConfig in createAsync (ffi/codegen/cpp.nim) createAsync now uses the actual ctorParams list (same as create), so it's correct for any library, not just nim_timer. 5. Opaque exceptions → clear error messages (ffi/codegen/cpp.nim) deserializeFfiResult wraps nlohmann::json::parse + .get() in a catch that rethrows as "FFI response deserialization failed: ...". The stoull in create() is also try-caught with "FFI create returned non-numeric address: " + raw. --- examples/nim_timer/cpp_bindings/nimtimer.hpp | 105 +++++++--- examples/nim_timer/nim_timer.nim | 8 + ffi/codegen/cpp.nim | 193 +++++++++++++------ 3 files changed, 221 insertions(+), 85 deletions(-) diff --git a/examples/nim_timer/cpp_bindings/nimtimer.hpp b/examples/nim_timer/cpp_bindings/nimtimer.hpp index e38f621..b753a4d 100644 --- a/examples/nim_timer/cpp_bindings/nimtimer.hpp +++ b/examples/nim_timer/cpp_bindings/nimtimer.hpp @@ -1,9 +1,11 @@ #pragma once #include #include +#include #include #include #include +#include #include #include #include @@ -71,9 +73,9 @@ int nimtimer_create(const char* config_json, FfiCallback callback, void* user_da int nimtimer_echo(void* ctx, FfiCallback callback, void* user_data, const char* req_json); int nimtimer_version(void* ctx, FfiCallback callback, void* user_data); int nimtimer_complex(void* ctx, FfiCallback callback, void* user_data, const char* req_json); +void nimtimer_destroy(void* ctx); } // extern "C" - template inline std::string serializeFfiArg(const T& value) { return nlohmann::json(value).dump(); @@ -85,12 +87,20 @@ inline std::string serializeFfiArg(void* value) { template inline T deserializeFfiResult(const std::string& raw) { - return nlohmann::json::parse(raw).get(); + try { + return nlohmann::json::parse(raw).get(); + } catch (const nlohmann::json::exception& e) { + throw std::runtime_error(std::string("FFI response deserialization failed: ") + e.what()); + } } template<> inline void* deserializeFfiResult(const std::string& raw) { - return reinterpret_cast(static_cast(std::stoull(raw))); + try { + return reinterpret_cast(static_cast(std::stoull(raw))); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("FFI returned non-numeric address: ") + raw); + } } // ============================================================ @@ -108,24 +118,34 @@ struct FfiCallState_ { }; inline void ffi_cb_(int ret, const char* msg, size_t /*len*/, void* ud) { - auto* s = static_cast(ud); - std::lock_guard lock(s->mtx); - s->ok = (ret == 0); - s->msg = msg ? std::string(msg) : std::string{}; - s->done = true; - s->cv.notify_one(); + auto* sptr = static_cast*>(ud); + { + auto& s = **sptr; + std::lock_guard lock(s.mtx); + s.ok = (ret == 0); + s.msg = msg ? std::string(msg) : std::string{}; + s.done = true; + s.cv.notify_one(); + } + delete sptr; } -inline std::string ffi_call_(std::function f) { - FfiCallState_ state; - const int ret = f(ffi_cb_, &state); - if (ret == 2) +inline std::string ffi_call_(std::function f, + std::chrono::milliseconds timeout) { + auto state = std::make_shared(); + auto* cb_ref = new std::shared_ptr(state); + const int ret = f(ffi_cb_, cb_ref); + if (ret == 2) { + delete cb_ref; throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); - std::unique_lock lock(state.mtx); - state.cv.wait(lock, [&state]{ return state.done; }); - if (!state.ok) - throw std::runtime_error(state.msg); - return state.msg; + } + std::unique_lock lock(state->mtx); + const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; }); + if (!fired) + throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms"); + if (!state->ok) + throw std::runtime_error(state->msg); + return state->msg; } } // anonymous namespace @@ -136,25 +156,51 @@ inline std::string ffi_call_(std::function f) { class NimTimerCtx { public: - static NimTimerCtx create(const TimerConfig& config) { + static NimTimerCtx create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto config_json = serializeFfiArg(config); const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { return nimtimer_create(config_json.c_str(), cb, ud); - }); - // ctor returns the context address as a plain decimal string - const auto addr = std::stoull(raw); - return NimTimerCtx(reinterpret_cast(static_cast(addr))); + }, timeout); + try { + const auto addr = std::stoull(raw); + return NimTimerCtx(reinterpret_cast(static_cast(addr)), timeout); + } catch (const std::exception&) { + throw std::runtime_error("FFI create returned non-numeric address: " + raw); + } } - static std::future createAsync(const TimerConfig& config) { - return std::async(std::launch::async, [config]() { return create(config); }); + static std::future createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); + } + + ~NimTimerCtx() { + if (ptr_) { + nimtimer_destroy(ptr_); + ptr_ = nullptr; + } + } + + NimTimerCtx(const NimTimerCtx&) = delete; + NimTimerCtx& operator=(const NimTimerCtx&) = delete; + + NimTimerCtx(NimTimerCtx&& other) noexcept : ptr_(other.ptr_), timeout_(other.timeout_) { + other.ptr_ = nullptr; + } + NimTimerCtx& operator=(NimTimerCtx&& other) noexcept { + if (this != &other) { + if (ptr_) nimtimer_destroy(ptr_); + ptr_ = other.ptr_; + timeout_ = other.timeout_; + other.ptr_ = nullptr; + } + return *this; } EchoResponse echo(const EchoRequest& req) const { const auto req_json = serializeFfiArg(req); const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { return nimtimer_echo(ptr_, cb, ud, req_json.c_str()); - }); + }, timeout_); return deserializeFfiResult(raw); } @@ -165,7 +211,7 @@ public: std::string version() const { const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { return nimtimer_version(ptr_, cb, ud); - }); + }, timeout_); return deserializeFfiResult(raw); } @@ -177,7 +223,7 @@ public: const auto req_json = serializeFfiArg(req); const auto raw = ffi_call_([&](FfiCallback cb, void* ud) { return nimtimer_complex(ptr_, cb, ud, req_json.c_str()); - }); + }, timeout_); return deserializeFfiResult(raw); } @@ -187,5 +233,6 @@ public: private: void* ptr_; - explicit NimTimerCtx(void* p) : ptr_(p) {} + std::chrono::milliseconds timeout_; + explicit NimTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {} }; diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index e595cf6..5e152ec 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -66,3 +66,11 @@ proc nimtimerComplex*( ok(ComplexResponse(summary: summary, itemCount: count, hasNote: req.note.isSome)) genBindings() # reads -d:ffiOutputDir, -d:ffiNimSrcRelPath, -d:targetLang from compile flags + +proc nimtimer_destroy*(ctx: pointer) {.dynlib, exportc, cdecl, raises: [].} = + ## Tears down the FFI context created by nimtimer_create. + ## Blocks until the FFI thread and watchdog thread have joined. + try: + discard destroyFFIContext[NimTimer](cast[ptr FFIContext[NimTimer]](ctx)) + except: + discard diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index fea7dc0..91dd8b1 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -46,18 +46,23 @@ proc generateCppHeader*( ): string = var lines: seq[string] = @[] + # ── Includes ─────────────────────────────────────────────────────────────── lines.add("#pragma once") lines.add("#include ") lines.add("#include ") + lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") + lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("") + + # ── nlohmann optional support ────────────────────────────────────────── lines.add("namespace nlohmann {") lines.add(" template") lines.add(" void to_json(json& j, const std::optional& opt) {") @@ -73,7 +78,7 @@ proc generateCppHeader*( lines.add("}") lines.add("") - # Types + # ── Types ────────────────────────────────────────────────────────────────── if types.len > 0: lines.add("// ============================================================") lines.add("// Types") @@ -82,8 +87,7 @@ proc generateCppHeader*( for t in types: lines.add("struct $1 {" % [t.name]) for f in t.fields: - let cppType = nimTypeToCpp(f.typeName) - lines.add(" $1 $2;" % [cppType, f.name]) + lines.add(" $1 $2;" % [nimTypeToCpp(f.typeName), f.name]) lines.add("};") var fieldNames: seq[string] = @[] for f in t.fields: @@ -93,7 +97,7 @@ proc generateCppHeader*( ) lines.add("") - # C extern declarations + # ── C FFI declarations ───────────────────────────────────────────────────── lines.add("// ============================================================") lines.add("// C FFI declarations") lines.add("// ============================================================") @@ -103,7 +107,6 @@ proc generateCppHeader*( "typedef void (*FfiCallback)(int ret, const char* msg, size_t len, void* user_data);" ) lines.add("") - for p in procs: var params: seq[string] = @[] if p.kind == ffiFfiKind: @@ -118,12 +121,12 @@ proc generateCppHeader*( params.add("FfiCallback callback") params.add("void* user_data") lines.add("int $1($2);" % [p.procName, params.join(", ")]) - + # Destroy is a plain synchronous call — no callback needed + lines.add("void $1_destroy(void* ctx);" % [libName]) lines.add("} // extern \"C\"") lines.add("") - # Transport serialization helpers - lines.add("") + # ── Serialization helpers ────────────────────────────────────────────────── lines.add("template") lines.add("inline std::string serializeFfiArg(const T& value) {") lines.add(" return nlohmann::json(value).dump();") @@ -133,20 +136,34 @@ proc generateCppHeader*( lines.add(" return std::to_string(reinterpret_cast(value));") lines.add("}") lines.add("") + # Wrap parse + get in a single try/catch so callers get a clear FFI error + # rather than a raw nlohmann exception with an opaque JSON pointer message. lines.add("template") lines.add("inline T deserializeFfiResult(const std::string& raw) {") - lines.add(" return nlohmann::json::parse(raw).get();") + lines.add(" try {") + lines.add(" return nlohmann::json::parse(raw).get();") + lines.add(" } catch (const nlohmann::json::exception& e) {") + lines.add( + " throw std::runtime_error(std::string(\"FFI response deserialization failed: \") + e.what());" + ) + lines.add(" }") lines.add("}") lines.add("") lines.add("template<>") lines.add("inline void* deserializeFfiResult(const std::string& raw) {") + lines.add(" try {") lines.add( - " return reinterpret_cast(static_cast(std::stoull(raw)));" + " return reinterpret_cast(static_cast(std::stoull(raw)));" ) + lines.add(" } catch (const std::exception& e) {") + lines.add( + " throw std::runtime_error(std::string(\"FFI returned non-numeric address: \") + raw);" + ) + lines.add(" }") lines.add("}") lines.add("") - # Anonymous namespace with synchronous call helper + # ── Call helper (anonymous namespace, header-only) ───────────────────────── lines.add("// ============================================================") lines.add("// Synchronous call helper (anonymous namespace, header-only)") lines.add("// ============================================================") @@ -161,46 +178,63 @@ proc generateCppHeader*( lines.add(" std::string msg;") lines.add("};") lines.add("") + # user_data is a heap-allocated shared_ptr. + # Ownership: ffi_call_ holds one copy; this callback holds the other. + # When ffi_call_ times out and returns before the callback fires, the + # state stays alive (refcount 1) until Nim eventually calls back and + # deletes cb_ref — eliminating the UAF that a stack-allocated state has. lines.add("inline void ffi_cb_(int ret, const char* msg, size_t /*len*/, void* ud) {") - lines.add(" auto* s = static_cast(ud);") - lines.add(" std::lock_guard lock(s->mtx);") - lines.add(" s->ok = (ret == 0);") - lines.add(" s->msg = msg ? std::string(msg) : std::string{};") - lines.add(" s->done = true;") - lines.add(" s->cv.notify_one();") + lines.add(" auto* sptr = static_cast*>(ud);") + lines.add(" {") + lines.add(" auto& s = **sptr;") + lines.add(" std::lock_guard lock(s.mtx);") + lines.add(" s.ok = (ret == 0);") + lines.add(" s.msg = msg ? std::string(msg) : std::string{};") + lines.add(" s.done = true;") + lines.add(" s.cv.notify_one();") + lines.add(" }") + lines.add(" delete sptr;") lines.add("}") lines.add("") - lines.add("inline std::string ffi_call_(std::function f) {") - lines.add(" FfiCallState_ state;") - lines.add(" const int ret = f(ffi_cb_, &state);") - lines.add(" if (ret == 2)") + lines.add( + "inline std::string ffi_call_(std::function f," + ) + lines.add(" std::chrono::milliseconds timeout) {") + lines.add(" auto state = std::make_shared();") + lines.add(" auto* cb_ref = new std::shared_ptr(state);") + lines.add(" const int ret = f(ffi_cb_, cb_ref);") + lines.add(" if (ret == 2) {") + lines.add(" delete cb_ref;") lines.add( " throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");" ) - lines.add(" std::unique_lock lock(state.mtx);") - lines.add(" state.cv.wait(lock, [&state]{ return state.done; });") - lines.add(" if (!state.ok)") - lines.add(" throw std::runtime_error(state.msg);") - lines.add(" return state.msg;") + lines.add(" }") + lines.add(" std::unique_lock lock(state->mtx);") + lines.add( + " const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; });" + ) + lines.add(" if (!fired)") + lines.add( + " throw std::runtime_error(\"FFI call timed out after \" + std::to_string(timeout.count()) + \"ms\");" + ) + lines.add(" if (!state->ok)") + lines.add(" throw std::runtime_error(state->msg);") + lines.add(" return state->msg;") lines.add("}") lines.add("") lines.add("} // anonymous namespace") lines.add("") - # Derive context type name and separate ctors / methods + # ── High-level C++ context class ────────────────────────────────────────── var ctors: seq[FFIProcMeta] = @[] var methods: seq[FFIProcMeta] = @[] for p in procs: - if p.kind == ffiCtorKind: - ctors.add(p) - else: - methods.add(p) + if p.kind == ffiCtorKind: ctors.add(p) + else: methods.add(p) let libTypeName = - if ctors.len > 0: - ctors[0].libTypeName - else: - libName[0 .. 0].toUpperAscii() & libName[1 .. ^1] + if ctors.len > 0: ctors[0].libTypeName + else: libName[0 .. 0].toUpperAscii() & libName[1 .. ^1] let ctxTypeName = libTypeName & "Ctx" @@ -211,45 +245,92 @@ proc generateCppHeader*( lines.add("class $1 {" % [ctxTypeName]) lines.add("public:") - # Static create() factory + # ── Constructors ──────────────────────────────────────────────────────── for ctor in ctors: var ctorParams: seq[string] = @[] + var epNames: seq[string] = @[] for ep in ctor.extraParams: - let cppType = nimTypeToCpp(ep.typeName) - ctorParams.add("const $1& $2" % [cppType, ep.name]) + ctorParams.add("const $1& $2" % [nimTypeToCpp(ep.typeName), ep.name]) + epNames.add(ep.name) + let timeoutParam = "std::chrono::milliseconds timeout = std::chrono::seconds{30}" + let ctorParamsWithTimeout = + if ctorParams.len > 0: ctorParams.join(", ") & ", " & timeoutParam + else: timeoutParam - lines.add(" static $1 create($2) {" % [ctxTypeName, ctorParams.join(", ")]) + # -- create() factory -- + lines.add(" static $1 create($2) {" % [ctxTypeName, ctorParamsWithTimeout]) for ep in ctor.extraParams: lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) - var callArgs: seq[string] = @[] for ep in ctor.extraParams: callArgs.add("$1_json.c_str()" % [ep.name]) callArgs.add("cb") callArgs.add("ud") - lines.add(" const auto raw = ffi_call_([&](FfiCallback cb, void* ud) {") lines.add(" return $1($2);" % [ctor.procName, callArgs.join(", ")]) - lines.add(" });") - lines.add(" // ctor returns the context address as a plain decimal string") - lines.add(" const auto addr = std::stoull(raw);") + lines.add(" }, timeout);") + lines.add(" try {") + lines.add(" const auto addr = std::stoull(raw);") lines.add( - " return $1(reinterpret_cast(static_cast(addr)));" % + " return $1(reinterpret_cast(static_cast(addr)), timeout);" % [ctxTypeName] ) + lines.add(" } catch (const std::exception&) {") + lines.add( + " throw std::runtime_error(\"FFI create returned non-numeric address: \" + raw);" + ) + lines.add(" }") lines.add(" }") lines.add("") + + # -- createAsync() factory: uses actual param types, not hardcoded -- + let captureList = + if epNames.len > 0: epNames.join(", ") & ", timeout" + else: "timeout" + let callList = + if epNames.len > 0: epNames.join(", ") & ", timeout" + else: "timeout" lines.add( - " static std::future<$1> createAsync(const TimerConfig& config) {" % - [ctxTypeName] + " static std::future<$1> createAsync($2) {" % + [ctxTypeName, ctorParamsWithTimeout] ) lines.add( - " return std::async(std::launch::async, [config]() { return create(config); });" + " return std::async(std::launch::async, [$1]() { return create($2); });" % + [captureList, callList] ) lines.add(" }") lines.add("") - # Instance methods + # ── Rule of 5 ────────────────────────────────────────────────────────── + # Destructor tears down Nim threads; copies are deleted; moves transfer ownership. + lines.add(" ~$1() {" % [ctxTypeName]) + lines.add(" if (ptr_) {") + lines.add(" $1_destroy(ptr_);" % [libName]) + lines.add(" ptr_ = nullptr;") + lines.add(" }") + lines.add(" }") + lines.add("") + lines.add(" $1(const $1&) = delete;" % [ctxTypeName]) + lines.add(" $1& operator=(const $1&) = delete;" % [ctxTypeName]) + lines.add("") + lines.add( + " $1($1&& other) noexcept : ptr_(other.ptr_), timeout_(other.timeout_) {" % + [ctxTypeName] + ) + lines.add(" other.ptr_ = nullptr;") + lines.add(" }") + lines.add(" $1& operator=($1&& other) noexcept {" % [ctxTypeName]) + lines.add(" if (this != &other) {") + lines.add(" if (ptr_) $1_destroy(ptr_);" % [libName]) + lines.add(" ptr_ = other.ptr_;") + lines.add(" timeout_ = other.timeout_;") + lines.add(" other.ptr_ = nullptr;") + lines.add(" }") + lines.add(" return *this;") + lines.add(" }") + lines.add("") + + # ── Instance methods ──────────────────────────────────────────────────── for m in methods: let methodName = stripLibPrefixCpp(m.procName, libName) let retCppType = nimTypeToCpp(m.returnTypeName) @@ -257,8 +338,7 @@ proc generateCppHeader*( var methParams: seq[string] = @[] var methParamNames: seq[string] = @[] for ep in m.extraParams: - let cppType = nimTypeToCpp(ep.typeName) - methParams.add("const $1& $2" % [cppType, ep.name]) + methParams.add("const $1& $2" % [nimTypeToCpp(ep.typeName), ep.name]) methParamNames.add(ep.name) let methParamsStr = methParams.join(", ") let methParamNamesStr = methParamNames.join(", ") @@ -266,15 +346,12 @@ proc generateCppHeader*( lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) for ep in m.extraParams: lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) - var callArgs = @["ptr_", "cb", "ud"] for ep in m.extraParams: callArgs.add("$1_json.c_str()" % [ep.name]) - lines.add(" const auto raw = ffi_call_([&](FfiCallback cb, void* ud) {") lines.add(" return $1($2);" % [m.procName, callArgs.join(", ")]) - lines.add(" });") - + lines.add(" }, timeout_);") if retCppType == "void*": lines.add(" return deserializeFfiResult(raw);") else: @@ -302,7 +379,11 @@ proc generateCppHeader*( lines.add("private:") lines.add(" void* ptr_;") - lines.add(" explicit $1(void* p) : ptr_(p) {}" % [ctxTypeName]) + lines.add(" std::chrono::milliseconds timeout_;") + lines.add( + " explicit $1(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {}" % + [ctxTypeName] + ) lines.add("};") lines.add("") From 558356149bb3dfa398a2d06702116865a49ac42a Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Mon, 4 May 2026 11:18:32 +0200 Subject: [PATCH 10/10] better comments about where genBindings() should be invoked --- examples/nim_timer/nim_timer.nim | 7 +++++++ ffi/internal/ffi_macro.nim | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/examples/nim_timer/nim_timer.nim b/examples/nim_timer/nim_timer.nim index 5e152ec..947e749 100644 --- a/examples/nim_timer/nim_timer.nim +++ b/examples/nim_timer/nim_timer.nim @@ -65,6 +65,13 @@ proc nimtimerComplex*( return ok(ComplexResponse(summary: summary, itemCount: count, hasNote: req.note.isSome)) +# --- genBindings() must come AFTER every {.ffi.} / {.ffiCtor.} annotation --- +# Each pragma populates ffiProcRegistry / ffiTypeRegistry at compile time as +# the compiler processes the AST. genBindings() reads those registries to emit +# the binding files, so placing it any earlier would produce incomplete output. +# In a multi-file library, import all sub-modules first and call genBindings() +# once, at the bottom of the top-level compilation-root file. +# This call is a no-op unless -d:ffiGenBindings is passed to the compiler. genBindings() # reads -d:ffiOutputDir, -d:ffiNimSrcRelPath, -d:targetLang from compile flags proc nimtimer_destroy*(ctx: pointer) {.dynlib, exportc, cdecl, raises: [].} = diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 6f4203e..7bc7c09 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1461,8 +1461,18 @@ macro genBindings*( outputDir: static[string] = ffiOutputDir, nimSrcRelPath: static[string] = ffiNimSrcRelPath, ): untyped = - ## Generates binding files for the language set by -d:targetLang=. - ## Supported values: "rust" (default), "cpp" (case-insensitive). + ## Emits C++ or Rust binding files from the compile-time FFI registries. + ## + ## PLACEMENT REQUIREMENT: genBindings() must be called AFTER every {.ffi.} + ## and {.ffiCtor.} annotation in the compilation unit. Each pragma populates + ## ffiProcRegistry and ffiTypeRegistry as the compiler expands the AST; + ## calling genBindings() earlier produces incomplete bindings. + ## + ## In a single-file library, place it at the bottom of the file. + ## In a multi-file library, import all sub-modules first and call + ## genBindings() once at the bottom of the top-level compilation-root file. + ## + ## Supported languages (-d:targetLang): "rust" (default), "cpp". ## Output path and nim source path default to -d:ffiOutputDir and ## -d:ffiNimSrcRelPath, or can be passed as explicit arguments. ## This macro is a no-op unless -d:ffiGenBindings is set.