From 64cb4bfce4a633f87aa985b954bbc484ce467aff Mon Sep 17 00:00:00 2001 From: Gabriel Cruz <8129788+gmelodie@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:00:24 -0300 Subject: [PATCH] feat(codegen): {.ffiConst.} constants and {.ffi.} enums for C/C++/Rust (#140) --- CHANGELOG.md | 19 ++- README.md | 72 ++++++++- examples/echo/c_abi_bindings/echo.h | 6 + examples/echo/c_bindings/echo.h | 6 + examples/echo/cpp_bindings/echo.hpp | 6 + examples/echo/echo.nim | 5 + examples/timer/c_bindings/main.c | 13 +- examples/timer/c_bindings/my_timer.h | 57 ++++++- examples/timer/cddl_bindings/my_timer.cddl | 5 +- examples/timer/cpp_bindings/main.cpp | 7 +- examples/timer/cpp_bindings/my_timer.hpp | 41 ++++- examples/timer/rust_bindings/src/types.rs | 17 +- examples/timer/rust_client/src/main.rs | 10 +- examples/timer/rust_client/src/tokio_main.rs | 9 +- examples/timer/timer.nim | 32 +++- ffi/codegen/c.nim | 112 ++++++++++++- ffi/codegen/cddl.nim | 15 +- ffi/codegen/consts.nim | 69 ++++++++ ffi/codegen/cpp.nim | 70 +++++++- ffi/codegen/meta.nim | 26 +++ ffi/codegen/rust.nim | 43 ++++- ffi/codegen/string_helpers.nim | 18 +++ ffi/internal/c_macro_helpers.nim | 12 +- ffi/internal/ffi_macro.nim | 128 ++++++++++++++- tests/e2e/c/test_timer_e2e.c | 23 ++- tests/e2e/c_abi/test_echo_c_abi.c | 16 ++ tests/e2e/cpp/test_timer_e2e.cpp | 34 +++- tests/unit/fixture_gen.nim | 48 ++++++ tests/unit/fixtures/consts_fixture.nim | 32 ++++ .../fixtures/enum_abi_c_field_fixture.nim | 18 +++ .../unit/fixtures/enum_abi_c_type_fixture.nim | 15 ++ tests/unit/fixtures/enum_fixture.nim | 48 ++++++ tests/unit/test_ffi_const.nim | 105 ++++++++++++ tests/unit/test_ffi_enum.nim | 153 ++++++++++++++++++ tests/unit/test_string_helpers.nim | 28 ++++ 35 files changed, 1256 insertions(+), 62 deletions(-) create mode 100644 ffi/codegen/consts.nim create mode 100644 tests/unit/fixture_gen.nim create mode 100644 tests/unit/fixtures/consts_fixture.nim create mode 100644 tests/unit/fixtures/enum_abi_c_field_fixture.nim create mode 100644 tests/unit/fixtures/enum_abi_c_type_fixture.nim create mode 100644 tests/unit/fixtures/enum_fixture.nim create mode 100644 tests/unit/test_ffi_const.nim create mode 100644 tests/unit/test_ffi_enum.nim diff --git a/CHANGELOG.md b/CHANGELOG.md index ed79344..1508470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,21 @@ 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 @@ -39,10 +54,6 @@ All notable changes to this project are documented in this file. `##` comment now changes the generated bindings, so `nimble check_bindings` flags them stale until regenerated; an undocumented proc still generates byte-identical output. -- `{.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. - 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 5451f8b..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,76 @@ 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 @@ -182,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 db044d3..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 { diff --git a/examples/echo/c_bindings/echo.h b/examples/echo/c_bindings/echo.h index c222bf4..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) */ /* ============================================================ */ diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index c17a244..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 // ============================================================ diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim index 5471252..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 @@ -31,6 +34,8 @@ 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)) 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 09d996a..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) { diff --git a/examples/timer/cddl_bindings/my_timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl index 3497664..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 } 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 6c89b02..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); } 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 88810fb..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 @@ -48,6 +54,8 @@ 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)) @@ -55,7 +63,7 @@ proc myTimerEcho*( # Sync method: no await, so the macro fires the callback inline. proc myTimerVersion*(timer: MyTimer): Future[Result[string, string]] {.ffi.} = ## Returns the library's version string. - return ok("nim-timer v0.1.0") + return ok(TimerVersion) proc myTimerComplex*( timer: MyTimer, req: ComplexRequest @@ -68,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 @@ -89,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 @@ -110,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 77dea25..aa41a94 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -3,7 +3,7 @@ ## generics, each distinct `seq[T]`/`Option[T]` is monomorphised per type. import std/[os, strutils, tables, sets, options] -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` (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: @@ -746,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" @@ -759,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) @@ -777,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("/* ============================================================ */") @@ -1362,6 +1453,7 @@ proc generateCAbiLibHeader*( types: seq[FFITypeMeta], libName: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ): string = if events.len > 0: raise newException( @@ -1398,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" ) @@ -1451,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) @@ -1466,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/cddl.nim b/ffi/codegen/cddl.nim index 1589d85..62cadd7 100644 --- a/ffi/codegen/cddl.nim +++ b/ffi/codegen/cddl.nim @@ -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). 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 29089f6..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)] ) = @@ -186,6 +218,7 @@ proc generateCppHeader*( types: seq[FFITypeMeta], libName: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ): string = var lines: seq[string] = @[] @@ -198,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]) @@ -478,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 cad7150..7886d9a 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -40,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 @@ -58,6 +74,7 @@ type 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. @@ -120,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 545c593..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). @@ -217,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: @@ -742,6 +778,7 @@ proc generateRustCrate*( outputDir: string, nimSrcRelPath: string, events: seq[FFIEventMeta] = @[], + consts: seq[FFIConstMeta] = @[], ) = ## Generates a complete Rust crate in outputDir. createDir(outputDir) @@ -751,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 681eb77..663c5f7 100644 --- a/ffi/codegen/string_helpers.nim +++ b/ffi/codegen/string_helpers.nim @@ -69,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 18b3c7c..b51868e 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 6bf5bdf..34fd413 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: @@ -643,6 +715,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 @@ -1586,15 +1703,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 a4d864f..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,6 +272,7 @@ int main(void) { test_complex(ctx); test_schedule_ok(ctx); test_schedule_error(ctx); + test_delay_limit(ctx); test_event(ctx); assert(my_timer_ctx_destroy(ctx) == NIMFFI_RET_OK); printf("all C e2e checks passed\n"); diff --git a/tests/e2e/c_abi/test_echo_c_abi.c b/tests/e2e/c_abi/test_echo_c_abi.c index bf9967c..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,6 +121,7 @@ static void test_version(EchoCtx* ctx) { int main(void) { EchoCtx* ctx = make_ctx(); test_shout(ctx); + test_shout_too_long(ctx); test_version(ctx); assert(echo_ctx_destroy(ctx) == NIMFFI_RET_OK); printf("all abi=c echo e2e checks passed\n"); 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_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 6b9d1bf..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("") == ""