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