diff --git a/CHANGELOG.md b/CHANGELOG.md index c18d679..06119eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### Added +- `examples/skeleton/` — a minimal, compilable new-library template (constructor, + one async method, a `{.ffiEvent.}`, a destructor, and `genBindings()`) with a + copy-rename checklist README, so starting a library no longer means + reverse-engineering the feature-tour `examples/timer/`. Its C++ bindings are + checked in and diff-verified by CI via new `genbindings_cpp_skeleton` / + `check_bindings_skeleton` tasks wired into `nimble check_bindings`. - `{.ffiEvent.}` no longer requires an explicit wire-name string: when omitted it is derived from the proc name via `camelToSnakeCase` (`onPeerConnected` → `on_peer_connected`), matching how `{.ffi.}` derives its diff --git a/examples/skeleton/README.md b/examples/skeleton/README.md new file mode 100644 index 0000000..55e2262 --- /dev/null +++ b/examples/skeleton/README.md @@ -0,0 +1,40 @@ +# skeleton — new-library template + +A minimal, compilable nim-ffi library: a constructor, one async method, a library-initiated event, and a destructor, plus a nimble file and checked-in C++ bindings. Copy this directory instead of reverse-engineering `examples/timer/` (which is a feature tour, not a starting point). + +## What's here + +- `skeleton.nim` — the library. Every nim-ffi lib needs these four pieces, and nothing else: + - `declareLibrary("skeleton", Skeleton)` — names the lib and its state object; must come before any FFI annotation. + - `{.ffiCtor.}` constructor returning `Future[Result[T, string]]`. + - one `{.ffi.}` async method, same return contract. + - `{.ffiEvent.}` for a library-initiated callback. + - `{.ffiDtor.}` destructor. + - `genBindings()` as the **last** top-level call. +- `skeleton.nimble` — `build` and `genbindings_cpp` tasks. +- `cpp_bindings/` — generated C++ bindings, checked in and diff-verified by CI (`nimble check_bindings`). + +## Copy-rename checklist + +Pick your library name in two casings: a snake_case wire name (e.g. `my_lib`) and its PascalCase state type (e.g. `MyLib`). + +1. Copy the directory: `cp -r examples/skeleton examples/my_lib` +2. Rename both files: `skeleton.nim` → `my_lib.nim`, `skeleton.nimble` → `my_lib.nimble`. +3. In `my_lib.nim`, replace every `skeleton` → `my_lib` and `Skeleton` → `MyLib`. That covers `declareLibrary("my_lib", MyLib)`, the state type, and the `myLibCreate` / `myLibHello` / `my_lib_destroy` proc names. Rename the request/response/event/config types to whatever your API needs. +4. In `my_lib.nimble`, update `packageName`, `description`, and every `libskeleton` → `libmy_lib` / `skeleton.nim` → `my_lib.nim`. The `--nimMainPrefix:libmy_lib` must match your `declareLibrary` name. +5. Regenerate bindings: `cd examples/my_lib && nimble genbindings_cpp` (add `-d:targetLang=rust` / `c` / `c_abi` variants as needed — see the root `ffi.nimble` for the full flag set). +6. If you keep the library long-term, wire it into the root `ffi.nimble` `check_bindings` task the same way `skeleton` is, so CI catches binding drift. + +## Contracts worth remembering + +- Every `{.ffi.}` / `{.ffiCtor.}` returns `Future[Result[T, string]]`: `return ok(value)` on success, `return err("reason")` on failure. +- A `{.ffi.}` method takes the state object as its **first** parameter. +- The dtor's exported symbol name (`skeleton_destroy`) avoids the camelCase→snake_case derivation and reads naturally in C; name yours the same way. +- `genBindings()` reads compile-time registries populated by the pragmas above it. Anything declared after it — or in a module imported after it — is silently missing from the output. + +## Build & generate + +```sh +cd examples/skeleton +nimble genbindings_cpp # writes cpp_bindings/ +``` diff --git a/examples/skeleton/cpp_bindings/CMakeLists.txt b/examples/skeleton/cpp_bindings/CMakeLists.txt new file mode 100644 index 0000000..17be81c --- /dev/null +++ b/examples/skeleton/cpp_bindings/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.14) +project(skeleton_cpp_bindings CXX C) + +# The generated bindings target C++20: designated initializers and other +# C++20 constructs are used throughout the emitted code. +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# MSVC defaults __cplusplus to 199711L regardless of the active /std:c++XX +# level — the generated header's C++20 guard would then misfire. /Zc:__cplusplus +# makes MSVC report the actual standard. Harmless on every other compiler. +if(MSVC) + add_compile_options(/Zc:__cplusplus) +endif() + +# ── 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() + +# Build the Nim dylib + vendored TinyCBOR (shared with the C backend). +set(NIM_FFI_LIB skeleton) +set(NIM_FFI_SRC ../skeleton.nim) +include("${REPO_ROOT}/ffi/codegen/templates/nim_ffi_lib.cmake") + +add_library(skeleton_headers INTERFACE) +target_include_directories(skeleton_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(skeleton_headers INTERFACE skeleton tinycbor) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp") + add_executable(skeleton_example main.cpp) + target_link_libraries(skeleton_example PRIVATE skeleton_headers) + add_dependencies(skeleton_example skeleton_nim_lib) + if(CMAKE_SYSTEM_NAME STREQUAL "Windows") + add_custom_command(TARGET skeleton_example POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${skeleton_RUNTIME_LIB}" + "$" + COMMENT "Staging skeleton.dll next to skeleton_example.exe") + endif() +endif() diff --git a/examples/skeleton/cpp_bindings/skeleton.hpp b/examples/skeleton/cpp_bindings/skeleton.hpp new file mode 100644 index 0000000..9229125 --- /dev/null +++ b/examples/skeleton/cpp_bindings/skeleton.hpp @@ -0,0 +1,615 @@ +#pragma once +// Generated bindings require C++20 (designated initializers and other +// C++20 constructs are used throughout the emitted code). +// MSVC keeps __cplusplus at 199711L unless /Zc:__cplusplus is passed, +// so consult _MSVC_LANG when present (it always reflects the active +// /std:c++XX level). +#if defined(_MSVC_LANG) +# if _MSVC_LANG < 202002L +# error "nim-ffi generated headers require C++20 or later (use /std:c++20)" +# endif +#elif !defined(__cplusplus) || __cplusplus < 202002L +# error "nim-ffi generated headers require C++20 or later" +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include +} + +#include +// ============================================================ +// Result — exception-free error channel +// ============================================================ +// The generated bindings never throw: every fallible entry point (create, +// instance methods, and their *Async futures) returns a Result. Callers +// branch on isOk()/isErr() (or the explicit bool conversion) and read +// value()/error(). This mirrors the Nim side's Result[T, string] and keeps +// us off C++23's std::expected. +#ifndef NIM_FFI_RESULT_HPP_INCLUDED +#define NIM_FFI_RESULT_HPP_INCLUDED + +template +class Result { + std::optional value_; + std::string error_; +public: + static Result ok(T value) { + Result r; + r.value_ = std::move(value); + return r; + } + static Result err(std::string message) { + Result r; + r.error_ = std::move(message); + return r; + } + bool isOk() const { return value_.has_value(); } + bool isErr() const { return !value_.has_value(); } + explicit operator bool() const { return isOk(); } + const T& value() const { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + T& value() { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + const T& operator*() const { assert(value_.has_value() && "Result::operator*() called on err Result — check isOk() first"); return *value_; } + const T* operator->() const { assert(value_.has_value() && "Result::operator->() called on err Result — check isOk() first"); return &*value_; } + T&& take() { assert(value_.has_value() && "Result::take() called on err Result — check isOk() first"); return std::move(*value_); } + const std::string& error() const { assert(!value_.has_value() && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +template <> +class Result { + bool ok_ = true; + std::string error_; +public: + static Result ok() { + Result r; + r.ok_ = true; + return r; + } + static Result err(std::string message) { + Result r; + r.ok_ = false; + r.error_ = std::move(message); + return r; + } + Result() = default; + bool isOk() const { return ok_; } + bool isErr() const { return !ok_; } + explicit operator bool() const { return isOk(); } + const std::string& error() const { assert(!ok_ && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +#endif // NIM_FFI_RESULT_HPP_INCLUDED + +// ── encode_cbor overloads (primitives + containers) ───────────────────── +// Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each +// generated struct; these helpers cover the leaf types they defer into. +// Guarded so two nim-ffi headers can share a translation unit. +#ifndef NIM_FFI_CBOR_HELPERS_HPP_INCLUDED +#define NIM_FFI_CBOR_HELPERS_HPP_INCLUDED + +inline CborError encode_cbor(CborEncoder& e, bool v) { + return cbor_encode_boolean(&e, v); +} +inline CborError encode_cbor(CborEncoder& e, int64_t v) { + return cbor_encode_int(&e, v); +} +inline CborError encode_cbor(CborEncoder& e, int32_t v) { + return cbor_encode_int(&e, static_cast(v)); +} +inline CborError encode_cbor(CborEncoder& e, uint64_t v) { + return cbor_encode_uint(&e, v); +} +inline CborError encode_cbor(CborEncoder& e, double v) { + return cbor_encode_double(&e, v); +} +inline CborError encode_cbor(CborEncoder& e, const std::string& v) { + return cbor_encode_text_string(&e, v.data(), v.size()); +} + +template +inline CborError encode_cbor(CborEncoder& e, const std::vector& v) { + CborEncoder arr; + CborError err = cbor_encoder_create_array(&e, &arr, v.size()); + if (err) return err; + for (const auto& item : v) { + err = encode_cbor(arr, item); + if (err) return err; + } + return cbor_encoder_close_container(&e, &arr); +} + +// `seq[byte]` rides the wire as a CBOR byte string (major type 2), matching +// Nim's cbor_serialization. This non-template overload beats the std::vector +// template in overload resolution, so std::vector fields use it +// automatically. +inline CborError encode_cbor(CborEncoder& e, const std::vector& v) { + return cbor_encode_byte_string(&e, v.data(), v.size()); +} + +template +inline CborError encode_cbor(CborEncoder& e, const std::optional& v) { + if (!v) return cbor_encode_null(&e); + return encode_cbor(e, *v); +} + +// ── decode_cbor overloads ─────────────────────────────────────────────── + +// After reading a leaf value, the parser must advance past it; both steps +// short-circuit on the same CborError, so they always travel together. +inline CborError advance_if_ok(CborValue& it, CborError err) { + if (err) return err; + return cbor_value_advance(&it); +} + +inline CborError decode_cbor(CborValue& it, bool& out) { + if (!cbor_value_is_boolean(&it)) return CborErrorImproperValue; + return advance_if_ok(it, cbor_value_get_boolean(&it, &out)); +} +inline CborError decode_cbor(CborValue& it, int64_t& out) { + if (!cbor_value_is_integer(&it)) return CborErrorImproperValue; + return advance_if_ok(it, cbor_value_get_int64_checked(&it, &out)); +} +inline CborError decode_cbor(CborValue& it, int32_t& out) { + int64_t tmp = 0; + CborError err = decode_cbor(it, tmp); + if (err) return err; + out = static_cast(tmp); + return CborNoError; +} +inline CborError decode_cbor(CborValue& it, uint64_t& out) { + if (!cbor_value_is_unsigned_integer(&it)) return CborErrorImproperValue; + return advance_if_ok(it, cbor_value_get_uint64(&it, &out)); +} +inline CborError decode_cbor(CborValue& it, double& out) { + if (cbor_value_is_double(&it)) { + return advance_if_ok(it, cbor_value_get_double(&it, &out)); + } + if (cbor_value_is_float(&it)) { + float f = 0.0f; + CborError err = cbor_value_get_float(&it, &f); + if (err) return err; + out = static_cast(f); + return cbor_value_advance(&it); + } + return CborErrorImproperValue; +} +inline CborError decode_cbor(CborValue& it, std::string& out) { + if (!cbor_value_is_text_string(&it)) return CborErrorImproperValue; + size_t len = 0; + CborError err = cbor_value_get_string_length(&it, &len); + if (err) return err; + out.resize(len); + return advance_if_ok( + it, cbor_value_copy_text_string(&it, out.empty() ? nullptr : &out[0], &len, nullptr)); +} + +template +inline CborError decode_cbor(CborValue& it, std::vector& out) { + if (!cbor_value_is_array(&it)) return CborErrorImproperValue; + size_t len = 0; + CborError err = cbor_value_get_array_length(&it, &len); + if (err) return err; + out.clear(); + out.resize(len); + CborValue inner; + err = cbor_value_enter_container(&it, &inner); + if (err) return err; + for (size_t i = 0; i < len; ++i) { + err = decode_cbor(inner, out[i]); + if (err) return err; + } + return cbor_value_leave_container(&it, &inner); +} + +// Counterpart to the byte-string encoder above: decode a CBOR byte string +// (major type 2) back into std::vector. +inline CborError decode_cbor(CborValue& it, std::vector& out) { + if (!cbor_value_is_byte_string(&it)) return CborErrorImproperValue; + size_t len = 0; + CborError err = cbor_value_get_string_length(&it, &len); + if (err) return err; + out.resize(len); + return advance_if_ok( + it, cbor_value_copy_byte_string(&it, out.empty() ? nullptr : out.data(), &len, nullptr)); +} + +template +inline CborError decode_cbor(CborValue& it, std::optional& out) { + if (cbor_value_is_null(&it)) { + out = std::nullopt; + return cbor_value_advance(&it); + } + T tmp{}; + CborError err = decode_cbor(it, tmp); + if (err) return err; + out = std::move(tmp); + return CborNoError; +} + +// ── Public entry points ───────────────────────────────────────────────── + +template +inline Result> encodeCborFFI(const T& value) { + // Start with a generous 4 KiB buffer; double on overflow until it fits. + std::vector buf(4096); + while (true) { + CborEncoder enc; + cbor_encoder_init(&enc, buf.data(), buf.size(), 0); + CborError err = encode_cbor(enc, value); + if (err == CborNoError) { + const size_t used = cbor_encoder_get_buffer_size(&enc, buf.data()); + buf.resize(used); + return Result>::ok(std::move(buf)); + } + if (err == CborErrorOutOfMemory) { + const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc); + buf.resize(buf.size() + (extra > 0 ? extra : buf.size())); + continue; + } + return Result>::err( + std::string("FFI CBOR encode failed: ") + cbor_error_string(err)); + } +} + +template +inline Result decodeCborFFI(const std::vector& bytes) { + CborParser parser; + CborValue it; + CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it); + if (err != CborNoError) { + return Result::err(std::string("FFI CBOR parse init failed: ") + + cbor_error_string(err)); + } + T out{}; + err = decode_cbor(it, out); + if (err != CborNoError) { + return Result::err(std::string("FFI CBOR decode failed: ") + + cbor_error_string(err)); + } + return Result::ok(std::move(out)); +} + +#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED + +// ============================================================ +// User-declared FFI types +// ============================================================ + +struct SkeletonConfig { + std::string greeting; +}; +inline CborError encode_cbor(CborEncoder& e, const SkeletonConfig& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "greeting"); if (err) return err; + err = encode_cbor(m, v.greeting); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, SkeletonConfig& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "greeting", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.greeting); if (err) return err; + return cbor_value_advance(&it); +} + +struct HelloRequest { + std::string name; +}; +inline CborError encode_cbor(CborEncoder& e, const HelloRequest& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "name"); if (err) return err; + err = encode_cbor(m, v.name); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, HelloRequest& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "name", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.name); if (err) return err; + return cbor_value_advance(&it); +} + +struct HelloResponse { + std::string message; +}; +inline CborError encode_cbor(CborEncoder& e, const HelloResponse& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "message"); if (err) return err; + err = encode_cbor(m, v.message); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, HelloResponse& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "message", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.message); if (err) return err; + return cbor_value_advance(&it); +} + +struct HelloEvent { + std::string name; +}; +inline CborError encode_cbor(CborEncoder& e, const HelloEvent& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "name"); if (err) return err; + err = encode_cbor(m, v.name); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, HelloEvent& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "name", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.name); if (err) return err; + return cbor_value_advance(&it); +} + +// ============================================================ +// Per-proc request envelopes (CBOR encoded on the wire) +// ============================================================ + +struct SkeletonCreateCtorReq { + SkeletonConfig config; +}; +inline CborError encode_cbor(CborEncoder& e, const SkeletonCreateCtorReq& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "config"); if (err) return err; + err = encode_cbor(m, v.config); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, SkeletonCreateCtorReq& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "config", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.config); if (err) return err; + return cbor_value_advance(&it); +} + +struct SkeletonHelloReq { + HelloRequest req; +}; +inline CborError encode_cbor(CborEncoder& e, const SkeletonHelloReq& v) { + CborEncoder m; + CborError err = cbor_encoder_create_map(&e, &m, 1); + if (err) return err; + err = cbor_encode_text_stringz(&m, "req"); if (err) return err; + err = encode_cbor(m, v.req); if (err) return err; + return cbor_encoder_close_container(&e, &m); +} +inline CborError decode_cbor(CborValue& it, SkeletonHelloReq& v) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + CborValue field; + CborError err; + err = cbor_value_map_find_value(&it, "req", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.req); if (err) return err; + return cbor_value_advance(&it); +} + +// ============================================================ +// C FFI declarations +// ============================================================ + +extern "C" { +typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data); + +void* skeleton_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +int skeleton_hello(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int skeleton_destroy(void* ctx); +uint64_t skeleton_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); +int skeleton_remove_event_listener(void* ctx, uint64_t listener_id); +} // extern "C" + +// ============================================================ +// Synchronous call helper +// ============================================================ +// Guarded so two nim-ffi headers can share a translation unit. +#ifndef NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED +#define NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED + +namespace { + +struct FFICallState_ { + std::mutex mtx; + std::condition_variable cv; + bool done{false}; + bool ok{false}; + std::vector bytes; + std::string err; +}; + +inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) { + // ffi_call_ heap-allocated a shared_ptr and passed its address as ud; + // take ownership here so it's freed on every exit path. + std::unique_ptr> handle( + static_cast*>(ud)); + FFICallState_& s = **handle; + + std::lock_guard lock(s.mtx); + s.ok = (ret == 0); + if (msg && len > 0) { + const auto* p = reinterpret_cast(msg); + if (s.ok) s.bytes.assign(p, p + len); + else s.err.assign(msg, len); + } + s.done = true; + s.cv.notify_one(); +} + +inline Result> ffi_call_( + std::function f, + std::chrono::milliseconds timeout) { + using Bytes = std::vector; + auto state = std::make_shared(); + auto* cb_ref = new std::shared_ptr(state); + const int ret = f(ffi_cb_, cb_ref); + if (ret == 2) { + delete cb_ref; + return Result::err("RET_MISSING_CALLBACK (internal error)"); + } + std::unique_lock lock(state->mtx); + const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; }); + if (!fired) + return Result::err("FFI call timed out after " + + std::to_string(timeout.count()) + "ms"); + if (!state->ok) + return Result::err(state->err); + return Result::ok(std::move(state->bytes)); +} + +} // anonymous namespace + +#endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED + +// ============================================================ +// High-level C++ context class +// ============================================================ + +class SkeletonCtx { +public: + static Result> create(const SkeletonConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + const auto ffi_req_ = SkeletonCreateCtorReq{config}; + auto ffi_enc_ = encodeCborFFI(ffi_req_); + if (ffi_enc_.isErr()) return Result>::err(ffi_enc_.error()); + const auto& ffi_req_bytes_ = ffi_enc_.value(); + auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + (void)skeleton_create(ffi_req_bytes_.data(), ffi_req_bytes_.size(), cb, ud); + return 0; + }, timeout); + if (ffi_raw_.isErr()) return Result>::err(ffi_raw_.error()); + auto ffi_addr_ = decodeCborFFI(ffi_raw_.value()); + if (ffi_addr_.isErr()) return Result>::err(ffi_addr_.error()); + const auto& addr_str = ffi_addr_.value(); + std::uint64_t addr = 0; + const char* addr_begin = addr_str.data(); + const char* addr_end = addr_begin + addr_str.size(); + const auto fc_ = std::from_chars(addr_begin, addr_end, addr); + if (fc_.ec != std::errc() || fc_.ptr != addr_end) { + return Result>::err("FFI create returned non-numeric address: " + addr_str); + } + return Result>::ok(std::unique_ptr(new SkeletonCtx(reinterpret_cast(static_cast(addr)), timeout))); + } + + static std::future>> createAsync(const SkeletonConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); + } + + // Special-member policy: this class owns a skeleton context, which in + // turn owns the library's worker thread(s) and internal state. Moving + // such an object out from under a caller silently tears that state + // down and is easy to misuse (e.g. storing in a container that + // relocates its elements). It also has no clean analogue in the other + // binding languages we generate. So copies and moves are both + // deleted; ownership is transferred via SkeletonCtx::create returning a + // std::unique_ptr. The destructor still releases the + // context. + ~SkeletonCtx() { + if (ptr_) { + skeleton_destroy(ptr_); + ptr_ = nullptr; + } + } + + SkeletonCtx(const SkeletonCtx&) = delete; + SkeletonCtx& operator=(const SkeletonCtx&) = delete; + SkeletonCtx(SkeletonCtx&&) = delete; + SkeletonCtx& operator=(SkeletonCtx&&) = delete; + + // ── Event listener API ────────────────────────────────── + struct ListenerHandle { std::uint64_t id = 0; }; + + ListenerHandle addOnHelloListener(std::function handler) { + auto owned = std::make_unique>(std::move(handler)); + auto* raw = owned.get(); + const auto id = skeleton_add_event_listener( + ptr_, "on_hello", &SkeletonCtx::typedTrampoline, raw); + if (id == 0) return ListenerHandle{0}; + listeners_.emplace(id, std::move(owned)); + return ListenerHandle{id}; + } + + bool removeEventListener(ListenerHandle handle) { + if (handle.id == 0) return false; + const auto rc = skeleton_remove_event_listener(ptr_, handle.id); + listeners_.erase(handle.id); + return rc == 0; + } + + Result hello(const HelloRequest& req) const { + const auto ffi_req_ = SkeletonHelloReq{req}; + auto ffi_enc_ = encodeCborFFI(ffi_req_); + if (ffi_enc_.isErr()) return Result::err(ffi_enc_.error()); + const auto& ffi_req_bytes_ = ffi_enc_.value(); + auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + return skeleton_hello(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); + }, timeout_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); + } + + std::future> helloAsync(const HelloRequest& req) const { + return std::async(std::launch::async, [this, req]() { return this->hello(req); }); + } + +private: + struct ListenerBase { + virtual ~ListenerBase() = default; + }; + + template + struct TypedListener : ListenerBase { + std::function fn; + explicit TypedListener(std::function f) : fn(std::move(f)) {} + }; + + template + static void typedTrampoline(int ret, const char* msg, std::size_t len, void* ud) { + if (!ud || ret != 0 || !msg || len == 0) return; + auto* listener = static_cast*>(ud); + if (!listener->fn) return; + CborParser parser; CborValue it; + if (cbor_parser_init(reinterpret_cast(msg), len, 0, &parser, &it) != CborNoError) return; + if (!cbor_value_is_map(&it)) return; + CborValue payloadField; + if (cbor_value_map_find_value(&it, "payload", &payloadField) != CborNoError) return; + T payload{}; + if (decode_cbor(payloadField, payload) != CborNoError) return; + listener->fn(payload); + } + + void* ptr_; + std::chrono::milliseconds timeout_; + std::unordered_map> listeners_; + explicit SkeletonCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {} +}; diff --git a/examples/skeleton/skeleton.nim b/examples/skeleton/skeleton.nim new file mode 100644 index 0000000..77e2984 --- /dev/null +++ b/examples/skeleton/skeleton.nim @@ -0,0 +1,61 @@ +## Minimal nim-ffi library template. Copy this directory, rename every +## `skeleton` / `Skeleton` to your library's name (see README.md), and build +## from here instead of reverse-engineering the feature-tour examples/timer/. +## It wires up the four pieces every nim-ffi library needs — a constructor, one +## async method, a library-initiated event, and a destructor — and nothing else. + +import ffi, chronos + +# The FFI context owns exactly one of these, built by the {.ffiCtor.} and torn +# down by the {.ffiDtor.} below. +type Skeleton = object + greeting: string # set at creation, read back in each response + +# Names the library and its state object, and picks the wire format every +# {.ffi.} / {.ffiEvent.} inherits ("cbor" default; pass defaultABIFormat = "c" +# for the CBOR-free flat-struct ABI). Must precede every FFI annotation below. +declareLibrary("skeleton", Skeleton) + +# Types crossing the boundary are plain objects annotated {.ffi.}; the generator +# emits a matching struct/class on the foreign side for each. +type SkeletonConfig {.ffi.} = object + greeting: string + +type HelloRequest {.ffi.} = object + name: string + +type HelloResponse {.ffi.} = object + message: string + +type HelloEvent {.ffi.} = object + name: string + +# A library-initiated event: call `onHello(...)` from any {.ffi.} handler to +# dispatch it to the foreign side's callback. The wire name (`on_hello`) is +# derived from the proc name; pass a string literal to override it. +proc onHello*(evt: HelloEvent) {.ffiEvent.} + +# The constructor, called once from the foreign side. `err(msg)` here surfaces a +# construction failure; the async body may `await`. +proc skeletonCreate*( + config: SkeletonConfig +): Future[Result[Skeleton, string]] {.ffiCtor.} = + ok(Skeleton(greeting: config.greeting)) + +# A {.ffi.} method takes the state object first, may `await`, and returns +# Future[Result[T, string]]. +proc skeletonHello*( + skeleton: Skeleton, req: HelloRequest +): Future[Result[HelloResponse, string]] {.ffi.} = + await sleepAsync(1.milliseconds) + onHello(HelloEvent(name: req.name)) + ok(HelloResponse(message: skeleton.greeting & ", " & req.name & "!")) + +proc skeleton_destroy*(skeleton: Skeleton) {.ffiDtor.} = + discard + +# genBindings() must be the LAST top-level call: each pragma above registers its +# proc/type into a compile-time registry that genBindings() reads to emit the +# bindings, so anything declared after it is silently missing. No-op unless +# -d:ffiGenBindings is set. +genBindings() diff --git a/examples/skeleton/skeleton.nimble b/examples/skeleton/skeleton.nimble new file mode 100644 index 0000000..0d1f16d --- /dev/null +++ b/examples/skeleton/skeleton.nimble @@ -0,0 +1,22 @@ +version = "0.1.0" +packageName = "skeleton" +author = "Institute of Free Technology" +description = "Minimal nim-ffi library template — copy-rename to start a new lib" +license = "MIT or Apache License 2.0" + +requires "nim >= 2.2.6" +requires "chronos" +requires "chronicles" +requires "taskpools" +requires "https://github.com/logos-messaging/nim-ffi >= 0.2.0" + +const nimFlags = "--mm:orc -d:chronicles_log_level=WARN" + +task build, "Compile the skeleton library": + exec "nim c " & nimFlags & + " --app:lib --noMain --nimMainPrefix:libskeleton skeleton.nim" + +task genbindings_cpp, "Generate C++ bindings for the skeleton example": + exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libskeleton" & + " -d:ffiGenBindings -d:targetLang=cpp" & " -d:ffiOutputDir=cpp_bindings" & + " -d:ffiSrcPath=skeleton.nim" & " -o:/dev/null skeleton.nim" diff --git a/ffi.nimble b/ffi.nimble index 7988a8f..1344ae2 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -232,6 +232,16 @@ task genbindings_cpp, "Generate C++ bindings for the timer example": " -d:ffiOutputDir=examples/timer/cpp_bindings" & " -d:ffiSrcPath=../timer.nim" & " -o:/dev/null examples/timer/timer.nim" +task genbindings_cpp_skeleton, "Generate C++ bindings for the skeleton example": + exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libskeleton" & + " -d:ffiGenBindings -d:targetLang=cpp" & + " -d:ffiOutputDir=examples/skeleton/cpp_bindings" & " -d:ffiSrcPath=../skeleton.nim" & + " -o:/dev/null examples/skeleton/skeleton.nim" + exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libskeleton" & + " -d:ffiGenBindings -d:targetLang=cpp" & + " -d:ffiOutputDir=examples/skeleton/cpp_bindings" & " -d:ffiSrcPath=../skeleton.nim" & + " -o:/dev/null examples/skeleton/skeleton.nim" + task genbindings_cpp_echo, "Generate C++ bindings for the echo example": exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" & " -d:ffiGenBindings -d:targetLang=cpp" & @@ -296,8 +306,15 @@ task check_bindings_c_abi, "Verify checked-in abi=c C bindings match Nim source" exec "git diff --exit-code --" & " examples/echo/c_abi_bindings/echo.h" & " examples/echo/c_abi_bindings/CMakeLists.txt" +task check_bindings_skeleton, + "Verify checked-in skeleton template bindings match Nim source": + exec "nimble genbindings_cpp_skeleton" + exec "git diff --exit-code --" & " examples/skeleton/cpp_bindings/skeleton.hpp" & + " examples/skeleton/cpp_bindings/CMakeLists.txt" + task check_bindings, "Verify all checked-in example bindings match Nim source": exec "nimble check_bindings_rust" exec "nimble check_bindings_cpp" exec "nimble check_bindings_c" exec "nimble check_bindings_c_abi" + exec "nimble check_bindings_skeleton"