diff --git a/CHANGELOG.md b/CHANGELOG.md index 9519f75..a4f9c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,17 @@ All notable changes to this project are documented in this file. thread can be freed on the FFI thread — the cross-thread ownership the CBOR-free request path relies on, and consistent with the libc-backed request envelope. +- The generated C `_ctx_destroy()` now returns `int` instead of `void`, + propagating the exported `_destroy()` status code (`NIMFFI_RET_OK` on + success, `RET_ERR` on a null/invalid context or a failed context teardown) + instead of discarding it, so a host can observe a failed teardown. Existing + callers that invoke it as a statement are unaffected + ([#133](https://github.com/logos-messaging/nim-ffi/issues/133)). +- A failed `_ctx_destroy()` no longer frees the event-listener boxes. A + non-`NIMFFI_RET_OK` teardown leaves the worker threads live, and they still + hold each box as callback `user_data`; the boxes are now leaked rather than + freed out from under a running event thread. The context struct and the + listener array are still freed unconditionally. - User event callbacks now run on a dedicated event thread fed by a bounded SPSC queue (default capacity 1024), so a slow listener can no longer block the FFI thread or concurrent `add_event_listener` / @@ -35,10 +46,29 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### Added +- `{.ffi.}` now accepts an `enum` type, emitting a native enum in every target + (C `enum`, C++ `enum class`, Rust enum, CDDL string choice). Values cross the + wire as the text `$value` yields — the associated string if declared, else the + symbol name — matching what `cbor_serialization` writes. Enums are supported + on the CBOR wire only; reaching one from an `abi = c` type or proc is now a + compile error naming the type, where it previously registered as a + fieldless struct and silently dropped the value. +- `{.ffiConst.}` exposes a Nim `const` to every generated binding as a native + constant (`static const` in C, `constexpr` in C++, `pub const` in Rust). + Integer, float, `bool` and `string` values are supported, computed + expressions arrive folded, and names are re-cased to `UPPER_SNAKE`. - `{.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 C export symbol. Pass a string literal only to override it. +- Doc comments (`##`) on `{.ffi.}` / `{.ffiCtor.}` / `{.ffiDtor.}` procs are now + propagated to the generated bindings — `/** ... */` on the C declarations, + `///` on the C++ class methods and Rust `pub fn`s, and `;` comments in the + CDDL schema — so the exported API is documented once, in the Nim source + ([#127](https://github.com/logos-messaging/nim-ffi/issues/127)). Editing a + `##` comment now changes the generated bindings, so `nimble check_bindings` + flags them stale until regenerated; an undocumented proc still generates + byte-identical output. - FFI annotations (`{.ffi.}`, `{.ffiCtor.}`, `{.ffiDtor.}`, `{.ffiEvent.}`, `{.ffiHandle.}`, `{.ffiRaw.}`) that expand after `genBindings()` now produce a loud compile error instead of being silently dropped from the generated diff --git a/README.md b/README.md index 00637cd..a704df1 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ type Counter = object declareLibrary("counter", Counter) -# 2. Request/response shapes. Any {.ffi.} object type becomes a first-class -# struct/class in the generated bindings and rides the wire in the library's -# ABI format (CBOR by default). +# 2. Request/response shapes. Any {.ffi.} object or enum type becomes a +# first-class struct/class in the generated bindings and rides the wire in +# the library's ABI format (CBOR by default). type BumpRequest {.ffi.} = object by: int @@ -76,14 +76,98 @@ The generated C export names are the snake_case form of the proc names, e.g. | Pragma | Applies to | Purpose | | --- | --- | --- | | `declareLibrary(name, LibType[, defaultABIFormat])` | call | Registers the library, its state type, and the default wire format. Must run before any annotation. | -| `{.ffi.}` on a `type` | `object` | Registers the type for binding generation; it serializes via the library's ABI format (CBOR by default). | +| `{.ffi.}` on a `type` | `object`, `enum` | Registers the type for binding generation; it serializes via the library's ABI format (CBOR by default). Enums are CBOR-only — see below. | | `{.ffi.}` on a `proc` | proc | Exposes a method. First param is the library value, then typed params; returns `Future[Result[T, string]]`. | | `{.ffiCtor.}` | proc | The constructor. Returns `Future[Result[LibType, string]]`; creates the FFI context. | | `{.ffiDtor.}` | proc | The destructor. Exactly one param `(x: LibType)`; tears the context down. | | `{.ffiEvent[: "wire_name"].}` | proc (empty body) | A library-initiated callback. Call the proc from any `{.ffi.}` handler to fire it. The wire name is optional — see below. | | `{.ffiHandle.}` | `ref object` | Marks a type as an opaque handle: it stays server-side and crosses the wire as a `uint64` id. | +| `{.ffiConst.}` | `const` | Re-emits the value as a native constant in every generated binding — see below. | | `genBindings()` | call | Emits the bindings. Must be the **last** FFI call in the compilation root. | +### Enums + +`{.ffi.}` on an `enum` gives each language its own native enum: + +```nim +type Level {.ffi.} = enum + lLow = "low" + lHigh = "high" +``` + +```c +typedef enum { LEVEL_L_LOW = 0, LEVEL_L_HIGH = 1 } Level; /* C */ +enum class Level { lLow = 0, lHigh = 1 }; // C++ +pub enum Level { #[serde(rename = "low")] LLow, ... } // Rust +``` + +An enum crosses the wire as **text**, not as its ordinal — whatever `$value` +yields, so the associated string if the enum declares one (`"low"` above) and +the symbol name otherwise (`lLow`). That is what `cbor_serialization` writes on +the Nim side, and the generated codecs map name ↔ value on the far side, so +reordering or renumbering values doesn't break an already-deployed peer. +Explicit ordinals are carried into the foreign enum so the two sides agree if +you ever cast. + +Enums are supported on the CBOR wire only. Reaching one from an `abi = c` type +or proc is a compile error naming the type — the `abi = c` `_CWire` structs have +no enum form yet. + +### Constants + +`{.ffiConst.}` copies a Nim `const` into the generated bindings, so callers +don't hand-maintain a second copy of a limit, a default or a protocol string: + +```nim +const + MaxPeers* {.ffiConst.} = 42 + DefaultTimeoutMs* {.ffiConst.}: uint32 = 3 * 1000 + Greeting* {.ffiConst.} = "hello" +``` + +```c +static const int64_t MAX_PEERS = 42LL; /* C */ +static const uint32_t DEFAULT_TIMEOUT_MS = 3000; +static const char* const GREETING = "hello"; +``` + +```cpp +constexpr int64_t MAX_PEERS = 42LL; // C++ +``` + +```rust +pub const MAX_PEERS: i64 = 42; // Rust +``` + +Integer, float, `bool` and `string` consts are supported; anything else is a +compile error. The value is whatever the const evaluates to, so computed +expressions arrive folded. Names are re-cased to `UPPER_SNAKE`, preserving +acronyms (`httpTTL` → `HTTP_TTL`). A constant is a compile-time value in each +language, not a symbol exported by the shared library — it never crosses the +wire, so `{.ffiConst.}` is ABI-agnostic. + +### Doc comments + +A `##` doc comment on an annotated proc is carried through to every generated +binding, so the exported API is documented once, at the source: + +```nim +proc myTimerEcho*( + timer: MyTimer, req: EchoRequest +): Future[Result[EchoResponse, string]] {.ffi.} = + ## Sleeps `delayMs` then echoes the message back. + ... +``` + +becomes ``/** Sleeps `delayMs` then echoes the message back. */`` above both the +exported symbol and the `_ctx_echo` wrapper in the C header, `/// ...` on +the C++ class method and the Rust `pub fn`, and a `;` comment in the CDDL +schema. Multi-line doc comments are preserved as-is. + +Only `##` doc comments are propagated, and only on procs — a plain `#` comment +above the proc, and any comment on a `{.ffi.}` type or its fields, is dropped by +Nim's parser before the macro can see it. + ### The return-type contract Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit @@ -160,6 +244,8 @@ header shape from the library's ABI format. It carries two honest limits today: - **Events are CBOR-only.** Applying `abi = c` to an `{.ffiEvent.}` proc is a hard compile error; declare events with `abi = cbor` (they ride CBOR internally regardless of the library default). +- **Enums are CBOR-only.** A `{.ffi.}` enum in an `abi = c` library, or reached + from an `abi = c` type or proc, is a hard compile error naming the type. - **All-scalar `abi = c` procs bind only in the `abi = c` C header.** A `{.ffi: "abi = c".}` method whose params and return are all scalars — ints, floats, bools; a `string` return is fine, a `string` param is not — takes a diff --git a/examples/echo/c_abi_bindings/echo.h b/examples/echo/c_abi_bindings/echo.h index def7d56..27a821c 100644 --- a/examples/echo/c_abi_bindings/echo.h +++ b/examples/echo/c_abi_bindings/echo.h @@ -14,6 +14,12 @@ terminal RET_OK/RET_ERR. Ignore it unless you want progress. */ #define NIMFFI_RET_STALE_WARN 3 +/* ============================================================ */ +/* Generated constants */ +/* ============================================================ */ + +static const int64_t MAX_SHOUT_LEN = 512; + /* `abi = c` wire structs — the C ABI. Strings are borrowed, NUL-terminated `const char*` valid only for the duration of the call they cross. */ typedef struct { @@ -58,9 +64,13 @@ static inline char* nimffi_abi_dup_cstr_n(const char* s, size_t n) { extern "C" { #endif +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req); +/** Returns the library's version string. */ int echo_version(void* ctx, EchoScalarRawFn callback, void* user_data); +/** Releases the echo context. */ int echo_destroy(void* ctx); #ifdef __cplusplus @@ -103,6 +113,7 @@ static void echo_create_trampoline(int ret, const char* ctx_addr, const char* er free(box); } +/** Creates an echo context that prefixes every reply with `config.prefix`. */ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) { EchoCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -118,12 +129,16 @@ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_crea return 0; } -static inline void echo_ctx_destroy(EchoCtx* ctx) { - if (!ctx) return; - if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; } +/** Releases the echo context. */ +static inline int echo_ctx_destroy(EchoCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = echo_destroy(ctx->ptr); ctx->ptr = NULL; } free(ctx); + return rc; } +/** Upper-cases `req.text` and returns it behind the context's prefix. */ static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) { EchoShoutReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -154,6 +169,7 @@ static void echo_version_scalar_reply(int caller_ret, char* msg, size_t len, voi free(reply); } +/** Returns the library's version string. */ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_reply, void* user_data) { EchoVersionScalarBox* box = (EchoVersionScalarBox*)malloc(sizeof(EchoVersionScalarBox)); if (!box) { diff --git a/examples/echo/c_bindings/echo.h b/examples/echo/c_bindings/echo.h index 5e3daa3..4fa7f11 100644 --- a/examples/echo/c_bindings/echo.h +++ b/examples/echo/c_bindings/echo.h @@ -2,6 +2,12 @@ #define NIM_FFI_LIB_ECHO_H_INCLUDED #include "nim_ffi_cbor.h" +/* ============================================================ */ +/* Generated constants */ +/* ============================================================ */ + +static const int64_t MAX_SHOUT_LEN = 512; + /* ============================================================ */ /* Generated types (user-declared + per-proc request envelopes) */ /* ============================================================ */ @@ -193,9 +199,13 @@ static inline CborError echo_dec_EchoVersionReq( extern "C" { #endif +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int echo_remove_event_listener(void* ctx, uint64_t listener_id); @@ -264,6 +274,7 @@ static void echo_create_trampoline(int ret, const char* msg, size_t len, void* u free(box); } +/** Creates an echo context that prefixes every reply with `config.prefix`. */ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) { EchoCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -289,10 +300,13 @@ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_crea return 0; } -static inline void echo_ctx_destroy(EchoCtx* ctx) { - if (!ctx) return; - if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; } +/** Releases the echo context. */ +static inline int echo_ctx_destroy(EchoCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = echo_destroy(ctx->ptr); ctx->ptr = NULL; } free(ctx); + return rc; } typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data); @@ -327,6 +341,7 @@ static void echo_shout_reply_trampoline(int ret, const char* msg, size_t len, vo echo_free_ShoutResponse(&out); free(box); } +/** Upper-cases `req.text` and returns it behind the context's prefix. */ static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) { EchoShoutReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -389,6 +404,7 @@ static void echo_version_reply_trampoline(int ret, const char* msg, size_t len, nimffi_free_str(&out); free(box); } +/** Returns the library's version string. */ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_reply, void* user_data) { EchoVersionReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index 4c47cf3..db98b76 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -292,6 +292,12 @@ inline Result decodeCborFFI(const std::vector& bytes) { #endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED +// ============================================================ +// Generated constants +// ============================================================ + +constexpr int64_t MAX_SHOUT_LEN = 512; + // ============================================================ // User-declared FFI types // ============================================================ @@ -431,9 +437,13 @@ inline CborError decode_cbor(CborValue& it, EchoVersionReq&) { extern "C" { typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data); +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int echo_remove_event_listener(void* ctx, uint64_t listener_id); @@ -512,6 +522,7 @@ inline Result> ffi_call_( class EchoCtx { public: + /// Creates an echo context that prefixes every reply with `config.prefix`. static Result> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = EchoCreateCtorReq{config}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -535,6 +546,7 @@ public: return Result>::ok(std::unique_ptr(new EchoCtx(reinterpret_cast(static_cast(addr)), timeout))); } + /// Creates an echo context that prefixes every reply with `config.prefix`. static std::future>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -560,6 +572,7 @@ public: EchoCtx(EchoCtx&&) = delete; EchoCtx& operator=(EchoCtx&&) = delete; + /// Upper-cases `req.text` and returns it behind the context's prefix. Result shout(const ShoutRequest& req) const { const auto ffi_req_ = EchoShoutReq{req}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -572,10 +585,12 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Upper-cases `req.text` and returns it behind the context's prefix. std::future> shoutAsync(const ShoutRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->shout(req); }); } + /// Returns the library's version string. Result version() const { const auto ffi_req_ = EchoVersionReq{}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -588,6 +603,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Returns the library's version string. std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim index cb689f1..991e370 100644 --- a/examples/echo/echo.nim +++ b/examples/echo/echo.nim @@ -12,6 +12,9 @@ when defined(ffiEchoAbiC): else: declareLibrary("echo", Echo) +# Constants never cross the wire, so {.ffiConst.} lands in the abi = c header too. +const MaxShoutLen* {.ffiConst.} = 512 + type EchoConfig {.ffi.} = object prefix: string @@ -23,20 +26,26 @@ type ShoutResponse {.ffi.} = object prefix: string proc echoCreate*(config: EchoConfig): Future[Result[Echo, string]] {.ffiCtor.} = + ## Creates an echo context that prefixes every reply with `config.prefix`. await sleepAsync(1.milliseconds) return ok(Echo(prefix: config.prefix)) proc echoShout*( e: Echo, req: ShoutRequest ): Future[Result[ShoutResponse, string]] {.ffi.} = + ## Upper-cases `req.text` and returns it behind the context's prefix. + if req.text.len > MaxShoutLen: + return err("text must not exceed " & $MaxShoutLen & " bytes") await sleepAsync(1.milliseconds) let upper = req.text.toUpperAscii return ok(ShoutResponse(shouted: e.prefix & ": " & upper, prefix: e.prefix)) proc echoVersion*(e: Echo): Future[Result[string, string]] {.ffi.} = + ## Returns the library's version string. return ok("nim-echo v0.1.0") proc echo_destroy*(e: Echo) {.ffiDtor.} = + ## Releases the echo context. discard genBindings() diff --git a/examples/timer/c_bindings/main.c b/examples/timer/c_bindings/main.c index 0c73bbd..a68ecd7 100644 --- a/examples/timer/c_bindings/main.c +++ b/examples/timer/c_bindings/main.c @@ -116,7 +116,8 @@ static void on_schedule(int ec, const ScheduleResult* reply, const char* em, voi w->err_code = ec; if (reply) { w->num_a = (long long)reply->willRunCount; - w->num_b = (long long)reply->firstRunAtMs; + w->num_b = (long long)reply->effectiveBackoffMs; + w->flag = (int)reply->priority; if (reply->jobId.data) snprintf(w->text_a, sizeof(w->text_a), "%s", reply->jobId.data); } @@ -160,6 +161,9 @@ int main(void) { RUN(my_timer_ctx_version(ctx, on_version, &w), w); printf("[2] Version: %s\n", w.text_a); + printf("[2b] Header consts: TIMER_VERSION=%s, MAX_DELAY_MS=%lld\n", TIMER_VERSION, + (long long)MAX_DELAY_MS); + EchoRequest echo_req = {nimffi_str("hello from C"), 50}; RUN(my_timer_ctx_echo(ctx, &echo_req, on_echo, &w), w); printf("[3] Echo: echoed=%s, timerName=%s\n", w.text_a, w.text_b); @@ -188,7 +192,7 @@ int main(void) { job.name = nimffi_str("nightly-rollup"); job.payload.data = job_payload; job.payload.len = 2; - job.priority = 10; + job.priority = JOB_PRIORITY_JP_HIGH; NimFfiStr retry_on[2] = {nimffi_str("timeout"), nimffi_str("5xx")}; RetryPolicy retry; @@ -204,8 +208,9 @@ int main(void) { schedule.jitter.value = 250; RUN(my_timer_ctx_schedule(ctx, &job, &retry, &schedule, on_schedule, &w), w); - printf("[5] Schedule: jobId=%s, willRunCount=%lld, firstRunAtMs=%lld\n", - w.text_a, w.num_a, w.num_b); + printf("[5] Schedule: jobId=%s, willRunCount=%lld, effectiveBackoffMs=%lld, " + "priority=%d\n", + w.text_a, w.num_a, w.num_b, w.flag); uint64_t handle = my_timer_ctx_add_on_echo_fired_listener(ctx, on_echo_fired, NULL); diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index 8079020..470d84c 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -2,6 +2,14 @@ #define NIM_FFI_LIB_MY_TIMER_H_INCLUDED #include "nim_ffi_cbor.h" +/* ============================================================ */ +/* Generated constants */ +/* ============================================================ */ + +static const int64_t MAX_DELAY_MS = 5000; +static const uint32_t DEFAULT_BACKOFF_MS = 250; +static const char* const TIMER_VERSION = "nim-timer v0.1.0"; + /* ============================================================ */ /* Generated types (user-declared + per-proc request envelopes) */ /* ============================================================ */ @@ -48,10 +56,15 @@ typedef struct { NimFfiStr message; int64_t echoCount; } EchoEvent; +typedef enum { + JOB_PRIORITY_JP_LOW = 0, + JOB_PRIORITY_JP_NORMAL = 1, + JOB_PRIORITY_JP_HIGH = 2 +} JobPriority; typedef struct { NimFfiStr name; MyTimerSeq_Str payload; - int64_t priority; + JobPriority priority; } JobSpec; typedef struct { int64_t maxAttempts; @@ -68,6 +81,7 @@ typedef struct { int64_t willRunCount; int64_t firstRunAtMs; int64_t effectiveBackoffMs; + JobPriority priority; } ScheduleResult; typedef struct { TimerConfig config; @@ -431,6 +445,32 @@ static inline void my_timer_free_EchoEvent(EchoEvent* v) { if (!v) return; nimffi_free_str(&v->message); } +static inline CborError my_timer_enc_JobPriority( + CborEncoder* e, const JobPriority* v) { + switch (*v) { + case JOB_PRIORITY_JP_LOW: return cbor_encode_text_stringz(e, "low"); + case JOB_PRIORITY_JP_NORMAL: return cbor_encode_text_stringz(e, "normal"); + case JOB_PRIORITY_JP_HIGH: return cbor_encode_text_stringz(e, "high"); + } + return CborErrorImproperValue; +} +static inline CborError my_timer_dec_JobPriority( + CborValue* it, JobPriority* 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; + char buf[7]; + if (len >= sizeof(buf)) return CborErrorImproperValue; + size_t copied = sizeof(buf); + err = cbor_value_copy_text_string(it, buf, &copied, NULL); + if (err) return err; + buf[len] = '\0'; + if (strcmp(buf, "low") == 0) { *out = JOB_PRIORITY_JP_LOW; return cbor_value_advance(it); } + if (strcmp(buf, "normal") == 0) { *out = JOB_PRIORITY_JP_NORMAL; return cbor_value_advance(it); } + if (strcmp(buf, "high") == 0) { *out = JOB_PRIORITY_JP_HIGH; return cbor_value_advance(it); } + return CborErrorImproperValue; +} static inline CborError my_timer_enc_JobSpec( CborEncoder* e, const JobSpec* v) { CborEncoder m; @@ -446,7 +486,7 @@ static inline CborError my_timer_enc_JobSpec( if (err) return err; err = cbor_encode_text_stringz(&m, "priority"); if (err) return err; - err = nimffi_enc_i64(&m, &v->priority); + err = my_timer_enc_JobPriority(&m, &v->priority); if (err) return err; return cbor_encoder_close_container(e, &m); } @@ -468,7 +508,7 @@ static inline CborError my_timer_dec_JobSpec( err = cbor_value_map_find_value(it, "priority", &field); if (err) return err; if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; - err = nimffi_dec_i64(&field, &out->priority); + err = my_timer_dec_JobPriority(&field, &out->priority); if (err) return err; return cbor_value_advance(it); } @@ -566,7 +606,7 @@ static inline CborError my_timer_dec_ScheduleConfig( static inline CborError my_timer_enc_ScheduleResult( CborEncoder* e, const ScheduleResult* v) { CborEncoder m; - CborError err = cbor_encoder_create_map(e, &m, 4); + CborError err = cbor_encoder_create_map(e, &m, 5); if (err) return err; err = cbor_encode_text_stringz(&m, "jobId"); if (err) return err; @@ -584,6 +624,10 @@ static inline CborError my_timer_enc_ScheduleResult( if (err) return err; err = nimffi_enc_i64(&m, &v->effectiveBackoffMs); if (err) return err; + err = cbor_encode_text_stringz(&m, "priority"); + if (err) return err; + err = my_timer_enc_JobPriority(&m, &v->priority); + if (err) return err; return cbor_encoder_close_container(e, &m); } static inline CborError my_timer_dec_ScheduleResult( @@ -611,6 +655,11 @@ static inline CborError my_timer_dec_ScheduleResult( if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; err = nimffi_dec_i64(&field, &out->effectiveBackoffMs); if (err) return err; + err = cbor_value_map_find_value(it, "priority", &field); + if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = my_timer_dec_JobPriority(&field, &out->priority); + if (err) return err; return cbor_value_advance(it); } static inline void my_timer_free_ScheduleResult(ScheduleResult* v) { @@ -766,11 +815,16 @@ static inline void my_timer_free_MyTimerScheduleReq(MyTimerScheduleReq* v) { extern "C" { #endif +/** Creates the FFIContext + MyTimer; async via chronos. */ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ int my_timer_destroy(void* ctx); uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int my_timer_remove_event_listener(void* ctx, uint64_t listener_id); @@ -871,6 +925,7 @@ static void my_timer_create_trampoline(int ret, const char* msg, size_t len, voi free(box); } +/** Creates the FFIContext + MyTimer; async via chronos. */ static inline int my_timer_ctx_create(const TimerConfig* config, MyTimerCreateFn on_created, void* user_data) { MyTimerCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -896,14 +951,20 @@ static inline int my_timer_ctx_create(const TimerConfig* config, MyTimerCreateFn return 0; } -static inline void my_timer_ctx_destroy(MyTimerCtx* ctx) { - if (!ctx) return; - if (ctx->ptr) { my_timer_destroy(ctx->ptr); ctx->ptr = NULL; } - for (size_t i = 0; i < ctx->listeners_len; i++) free(ctx->listeners[i].box); +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ +static inline int my_timer_ctx_destroy(MyTimerCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = my_timer_destroy(ctx->ptr); ctx->ptr = NULL; } + if (rc == NIMFFI_RET_OK) { + for (size_t i = 0; i < ctx->listeners_len; i++) free(ctx->listeners[i].box); + } free(ctx->listeners); free(ctx); + return rc; } +/** Fired by `myTimerEcho` once the reply is ready. */ static inline uint64_t my_timer_ctx_add_on_echo_fired_listener(MyTimerCtx* ctx, MyTimerOnEchoFiredFn fn, void* user_data) { MyTimerOnEchoFiredBox* box = (MyTimerOnEchoFiredBox*)malloc(sizeof(MyTimerOnEchoFiredBox)); if (!box) return 0; @@ -970,6 +1031,7 @@ static void my_timer_echo_reply_trampoline(int ret, const char* msg, size_t len, my_timer_free_EchoResponse(&out); free(box); } +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ static inline int my_timer_ctx_echo(const MyTimerCtx* ctx, const EchoRequest* req, MyTimerEchoReplyFn on_reply, void* user_data) { MyTimerEchoReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -1032,6 +1094,7 @@ static void my_timer_version_reply_trampoline(int ret, const char* msg, size_t l nimffi_free_str(&out); free(box); } +/** Returns the library's version string. */ static inline int my_timer_ctx_version(const MyTimerCtx* ctx, MyTimerVersionReplyFn on_reply, void* user_data) { MyTimerVersionReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -1155,6 +1218,7 @@ static void my_timer_schedule_reply_trampoline(int ret, const char* msg, size_t my_timer_free_ScheduleResult(&out); free(box); } +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ static inline int my_timer_ctx_schedule(const MyTimerCtx* ctx, const JobSpec* job, const RetryPolicy* retry, const ScheduleConfig* schedule, MyTimerScheduleReplyFn on_reply, void* user_data) { MyTimerScheduleReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); diff --git a/examples/timer/cddl_bindings/my_timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl index 295441f..7adbd70 100644 --- a/examples/timer/cddl_bindings/my_timer.cddl +++ b/examples/timer/cddl_bindings/my_timer.cddl @@ -9,10 +9,11 @@ EchoResponse = { echoed: tstr, timerName: tstr } ComplexRequest = { messages: [* EchoRequest], tags: [* tstr], note: tstr / nil, retries: int / nil } ComplexResponse = { summary: tstr, itemCount: int, hasNote: bool } EchoEvent = { message: tstr, echoCount: int } -JobSpec = { name: tstr, payload: [* tstr], priority: int } +JobPriority = "low" / "normal" / "high" +JobSpec = { name: tstr, payload: [* tstr], priority: JobPriority } RetryPolicy = { maxAttempts: int, backoffMs: int, retryOn: [* tstr] } ScheduleConfig = { startAtMs: int, intervalMs: int, jitter: int / nil } -ScheduleResult = { jobId: tstr, willRunCount: int, firstRunAtMs: int, effectiveBackoffMs: int } +ScheduleResult = { jobId: tstr, willRunCount: int, firstRunAtMs: int, effectiveBackoffMs: int, priority: JobPriority } ; ─── Request envelopes (one CBOR blob per request) ──────────────── MyTimerCreateCtorReq = { config: TimerConfig } @@ -23,14 +24,17 @@ MyTimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfi ; ─── Procs ───────────────────────────────────────────────────────── ; my_timer_create (ctor) +; Creates the FFIContext + MyTimer; async via chronos. my_timer_create-request = MyTimerCreateCtorReq my_timer_create-response = tstr ; my_timer_echo (ffi) +; Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. my_timer_echo-request = MyTimerEchoReq my_timer_echo-response = EchoResponse ; my_timer_version (ffi) +; Returns the library's version string. my_timer_version-request = MyTimerVersionReq my_timer_version-response = tstr @@ -39,8 +43,10 @@ my_timer_complex-request = MyTimerComplexReq my_timer_complex-response = ComplexResponse ; my_timer_schedule (ffi) +; Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. my_timer_schedule-request = MyTimerScheduleReq my_timer_schedule-response = ScheduleResult ; my_timer_destroy (dtor) +; Tears down the FFI context; blocks until FFI + watchdog threads join. my_timer_destroy-response = nil diff --git a/examples/timer/cpp_bindings/main.cpp b/examples/timer/cpp_bindings/main.cpp index e754a85..8baad68 100644 --- a/examples/timer/cpp_bindings/main.cpp +++ b/examples/timer/cpp_bindings/main.cpp @@ -25,7 +25,8 @@ int main() { std::cerr << "Error: " << version.error() << "\n"; return 1; } - std::cout << "[2] Version: " << version.value() << "\n"; + std::cout << "[2] Version: " << version.value() << " (const TIMER_VERSION=" + << TIMER_VERSION << ", MAX_DELAY_MS=" << MAX_DELAY_MS << ")\n"; auto echo = echo1Future.get(); if (echo.isErr()) { @@ -66,7 +67,7 @@ int main() { auto job = JobSpec{ /*name*/ "nightly-rollup", /*payload*/ std::vector{"rollup", "v2"}, - /*priority*/ 10, + JobPriority::jpHigh, }; auto retry = RetryPolicy{ /*maxAttempts*/ 3, @@ -88,7 +89,7 @@ int main() { << ", willRunCount=" << scheduleRes->willRunCount << ", firstRunAtMs=" << scheduleRes->firstRunAtMs << ", effectiveBackoffMs=" << scheduleRes->effectiveBackoffMs - << "\n"; + << ", priority=" << static_cast(scheduleRes->priority) << "\n"; // Each `{.ffiEvent.}` declared on the Nim side gets a typed // registration method — `addOnEchoFiredListener(handler)` here. diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 9190cfb..ded2cbf 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -293,6 +293,37 @@ inline Result decodeCborFFI(const std::vector& bytes) { #endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED +// ============================================================ +// Generated constants +// ============================================================ + +constexpr int64_t MAX_DELAY_MS = 5000; +constexpr uint32_t DEFAULT_BACKOFF_MS = 250; +constexpr const char* TIMER_VERSION = "nim-timer v0.1.0"; + +enum class JobPriority { + jpLow = 0, + jpNormal = 1, + jpHigh = 2, +}; +inline CborError encode_cbor(CborEncoder& e, const JobPriority& v) { + switch (v) { + case JobPriority::jpLow: return cbor_encode_text_stringz(&e, "low"); + case JobPriority::jpNormal: return cbor_encode_text_stringz(&e, "normal"); + case JobPriority::jpHigh: return cbor_encode_text_stringz(&e, "high"); + } + return CborErrorImproperValue; +} +inline CborError decode_cbor(CborValue& it, JobPriority& v) { + std::string name; + CborError err = decode_cbor(it, name); + if (err) return err; + if (name == "low") { v = JobPriority::jpLow; return CborNoError; } + if (name == "normal") { v = JobPriority::jpNormal; return CborNoError; } + if (name == "high") { v = JobPriority::jpHigh; return CborNoError; } + return CborErrorImproperValue; +} + // ============================================================ // User-declared FFI types // ============================================================ @@ -474,7 +505,7 @@ inline CborError decode_cbor(CborValue& it, EchoEvent& v) { struct JobSpec { std::string name; std::vector payload; - int64_t priority; + JobPriority priority; }; inline CborError encode_cbor(CborEncoder& e, const JobSpec& v) { CborEncoder m; @@ -575,10 +606,11 @@ struct ScheduleResult { int64_t willRunCount; int64_t firstRunAtMs; int64_t effectiveBackoffMs; + JobPriority priority; }; inline CborError encode_cbor(CborEncoder& e, const ScheduleResult& v) { CborEncoder m; - CborError err = cbor_encoder_create_map(&e, &m, 4); + CborError err = cbor_encoder_create_map(&e, &m, 5); if (err) return err; err = cbor_encode_text_stringz(&m, "jobId"); if (err) return err; err = encode_cbor(m, v.jobId); if (err) return err; @@ -588,6 +620,8 @@ inline CborError encode_cbor(CborEncoder& e, const ScheduleResult& v) { err = encode_cbor(m, v.firstRunAtMs); if (err) return err; err = cbor_encode_text_stringz(&m, "effectiveBackoffMs"); if (err) return err; err = encode_cbor(m, v.effectiveBackoffMs); if (err) return err; + err = cbor_encode_text_stringz(&m, "priority"); if (err) return err; + err = encode_cbor(m, v.priority); if (err) return err; return cbor_encoder_close_container(&e, &m); } inline CborError decode_cbor(CborValue& it, ScheduleResult& v) { @@ -606,6 +640,9 @@ inline CborError decode_cbor(CborValue& it, ScheduleResult& v) { err = cbor_value_map_find_value(&it, "effectiveBackoffMs", &field); if (err) return err; if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; err = decode_cbor(field, v.effectiveBackoffMs); if (err) return err; + err = cbor_value_map_find_value(&it, "priority", &field); if (err) return err; + if (!cbor_value_is_valid(&field)) return CborErrorImproperValue; + err = decode_cbor(field, v.priority); if (err) return err; return cbor_value_advance(&it); } @@ -729,11 +766,16 @@ inline CborError decode_cbor(CborValue& it, MyTimerScheduleReq& v) { extern "C" { typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data); +/** Creates the FFIContext + MyTimer; async via chronos. */ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ int my_timer_destroy(void* ctx); uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int my_timer_remove_event_listener(void* ctx, uint64_t listener_id); @@ -812,6 +854,7 @@ inline Result> ffi_call_( class MyTimerCtx { public: + /// Creates the FFIContext + MyTimer; async via chronos. static Result> create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = MyTimerCreateCtorReq{config}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -835,6 +878,7 @@ public: return Result>::ok(std::unique_ptr(new MyTimerCtx(reinterpret_cast(static_cast(addr)), timeout))); } + /// Creates the FFIContext + MyTimer; async via chronos. static std::future>> createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -863,6 +907,7 @@ public: // ── Event listener API ────────────────────────────────── struct ListenerHandle { std::uint64_t id = 0; }; + /// Fired by `myTimerEcho` once the reply is ready. ListenerHandle addOnEchoFiredListener(std::function handler) { auto owned = std::make_unique>(std::move(handler)); auto* raw = owned.get(); @@ -880,6 +925,7 @@ public: return rc == 0; } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. Result echo(const EchoRequest& req) const { const auto ffi_req_ = MyTimerEchoReq{req}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -892,10 +938,12 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. std::future> echoAsync(const EchoRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->echo(req); }); } + /// Returns the library's version string. Result version() const { const auto ffi_req_ = MyTimerVersionReq{}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -908,6 +956,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Returns the library's version string. std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } @@ -928,6 +977,7 @@ public: return std::async(std::launch::async, [this, req]() { return this->complex(req); }); } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. Result schedule(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { const auto ffi_req_ = MyTimerScheduleReq{job, retry, schedule}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -940,6 +990,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. std::future> scheduleAsync(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { return std::async(std::launch::async, [this, job, retry, schedule]() { return this->schedule(job, retry, schedule); }); } diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index 829113b..fb05b7c 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -159,6 +159,7 @@ impl Drop for MyTimerCtx { } impl MyTimerCtx { + /// Creates the FFIContext + MyTimer; async via chronos. pub fn create(config: TimerConfig, timeout: Duration) -> Result { let req = MyTimerCreateCtorReq { config }; let req_bytes = encode_cbor(&req)?; @@ -171,6 +172,7 @@ impl MyTimerCtx { Ok(Self { ptr: addr as *mut c_void, timeout, listeners: std::sync::Mutex::new(std::collections::HashMap::new()) }) } + /// Creates the FFIContext + MyTimer; async via chronos. pub async fn new_async(config: TimerConfig, timeout: Duration) -> Result { let req = MyTimerCreateCtorReq { config }; let req_bytes = encode_cbor(&req)?; @@ -199,6 +201,7 @@ impl MyTimerCtx { ListenerHandle { id } } + /// Fired by `myTimerEcho` once the reply is ready. /// Register a typed listener for `on_echo_fired`. The returned handle can be /// passed to `remove_event_listener` to unregister. pub fn add_on_echo_fired_listener(&self, handler: F) -> ListenerHandle @@ -220,6 +223,7 @@ impl MyTimerCtx { rc == 0 } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub fn echo(&self, req: EchoRequest) -> Result { let req = MyTimerEchoReq { req }; let req_bytes = encode_cbor(&req)?; @@ -229,6 +233,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub async fn echo_async(&self, req: EchoRequest) -> Result { let req = MyTimerEchoReq { req }; let req_bytes = encode_cbor(&req)?; @@ -239,6 +244,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Returns the library's version string. pub fn version(&self) -> Result { let req = MyTimerVersionReq {}; let req_bytes = encode_cbor(&req)?; @@ -248,6 +254,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Returns the library's version string. pub async fn version_async(&self) -> Result { let req = MyTimerVersionReq {}; let req_bytes = encode_cbor(&req)?; @@ -277,6 +284,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub fn schedule(&self, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig) -> Result { let req = MyTimerScheduleReq { job, retry, schedule }; let req_bytes = encode_cbor(&req)?; @@ -286,6 +294,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub async fn schedule_async(&self, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig) -> Result { let req = MyTimerScheduleReq { job, retry, schedule }; let req_bytes = encode_cbor(&req)?; diff --git a/examples/timer/rust_bindings/src/ffi.rs b/examples/timer/rust_bindings/src/ffi.rs index 905acf4..3a6cf73 100644 --- a/examples/timer/rust_bindings/src/ffi.rs +++ b/examples/timer/rust_bindings/src/ffi.rs @@ -9,11 +9,16 @@ pub type FFICallback = unsafe extern "C" fn( #[link(name = "my_timer")] extern "C" { + /// Creates the FFIContext + MyTimer; async via chronos. pub fn my_timer_create(req_cbor: *const u8, req_cbor_len: usize, callback: FFICallback, user_data: *mut c_void) -> *mut c_void; + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub fn my_timer_echo(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Returns the library's version string. pub fn my_timer_version(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Tears down the FFI context; blocks until FFI + watchdog threads join. pub fn my_timer_destroy(ctx: *mut c_void) -> c_int; pub fn my_timer_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64; pub fn my_timer_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int; diff --git a/examples/timer/rust_bindings/src/types.rs b/examples/timer/rust_bindings/src/types.rs index cbb731f..8d5c9b7 100644 --- a/examples/timer/rust_bindings/src/types.rs +++ b/examples/timer/rust_bindings/src/types.rs @@ -1,5 +1,19 @@ use serde::{Deserialize, Serialize}; +pub const MAX_DELAY_MS: i64 = 5000; +pub const DEFAULT_BACKOFF_MS: u32 = 250; +pub const TIMER_VERSION: &str = "nim-timer v0.1.0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JobPriority { + #[serde(rename = "low")] + JpLow, + #[serde(rename = "normal")] + JpNormal, + #[serde(rename = "high")] + JpHigh, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimerConfig { pub name: String, @@ -47,7 +61,7 @@ pub struct EchoEvent { pub struct JobSpec { pub name: String, pub payload: Vec, - pub priority: i64, + pub priority: JobPriority, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -79,6 +93,7 @@ pub struct ScheduleResult { pub first_run_at_ms: i64, #[serde(rename = "effectiveBackoffMs")] pub effective_backoff_ms: i64, + pub priority: JobPriority, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/examples/timer/rust_client/src/main.rs b/examples/timer/rust_client/src/main.rs index e437f6d..8f54952 100644 --- a/examples/timer/rust_client/src/main.rs +++ b/examples/timer/rust_client/src/main.rs @@ -7,7 +7,8 @@ // nimble genbindings_rust use std::time::Duration; use my_timer::{ - EchoRequest, JobSpec, MyTimerCtx, RetryPolicy, ScheduleConfig, TimerConfig, + EchoRequest, JobPriority, JobSpec, MyTimerCtx, RetryPolicy, ScheduleConfig, + TimerConfig, MAX_DELAY_MS, TIMER_VERSION, }; fn main() { @@ -21,6 +22,8 @@ fn main() { // ── 2. Sync call: version ────────────────────────────────────────────── let version = ctx.version().expect("my_timer_version failed"); println!("[2] Version (sync call, callback fired inline): {version}"); + assert_eq!(version, TIMER_VERSION); + println!("[2b] Consts from the bindings: MAX_DELAY_MS={MAX_DELAY_MS}"); // ── 3. Async call: echo (200 ms delay) ──────────────────────────────── let echo = ctx @@ -52,7 +55,7 @@ fn main() { JobSpec { name: "nightly-rollup".into(), payload: vec!["rollup".into(), "v2".into()], - priority: 10, + priority: JobPriority::JpHigh, }, RetryPolicy { max_attempts: 3, @@ -67,11 +70,12 @@ fn main() { ) .expect("my_timer_schedule failed"); println!( - "[5] Schedule (3 complex params): jobId={}, willRunCount={}, firstRunAtMs={}, effectiveBackoffMs={}", + "[5] Schedule (3 complex params): jobId={}, willRunCount={}, firstRunAtMs={}, effectiveBackoffMs={}, priority={:?}", schedule.job_id, schedule.will_run_count, schedule.first_run_at_ms, schedule.effective_backoff_ms, + schedule.priority, ); println!("\nDone. The Nim FFI thread and watchdog are still running."); diff --git a/examples/timer/rust_client/src/tokio_main.rs b/examples/timer/rust_client/src/tokio_main.rs index e1bdb17..b1ee596 100644 --- a/examples/timer/rust_client/src/tokio_main.rs +++ b/examples/timer/rust_client/src/tokio_main.rs @@ -1,6 +1,7 @@ use std::time::Duration; use my_timer::{ - EchoRequest, JobSpec, MyTimerCtx, RetryPolicy, ScheduleConfig, TimerConfig, + EchoRequest, JobPriority, JobSpec, MyTimerCtx, RetryPolicy, ScheduleConfig, + TimerConfig, }; #[tokio::main(flavor = "multi_thread", worker_threads = 2)] @@ -40,7 +41,7 @@ async fn main() -> Result<(), Box> { JobSpec { name: "hourly-sync".into(), payload: vec!["sync".into(), "users".into()], - priority: 5, + priority: JobPriority::JpNormal, }, RetryPolicy { max_attempts: 5, @@ -55,8 +56,8 @@ async fn main() -> Result<(), Box> { ) .await?; println!( - "[5] Schedule (3 complex params, awaited): jobId={}, willRunCount={}, firstRunAtMs={}", - schedule.job_id, schedule.will_run_count, schedule.first_run_at_ms, + "[5] Schedule (3 complex params, awaited): jobId={}, willRunCount={}, firstRunAtMs={}, priority={:?}", + schedule.job_id, schedule.will_run_count, schedule.first_run_at_ms, schedule.priority, ); println!("\nDone. Tokio runtime shut down."); diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index eb52568..fb83133 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -9,6 +9,12 @@ type MyTimer = object # `defaultABIFormat` is the wire format every annotation inherits (override per-annotation with "abi = ..."). declareLibrary("my_timer", MyTimer, defaultABIFormat = "cbor") +# {.ffiConst.}: re-emitted as a native constant in every binding. +const + MaxDelayMs* {.ffiConst.} = 5_000 + DefaultBackoffMs* {.ffiConst.}: uint32 = 250 + TimerVersion* {.ffiConst.} = "nim-timer v0.1.0" + type TimerConfig {.ffi.} = object name: string @@ -36,24 +42,28 @@ type EchoEvent {.ffi.} = object message: string echoCount: int -proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} +proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} = + ## Fired by `myTimerEcho` once the reply is ready. -# Constructor: creates the FFIContext + MyTimer; async via chronos. proc myTimerCreate*(config: TimerConfig): Future[Result[MyTimer, string]] {.ffiCtor.} = + ## Creates the FFIContext + MyTimer; async via chronos. await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread return ok(MyTimer(name: config.name)) -# Async method: sleeps `delayMs` then echoes the message back. proc myTimerEcho*( timer: MyTimer, req: EchoRequest ): Future[Result[EchoResponse, string]] {.ffi.} = + ## Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. + if req.delayMs > MaxDelayMs: + return err("delayMs must not exceed " & $MaxDelayMs) await sleepAsync(req.delayMs.milliseconds) onEchoFired(EchoEvent(message: req.message, echoCount: 1)) return ok(EchoResponse(echoed: req.message, timerName: timer.name)) # Sync method: no await, so the macro fires the callback inline. proc myTimerVersion*(timer: MyTimer): Future[Result[string, string]] {.ffi.} = - return ok("nim-timer v0.1.0") + ## Returns the library's version string. + return ok(TimerVersion) proc myTimerComplex*( timer: MyTimer, req: ComplexRequest @@ -66,11 +76,17 @@ proc myTimerComplex*( return ok(ComplexResponse(summary: summary, itemCount: count, hasNote: req.note.isSome)) +# {.ffi.} on an enum: a native enum in every binding, crossing the wire as text. +type JobPriority {.ffi.} = enum + jpLow = "low" + jpNormal = "normal" + jpHigh = "high" + # Multiple object-typed params: each is its own {.ffi.} type, all carried in one per-proc Req envelope. type JobSpec {.ffi.} = object name: string payload: seq[string] - priority: int # higher = runs sooner + priority: JobPriority type RetryPolicy {.ffi.} = object maxAttempts: int @@ -87,6 +103,17 @@ type ScheduleResult {.ffi.} = object willRunCount: int firstRunAtMs: int effectiveBackoffMs: int + priority: JobPriority + +func effectiveBackoff(priority: JobPriority, backoffMs: int): int = + let base = if backoffMs > 0: backoffMs else: DefaultBackoffMs.int + case priority + of jpHigh: + return base div 2 + of jpNormal: + return base + of jpLow: + return base * 2 proc myTimerSchedule*( timer: MyTimer, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig @@ -108,7 +135,8 @@ proc myTimerSchedule*( jobId: timer.name & ":" & job.name, willRunCount: willRunCount, firstRunAtMs: schedule.startAtMs + jitter, - effectiveBackoffMs: retry.backoffMs, + effectiveBackoffMs: effectiveBackoff(job.priority, retry.backoffMs), + priority: job.priority, ) ) diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index a8a112e..e4a2b5b 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -2,8 +2,8 @@ ## `abi = c` emits one header whose structs are the C ABI directly. Lacking ## generics, each distinct `seq[T]`/`Option[T]` is monomorphised per type. -import std/[os, strutils, tables, sets] -import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir +import std/[os, strutils, tables, sets, options] +import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir, ./consts ## Fixed 64-bit wire type for any Nim `ptr T`/`pointer` (mirrors CppPtrType). const CPtrType* = "uint64_t" @@ -174,6 +174,60 @@ proc emitOptType(reg: var CTypeReg, name, elemC: string, elemOwns: bool) = proc ensureCType(reg: var CTypeReg, nimType: string): tuple[cType: string, owns: bool] +func enumConstName*(typeName, valueName: string): string = + ## C/CDDL-safe constant name for an enum value, e.g. ("Color", "cRed") → COLOR_C_RED. + return identToUpperSnake(typeName) & "_" & identToUpperSnake(valueName) + +proc emitEnumType(reg: var CTypeReg, t: FFITypeMeta) = + ## A `{.ffi.}` enum becomes a C enum whose codec maps to the CBOR text form + ## (the value's Nim symbol name, or its associated string) that + ## cbor_serialization writes. + var members: seq[string] = @[] + for v in t.enumValues: + members.add(" " & enumConstName(t.name, v.name) & " = " & $v.ord & ",") + members[^1].removeSuffix(',') + reg.decls.add("typedef enum {\n" & members.join("\n") & "\n} " & t.name & ";") + + var longest = 0 + for v in t.enumValues: + longest = max(longest, v.wire.len) + + var body: seq[string] = @[] + body.add("static inline CborError " & reg.libName & "_enc_" & t.name & "(") + body.add(" CborEncoder* e, const " & t.name & "* v) {") + body.add(" switch (*v) {") + for v in t.enumValues: + body.add( + " case " & enumConstName(t.name, v.name) & + ": return cbor_encode_text_stringz(e, \"" & v.wire & "\");" + ) + body.add(" }") + body.add(" return CborErrorImproperValue;") + body.add("}") + + body.add("static inline CborError " & reg.libName & "_dec_" & t.name & "(") + body.add(" CborValue* it, " & t.name & "* out) {") + body.add(" if (!cbor_value_is_text_string(it)) return CborErrorImproperValue;") + body.add(" size_t len = 0;") + body.add(" CborError err = cbor_value_get_string_length(it, &len);") + body.add(" if (err) return err;") + body.add(" char buf[" & $(longest + 1) & "];") + body.add(" if (len >= sizeof(buf)) return CborErrorImproperValue;") + body.add(" size_t copied = sizeof(buf);") + body.add(" err = cbor_value_copy_text_string(it, buf, &copied, NULL);") + body.add(" if (err) return err;") + body.add(" buf[len] = '\\0';") + for v in t.enumValues: + body.add( + " if (strcmp(buf, \"" & v.wire & "\") == 0) { *out = " & + enumConstName(t.name, v.name) & "; return cbor_value_advance(it); }" + ) + body.add(" return CborErrorImproperValue;") + body.add("}") + + reg.codecs.add(body.join("\n")) + reg.owns[t.name] = false + proc emitStructType(reg: var CTypeReg, t: FFITypeMeta) = var fieldDecls: seq[string] = @[] var members: seq[tuple[name, cType: string, owns: bool]] = @[] @@ -269,7 +323,11 @@ proc ensureCType(reg: var CTypeReg, t: FFIType): tuple[cType: string, owns: bool if name notin reg.emitted: reg.emitted.incl(name) if name in reg.typeTable: - emitStructType(reg, reg.typeTable[name]) + let meta = reg.typeTable[name] + if meta.isEnum(): + emitEnumType(reg, meta) + else: + emitStructType(reg, meta) else: reg.decls.add("/* unknown type referenced: " & name & " */") return (name, reg.owns.getOrDefault(name, false)) @@ -285,11 +343,14 @@ proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta = fields.add(FFIFieldMeta(name: ep.name, typeName: typeName)) return FFITypeMeta(name: reqStructName(p), fields: fields) -func paramByValue(nimType: string, ridesAsPtr: bool): bool = - ## Scalars/pointers/string views pass by value; aggregates by const pointer. +func paramByValue(reg: CTypeReg, nimType: string, ridesAsPtr: bool): bool = + ## Scalars/pointers/string views and enums pass by value; aggregates by const pointer. if ridesAsPtr: return true - return parseFFIType(nimType).kind in {ftScalar, ftStr, ftPtr} + let t = parseFFIType(nimType) + if t.kind == ftStruct and reg.typeTable.getOrDefault(t.name).isEnum(): + return true + return t.kind in {ftScalar, ftStr, ftPtr} proc cReturnType(reg: var CTypeReg, p: FFIProcMeta): string = if p.returnRidesAsPtr(): @@ -308,7 +369,7 @@ proc buildReqParams( CPtrType else: ensureCType(reg, ep.typeName).cType - if paramByValue(ep.typeName, rides): + if paramByValue(reg, ep.typeName, rides): params.add(cType & " " & ep.name) assigns.add(" ffi_req." & ep.name & " = " & ep.name & ";") else: @@ -487,6 +548,7 @@ proc emitConstructors( head & params.join(", ") & ", " & fnType & " on_created, void* user_data) {" else: head & fnType & " on_created, void* user_data) {" + lines.add(renderBlockDocComment(ctor.doc)) lines.add(sig) lines.add(" " & reqName & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -525,19 +587,32 @@ proc emitConstructors( proc emitDestructor( lines: var seq[string], - ctxType, libName, dtorProcName: string, + ctxType, libName: string, + dtor: Option[FFIProcMeta], events: seq[FFIEventMeta], ) = - lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {") - lines.add(" if (!ctx) return;") - if dtorProcName.len > 0: - lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }") - if events.len > 0: + if dtor.isSome(): + lines.add(renderBlockDocComment(dtor.get().doc)) + lines.add("static inline int " & libName & "_ctx_destroy(" & ctxType & "* ctx) {") + lines.add(" if (!ctx) return NIMFFI_RET_OK;") + lines.add(" int rc = NIMFFI_RET_OK;") + if dtor.isSome(): lines.add( - " for (size_t i = 0; i < ctx->listeners_len; i++) free(ctx->listeners[i].box);" + " if (ctx->ptr) { rc = " & dtor.get().procName & + "(ctx->ptr); ctx->ptr = NULL; }" ) + if events.len > 0: + # A failed teardown leaves the worker threads live (ffi_context.nim: + # stopAndJoinThreads), and they still hold each box as callback user_data. + # Leaking a box beats handing a running event thread a dangling pointer. + lines.add(" if (rc == NIMFFI_RET_OK) {") + lines.add( + " for (size_t i = 0; i < ctx->listeners_len; i++) free(ctx->listeners[i].box);" + ) + lines.add(" }") lines.add(" free(ctx->listeners);") lines.add(" free(ctx);") + lines.add(" return rc;") lines.add("}") lines.add("") @@ -548,6 +623,7 @@ proc emitListenerApi( return for ev in events: let n = evNames(libType, libName, ev) + lines.add(renderBlockDocComment(ev.doc)) lines.add( "static inline uint64_t " & n.regName & "(" & ctxType & "* ctx, " & n.fnType & " fn, void* user_data) {" @@ -652,6 +728,7 @@ proc emitMethod( head & params.join(", ") & ", " & fnType & " on_reply, void* user_data) {" else: head & fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add(" " & reqName & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -730,6 +807,33 @@ proc monomorphiseAll( discard ensureCType(reg, ev.payloadTypeName) return (reqTypes, respTypes) +func constDeclLines(consts: seq[FFIConstMeta]): seq[string] = + ## `{.ffiConst.}` values as typed `static const` definitions; shared by the + ## CBOR and `abi = c` headers. + if consts.len == 0: + return @[] + var lines = @[ + "/* ============================================================ */", + "/* Generated constants */", + "/* ============================================================ */", "", + ] + for c in consts: + let t = parseFFIType(c.typeName) + let name = identToUpperSnake(c.name) + let value = cConstValue(t, c.value) + case t.kind + of ftStr: + lines.add("static const char* const " & name & " = " & value & ";") + of ftScalar: + lines.add( + "static const " & scalarCInfoTable[t.scalar].cType & " " & name & " = " & value & + ";" + ) + else: + discard + lines.add("") + return lines + func generateCPreludeHeader*(): string = ## The library-agnostic `nim_ffi_prelude.h`, emitted verbatim. return HeaderPreludeTpl & "\n" @@ -743,6 +847,7 @@ proc generateCLibHeader*( types: seq[FFITypeMeta], libName: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ): string = ## The `.h` header: library structs, monomorphised codecs and async API. let classified = classifyProcs(procs) @@ -761,6 +866,8 @@ proc generateCLibHeader*( lines.add("#include \"" & CborHeaderName & "\"") lines.add("") + lines.add(constDeclLines(consts)) + lines.add("/* ============================================================ */") lines.add("/* Generated types (user-declared + per-proc request envelopes) */") lines.add("/* ============================================================ */") @@ -780,6 +887,7 @@ proc generateCLibHeader*( lines.add("#endif") lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: lines.add( @@ -833,7 +941,7 @@ proc generateCLibHeader*( emitEventMachinery(lines, reg, libType, libName, events) emitContextStruct(lines, ctxType, events) emitConstructors(lines, reg, ctxType, libType, libName, ctors) - emitDestructor(lines, ctxType, libName, classified.dtorProcName, events) + emitDestructor(lines, ctxType, libName, classified.dtor, events) emitListenerApi(lines, ctxType, libType, libName, events) for m in methods: emitMethod(lines, reg, ctxType, libType, libName, m) @@ -1076,6 +1184,7 @@ proc emitAbiExternDecls( lines.add("#endif") lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: if p.scalarFastPath: @@ -1172,6 +1281,7 @@ proc emitAbiCtxAndCtor( head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {" else: head & createFn & " on_created, void* user_data) {" + lines.add(renderBlockDocComment(ctor.doc)) lines.add(sig) lines.add(" " & reqStruct & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -1194,15 +1304,6 @@ proc emitAbiCtxAndCtor( lines.add("}") lines.add("") -proc emitAbiDestructor(lines: var seq[string], ctxType, libName, dtorProcName: string) = - lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {") - lines.add(" if (!ctx) return;") - if dtorProcName.len > 0: - lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }") - lines.add(" free(ctx);") - lines.add("}") - lines.add("") - proc emitAbiMethod( lines: var seq[string], reg: var AbiReg, @@ -1220,6 +1321,7 @@ proc emitAbiMethod( head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {" else: head & info.fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add(" " & reqStruct & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -1326,6 +1428,7 @@ proc emitAbiScalarMethod( head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {" else: head & info.fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add( " " & boxType & "* box = (" & boxType & "*)malloc(sizeof(" & boxType & "));" @@ -1350,6 +1453,7 @@ proc generateCAbiLibHeader*( types: seq[FFITypeMeta], libName: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ): string = if events.len > 0: raise newException( @@ -1386,6 +1490,7 @@ proc generateCAbiLibHeader*( lines.add(" terminal RET_OK/RET_ERR. Ignore it unless you want progress. */") lines.add("#define NIMFFI_RET_STALE_WARN 3") lines.add("") + lines.add(constDeclLines(consts)) lines.add( "/* `abi = c` wire structs — the C ABI. Strings are borrowed, NUL-terminated" ) @@ -1400,7 +1505,8 @@ proc generateCAbiLibHeader*( lines.add("/* High-level context wrapper */") emitAbiCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors) - emitAbiDestructor(lines, ctxType, libName, classified.dtorProcName) + # abi = c has no events, so the destructor is the CBOR one minus the listener sweep. + emitDestructor(lines, ctxType, libName, classified.dtor, @[]) for m in classified.methods: if m.scalarFastPath: emitAbiScalarMethod(lines, reg, ctxType, libName, libType, m) @@ -1438,13 +1544,15 @@ proc generateCBindings*( outputDir: string, nimSrcRelPath: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ) = ## Emits the C binding for `libName`, picking the `abi = c` or CBOR shape. createDir(outputDir) case libWireFormat(procs, types) of ABIFormat.C: writeFile( - outputDir / (libName & ".h"), generateCAbiLibHeader(procs, types, libName, events) + outputDir / (libName & ".h"), + generateCAbiLibHeader(procs, types, libName, events, consts), ) writeFile( outputDir / "CMakeLists.txt", generateCAbiCMakeLists(libName, nimSrcRelPath) @@ -1453,6 +1561,7 @@ proc generateCBindings*( writeFile(outputDir / PreludeHeaderName, generateCPreludeHeader()) writeFile(outputDir / CborHeaderName, generateCCborHeader()) writeFile( - outputDir / (libName & ".h"), generateCLibHeader(procs, types, libName, events) + outputDir / (libName & ".h"), + generateCLibHeader(procs, types, libName, events, consts), ) writeFile(outputDir / "CMakeLists.txt", generateCCMakeLists(libName, nimSrcRelPath)) diff --git a/ffi/codegen/c_cpp_common.nim b/ffi/codegen/c_cpp_common.nim index 5ca3ce0..b23484c 100644 --- a/ffi/codegen/c_cpp_common.nim +++ b/ffi/codegen/c_cpp_common.nim @@ -1,6 +1,6 @@ ## Helpers shared by the C/C++ binding generators (cpp.nim, c.nim). -import std/strutils +import std/[strutils, options] import ./meta, ./string_helpers proc stripLibPrefix*(procName, libName: string): string = @@ -21,7 +21,7 @@ proc reqStructName*(p: FFIProcMeta): string = type ClassifiedProcs* = object ctors*: seq[FFIProcMeta] methods*: seq[FFIProcMeta] - dtorProcName*: string + dtor*: Option[FFIProcMeta] proc classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = ## Splits the registry into constructors, methods and the first destructor. @@ -33,8 +33,8 @@ proc classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = of FFIKind.FFI: c.methods.add(p) of FFIKind.DTOR: - if c.dtorProcName.len == 0: - c.dtorProcName = p.procName + if c.dtor.isNone(): + c.dtor = some(p) c proc libTypeName*(ctors: seq[FFIProcMeta], libName: string): string = diff --git a/ffi/codegen/cddl.nim b/ffi/codegen/cddl.nim index c2e34f2..62cadd7 100644 --- a/ffi/codegen/cddl.nim +++ b/ffi/codegen/cddl.nim @@ -2,7 +2,7 @@ ## ffi/cbor_serial.nim: types become rules, procs get request/response rules. import std/[os, strutils, unicode] -import ./meta +import ./meta, ./string_helpers proc innerOf(typeName, prefix: string): string = if typeName.startsWith(prefix) and typeName.endsWith("]"): @@ -81,6 +81,14 @@ proc emitMap( parts.add(f.name & ": " & cddlType) "{ " & parts.join(", ") & " }" +proc emitEnumAlternatives(t: FFITypeMeta): string = + ## An enum rides as the CBOR text `$value` yields, so the rule is a choice of + ## string literals. + var alts: seq[string] = @[] + for v in t.enumValues: + alts.add("\"" & v.wire & "\"") + alts.join(" / ") + proc emitObjectFields(t: FFITypeMeta): string = var fields: seq[tuple[name: string, typeName: string, isPtr: bool]] = @[] for f in t.fields: @@ -125,7 +133,12 @@ proc generateCddlSchema*( "; ─── User-declared FFI types ──────────────────────────────────────" ) for t in types: - L.add(t.name & " = " & emitObjectFields(t)) + let rule = + if t.isEnum(): + emitEnumAlternatives(t) + else: + emitObjectFields(t) + L.add(t.name & " = " & rule) L.add("") # Per-proc request envelopes (one CBOR blob per request). @@ -154,6 +167,7 @@ proc generateCddlSchema*( of FFIKind.DTOR: "dtor" of FFIKind.FFI: "ffi" L.add("; " & p.procName & " (" & kindTag & ")") + L.add(renderDocComment(p.doc, "", "; ")) if p.kind != FFIKind.DTOR: L.add(p.procName & "-request = " & reqStructName(p)) L.add(p.procName & "-response = " & responseRule(p)) diff --git a/ffi/codegen/consts.nim b/ffi/codegen/consts.nim new file mode 100644 index 0000000..713ed9f --- /dev/null +++ b/ffi/codegen/consts.nim @@ -0,0 +1,69 @@ +## Literal rendering for `{.ffiConst.}` values, shared by the C/C++/Rust generators. +## The registry stores Nim's `$value`; each backend re-quotes it for its syntax. + +import std/strutils +import ./types_ir + +func cByteEscape(ch: char): string = + ## 3-digit octal: C caps an octal escape at 3 digits, so a following digit + ## can't be swallowed into it the way it can with `\x`. + return "\\" & toOct(ord(ch), 3) + +func rustByteEscape(ch: char): string = + return "\\x" & toHex(ord(ch), 2) + +func escapeLit( + s: string, byteEscape: proc(ch: char): string {.noSideEffect, nimcall.} +): string = + var escaped = "" + for ch in s: + case ch + of '"': + escaped.add("\\\"") + of '\\': + escaped.add("\\\\") + of '\n': + escaped.add("\\n") + of '\r': + escaped.add("\\r") + of '\t': + escaped.add("\\t") + else: + if ch < ' ' or ch == '\x7F': + escaped.add(byteEscape(ch)) + else: + escaped.add(ch) + return escaped + +func cEscapeStringLit*(s: string): string = + return escapeLit(s, cByteEscape) + +func rustEscapeStringLit*(s: string): string = + return escapeLit(s, rustByteEscape) + +func cConstValue*(t: FFIType, value: string): string = + ## C/C++ literal. Every emission site is a typed declaration, so the declared + ## type already fixes the width; only the two cases the type can't rescue get + ## a suffix — `ULL` because a decimal above `INT64_MAX` fits no signed type, + ## and `f` because a bare `1.5` is a double and narrowing it warns. + case t.kind + of ftStr: + return "\"" & cEscapeStringLit(value) & "\"" + of ftScalar: + case t.scalar + of skU64: + return value & "ULL" + of skF32: + return value & "f" + else: + return value + else: + return value + +func rustConstValue*(t: FFIType, value: string): string = + ## Rust literal; the declared type annotation carries the width, so no suffix. + case t.kind + of ftStr: + return "\"" & rustEscapeStringLit(value) & "\"" + else: + return value diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index b75d76c..2b52a31 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -1,7 +1,7 @@ ## C++ binding generator: header-only binding + CMakeLists, CBOR over the wire. import std/[os, strutils] -import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir +import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir, ./consts ## Fixed 64-bit wire type for any Nim `ptr T` / `pointer`. const CppPtrType* = "uint64_t" @@ -46,6 +46,38 @@ const cppMap = NativeTypeMap( proc nimTypeToCpp*(typeName: string): string = renderNative(cppMap, parseFFIType(typeName)) +proc emitEnumCborCodec(lines: var seq[string], t: FFITypeMeta) = + ## Appends the `enum class` plus its TinyCBOR codec pair. The wire form is the + ## CBOR text `$value` yields on the Nim side, so the codec maps name ↔ value. + lines.add("enum class $1 {" % [t.name]) + for v in t.enumValues: + lines.add(" $1 = $2," % [v.name, $v.ord]) + lines.add("};") + + lines.add("inline CborError encode_cbor(CborEncoder& e, const $1& v) {" % [t.name]) + lines.add(" switch (v) {") + for v in t.enumValues: + lines.add( + " case $1::$2: return cbor_encode_text_stringz(&e, \"$3\");" % + [t.name, v.name, v.wire] + ) + lines.add(" }") + lines.add(" return CborErrorImproperValue;") + lines.add("}") + + lines.add("inline CborError decode_cbor(CborValue& it, $1& v) {" % [t.name]) + lines.add(" std::string name;") + lines.add(" CborError err = decode_cbor(it, name);") + lines.add(" if (err) return err;") + for v in t.enumValues: + lines.add( + " if (name == \"$1\") { v = $2::$3; return CborNoError; }" % + [v.wire, t.name, v.name] + ) + lines.add(" return CborErrorImproperValue;") + lines.add("}") + lines.add("") + proc emitStructCborCodec( lines: var seq[string], structName: string, fields: seq[(string, string)] ) = @@ -112,6 +144,7 @@ proc emitEventDispatcher( for ev in events: let methodName = "addOn" & capitalizeFirstLetter(ev.nimProcName).substr(2) & "Listener" + lines.add(renderMemberDocComment(ev.doc)) lines.add( " ListenerHandle $1(std::function handler) {" % [methodName, ev.payloadTypeName] @@ -185,6 +218,7 @@ proc generateCppHeader*( types: seq[FFITypeMeta], libName: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ): string = var lines: seq[string] = @[] @@ -197,12 +231,39 @@ proc generateCppHeader*( # Generic CBOR overloads must precede the non-template struct codecs that call them (parse-time name lookup). lines.add(CborHelpersTpl) - if types.len > 0: + if consts.len > 0: + lines.add("// ============================================================") + lines.add("// Generated constants") + lines.add("// ============================================================") + lines.add("") + for c in consts: + let t = parseFFIType(c.typeName) + # A string const is a `const char*`, not std::string: constexpr can't own a heap value. + let cppType = + if t.kind == ftStr: + "const char*" + else: + nimTypeToCpp(c.typeName) + lines.add( + "constexpr $1 $2 = $3;" % + [cppType, identToUpperSnake(c.name), cConstValue(t, c.value)] + ) + lines.add("") + + # Enums first: a struct codec that takes one must see its overload already declared. + var structTypes: seq[FFITypeMeta] = @[] + for t in types: + if t.isEnum(): + emitEnumCborCodec(lines, t) + else: + structTypes.add(t) + + if structTypes.len > 0: lines.add("// ============================================================") lines.add("// User-declared FFI types") lines.add("// ============================================================") lines.add("") - for t in types: + for t in structTypes: lines.add("struct $1 {" % [t.name]) for f in t.fields: lines.add(" $1 $2;" % [nimTypeToCpp(f.typeName), f.name]) @@ -251,6 +312,7 @@ proc generateCppHeader*( ) lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: lines.add( @@ -312,6 +374,7 @@ proc generateCppHeader*( # `create` yields the ctx via the callback's CBOR address (sync void* return discarded), owned as a unique_ptr since the class forbids copy/move. let createRet = "Result>" % [ctxTypeName] + lines.add(renderMemberDocComment(ctor.doc)) lines.add(" static $1 create($2) {" % [createRet, ctorParamsWithTimeout]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") @@ -363,6 +426,7 @@ proc generateCppHeader*( epNames.join(", ") & ", timeout" else: "timeout" + lines.add(renderMemberDocComment(ctor.doc)) lines.add( " static std::future>> createAsync($2) {" % [ctxTypeName, ctorParamsWithTimeout] @@ -405,6 +469,7 @@ proc generateCppHeader*( let reqInit = cppBracedInit(reqName, methParamNames) let methRet = "Result<$1>" % [retCppType] + lines.add(renderMemberDocComment(m.doc)) lines.add(" $1 $2($3) const {" % [methRet, methodName, methParamsStr]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") @@ -425,6 +490,7 @@ proc generateCppHeader*( lines.add(" }") lines.add("") # `this->methodName(...)` so a same-named param can't shadow the call target. + lines.add(renderMemberDocComment(m.doc)) if methParamsStr.len > 0: lines.add( " std::future<$1> $2Async($3) const {" % [methRet, methodName, methParamsStr] @@ -472,9 +538,11 @@ proc generateCppBindings*( outputDir: string, nimSrcRelPath: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ) = createDir(outputDir) writeFile( - outputDir / (libName & ".hpp"), generateCppHeader(procs, types, libName, events) + outputDir / (libName & ".hpp"), + generateCppHeader(procs, types, libName, events, consts), ) writeFile(outputDir / "CMakeLists.txt", generateCppCMakeLists(libName, nimSrcRelPath)) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 1aa7314..7886d9a 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -26,6 +26,7 @@ type libName*: string kind*: FFIKind libTypeName*: string + doc*: string extraParams*: seq[FFIParamMeta] # all params except the lib param returnTypeName*: string returnIsPtr*: bool @@ -39,10 +40,26 @@ type name*: string typeName*: string + FFIEnumValueMeta* = object + ## One `{.ffi.}` enum value. `wire` is what `$value` yields — the symbol name, + ## or the associated string if the enum declares one — which is exactly what + ## cbor_serialization puts on the wire. + name*: string + wire*: string + ord*: int + FFITypeMeta* = object name*: string fields*: seq[FFIFieldMeta] abiFormat*: ABIFormat + enumValues*: seq[FFIEnumValueMeta] ## non-empty iff the type is an enum + + FFIConstMeta* = object + ## A `{.ffiConst.}` value. `value` is the compile-time-evaluated result of + ## `$theConst`, re-rendered as a literal by each backend. + name*: string + typeName*: string + value*: string FFIEventMeta* = object ## Library-initiated event from `{.ffiEvent: "wire_name".}`; `wireName` is @@ -52,10 +69,12 @@ type libName*: string payloadTypeName*: string abiFormat*: ABIFormat + doc*: string var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta] var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] var ffiEventRegistry* {.compileTime.}: seq[FFIEventMeta] +var ffiConstRegistry* {.compileTime.}: seq[FFIConstMeta] var currentLibName* {.compileTime.}: string # Set by `declareLibrary`; the FFI annotations require it. @@ -118,6 +137,15 @@ var ffiHandleTypeNames* {.compileTime.}: seq[string] proc isFFIHandleTypeName*(name: string): bool {.compileTime.} = name in ffiHandleTypeNames +func isEnum*(t: FFITypeMeta): bool = + return t.enumValues.len > 0 + +# Names of `{.ffi.}` enum types; the `abi = c` wire path has to reject them. +var ffiEnumTypeNames* {.compileTime.}: seq[string] + +proc isFFIEnumTypeName*(name: string): bool {.compileTime.} = + name in ffiEnumTypeNames + proc ridesAsPtr*(ep: FFIParamMeta): bool = ## True if the param crosses the wire as an opaque uint64 (raw ptr or handle). ep.isPtr or ep.isHandle diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index f07d655..4efe46e 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -1,7 +1,7 @@ ## Rust binding generator: emits a complete Rust crate using CBOR (ciborium). import std/[os, strutils] -import ./meta, ./string_helpers, ./types_ir +import ./meta, ./string_helpers, ./types_ir, ./consts ## Wire-format Rust type for any Nim `ptr T`/`pointer`; fixed 64-bit for a ## host-independent CBOR payload size (mirrors CppPtrType). @@ -183,6 +183,7 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string = for p in procs: var params: seq[string] = @[] + lines.add(renderMemberDocComment(p.doc)) case p.kind of FFIKind.FFI: # Method-style: ctx first. @@ -216,13 +217,49 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string = lines.add("}") return lines.join("\n") & "\n" -proc generateTypesRs*(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): string = +func rustConstType(typeName: string): string = + ## `&str` rather than `String`: a `pub const` can't own a heap value. The + ## 'static lifetime is implied, and spelling it out trips clippy. + let t = parseFFIType(typeName) + if t.kind == ftStr: + return "&str" + return renderNative(rustMap, t) + +proc generateTypesRs*( + types: seq[FFITypeMeta], procs: seq[FFIProcMeta], consts: seq[FFIConstMeta] = @[] +): string = ## Generates types.rs: Rust structs for user FFI types and each per-proc Req. var lines: seq[string] = @[] lines.add("use serde::{Deserialize, Serialize};") lines.add("") + for c in consts: + let t = parseFFIType(c.typeName) + lines.add( + "pub const $1: $2 = $3;" % [ + identToUpperSnake(c.name), rustConstType(c.typeName), rustConstValue(t, c.value) + ] + ) + if consts.len > 0: + lines.add("") + for t in types: + if not t.isEnum(): + continue + lines.add("#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]") + lines.add("pub enum $1 {" % [t.name]) + for v in t.enumValues: + let variant = capitalizeFirstLetter(v.name) + # serde carries the same text form Nim's cbor_serialization writes. + if variant != v.wire: + lines.add(" #[serde(rename = \"$1\")]" % [v.wire]) + lines.add(" $1," % [variant]) + lines.add("}") + lines.add("") + + for t in types: + if t.isEnum(): + continue lines.add("#[derive(Debug, Clone, Serialize, Deserialize)]") lines.add("pub struct $1 {" % [t.name]) for f in t.fields: @@ -544,6 +581,7 @@ proc generateApiRs*( else: reqName & " {}" + lines.add(renderMemberDocComment(ctor.doc)) lines.add(" pub fn create($1) -> Result {" % [ctorParamsStr]) lines.add(" let req = $1;" % [reqLit]) lines.add(" let req_bytes = encode_cbor(&req)?;") @@ -569,6 +607,7 @@ proc generateApiRs*( lines.add(" }") lines.add("") + lines.add(renderMemberDocComment(ctor.doc)) lines.add( " pub async fn new_async($1) -> Result {" % [ctorParamsStr] ) @@ -621,6 +660,7 @@ proc generateApiRs*( let methodName = "add_" & camelToSnakeCase(ev.nimProcName) & "_listener" let handlerStruct = capitalizeFirstLetter(ev.nimProcName) & "Handler" let trampolineName = camelToSnakeCase(ev.nimProcName) & "_trampoline" + lines.add(renderMemberDocComment(ev.doc)) lines.add( " /// Register a typed listener for `$1`. The returned handle can be" % [ev.wireName] @@ -690,6 +730,7 @@ proc generateApiRs*( let retTypeForApi = if m.returnRidesAsPtr(): RustPtrType else: retRustType + lines.add(renderMemberDocComment(m.doc)) lines.add( " pub fn $1(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] @@ -707,6 +748,7 @@ proc generateApiRs*( lines.add("") # async method: ptr cast to usize (Copy + Send) keeps the move closure and returned future Send for multi-threaded tokio runtimes. + lines.add(renderMemberDocComment(m.doc)) lines.add( " pub async fn $1_async(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] @@ -736,6 +778,7 @@ proc generateRustCrate*( outputDir: string, nimSrcRelPath: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ) = ## Generates a complete Rust crate in outputDir. createDir(outputDir) @@ -745,5 +788,5 @@ proc generateRustCrate*( 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, procs)) + writeFile(outputDir / "src" / "types.rs", generateTypesRs(types, procs, consts)) writeFile(outputDir / "src" / "api.rs", generateApiRs(procs, libName, events)) diff --git a/ffi/codegen/string_helpers.nim b/ffi/codegen/string_helpers.nim index a44eef5..663c5f7 100644 --- a/ffi/codegen/string_helpers.nim +++ b/ffi/codegen/string_helpers.nim @@ -1,7 +1,48 @@ -## Unicode-aware identifier-casing helpers shared by codegen and the FFI macro. +## Unicode-aware identifier casing and doc-comment rendering, shared by codegen +## and the FFI macro. import std/[strutils, unicode] +func docLines(doc: string): seq[string] = + ## `doc` split into lines, trailing blank ones dropped. + if doc.strip().len == 0: + return @[] + var lines = doc.splitLines() + while lines.len > 0 and lines[^1].strip().len == 0: + lines.setLen(lines.len - 1) + return lines + +func renderDocComment*(doc, indent, prefix: string): seq[string] = + ## `doc` as one `prefix`-led line comment per source line, at `indent`. + var rendered: seq[string] = @[] + for line in docLines(doc): + # A trailing `\` would splice the next generated line into a `//` comment. + rendered.add( + indent & (prefix & line).strip(leading = false, chars = Whitespace + {'\\'}) + ) + return rendered + +func renderMemberDocComment*(doc: string): seq[string] = + ## `///` at the indent C++ class members and Rust `impl` items sit at. + return doc.renderDocComment(" ", "/// ") + +func escapeBlockComment(line: string): string = + ## `*/` would close the comment early and splice the rest in as code. + return line.replace("*/", "* /") + +func renderBlockDocComment*(doc: string, indent = ""): seq[string] = + ## `doc` as a `/** ... */` block at `indent`; one-liners stay on one line. + let lines = docLines(doc) + if lines.len == 0: + return @[] + if lines.len == 1: + return @[indent & "/** " & escapeBlockComment(lines[0].strip()) & " */"] + var rendered = @[indent & "/**"] + for line in lines: + rendered.add((indent & " * " & escapeBlockComment(line)).strip(leading = false)) + rendered.add(indent & " */") + return rendered + proc toLower*(s: string): string = ## Unicode-aware lowercase for an entire string. var buf = "" @@ -28,6 +69,24 @@ func capitalizeFirstLetter*(s: string): string = runesSeq[0] = runesSeq[0].toUpper() return $runesSeq +func identToUpperSnake*(s: string): string = + ## Nim identifier → UPPER_SNAKE, keeping acronym runs intact: "maxPeers" and + ## "MAX_PEERS" both give "MAX_PEERS", "httpTTL" gives "HTTP_TTL". + var upper = "" + let rs = toRunes(s) + for i, r in rs: + if r == Rune('_'): + if upper.len > 0 and upper[^1] != '_': + upper.add('_') + continue + let startsWord = + i > 0 and r.isUpper() and + (not rs[i - 1].isUpper() or (i + 1 < rs.len and rs[i + 1].isLower())) + if startsWord and upper.len > 0 and upper[^1] != '_': + upper.add('_') + upper.add($r.toUpper()) + return upper + proc snakeToPascalCase*(s: string): string = ## snake_case → PascalCase, e.g. "hello_world" → "HelloWorld". let parts = s.split('_') diff --git a/ffi/internal/c_macro_helpers.nim b/ffi/internal/c_macro_helpers.nim index 0d1a23c..59ed6e6 100644 --- a/ffi/internal/c_macro_helpers.nim +++ b/ffi/internal/c_macro_helpers.nim @@ -62,13 +62,22 @@ proc tupleComponents(t: NimNode): seq[tuple[name: string, typ: NimNode]] = proc isKnownFFIType(name: string): bool {.compileTime.} = for typeMeta in ffiTypeRegistry: - if typeMeta.name == name: + if typeMeta.name == name and not typeMeta.isEnum(): return true false proc isNestedFFIType(t: NimNode): bool = + ## Enums are excluded: they are registered types but have no `_CWire` + ## companion, and treating one as a nested struct would silently drop its value. t.kind == nnkIdent and isKnownFFIType($t) +proc rejectEnumOnCWire(t: NimNode) {.compileTime.} = + if t.kind == nnkIdent and isFFIEnumTypeName($t): + error( + "cwire: `abi = c` does not support enum types yet, but " & $t & + " crosses the boundary here; use the CBOR ABI for this proc or type" + ) + proc cwireNeedsFree(t: NimNode): bool = ## Whether the wire form of `t` owns allocations `cwireFree` must release. if isStringType(t) or isNestedFFIType(t) or isOptionType(t) or isSeqType(t): @@ -90,6 +99,7 @@ proc rejectNestedSeq(t: NimNode) = proc wireValueType(t: NimNode): NimNode = ## Single-field wire form of value type `t`; `seq` has none, so it errors here. + rejectEnumOnCWire(t) if isStringType(t): return ident("cstring") if isNestedFFIType(t): diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 7625ab0..e46fda2 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1,4 +1,4 @@ -import std/[macros, tables, strutils] +import std/[macros, options, tables, strutils] from std/os import `/`, relativePath from std/compilesettings import querySetting, SingleValueSetting import chronos @@ -107,6 +107,74 @@ proc rejectRawPtrType(typ: NimNode, where: string) = "(only the ctx handle, managed by the framework, may be a pointer)" ) +proc enumWireName(rhs: NimNode, fieldName: string): string {.compileTime.} = + ## What `$value` yields: the associated string if the enum declares one + ## (`cRed = "red"` or `cRed = (3, "red")`), else the symbol name. + case rhs.kind + of nnkStrLit, nnkRStrLit, nnkTripleStrLit: + $rhs + of nnkTupleConstr, nnkPar: + if rhs.len == 2 and rhs[1].kind in {nnkStrLit, nnkRStrLit, nnkTripleStrLit}: + $rhs[1] + else: + fieldName + else: + fieldName + +proc enumValueMetas( + enumTy: NimNode, typeName: string +): seq[FFIEnumValueMeta] {.compileTime.} = + ## Walks an `nnkEnumTy`, resolving each value's wire name and ordinal. + var values: seq[FFIEnumValueMeta] = @[] + var nextOrd = 0 + for child in enumTy: + if child.kind == nnkEmpty: + continue + var name: string + var wire: string + var ordinal = nextOrd + case child.kind + of nnkIdent, nnkSym: + name = $child + wire = name + of nnkEnumFieldDef: + name = $child[0] + wire = enumWireName(child[1], name) + let explicitOrd = + if child[1].kind == nnkIntLit: + some(int(child[1].intVal)) + elif child[1].kind in {nnkTupleConstr, nnkPar} and child[1].len == 2 and + child[1][0].kind == nnkIntLit: + some(int(child[1][0].intVal)) + else: + none(int) + if explicitOrd.isSome(): + ordinal = explicitOrd.get() + else: + error("`.ffi.` enum " & typeName & ": unsupported enum value " & child.repr) + values.add(FFIEnumValueMeta(name: name, wire: wire, ord: ordinal)) + nextOrd = ordinal + 1 + values + +proc registerFFIEnumInfo( + typeDef: NimNode, typeNameStr: string, abiFormat: ABIFormat +) {.compileTime.} = + ## Registers an `{.ffi.}` enum. Only the CBOR wire carries enums; `abi = c` + ## has no representation for them yet, so reject it at the annotation. + if abiFormat == ABIFormat.C: + error( + "`.ffi.` enum " & typeNameStr & + ": `abi = c` does not support enum types yet; use the CBOR ABI for this type" + ) + ffiTypeRegistry.add( + FFITypeMeta( + name: typeNameStr, + abiFormat: abiFormat, + enumValues: enumValueMetas(typeDef[2], typeNameStr), + ) + ) + ffiEnumTypeNames.add(typeNameStr) + proc registerFFITypeInfo( typeDef: NimNode, abiFormat: ABIFormat ): NimNode {.compileTime.} = @@ -118,6 +186,10 @@ proc registerFFITypeInfo( typeDef[0] let typeNameStr = $typeName + if typeDef[2].kind == nnkEnumTy: + registerFFIEnumInfo(typeDef, typeNameStr, abiFormat) + return typeDef + var fieldMetas: seq[FFIFieldMeta] = @[] let objTy = typeDef[2] if objTy.kind == nnkObjectTy and objTy.len >= 3: @@ -143,6 +215,16 @@ proc registerFFITypeInfo( ) return typeDef +func extractDocComment(prc: NimNode): string {.compileTime.} = + ## The proc's leading `##`, or "". Nim drops comments outside a proc body, so + ## types and fields are unreachable from here. + let body = prc[^1] + if body.kind != nnkStmtList or body.len == 0: + return "" + if body[0].kind != nnkCommentStmt: + return "" + return body[0].strVal + proc nimTypeNameRepr(typ: NimNode): string = ## Stringifies a parameter or field type for the registry. case typ.kind @@ -680,6 +762,51 @@ macro ffiHandle*(args: varargs[untyped]): untyped = echo clean.repr return clean +proc registerFFIConst(nameNode: NimNode): NimNode {.compileTime.} = + ## Emits the type guard plus the `static:` block that records the const's + ## evaluated value; `$typeof` runs after the const is defined, so computed + ## expressions (`3 * 7`) land in the registry as their result. + let nameStr = newLit($nameNode) + let unsupported = newLit( + "`.ffiConst.` " & $nameNode & + ": only integer, float, bool and string consts can cross the FFI boundary" + ) + # bindSym: the emitted code lands in the user's module, which doesn't import meta. + let registry = bindSym("ffiConstRegistry") + let metaType = bindSym("FFIConstMeta") + quote: + when not (`nameNode` is (SomeInteger | SomeFloat | bool | string)): + {.error: `unsupported`.} + static: + `registry`.add( + `metaType`(name: `nameStr`, typeName: $typeof(`nameNode`), value: $(`nameNode`)) + ) + +macro ffiConst*(args: varargs[untyped]): untyped = + ## Exposes a Nim `const` to the generated bindings as a native constant + ## (`static const` in C/C++, `pub const` in Rust). An `"abi = ..."` spec is + ## accepted but only validated — a constant never rides the wire. + requireBeforeGenBindings("`.ffiConst.`") + requireLibraryDeclared("`.ffiConst.`") + let section = args[^1] + discard resolveABIFormat(args[0 ..^ 2]) + if section.kind != nnkConstSection: + error("`.ffiConst.` must be applied to a `const` definition") + + # Nim splits the section so only the annotated defs reach this macro. + var stmts = newStmtList(section.copyNimTree()) + for def in section: + let nameNode = + if def[0].kind == nnkPostfix: + def[0][1] + else: + def[0] + stmts.add(registerFFIConst(nameNode)) + + when defined(ffiDumpMacros): + echo stmts.repr + return stmts + macro ffi*(args: varargs[untyped]): untyped = ## Simplified FFI macro for procs or types: a type registers for binding gen; a ## proc takes a library-type param plus optional Nim params, returns @@ -807,6 +934,7 @@ macro ffi*(args: varargs[untyped]): untyped = returnIsPtr: retIsPtr, returnIsHandle: retIsHandle, abiFormat: abiFormat, + doc: extractDocComment(prc), ) # CBOR-free scalar fast path: only `abi = c` with all-scalar params/return that fit the inline slots; non-scalar `abi = c` rides the `_CWire` C-dispatch. @@ -1352,6 +1480,7 @@ macro ffiCtor*(args: varargs[untyped]): untyped = returnTypeName: $libTypeName, returnIsPtr: false, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) @@ -1487,6 +1616,7 @@ macro ffiDtor*(args: varargs[untyped]): untyped = returnTypeName: "", returnIsPtr: false, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) @@ -1576,6 +1706,7 @@ macro ffiEvent*(args: varargs[untyped]): untyped = libName: currentLibName, payloadTypeName: payloadTypeNameStr, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) @@ -1637,15 +1768,18 @@ when defined(ffiGenBindings): case lang of "rust": generateRustCrate( - genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry, + ffiConstRegistry, ) of "cpp", "c++": generateCppBindings( - genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry, + ffiConstRegistry, ) of "c": generateCBindings( - genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry, + ffiConstRegistry, ) of "cddl": generateCddlBindings(genProcs, ffiTypeRegistry, libName, outDir, srcRel) diff --git a/tests/e2e/c/test_timer_e2e.c b/tests/e2e/c/test_timer_e2e.c index e6939e8..3ab553c 100644 --- a/tests/e2e/c/test_timer_e2e.c +++ b/tests/e2e/c/test_timer_e2e.c @@ -74,6 +74,7 @@ typedef struct { char text_a[256]; char text_b[256]; long long num_a; + long long num_b; int flag; } ReplyWaiter; @@ -91,7 +92,7 @@ static void test_version(MyTimerCtx* ctx) { my_timer_ctx_version(ctx, on_version, &w); wait_done(&w.done); assert(w.err_code == 0); - assert(strcmp(w.text_a, "nim-timer v0.1.0") == 0); + assert(strcmp(w.text_a, TIMER_VERSION) == 0); } static void on_echo(int ec, const EchoResponse* reply, const char* em, void* ud) { @@ -158,6 +159,8 @@ static void on_schedule(int ec, const ScheduleResult* reply, const char* em, voi w->err_code = ec; if (reply) { w->num_a = (long long)reply->willRunCount; + w->num_b = (long long)reply->effectiveBackoffMs; + w->flag = (int)reply->priority; if (reply->jobId.data) snprintf(w->text_a, sizeof(w->text_a), "%s", reply->jobId.data); } if (em) snprintf(w->err, sizeof(w->err), "%s", em); @@ -170,7 +173,7 @@ static void test_schedule_ok(MyTimerCtx* ctx) { job.name = nimffi_str("rollup"); job.payload.data = payload; job.payload.len = 1; - job.priority = 1; + job.priority = JOB_PRIORITY_JP_HIGH; NimFfiStr retry_on[1] = {nimffi_str("timeout")}; RetryPolicy retry; @@ -192,6 +195,9 @@ static void test_schedule_ok(MyTimerCtx* ctx) { assert(w.err_code == 0); assert(strcmp(w.text_a, "c-e2e:rollup") == 0); assert(w.num_a == 1); + /* jpHigh halves the requested 100ms backoff. */ + assert(w.num_b == 50); + assert(w.flag == JOB_PRIORITY_JP_HIGH); } static void test_schedule_error(MyTimerCtx* ctx) { @@ -200,7 +206,7 @@ static void test_schedule_error(MyTimerCtx* ctx) { job.name = nimffi_str(""); /* empty name → handler returns err */ job.payload.data = payload; job.payload.len = 1; - job.priority = 1; + job.priority = JOB_PRIORITY_JP_LOW; NimFfiStr retry_on[1] = {nimffi_str("timeout")}; RetryPolicy retry; @@ -224,6 +230,16 @@ static void test_schedule_error(MyTimerCtx* ctx) { assert(strstr(w.err, "job name") != NULL); } +static void test_delay_limit(MyTimerCtx* ctx) { + ReplyWaiter w; + memset(&w, 0, sizeof(w)); + EchoRequest req = {nimffi_str("too-slow"), MAX_DELAY_MS + 1}; + my_timer_ctx_echo(ctx, &req, on_echo, &w); + wait_done(&w.done); + assert(w.err_code != 0); + assert(strstr(w.err, "delayMs") != NULL); +} + static void test_event(MyTimerCtx* ctx) { int hits = 0; uint64_t handle = @@ -256,8 +272,9 @@ int main(void) { test_complex(ctx); test_schedule_ok(ctx); test_schedule_error(ctx); + test_delay_limit(ctx); test_event(ctx); - my_timer_ctx_destroy(ctx); + assert(my_timer_ctx_destroy(ctx) == NIMFFI_RET_OK); printf("all C e2e checks passed\n"); return 0; } diff --git a/tests/e2e/c_abi/test_echo_c_abi.c b/tests/e2e/c_abi/test_echo_c_abi.c index 893ff43..e153b09 100644 --- a/tests/e2e/c_abi/test_echo_c_abi.c +++ b/tests/e2e/c_abi/test_echo_c_abi.c @@ -86,6 +86,21 @@ static void test_shout(EchoCtx* ctx) { assert(strcmp(w.text_b, "c-abi") == 0); } +/* Constants are ABI-agnostic: the abi = c header carries the same limit. */ +static void test_shout_too_long(EchoCtx* ctx) { + char text[MAX_SHOUT_LEN + 2]; + memset(text, 'a', sizeof(text) - 1); + text[sizeof(text) - 1] = '\0'; + + ReplyWaiter w; + memset(&w, 0, sizeof(w)); + ShoutRequest req = {text}; + echo_ctx_shout(ctx, &req, on_shout, &w); + wait_done(&w.done); + assert(w.err_code != 0); + assert(strstr(w.err, "must not exceed") != NULL); +} + static void on_version(int ec, const char* reply, const char* em, void* ud) { ReplyWaiter* w = (ReplyWaiter*)ud; w->err_code = ec; @@ -106,8 +121,9 @@ static void test_version(EchoCtx* ctx) { int main(void) { EchoCtx* ctx = make_ctx(); test_shout(ctx); + test_shout_too_long(ctx); test_version(ctx); - echo_ctx_destroy(ctx); + assert(echo_ctx_destroy(ctx) == NIMFFI_RET_OK); printf("all abi=c echo e2e checks passed\n"); return 0; } diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index 2f0dced..469de9c 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -56,13 +56,13 @@ TEST(TimerE2E, CreateAndDestroy) { TEST(TimerE2E, VersionSync) { auto ctx = makeCtx("version-sync"); const auto v = mustOk(ctx->version()); - EXPECT_EQ(v, "nim-timer v0.1.0"); + EXPECT_EQ(v, TIMER_VERSION); } TEST(TimerE2E, VersionAsync) { auto ctx = makeCtx("version-async"); auto fut = ctx->versionAsync(); - EXPECT_EQ(mustOk(fut.get()), "nim-timer v0.1.0"); + EXPECT_EQ(mustOk(fut.get()), TIMER_VERSION); } TEST(TimerE2E, EchoRoundTripsMessageAndTimerName) { @@ -152,6 +152,32 @@ TEST(TimerE2E, IndependentContextsKeepTheirOwnState) { EXPECT_EQ(rB.timerName, "beta"); } +// jpHigh halves the requested backoff, and the enum comes back as itself. +TEST(TimerE2E, SchedulePriorityRoundTrips) { + auto ctx = makeCtx("prio"); + const auto job = JobSpec{"rollup", {"p"}, JobPriority::jpHigh}; + const auto res = mustOk(ctx->schedule(job, RetryPolicy{3, 100, {}}, + ScheduleConfig{0, 0, std::nullopt})); + EXPECT_EQ(res.priority, JobPriority::jpHigh); + EXPECT_EQ(res.effectiveBackoffMs, 50); +} + +// backoffMs 0 falls back to DEFAULT_BACKOFF_MS, doubled for jpLow. +TEST(TimerE2E, ScheduleDefaultBackoffComesFromConst) { + auto ctx = makeCtx("prio-default"); + const auto job = JobSpec{"rollup", {}, JobPriority::jpLow}; + const auto res = mustOk(ctx->schedule(job, RetryPolicy{1, 0, {}}, + ScheduleConfig{0, 0, std::nullopt})); + EXPECT_EQ(res.effectiveBackoffMs, DEFAULT_BACKOFF_MS * 2); +} + +TEST(TimerE2E, EchoRejectsDelayAboveMaxDelayMs) { + auto ctx = makeCtx("max-delay"); + const auto res = ctx->echo(EchoRequest{"too-slow", MAX_DELAY_MS + 1}); + ASSERT_TRUE(res.isErr()); + EXPECT_EQ(res.error(), "delayMs must not exceed " + std::to_string(MAX_DELAY_MS)); +} + // N contexts keep independent state; an error on one must not poison siblings. // Empty JobSpec.name is the chosen error trigger: schedule() returns // err("job name must not be empty"), which the bindings surface as an @@ -170,7 +196,7 @@ TEST(TimerE2E, MultiContextIsolation) { EXPECT_EQ(resp.timerName, "iso-" + std::to_string(i)); } - const auto bad = JobSpec{/*name*/ "", /*payload*/ {}, /*priority*/ 0}; + const auto bad = JobSpec{/*name*/ "", /*payload*/ {}, JobPriority::jpNormal}; const auto retry = RetryPolicy{1, 10, {}}; const auto sched = ScheduleConfig{0, 0, std::nullopt}; const auto scheduleRes = ctxs[2]->schedule(bad, retry, sched); @@ -194,7 +220,7 @@ TEST(TimerE2E, CrossLibrary) { auto timerCtx = mustOk(MyTimerCtx::create(TimerConfig{"x-timer"})); auto echoCtx = mustOk(EchoCtx::create(EchoConfig{"X-ECHO"})); - EXPECT_EQ(mustOk(timerCtx->version()), "nim-timer v0.1.0"); + EXPECT_EQ(mustOk(timerCtx->version()), TIMER_VERSION); EXPECT_EQ(mustOk(echoCtx->version()), "nim-echo v0.1.0"); const auto timerResp = mustOk(timerCtx->echo(EchoRequest{"hello", 0})); diff --git a/tests/unit/fixture_gen.nim b/tests/unit/fixture_gen.nim new file mode 100644 index 0000000..4f6ef18 --- /dev/null +++ b/tests/unit/fixture_gen.nim @@ -0,0 +1,48 @@ +## Drives the compile fixtures under `fixtures/` through a child compiler, so a +## fixture's success or failure becomes an assertion instead of this suite's own +## compile error. + +import std/[os, osproc, strutils, compilesettings] + +const + nimExe = getCurrentCompilerExe() + ffiSearchPaths = querySettingSeq(searchPaths) + fixtureDir = currentSourcePath().parentDir() / "fixtures" + +func compilerCmd(subCmd, fixture, cacheTag: string): string = + var cmd = quoteShell(nimExe) & " " & subCmd & " --hints:off --warnings:off" + for p in ffiSearchPaths: + cmd.add(" --path:" & quoteShell(p)) + cmd.add(" --nimcache:" & quoteShell(getTempDir() / ("ffi_fixture_cache_" & cacheTag))) + return cmd + +proc genFixtureBindings*( + fixture, lang: string, extraDefs: openArray[string] = [] +): tuple[outDir: string, output: string, exitCode: int] = + ## Generates `lang` bindings for `fixtures/.nim`. The output dir is + ## wiped first, so a stale artifact from an earlier run can't satisfy an + ## assertion the current generator would have failed. + let tag = fixture & "_" & lang + let outDir = getTempDir() / ("ffi_fixture_out_" & tag) + removeDir(outDir) + createDir(outDir) + var cmd = compilerCmd("c --compileOnly", fixture, tag) + cmd.add(" -d:ffiGenBindings -d:targetLang=" & lang) + cmd.add(" -d:ffiOutputDir=" & quoteShell(outDir)) + for d in extraDefs: + cmd.add(" " & d) + cmd.add(" " & quoteShell(fixtureDir / (fixture & ".nim"))) + let (output, code) = execCmdEx(cmd) + if code != 0: + echo output + return (outDir, output, code) + +proc checkFixture*( + fixture: string, extraDefs: openArray[string] = [] +): tuple[output: string, exitCode: int] = + ## `nim check` of a fixture expected to fail, so its error is a test assertion. + var cmd = compilerCmd("check", fixture, fixture & "_check") + for d in extraDefs: + cmd.add(" " & d) + cmd.add(" " & quoteShell(fixtureDir / (fixture & ".nim"))) + return execCmdEx(cmd) diff --git a/tests/unit/fixtures/consts_fixture.nim b/tests/unit/fixtures/consts_fixture.nim new file mode 100644 index 0000000..570fd9f --- /dev/null +++ b/tests/unit/fixtures/consts_fixture.nim @@ -0,0 +1,32 @@ +## Compile fixture for `{.ffiConst.}` binding generation (see +## tests/unit/test_ffi_const.nim): one const of every supported shape, including +## a computed value and a string needing escapes. + +import ffi, chronos + +type ConstLib = object + base: int + +declareLibrary("constlib", ConstLib) + +const + MaxPeers* {.ffiConst.} = 42 + DefaultTimeoutMs* {.ffiConst.}: uint32 = 3 * 1000 + Ratio* {.ffiConst.} = 1.5 + DebugEnabled* {.ffiConst.} = false + Greeting* {.ffiConst.} = "he\"llo\n" + HTTPPort* {.ffiConst.} = 8080 + +type ConstConfig {.ffi.} = object + base: int + +proc constlib_create*(cfg: ConstConfig): Future[Result[ConstLib, string]] {.ffiCtor.} = + return ok(ConstLib(base: cfg.base)) + +proc constlib_base*(lib: ConstLib): Future[Result[int, string]] {.ffi.} = + return ok(lib.base) + +proc constlib_destroy*(lib: ConstLib) {.ffiDtor.} = + discard + +genBindings() diff --git a/tests/unit/fixtures/enum_abi_c_field_fixture.nim b/tests/unit/fixtures/enum_abi_c_field_fixture.nim new file mode 100644 index 0000000..c90153a --- /dev/null +++ b/tests/unit/fixtures/enum_abi_c_field_fixture.nim @@ -0,0 +1,18 @@ +## Compile fixture: a CBOR enum reached from an `abi = c` type has no `_CWire` +## form, so the cwire builder must reject it (see tests/unit/test_ffi_enum.nim). + +import ffi, chronos + +type AbiFieldLib = object + n: int + +declareLibrary("abifieldlib", AbiFieldLib) + +type Color {.ffi.} = enum + cRed + cGreen + +type Wrapper {.ffi: "abi = c".} = object + color: Color + +genBindings() diff --git a/tests/unit/fixtures/enum_abi_c_type_fixture.nim b/tests/unit/fixtures/enum_abi_c_type_fixture.nim new file mode 100644 index 0000000..cd2c5d1 --- /dev/null +++ b/tests/unit/fixtures/enum_abi_c_type_fixture.nim @@ -0,0 +1,15 @@ +## Compile fixture: an enum in an `abi = c` library must be rejected, not +## silently mis-wired (see tests/unit/test_ffi_enum.nim). + +import ffi, chronos + +type AbiEnumLib = object + n: int + +declareLibrary("abienumlib", AbiEnumLib, "c") + +type Color {.ffi.} = enum + cRed + cGreen + +genBindings() diff --git a/tests/unit/fixtures/enum_fixture.nim b/tests/unit/fixtures/enum_fixture.nim new file mode 100644 index 0000000..e594ed7 --- /dev/null +++ b/tests/unit/fixtures/enum_fixture.nim @@ -0,0 +1,48 @@ +## Compile fixture for `{.ffi.}` enum binding generation (see +## tests/unit/test_ffi_enum.nim): a plain enum, one with associated strings and +## one with explicit ordinals, used as a field and as a param/return type. + +import ffi, chronos + +type EnumLib = object + calls: int + +declareLibrary("enumlib", EnumLib) + +type + Color {.ffi.} = enum + cRed + cGreen + cBlue + + Level {.ffi.} = enum + lLow = "low" + lHigh = "high" + + Status {.ffi.} = enum + stIdle = 2 + stBusy = 7 + +type PaintRequest {.ffi.} = object + color: Color + level: Level + +type PaintResponse {.ffi.} = object + status: Status + applied: Color + +proc enumlib_create*(): Future[Result[EnumLib, string]] {.ffiCtor.} = + return ok(EnumLib(calls: 0)) + +proc enumlib_paint*( + lib: EnumLib, req: PaintRequest +): Future[Result[PaintResponse, string]] {.ffi.} = + return ok(PaintResponse(status: stBusy, applied: req.color)) + +proc enumlib_pick*(lib: EnumLib, level: Level): Future[Result[Color, string]] {.ffi.} = + return ok(if level == lHigh: cBlue else: cRed) + +proc enumlib_destroy*(lib: EnumLib) {.ffiDtor.} = + discard + +genBindings() diff --git a/tests/unit/test_c_abi_codegen.nim b/tests/unit/test_c_abi_codegen.nim index 3383ed7..8df85d0 100644 --- a/tests/unit/test_c_abi_codegen.nim +++ b/tests/unit/test_c_abi_codegen.nim @@ -133,6 +133,19 @@ suite "generateCAbiLibHeader": check "timer_ctx_version(" in header check "timer_ctx_destroy(" in header + test "the context destructor propagates the destructor's status code": + check( + """ +static inline int timer_ctx_destroy(TimerCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = timer_destroy(ctx->ptr); ctx->ptr = NULL; } + free(ctx); + return rc; +}""" in + header + ) + test "scalar-fast-path methods take inline args, not a Req struct": check "TimerAddReq" notin header check "TimerNameReq" notin header diff --git a/tests/unit/test_c_codegen.nim b/tests/unit/test_c_codegen.nim index a7ff5ab..972a8f5 100644 --- a/tests/unit/test_c_codegen.nim +++ b/tests/unit/test_c_codegen.nim @@ -124,6 +124,19 @@ suite "generateCLibHeader: ABI declarations and context API": check "timer_ctx_version(" in header check "timer_ctx_destroy(" in header + test "the context destructor propagates the destructor's status code": + check( + """ +static inline int timer_ctx_destroy(TimerCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = timer_destroy(ctx->ptr); ctx->ptr = NULL; } + free(ctx); + return rc; +}""" in + header + ) + test "the async API is callback-driven, not blocking": # methods take a typed reply callback + user_data; no out-param, no char** err check "typedef void (*TimerVersionReplyFn)(int err_code, const NimFfiStr* reply, const char* err_msg, void* user_data);" in @@ -192,6 +205,25 @@ suite "generateCLibHeader: events": test "the context tracks listeners only when events exist": check "TimerCtxListener* listeners;" in header + test "a failed teardown leaks the listener boxes instead of dangling them": + # A non-OK rc leaves the worker threads live, still holding each box as + # callback user_data, so the sweep must sit behind the rc guard. + check( + """ +static inline int timer_ctx_destroy(TimerCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + if (ctx->ptr) { rc = timer_destroy(ctx->ptr); ctx->ptr = NULL; } + if (rc == NIMFFI_RET_OK) { + for (size_t i = 0; i < ctx->listeners_len; i++) free(ctx->listeners[i].box); + } + free(ctx->listeners); + free(ctx); + return rc; +}""" in + header + ) + suite "generateCLibHeader: no-event libraries stay lean": test "a library without events has no listener bookkeeping": let procs = @[ @@ -208,6 +240,29 @@ suite "generateCLibHeader: no-event libraries stay lean": check "listeners_len" notin header check "_add_event_listener" in header # raw ABI symbol is always declared + test "a library without a dtor still reports success from ctx_destroy": + let procs = @[ + FFIProcMeta( + procName: "timer_create", + libName: "timer", + kind: FFIKind.CTOR, + libTypeName: "Timer", + extraParams: @[], + returnTypeName: "Timer", + ) + ] + let header = generateCLibHeader(procs, @[], "timer") + check( + """ +static inline int timer_ctx_destroy(TimerCtx* ctx) { + if (!ctx) return NIMFFI_RET_OK; + int rc = NIMFFI_RET_OK; + free(ctx); + return rc; +}""" in + header + ) + suite "generateCLibHeader: scalar-fast-path procs are excluded": setup: let procs = @[ diff --git a/tests/unit/test_doc_extraction.nim b/tests/unit/test_doc_extraction.nim new file mode 100644 index 0000000..5450bf9 --- /dev/null +++ b/tests/unit/test_doc_extraction.nim @@ -0,0 +1,98 @@ +## `##` comments on annotated procs → the registry. Codegen: test_doc_propagation.nim. + +import unittest2 +import results +import ffi +import ffi/codegen/meta + +type DocLib = object + +# Stub the importc NimMain declareLibrary emits (plain-exe link). +{.emit: "void libdoctestNimMain(void) {}".} + +declareLibrary("doctest", DocLib) + +type DocConfig {.ffi.} = object + v: int + +type Tocked {.ffi.} = object + n: int + +proc doctest_create*(c: DocConfig): Future[Result[DocLib, string]] {.ffiCtor.} = + ## Builds the lib. + return ok(DocLib()) + +proc doctest_documented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + ## First line. + ## Second line. + return ok("ok") + +proc doctest_undocumented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + return ok("ok") + +proc doctest_hash_commented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + # not a doc comment + return ok("ok") + +proc doctest_spec_documented*( + lib: DocLib, n: int +): Future[Result[int, string]] {.ffi: "abi = cbor".} = + ## Doc survives alongside a pragma spec. + return ok(n) + +proc doctest_destroy*(lib: DocLib) {.ffiDtor.} = + ## Tears it down. + discard + +proc doctest_tocked*(p: Tocked) {.ffiEvent: "on_tocked".} = + ## Fires on every tock. + +proc doctest_plain*(p: Tocked) {.ffiEvent: "on_plain".} + +# The registries are compile-time only, so snapshot them into consts. +proc docOf(procName: string): string {.compileTime.} = + for p in ffiProcRegistry: + if p.procName == procName: + return p.doc + doAssert false, "no proc " & procName & " in ffiProcRegistry" + +proc eventDocOf(wireName: string): string {.compileTime.} = + for e in ffiEventRegistry: + if e.wireName == wireName: + return e.doc + doAssert false, "no event " & wireName & " in ffiEventRegistry" + +const + CreateDoc = docOf("doctest_create") + DocumentedDoc = docOf("doctest_documented") + UndocumentedDoc = docOf("doctest_undocumented") + HashCommentedDoc = docOf("doctest_hash_commented") + SpecDocumentedDoc = docOf("doctest_spec_documented") + DestroyDoc = docOf("doctest_destroy") + TockedDoc = eventDocOf("on_tocked") + PlainEventDoc = eventDocOf("on_plain") + +suite "extractDocComment: what lands on the registry": + test "a single-line doc comment is captured": + check CreateDoc == "Builds the lib." + + test "a multi-line doc comment keeps its line breaks": + check DocumentedDoc == "First line.\nSecond line." + + test "an undocumented proc gets an empty doc": + check UndocumentedDoc == "" + + test "a plain `#` comment is not captured": + check HashCommentedDoc == "" + + test "a doc comment survives a pragma spec argument": + check SpecDocumentedDoc == "Doc survives alongside a pragma spec." + + test "the destructor's doc is captured": + check DestroyDoc == "Tears it down." + + test "an event proc's doc comment is captured": + check TockedDoc == "Fires on every tock." + + test "an undocumented event proc gets an empty doc": + check PlainEventDoc == "" diff --git a/tests/unit/test_doc_propagation.nim b/tests/unit/test_doc_propagation.nim new file mode 100644 index 0000000..4294d47 --- /dev/null +++ b/tests/unit/test_doc_propagation.nim @@ -0,0 +1,195 @@ +## FFIProcMeta.doc → each target's comment syntax. Extraction: test_doc_extraction.nim. + +import std/strutils +import unittest2 +import ffi/codegen/[meta, c, cpp, rust, cddl] + +const + CtorDoc = "Builds a widget from `config`." + MethodDoc = "Echoes `req` back.\nSecond line." + DtorDoc = "Releases the widget." + EventDoc = "Fires once the echo lands." + ScalarDoc = "Adds two numbers, no CBOR in sight." + +let types = @[ + FFITypeMeta( + name: "EchoRequest", fields: @[FFIFieldMeta(name: "message", typeName: "string")] + ), + FFITypeMeta( + name: "EchoResponse", fields: @[FFIFieldMeta(name: "echoed", typeName: "string")] + ), +] + +let procs = @[ + FFIProcMeta( + procName: "widget_create", + libName: "widget", + kind: FFIKind.CTOR, + libTypeName: "Widget", + extraParams: @[FFIParamMeta(name: "config", typeName: "EchoRequest")], + returnTypeName: "Widget", + doc: CtorDoc, + ), + FFIProcMeta( + procName: "widget_echo", + libName: "widget", + kind: FFIKind.FFI, + libTypeName: "Widget", + extraParams: @[FFIParamMeta(name: "req", typeName: "EchoRequest")], + returnTypeName: "EchoResponse", + doc: MethodDoc, + ), + FFIProcMeta( + procName: "widget_destroy", + libName: "widget", + kind: FFIKind.DTOR, + libTypeName: "Widget", + returnTypeName: "", + doc: DtorDoc, + ), +] + +let events = @[ + FFIEventMeta( + wireName: "on_echoed", + nimProcName: "onEchoed", + libName: "widget", + payloadTypeName: "EchoResponse", + doc: EventDoc, + ) +] + +let undocumented = block: + var stripped = procs + for p in stripped.mitems: + p.doc = "" + stripped + +func withoutDocLines(s: string): string = + ## Drops `///` lines by content, so a generator's own hardcoded ones survive. + var rendered: seq[string] = @[] + for doc in [CtorDoc, MethodDoc, DtorDoc]: + for line in doc.splitLines(): + rendered.add(("/// " & line).strip()) + var kept: seq[string] = @[] + for line in s.splitLines(): + if line.strip() notin rendered: + kept.add(line) + return kept.join("\n") + +proc checkNoDocText(rendered: string) = + for doc in [CtorDoc, MethodDoc, DtorDoc]: + for line in doc.splitLines(): + check line notin rendered + +suite "doc comments reach the C header": + setup: + let header = generateCLibHeader(procs, types, "widget") + + test "the exported symbol carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "the high-level wrapper carries the doc": + check "/** " & CtorDoc & " */\nstatic inline int widget_ctx_create(" in header + check "/** " & DtorDoc & " */\nstatic inline int widget_ctx_destroy(" in header + + test "a multi-line doc becomes a star block": + check "/**\n * Echoes `req` back.\n * Second line.\n */\nstatic inline int widget_ctx_echo(" in + header + + test "the listener registration carries the event's doc": + let withEvents = generateCLibHeader(procs, types, "widget", events) + check "/** " & EventDoc & + " */\nstatic inline uint64_t widget_ctx_add_on_echoed_listener(" in withEvents + + test "an undocumented registry leaks no doc text": + checkNoDocText(generateCLibHeader(undocumented, types, "widget")) + +suite "doc comments reach the abi = c header": + setup: + var abiProcs = procs + abiProcs.add( + FFIProcMeta( + procName: "widget_add", + libName: "widget", + kind: FFIKind.FFI, + libTypeName: "Widget", + extraParams: @[ + FFIParamMeta(name: "a", typeName: "int"), + FFIParamMeta(name: "b", typeName: "int"), + ], + returnTypeName: "int", + scalarFastPath: true, + doc: ScalarDoc, + ) + ) + for p in abiProcs.mitems: + p.abiFormat = ABIFormat.C + var abiTypes = types + for t in abiTypes.mitems: + t.abiFormat = ABIFormat.C + let header = generateCAbiLibHeader(abiProcs, abiTypes, "widget") + + test "the exported symbol carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "the high-level wrapper carries the doc": + check "/** " & CtorDoc & " */\nstatic inline int widget_ctx_create(" in header + check "/** " & DtorDoc & " */\nstatic inline int widget_ctx_destroy(" in header + + test "the scalar fast path carries the doc": + check "/** " & ScalarDoc & " */\nstatic inline int widget_ctx_add(" in header + +suite "doc comments reach the C++ header": + setup: + let header = generateCppHeader(procs, types, "widget") + + test "the extern \"C\" declaration carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "class members carry an indented /// doc": + check " /// " & CtorDoc & + "\n static Result> create(" in header + check " /// Echoes `req` back.\n /// Second line.\n Result echo(" in + header + + test "the async variant repeats the doc": + check " /// Echoes `req` back.\n /// Second line.\n std::future> echoAsync(" in + header + + test "the listener registration carries the event's doc": + check " /// " & EventDoc & "\n ListenerHandle addOnEchoedListener(" in + generateCppHeader(procs, types, "widget", events) + + test "an undocumented registry leaks no doc text": + checkNoDocText(generateCppHeader(undocumented, types, "widget")) + +suite "doc comments reach the Rust bindings": + test "the extern \"C\" block carries the doc": + let ffiRs = generateFFIRs(procs) + check " /// " & CtorDoc & "\n pub fn widget_create(" in ffiRs + check " /// Echoes `req` back.\n /// Second line.\n pub fn widget_echo(" in + ffiRs + + test "the high-level api carries the doc": + let apiRs = generateApiRs(procs, "widget") + check " /// " & CtorDoc & "\n pub fn create(" in apiRs + check " /// Echoes `req` back.\n /// Second line.\n pub fn echo(" in apiRs + check " /// Echoes `req` back.\n /// Second line.\n pub async fn echo_async(" in + apiRs + + test "the listener registration carries the event's doc above the generated one": + check " /// " & EventDoc & "\n /// Register a typed listener for `on_echoed`" in + generateApiRs(procs, "widget", events) + + test "docs are purely additive — stripping them reproduces the undocumented output": + check withoutDocLines(generateFFIRs(procs)) == generateFFIRs(undocumented) + check withoutDocLines(generateApiRs(procs, "widget")) == + generateApiRs(undocumented, "widget") + +suite "doc comments reach the CDDL schema": + test "each documented rule gets a `;` comment under its header": + let schema = generateCddlSchema(procs, types, "widget", "widget.nim") + check "; widget_create (ctor)\n; " & CtorDoc & "\n" in schema + check "; widget_echo (ffi)\n; Echoes `req` back.\n; Second line.\n" in schema + check "; widget_destroy (dtor)\n; " & DtorDoc & "\n" in schema diff --git a/tests/unit/test_ffi_const.nim b/tests/unit/test_ffi_const.nim new file mode 100644 index 0000000..e69df4c --- /dev/null +++ b/tests/unit/test_ffi_const.nim @@ -0,0 +1,105 @@ +## Unit tests for `{.ffiConst.}`: the per-backend rendering of a synthetic const +## registry, plus an end-to-end generation run over a fixture so the macro's own +## registration path is covered. + +import std/[os, strutils] +import unittest2 +import ./fixture_gen +import ffi/codegen/[meta, c, cpp, rust] + +proc constMeta(name, typeName, value: string): FFIConstMeta = + FFIConstMeta(name: name, typeName: typeName, value: value) + +let consts = @[ + constMeta("maxPeers", "int", "42"), + constMeta("DefaultTimeoutMs", "uint32", "3000"), + constMeta("Ratio", "float64", "1.5"), + constMeta("DebugEnabled", "bool", "false"), + constMeta("Greeting", "string", "he\"llo\n"), +] + +let ctor = FFIProcMeta( + procName: "constlib_create", + libName: "constlib", + kind: FFIKind.CTOR, + libTypeName: "ConstLib", + returnTypeName: "ConstLib", +) + +suite "constants in the C header": + setup: + let header = generateCLibHeader(@[ctor], @[], "constlib", @[], consts) + + test "scalars become typed static consts under an UPPER_SNAKE name": + check "static const int64_t MAX_PEERS = 42;" in header + check "static const uint32_t DEFAULT_TIMEOUT_MS = 3000;" in header + check "static const double RATIO = 1.5;" in header + check "static const bool DEBUG_ENABLED = false;" in header + + test "strings become const char* with escapes applied": + check "static const char* const GREETING = \"he\\\"llo\\n\";" in header + + test "no constants means no constants section": + let bare = generateCLibHeader(@[ctor], @[], "constlib") + check "Generated constants" notin bare + +suite "constants in the abi = c header": + test "the CBOR-free header carries the same declarations": + let cabiCtor = FFIProcMeta( + procName: "constlib_create", + libName: "constlib", + kind: FFIKind.CTOR, + libTypeName: "ConstLib", + returnTypeName: "ConstLib", + abiFormat: ABIFormat.C, + ) + let header = generateCAbiLibHeader(@[cabiCtor], @[], "constlib", @[], consts) + check "static const int64_t MAX_PEERS = 42;" in header + check "static const char* const GREETING = \"he\\\"llo\\n\";" in header + +suite "constants in the C++ header": + setup: + let header = generateCppHeader(@[ctor], @[], "constlib", @[], consts) + + test "constexpr, not static const": + check "constexpr int64_t MAX_PEERS = 42;" in header + check "constexpr bool DEBUG_ENABLED = false;" in header + + test "a string const is a const char*, not std::string": + check "constexpr const char* GREETING = \"he\\\"llo\\n\";" in header + +suite "constants in the Rust crate": + setup: + let typesRs = generateTypesRs(@[], @[ctor], consts) + + test "pub const with the mapped Rust type": + check "pub const MAX_PEERS: i64 = 42;" in typesRs + check "pub const DEFAULT_TIMEOUT_MS: u32 = 3000;" in typesRs + check "pub const RATIO: f64 = 1.5;" in typesRs + + test "a string const is a &str, without the redundant 'static": + check "pub const GREETING: &str = \"he\\\"llo\\n\";" in typesRs + +suite "{.ffiConst.} end-to-end generation": + test "the C header carries every annotated const, computed values included": + let (outDir, _, code) = genFixtureBindings("consts_fixture", "c") + require code == 0 + let header = readFile(outDir / "constlib.h") + check "static const int64_t MAX_PEERS = 42;" in header + # `3 * 1000` is evaluated before it reaches the registry. + check "static const uint32_t DEFAULT_TIMEOUT_MS = 3000;" in header + check "static const double RATIO = 1.5;" in header + check "static const bool DEBUG_ENABLED = false;" in header + check "static const char* const GREETING = \"he\\\"llo\\n\";" in header + check "static const int64_t HTTP_PORT = 8080;" in header + + test "the Rust crate carries them too": + let (outDir, _, code) = genFixtureBindings("consts_fixture", "rust") + require code == 0 + let typesRs = readFile(outDir / "src" / "types.rs") + check "pub const MAX_PEERS: i64 = 42;" in typesRs + check "pub const DEFAULT_TIMEOUT_MS: u32 = 3000;" in typesRs + check "pub const RATIO: f64 = 1.5;" in typesRs + check "pub const DEBUG_ENABLED: bool = false;" in typesRs + check "pub const GREETING: &str = \"he\\\"llo\\n\";" in typesRs + check "pub const HTTP_PORT: i64 = 8080;" in typesRs diff --git a/tests/unit/test_ffi_enum.nim b/tests/unit/test_ffi_enum.nim new file mode 100644 index 0000000..4817dfa --- /dev/null +++ b/tests/unit/test_ffi_enum.nim @@ -0,0 +1,153 @@ +## Unit tests for `{.ffi.}` enums: per-backend rendering of a synthetic registry, +## a Nim-side CBOR round-trip, and an end-to-end generation run over a fixture. + +import std/[os, strutils] +import unittest2 +import ./fixture_gen +import ffi +import ffi/codegen/[meta, c, cpp, rust, cddl] + +proc enumValue(name, wire: string, ord: int): FFIEnumValueMeta = + FFIEnumValueMeta(name: name, wire: wire, ord: ord) + +let colorMeta = FFITypeMeta( + name: "Color", + enumValues: @[enumValue("cRed", "cRed", 0), enumValue("cGreen", "green", 1)], +) + +let paintMeta = FFITypeMeta( + name: "PaintRequest", fields: @[FFIFieldMeta(name: "color", typeName: "Color")] +) + +let ctor = FFIProcMeta( + procName: "enumlib_create", + libName: "enumlib", + kind: FFIKind.CTOR, + libTypeName: "EnumLib", + returnTypeName: "EnumLib", +) + +let paint = FFIProcMeta( + procName: "enumlib_paint", + libName: "enumlib", + kind: FFIKind.FFI, + libTypeName: "EnumLib", + extraParams: @[FFIParamMeta(name: "color", typeName: "Color")], + returnTypeName: "PaintRequest", +) + +suite "enums in the C header": + setup: + let header = generateCLibHeader(@[ctor, paint], @[colorMeta, paintMeta], "enumlib") + + test "the enum becomes a C enum with explicit ordinals": + check "typedef enum {" in header + check "COLOR_C_RED = 0," in header + check "COLOR_C_GREEN = 1" in header + check "} Color;" in header + + test "the codec maps to the CBOR text form, not the ordinal": + check "case COLOR_C_RED: return cbor_encode_text_stringz(e, \"cRed\");" in header + check "case COLOR_C_GREEN: return cbor_encode_text_stringz(e, \"green\");" in header + check "if (strcmp(buf, \"green\") == 0) { *out = COLOR_C_GREEN;" in header + + test "the decode buffer is sized to the longest wire name": + # Wire forms are "cRed" (4) and "green" (5); the longest plus a NUL is 6. + check "char buf[6];" in header + + test "an enum field is a plain member, not a nested struct": + check " Color color;" in header + + test "an enum param passes by value": + check "int enumlib_ctx_paint(const EnumLibCtx* ctx, Color color," in header + check "const Color* color" notin header + +suite "enums in the C++ header": + setup: + let header = generateCppHeader(@[ctor, paint], @[colorMeta, paintMeta], "enumlib") + + test "the enum becomes a scoped enum class": + check "enum class Color {" in header + check " cRed = 0," in header + + test "the codec is declared before the struct codec that calls it": + check header.find("enum class Color") < header.find("struct PaintRequest") + + test "decode goes through the std::string overload": + check "if (name == \"green\") { v = Color::cGreen; return CborNoError; }" in header + +suite "enums in the Rust crate": + setup: + let typesRs = generateTypesRs(@[colorMeta, paintMeta], @[ctor, paint]) + + test "the enum becomes a fieldless Rust enum": + check "pub enum Color {" in typesRs + check " CRed," in typesRs + + test "serde renames each variant to its wire form": + check "#[serde(rename = \"green\")]" in typesRs + check " CGreen," in typesRs + + test "a variant whose name already matches the wire form needs no rename": + check "#[serde(rename = \"CRed\")]" notin typesRs + +suite "enums in the CDDL schema": + test "the rule is a choice of the wire strings": + let schema = + generateCddlSchema(@[ctor, paint], @[colorMeta, paintMeta], "enumlib", "x.nim") + check "Color = \"cRed\" / \"green\"" in schema + +type + RoundTripColor {.ffi.} = enum + rtRed + rtBlue + + RoundTripLevel {.ffi.} = enum + rtLow = "low" + rtHigh = "high" + +type RoundTripPaint {.ffi.} = object + color: RoundTripColor + level: RoundTripLevel + +suite "enum CBOR round-trip on the Nim side": + test "an enum field survives encode/decode": + let sent = RoundTripPaint(color: rtBlue, level: rtHigh) + let back = cborDecode(cborEncode(sent), RoundTripPaint) + check back.isOk() + check back.get() == sent + + test "the wire form is the associated string when the enum declares one": + var wire = "" + for b in cborEncode(RoundTripPaint(color: rtRed, level: rtLow)): + wire.add(char(b)) + check "low" in wire + check "rtRed" in wire + +let genned = genFixtureBindings("enum_fixture", "c") + +suite "{.ffi.} enum end-to-end generation": + setup: + require genned.exitCode == 0 + let header = readFile(genned.outDir / "enumlib.h") + + test "explicit ordinals and associated strings both survive to the C header": + check "STATUS_ST_IDLE = 2," in header + check "STATUS_ST_BUSY = 7" in header + check "case LEVEL_L_LOW: return cbor_encode_text_stringz(e, \"low\");" in header + + test "an enum return reaches the host through the reply callback": + check "(*EnumLibPickReplyFn)(int err_code, const Color* reply," in header + check "int enumlib_ctx_pick(const EnumLibCtx* ctx, Level level," in header + +suite "enums on the abi = c path are rejected, not silently mis-wired": + test "an enum in an abi = c library fails to compile": + let (output, code) = checkFixture("enum_abi_c_type_fixture") + check code != 0 + check "`abi = c` does not support enum types yet" in output + + test "an enum reached from an abi = c type fails to compile": + let (output, code) = checkFixture("enum_abi_c_field_fixture") + check code != 0 + check "cwire: `abi = c` does not support enum types yet" in output + check "Color" in output diff --git a/tests/unit/test_string_helpers.nim b/tests/unit/test_string_helpers.nim index a3f318a..eb8dfda 100644 --- a/tests/unit/test_string_helpers.nim +++ b/tests/unit/test_string_helpers.nim @@ -34,6 +34,34 @@ suite "camelToSnakeCase": test "already snake_case passes through": check camelToSnakeCase("already_snake") == "already_snake" +suite "identToUpperSnake": + test "empty string": + check identToUpperSnake("") == "" + + test "camelCase": + check identToUpperSnake("maxPeers") == "MAX_PEERS" + + test "PascalCase": + check identToUpperSnake("MaxPeers") == "MAX_PEERS" + + test "already UPPER_SNAKE passes through": + check identToUpperSnake("MAX_PEERS") == "MAX_PEERS" + + test "acronym run stays one word": + check identToUpperSnake("HTTPPort") == "HTTP_PORT" + + test "acronym after a word": + check identToUpperSnake("httpTTL") == "HTTP_TTL" + + test "digits don't split a word": + check identToUpperSnake("v2Enabled") == "V2_ENABLED" + + test "single word": + check identToUpperSnake("timeout") == "TIMEOUT" + + test "runs of underscores collapse": + check identToUpperSnake("a__b") == "A_B" + suite "capitalizeFirstLetter": test "empty string": check capitalizeFirstLetter("") == "" @@ -81,3 +109,53 @@ suite "snakeToPascalCase": test "already-mixed parts preserve their existing case after the first": # Existing caps after the first letter of each part are preserved. check snakeToPascalCase("already_HasCaps") == "AlreadyHasCaps" + +suite "renderDocComment": + test "empty doc renders nothing": + check renderDocComment("", "", "/// ").len == 0 + + test "blank doc renders nothing": + check renderDocComment(" \n ", "", "/// ").len == 0 + + test "single line gets the prefix": + check renderDocComment("does a thing", "", "/// ") == @["/// does a thing"] + + test "each line gets prefix and indent": + check renderDocComment("one\ntwo", " ", "/// ") == @[ + " /// one", " /// two" + ] + + test "a blank interior line keeps no trailing whitespace": + check renderDocComment("one\n\ntwo", "", "/// ") == @["/// one", "///", "/// two"] + + test "trailing blank lines are dropped": + check renderDocComment("one\n\n", "", "; ") == @["; one"] + + test "a trailing backslash cannot splice the next line into the comment": + check renderDocComment("one \\\ntwo", "", "/// ") == @["/// one", "/// two"] + +suite "renderBlockDocComment": + test "empty doc renders nothing": + check renderBlockDocComment("", "").len == 0 + + test "single line collapses to a one-line block": + check renderBlockDocComment("does a thing", "") == @["/** does a thing */"] + + test "multiple lines render a star block": + check renderBlockDocComment("one\ntwo", "") == @["/**", " * one", " * two", " */"] + + test "indent applies to every line of the block": + check renderBlockDocComment("one\ntwo", " ") == + @[" /**", " * one", " * two", " */"] + + test "a blank interior line keeps no trailing whitespace": + check renderBlockDocComment("one\n\ntwo", "") == + @["/**", " * one", " *", " * two", " */"] + + test "a one-liner cannot close its own comment early": + check renderBlockDocComment("Frees it. */ #define OOPS 1 /* x", "") == + @["/** Frees it. * / #define OOPS 1 /* x */"] + + test "a star block cannot close its own comment early": + check renderBlockDocComment("one */ #define OOPS 1\ntwo", "") == + @["/**", " * one * / #define OOPS 1", " * two", " */"]