From 26bf173e2c8000387f8a90ae8ec9304d91c41a65 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Sun, 3 May 2026 10:01:38 +0200 Subject: [PATCH] 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()