feat(codegen): {.ffiConst.} constants and {.ffi.} enums for C/C++/Rust (#140)

This commit is contained in:
Gabriel Cruz 2026-07-23 14:00:24 -03:00 committed by GitHub
parent c58e2bcdd2
commit 64cb4bfce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1256 additions and 62 deletions

View File

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

View File

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

View File

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

View File

@ -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) */
/* ============================================================ */

View File

@ -292,6 +292,12 @@ inline Result<T> decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
// ============================================================
// Generated constants
// ============================================================
constexpr int64_t MAX_SHOUT_LEN = 512;
// ============================================================
// User-declared FFI types
// ============================================================

View File

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

View File

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

View File

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

View File

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

View File

@ -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<std::string>{"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<int>(scheduleRes->priority) << "\n";
// Each `{.ffiEvent.}` declared on the Nim side gets a typed
// registration method — `addOnEchoFiredListener(handler)` here.

View File

@ -293,6 +293,37 @@ inline Result<T> decodeCborFFI(const std::vector<std::uint8_t>& 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<std::string> 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);
}

View File

@ -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<String>,
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)]

View File

@ -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.");

View File

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
)
.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.");

View File

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

View File

@ -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 `<lib>.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))

View File

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

69
ffi/codegen/consts.nim Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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");

View File

@ -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");

View File

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

View File

@ -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/<fixture>.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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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