Merge 558356149bb3dfa398a2d06702116865a49ac42a into e3eca63236384eee8f8a396ffeb89ebfc07306fc

This commit is contained in:
Ivan FB 2026-05-04 11:20:46 +02:00 committed by GitHub
commit b07f34c1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 3813 additions and 45 deletions

20
.gitignore vendored
View File

@ -1,3 +1,23 @@
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/**/rust_bindings/target/
# Example build artifacts
examples/**/cpp_bindings/build/
# Cargo build artifacts
examples/**/rust_client/target/
# Development plan (local only)
PLAN.md

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,44 @@
#include "nimtimer.hpp"
#include <iostream>
#include <future>
int main() {
try {
auto ctx = NimTimerCtx::create(TimerConfig{"cpp-demo"});
std::cout << "[1] Context created\n";
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 = echo1Future.get();
std::cout << "[3] Echo 1: echoed=" << echo.echoed
<< ", timerName=" << echo.timerName << "\n";
auto echo2 = echo2Future.get();
std::cout << "[4] Echo 2: echoed=" << echo2.echoed
<< ", timerName=" << echo2.timerName << "\n";
auto complexReq = ComplexRequest{
std::vector<EchoRequest>{EchoRequest{"one", 10}, EchoRequest{"two", 20}},
std::vector<std::string>{"fast", "async"},
std::optional<std::string>("extra note"),
std::optional<int64_t>(3)
};
auto complexFuture = ctx.complexAsync(complexReq);
auto complex = complexFuture.get();
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;
}

View File

@ -0,0 +1,238 @@
#pragma once
#include <string>
#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <mutex>
#include <condition_variable>
#include <memory>
#include <functional>
#include <future>
#include <vector>
#include <optional>
#include <nlohmann/json.hpp>
namespace nlohmann {
template<typename T>
void to_json(json& j, const std::optional<T>& opt) {
if (opt) j = *opt;
else j = nullptr;
}
template<typename T>
void from_json(const json& j, std::optional<T>& opt) {
if (j.is_null()) opt = std::nullopt;
else opt = j.get<T>();
}
}
// ============================================================
// 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<EchoRequest> messages;
std::vector<std::string> tags;
std::optional<std::string> note;
std::optional<int64_t> 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);
void nimtimer_destroy(void* ctx);
} // extern "C"
template<typename T>
inline std::string serializeFfiArg(const T& value) {
return nlohmann::json(value).dump();
}
inline std::string serializeFfiArg(void* value) {
return std::to_string(reinterpret_cast<uintptr_t>(value));
}
template<typename T>
inline T deserializeFfiResult(const std::string& raw) {
try {
return nlohmann::json::parse(raw).get<T>();
} catch (const nlohmann::json::exception& e) {
throw std::runtime_error(std::string("FFI response deserialization failed: ") + e.what());
}
}
template<>
inline void* deserializeFfiResult<void*>(const std::string& raw) {
try {
return reinterpret_cast<void*>(static_cast<uintptr_t>(std::stoull(raw)));
} catch (const std::exception& e) {
throw std::runtime_error(std::string("FFI returned non-numeric address: ") + 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* sptr = static_cast<std::shared_ptr<FfiCallState_>*>(ud);
{
auto& s = **sptr;
std::lock_guard<std::mutex> 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<int(FfiCallback, void*)> f,
std::chrono::milliseconds timeout) {
auto state = std::make_shared<FfiCallState_>();
auto* cb_ref = new std::shared_ptr<FfiCallState_>(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<std::mutex> 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
// ============================================================
// High-level C++ context class
// ============================================================
class NimTimerCtx {
public:
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);
}, timeout);
try {
const auto addr = std::stoull(raw);
return NimTimerCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout);
} catch (const std::exception&) {
throw std::runtime_error("FFI create returned non-numeric address: " + raw);
}
}
static std::future<NimTimerCtx> 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<EchoResponse>(raw);
}
std::future<EchoResponse> 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);
}, timeout_);
return deserializeFfiResult<std::string>(raw);
}
std::future<std::string> 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) {
return nimtimer_complex(ptr_, cb, ud, req_json.c_str());
}, timeout_);
return deserializeFfiResult<ComplexResponse>(raw);
}
std::future<ComplexResponse> complexAsync(const ComplexRequest& req) const {
return std::async(std::launch::async, [this, req]() { return complex(req); });
}
private:
void* ptr_;
std::chrono::milliseconds timeout_;
explicit NimTimerCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {}
};

View File

@ -0,0 +1,83 @@
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
type TimerConfig {.ffi.} = object
name: string
type EchoRequest {.ffi.} = object
message: string
delayMs: int # how long chronos sleeps before replying
type EchoResponse {.ffi.} = object
echoed: string
timerName: string # proves that the timer's own state is accessible
type ComplexRequest {.ffi.} = object
messages: seq[EchoRequest]
tags: seq[string]
note: Option[string]
retries: Maybe[int]
type ComplexResponse {.ffi.} = object
summary: string
itemCount: int
hasNote: bool
# --- Constructor -----------------------------------------------------------
# Called once from Rust. Creates the FFIContext + NimTimer.
# Uses chronos (await sleepAsync) so the body is async.
proc nimtimerCreate*(
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 nimtimerEcho*(
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 nimtimerVersion*(timer: NimTimer): Future[Result[string, string]] {.ffi.} =
return ok("nim-timer v0.1.0")
proc nimtimerComplex*(
timer: NimTimer, req: ComplexRequest
): Future[Result[ComplexResponse, string]] {.ffi.} =
let note = if req.note.isSome: req.note.get else: "<none>"
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() 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: [].} =
## 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

View File

@ -0,0 +1,27 @@
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 "https://github.com/logos-messaging/nim-ffi >= 0.1.3"
const nimFlags = "--mm:orc -d:chronicles_log_level=WARN"
task build, "Compile the nimtimer library":
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 " & 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 " & 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"

View File

@ -0,0 +1,9 @@
[package]
name = "nimtimer"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["sync"] }

View File

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

View File

@ -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());
}

View File

@ -0,0 +1,177 @@
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<Result<String, String>>,
}
type Pair = Arc<(Mutex<FfiCallbackResult>, 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<FfiCallbackResult>, 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<F>(timeout: Duration, f: F) -> Result<String, String>
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()
}
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<Result<String, String>>,
);
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: F) -> Result<String, String>
where
F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,
{
let rx = {
let (tx, rx) = tokio::sync::oneshot::channel::<Result<String, String>>();
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<Result<String, String>>,
)
});
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,
timeout: Duration,
}
unsafe impl Send for NimTimerCtx {}
unsafe impl Sync for NimTimerCtx {}
impl NimTimerCtx {
pub fn create(config: TimerConfig, timeout: Duration) -> Result<Self, String> {
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)
})?;
let addr: usize = raw.parse().map_err(|e: std::num::ParseIntError| e.to_string())?;
Ok(Self { ptr: addr as *mut c_void, timeout })
}
pub async fn new_async(config: TimerConfig) -> Result<Self, String> {
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<EchoResponse, String> {
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::<EchoResponse>(&raw).map_err(|e| e.to_string())
}
pub async fn echo_async(&self, req: EchoRequest) -> Result<EchoResponse, String> {
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::<EchoResponse>(&raw).map_err(|e| e.to_string())
}
pub fn version(&self) -> Result<String, String> {
let raw = ffi_call(self.timeout, |cb, ud| unsafe {
ffi::nimtimer_version(self.ptr, cb, ud)
})?;
serde_json::from_str::<String>(&raw).map_err(|e| e.to_string())
}
pub async fn version_async(&self) -> Result<String, String> {
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::<String>(&raw).map_err(|e| e.to_string())
}
pub fn complex(&self, req: ComplexRequest) -> Result<ComplexResponse, String> {
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::<ComplexResponse>(&raw).map_err(|e| e.to_string())
}
pub async fn complex_async(&self, req: ComplexRequest) -> Result<ComplexResponse, String> {
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::<ComplexResponse>(&raw).map_err(|e| e.to_string())
}
}

View File

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

View File

@ -0,0 +1,5 @@
mod ffi;
mod types;
mod api;
pub use types::*;
pub use api::*;

View File

@ -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<EchoRequest>,
pub tags: Vec<String>,
pub note: Option<String>,
pub retries: Option<i64>,
}
#[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,
}

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

@ -0,0 +1,144 @@
# 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",
"tokio",
]
[[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"
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",
"tokio",
]
[[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 = "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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@ -0,0 +1,17 @@
[package]
name = "rust_client"
version = "0.1.0"
edition = "2021"
[dependencies]
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"

View File

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

View File

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

View File

@ -0,0 +1,30 @@
use nimtimer::{EchoRequest, NimTimerCtx, TimerConfig};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 echo1 = ctx
.echo_async(EchoRequest {
message: "hello from tokio".into(),
delay_ms: 200,
})
.await?;
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(())
}

View File

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

View File

@ -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"
@ -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,22 @@ 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_rust, "Generate Rust bindings for the nim_timer example":
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:targetLang=cpp" &
" -d:ffiOutputDir=examples/nim_timer/cpp_bindings" &
" -d:ffiNimSrcRelPath=../nim_timer.nim" &
" -o:/dev/null examples/nim_timer/nim_timer.nim"

513
ffi/codegen/cpp.nim Normal file
View File

@ -0,0 +1,513 @@
## 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 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 =
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": "float"
of "float64": "double"
of "pointer": "void*"
else: trimmed
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] = @[]
# ── Includes ───────────────────────────────────────────────────────────────
lines.add("#pragma once")
lines.add("#include <string>")
lines.add("#include <cstdint>")
lines.add("#include <chrono>")
lines.add("#include <stdexcept>")
lines.add("#include <mutex>")
lines.add("#include <condition_variable>")
lines.add("#include <memory>")
lines.add("#include <functional>")
lines.add("#include <future>")
lines.add("#include <vector>")
lines.add("#include <optional>")
lines.add("#include <nlohmann/json.hpp>")
lines.add("")
# ── nlohmann optional<T> support ──────────────────────────────────────────
lines.add("namespace nlohmann {")
lines.add(" template<typename T>")
lines.add(" void to_json(json& j, const std::optional<T>& opt) {")
lines.add(" if (opt) j = *opt;")
lines.add(" else j = nullptr;")
lines.add(" }")
lines.add("")
lines.add(" template<typename T>")
lines.add(" void from_json(const json& j, std::optional<T>& opt) {")
lines.add(" if (j.is_null()) opt = std::nullopt;")
lines.add(" else opt = j.get<T>();")
lines.add(" }")
lines.add("}")
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:
lines.add(" $1 $2;" % [nimTypeToCpp(f.typeName), 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 FFI 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(", ")])
# Destroy is a plain synchronous call — no callback needed
lines.add("void $1_destroy(void* ctx);" % [libName])
lines.add("} // extern \"C\"")
lines.add("")
# ── Serialization helpers ──────────────────────────────────────────────────
lines.add("template<typename T>")
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<uintptr_t>(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<typename T>")
lines.add("inline T deserializeFfiResult(const std::string& raw) {")
lines.add(" try {")
lines.add(" return nlohmann::json::parse(raw).get<T>();")
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<void*>(const std::string& raw) {")
lines.add(" try {")
lines.add(
" return reinterpret_cast<void*>(static_cast<uintptr_t>(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("")
# ── Call helper (anonymous namespace, header-only) ─────────────────────────
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("")
# user_data is a heap-allocated shared_ptr<FfiCallState_>.
# 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* sptr = static_cast<std::shared_ptr<FfiCallState_>*>(ud);")
lines.add(" {")
lines.add(" auto& s = **sptr;")
lines.add(" std::lock_guard<std::mutex> 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<int(FfiCallback, void*)> f,"
)
lines.add(" std::chrono::milliseconds timeout) {")
lines.add(" auto state = std::make_shared<FfiCallState_>();")
lines.add(" auto* cb_ref = new std::shared_ptr<FfiCallState_>(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(" }")
lines.add(" std::unique_lock<std::mutex> 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("")
# ── 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)
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:")
# ── Constructors ────────────────────────────────────────────────────────
for ctor in ctors:
var ctorParams: seq[string] = @[]
var epNames: seq[string] = @[]
for ep in ctor.extraParams:
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
# -- 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(" }, timeout);")
lines.add(" try {")
lines.add(" const auto addr = std::stoull(raw);")
lines.add(
" return $1(reinterpret_cast<void*>(static_cast<uintptr_t>(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($2) {" %
[ctxTypeName, ctorParamsWithTimeout]
)
lines.add(
" return std::async(std::launch::async, [$1]() { return create($2); });" %
[captureList, callList]
)
lines.add(" }")
lines.add("")
# ── 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)
var methParams: seq[string] = @[]
var methParamNames: seq[string] = @[]
for ep in m.extraParams:
methParams.add("const $1& $2" % [nimTypeToCpp(ep.typeName), 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:
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(" }, timeout_);")
if retCppType == "void*":
lines.add(" return deserializeFfiResult<void*>(raw);")
else:
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 $1(); });" %
[methodName]
)
lines.add(" }")
lines.add("")
lines.add("private:")
lines.add(" void* ptr_;")
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("")
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))

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

@ -0,0 +1,45 @@
## 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
# 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.} = ""

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

@ -0,0 +1,507 @@
## 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, 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(t)
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"
tokio = { version = "1", features = ["sync"] }
""" %
[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 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] = @[]
var ctors: seq[FFIProcMeta] = @[]
var methods: seq[FFIProcMeta] = @[]
for p in procs:
if p.kind == ffiCtorKind: ctors.add(p)
else: methods.add(p)
var libTypeName = ""
if ctors.len > 0: libTypeName = ctors[0].libTypeName
else: 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("")
# ── Blocking trampoline ────────────────────────────────────────────────────
lines.add("#[derive(Default)]")
lines.add("struct FfiCallbackResult {")
lines.add(" payload: Option<Result<String, String>>,")
lines.add("}")
lines.add("")
lines.add("type Pair = Arc<(Mutex<FfiCallbackResult>, Condvar)>;")
lines.add("")
lines.add("unsafe extern \"C\" fn on_result(")
lines.add(" ret: c_int,")
lines.add(" msg: *const c_char,")
lines.add(" _len: usize,")
lines.add(" user_data: *mut c_void,")
lines.add(") {")
lines.add(" let pair = Arc::from_raw(user_data as *const (Mutex<FfiCallbackResult>, Condvar));")
lines.add(" {")
lines.add(" let (lock, cvar) = &*pair;")
lines.add(" let mut state = lock.lock().unwrap();")
lines.add(" state.payload = Some(if ret == 0 {")
lines.add(" Ok(CStr::from_ptr(msg).to_string_lossy().into_owned())")
lines.add(" } else {")
lines.add(" Err(CStr::from_ptr(msg).to_string_lossy().into_owned())")
lines.add(" });")
lines.add(" cvar.notify_one();")
lines.add(" }")
lines.add(" std::mem::forget(pair);")
lines.add("}")
lines.add("")
lines.add("fn ffi_call<F>(timeout: Duration, f: F) -> Result<String, String>")
lines.add("where")
lines.add(" F: FnOnce(ffi::FfiCallback, *mut c_void) -> c_int,")
lines.add("{")
lines.add(" let pair: Pair = Arc::new((Mutex::new(FfiCallbackResult::default()), Condvar::new()));")
lines.add(" let raw = Arc::into_raw(pair.clone()) as *mut c_void;")
lines.add(" let ret = f(on_result, raw);")
lines.add(" if ret == 2 {")
lines.add(" return Err(\"RET_MISSING_CALLBACK (internal error)\".into());")
lines.add(" }")
lines.add(" let (lock, cvar) = &*pair;")
lines.add(" let guard = lock.lock().unwrap();")
lines.add(" let (guard, timed_out) = cvar")
lines.add(" .wait_timeout_while(guard, timeout, |s| s.payload.is_none())")
lines.add(" .unwrap();")
lines.add(" if timed_out.timed_out() {")
lines.add(" return Err(format!(\"timed out after {:?}\", timeout));")
lines.add(" }")
lines.add(" guard.payload.clone().unwrap()")
lines.add("}")
lines.add("")
# ── 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<Result<String, String>>,")
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: F) -> Result<String, String>")
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::<Result<String, String>>();")
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<Result<String, String>>,")
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,")
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("")
lines.add("impl $1 {" % [ctxTypeName])
# ── Constructors ───────────────────────────────────────────────────────────
for ctor in ctors:
var asyncParamsList: seq[string] = @[]
for ep in ctor.extraParams:
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"
# Helper: emit JSON serialization lines for extra params
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])
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 ordered arg list for the raw FFI call (ctor: params, cb, ud)
var ffiCallArgs: seq[string] = @[]
for ep in ctor.extraParams:
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<Self, String> {" % [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(" 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("")
# -- 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<Self, String> {" % [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:
paramsList.add("$1: $2" % [toSnakeCase(ep.name), nimTypeToRust(ep.typeName)])
let paramsStr = if paramsList.len > 0: ", " & paramsList.join(", ") else: ""
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])
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])
template emitDeserialize(retRustType: string) =
if retRustType == "String":
lines.add(" serde_json::from_str::<String>(&raw).map_err(|e| e.to_string())")
elif retRustType == "usize":
lines.add(" raw.parse::<usize>().map_err(|e| e.to_string())")
else:
lines.add(
" serde_json::from_str::<$1>(&raw).map_err(|e| e.to_string())" % [retRustType]
)
# -- 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:
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("")
# -- 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("")
lines.add("}")
result = lines.join("\n") & "\n"
proc generateRustCrate*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
) =
## Generates a complete Rust crate in outputDir.
createDir(outputDir)
createDir(outputDir / "src")
writeFile(outputDir / "Cargo.toml", generateCargoToml(libName))
writeFile(outputDir / "build.rs", generateBuildRs(libName, nimSrcRelPath))
writeFile(outputDir / "src" / "lib.rs", generateLibRs())
writeFile(outputDir / "src" / "ffi.rs", generateFfiRs(procs))
writeFile(outputDir / "src" / "types.rs", generateTypesRs(types))
writeFile(outputDir / "src" / "api.rs", generateApiRs(procs, libName))

View File

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

View File

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

File diff suppressed because it is too large Load Diff

165
ffi/serial.nim Normal file
View File

@ -0,0 +1,165 @@
import std/[json, macros, options]
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 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 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()))
ok(address)
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)
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.
## Registers the type in ffiTypeRegistry for binding generation.
## Serialization is handled by the generic ffiSerialize/ffiDeserialize overloads.
## 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]
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 = body

View File

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

113
tests/test_serial.nim Normal file
View File

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