chore: unify c and c_abi

This commit is contained in:
Gabriel Cruz 2026-07-14 16:35:28 -03:00
parent 7b028e64c4
commit 331e2d4c6d
No known key found for this signature in database
GPG Key ID: 3C6977037D5A1EF5
22 changed files with 600 additions and 661 deletions

View File

@ -170,7 +170,7 @@ jobs:
# The CBOR-free abi=c C binding (issue #105). Linux-only: the generated
# header is platform-checked by the check-bindings job, and the runtime
# path only needs one OS to exercise the flat-struct dispatch.
# path only needs one OS to exercise the abi=c struct dispatch.
- name: Run abi=c C e2e test
if: matrix.label == 'Linux'
shell: bash

View File

@ -60,25 +60,25 @@ All notable changes to this project are documented in this file.
`"abi = cbor"` spec (e.g. `{.ffi: "abi = cbor".}`). `declareLibrary` is now
required before any FFI annotation
([#78](https://github.com/logos-messaging/nim-ffi/issues/78)).
- `c` (flat C-struct) ABI **codec**: every `{.ffi: "abi = c".}` type gets a
- `c` (`abi = c` C-struct) ABI **codec**: every `{.ffi: "abi = c".}` type gets a
`<T>_CWire` companion plus `cwirePack` / `cwireUnpack` / `cwireFree`. This
first slice covers the flat path — POD scalars and `string` (as `cstring`);
composite fields follow. (The `c` proc-dispatch path and its CBOR-free C
generator landed later in this release — see the `-d:targetLang=c_abi` entry
below; `c` events remain CBOR-only.)
- **CBOR-free `abi = c` C binding generator** (`-d:targetLang=c_abi`): emits a
single self-contained `<lib>.h` whose flat `_CWire` structs *are* the C ABI,
so the C consumer passes native structs and links no CBOR at all (contrast
the CBOR `-d:targetLang=c` backend). The `c` proc-dispatch path is now wired
end-to-end: the generated exported wrappers `cwireUnpack` the request into a
Nim object, reuse the existing CBOR thread transport internally, and a Nim
reply trampoline `cwirePack`s the response back into a flat struct for the
caller's typed callback. `abiCodegenImplemented` now accepts `c` for
proc/ctor/dtor annotations (events remain CBOR-only). New
`examples/echo/c_abi_bindings/` (checked in beside the CBOR `c_bindings/` for
comparison), `nimble genbindings_c_abi_echo` / `check_bindings_c_abi` /
`test_c_abi_e2e` / `test_c_abi_e2e_sanitized` tasks, and a `tests/e2e/c_abi`
ctest harness ([#105](https://github.com/logos-messaging/nim-ffi/issues/105)).
first slice covers the `abi = c` path — POD scalars and `string` (as `cstring`);
composite fields follow. (`c` events remain CBOR-only.)
- **CBOR-free (`abi = c`) C bindings, emitted by the single `c` target**
(`-d:targetLang=c`): the one `c` generator now picks its output from the
library's ABI format — the `abi = c` header or the CBOR header. The `abi = c`
header is a single self-contained `<lib>.h` whose `_CWire` structs *are* the C
ABI, so the C consumer passes native structs and links no CBOR at all. The `c`
proc-dispatch path is wired end-to-end: the generated exported wrappers
`cwireUnpack` the request into a Nim object, reuse the existing CBOR thread
transport internally, and a Nim reply trampoline `cwirePack`s the response
back into a `_CWire` struct for the caller's typed callback.
`abiCodegenImplemented` accepts `c` for proc/ctor/dtor annotations (events
remain CBOR-only). New `examples/echo/c_abi_bindings/` (checked in beside the
CBOR `c_bindings/` for comparison), `nimble genbindings_c_abi_echo` /
`check_bindings_c_abi` / `test_c_abi_e2e` / `test_c_abi_e2e_sanitized`
tasks, and a `tests/e2e/c_abi` ctest harness
([#105](https://github.com/logos-messaging/nim-ffi/issues/105)).
- `tests/bench/bench_codec.nim` (+ `nimble bench_codec`): a single-process
microbenchmark comparing the `cbor` and `c` codecs across payload shapes,
isolating codec cost from the (identical) thread/callback round-trip.

View File

@ -153,9 +153,9 @@ for; it does not change the wire.
event serializes through the generic CBOR path, and all binding generators emit
working callers for it.
`abi = c` is a newer, flat C-struct wire (no CBOR round-trip). Callers for it
are emitted only by the dedicated `c_abi` generator (`-d:targetLang=c_abi`). It
carries two honest limits today:
`abi = c` is a newer, native C-struct wire (no CBOR round-trip). The single `c`
generator (`-d:targetLang=c`) emits its callers, choosing the `abi = c` or CBOR
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
@ -206,19 +206,19 @@ nim c --app:lib --noMain --nimMainPrefix:libmylib mylib.nim
**2. Emit the foreign bindings** — add the binding defines and `--compileOnly`,
which stops after codegen: the binding files are written during macro expansion,
so there's no library to link (no `--app:lib`/`-o:/dev/null` needed). The
generated files (for `targetLang=c`/`c_abi`: the `<name>.h` header your host
includes, plus a `CMakeLists.txt`) land in `-d:ffiOutputDir`:
generated files (for `targetLang=c`: the `<name>.h` header your host includes,
plus a `CMakeLists.txt`) land in `-d:ffiOutputDir`:
```sh
nim c -d:ffiGenBindings -d:targetLang=rust,cpp,c --compileOnly mylib.nim
```
- `-d:targetLang` — which generator(s) run; pass a comma-separated list to emit
several from one compile. Two kinds:
- **Language bindings over the CBOR wire:** `rust` (default), `cpp`, `c`.
- **Non-peer generators:** `c_abi` — C bindings that speak the flat `abi = c`
wire instead of CBOR; `cddl` — a CDDL schema of the CBOR wire, not a
language binding at all.
several from one compile:
- **Language bindings:** `rust` (default), `cpp`, `c`. The `c` target follows
the library's ABI format — an `abi = c` C-struct header for `abi = c`, a CBOR
header otherwise; `rust`/`cpp` speak CBOR.
- **`cddl`** — a CDDL schema of the CBOR wire, not a language binding at all.
- `-d:ffiOutputDir` — override where the generated files land. Defaults to
`<lang>_bindings/` next to the compiled source.
- `-d:ffiSrcPath` — override the Nim source path embedded in the generated build

View File

@ -4,8 +4,8 @@ project(echo_c_abi_bindings C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The CBOR-free `abi = c` binding links no TinyCBOR the flat structs in the
# generated header are the ABI. Only the Nim dylib is built.
# The CBOR-free `abi = c` binding links no TinyCBOR the generated header
# structs are the ABI. Only the Nim dylib is built.
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
set(REPO_ROOT "")

View File

@ -1,12 +1,14 @@
# echo — CBOR-free `abi = c` C bindings
# echo — CBOR-free (`abi = c`) C bindings
Generated by `nimble genbindings_c_abi_echo` (`-d:targetLang=c_abi`), this is the
**pure C ABI** rendering of the echo library, meant to be read side-by-side with
the CBOR rendering in [`../c_bindings/`](../c_bindings/) (issue #105).
Generated by `nimble genbindings_c_abi_echo`, this is the **pure C ABI**
rendering of the echo library, meant to be read side-by-side with the CBOR
rendering in [`../c_bindings/`](../c_bindings/) (issue #105). Both come from the
single `c` target (`-d:targetLang=c`); the library's `defaultABIFormat` picks
the shape.
| | `../c_bindings/` (`-d:targetLang=c`) | this dir (`-d:targetLang=c_abi`) |
| | `../c_bindings/` (`abi = cbor`) | this dir (`abi = c`) |
|---|---|---|
| Wire format on the C side | CBOR | flat C structs (no serialization) |
| Wire format on the C side | CBOR | native C structs (no serialization) |
| Third-party dependency | vendored TinyCBOR | none |
| Files | `echo.h` + `nim_ffi_cbor.h` + `nim_ffi_prelude.h` | one self-contained `echo.h` |
| String type | `NimFfiStr` (owned) | `const char*` (borrowed for the call) |

View File

@ -14,7 +14,7 @@
terminal RET_OK/RET_ERR. Ignore it unless you want progress. */
#define NIMFFI_RET_STALE_WARN 3
/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated
/* `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 {
const char* prefix;
@ -58,7 +58,6 @@ typedef struct { EchoCreateFn fn; void* user_data; } EchoCreateBox;
static void echo_create_trampoline(int ret, const char* ctx_addr, const char* err_msg, void* ud) {
EchoCreateBox* box = (EchoCreateBox*)ud;
if (!box) return;
/* Non-terminal progress ping: keep the box for the terminal reply. */
if (ret == NIMFFI_RET_STALE_WARN) return;
if (!box->fn) { free(box); return; }
if (ret != 0) {

View File

@ -6,9 +6,9 @@ import ffi, chronos, strutils
type Echo = object
prefix: string
# `-d:ffiEchoAbiC` builds the CBOR-free `abi = c` variant (flat `_CWire` structs
# on the wire); the default is the CBOR ABI. The same source drives both the
# `c_bindings/` (CBOR) and `c_abi_bindings/` (flat) example outputs.
# `-d:ffiEchoAbiC` builds the `abi = c` variant (`_CWire` structs on the wire);
# the default is the CBOR ABI. The same source drives both the `c_bindings/`
# (CBOR) and `c_abi_bindings/` example outputs.
when defined(ffiEchoAbiC):
declareLibrary("echo", Echo, defaultABIFormat = "c")
else:

View File

@ -109,14 +109,9 @@ proc genBindingsCmd(flags, src: string, langs = "rust", outDir = ""): string =
cmd
proc removeStaleEchoLib() =
## The CBOR and `abi = c` echo e2e suites both compile examples/echo/echo.nim
## to the same repo-root `libecho.so`, differing only by `-d:ffiEchoAbiC`.
## CMake keys the dylib rebuild on echo.nim's mtime, not the ABI flag, so a
## `libecho.so` left by an earlier run is silently reused when the ABI flips —
## in *either* direction. The caller then reaches entry points of the wrong
## ABI (CBOR bytes decoded as a flat struct, or vice versa) and segfaults.
## Every echo-building e2e task must delete it first to force a fresh rebuild
## with the ABI its bindings expect.
## CMake keys the shared `libecho.so` rebuild on echo.nim's mtime, not on
## `-d:ffiEchoAbiC`, so a stale lib from the other ABI is reused and segfaults.
## Every echo e2e task deletes it first to force a fresh rebuild.
for name in ["libecho.so", "libecho.dylib", "echo.dll"]:
let path = thisDir() / name
if fileExists(path):
@ -180,7 +175,6 @@ task test_c_e2e, "Build and run the C end-to-end tests for the timer example":
runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug"
task test_c_abi_e2e, "Build and run the CBOR-free abi=c C end-to-end test (echo)":
# Regenerate the abi=c bindings so the suite always runs against fresh codegen.
runOrQuit "nimble genbindings_c_abi_echo"
removeStaleEchoLib()
runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build"
@ -259,15 +253,12 @@ task genbindings_c_echo, "Generate C bindings for the echo example":
exec genBindingsCmd(nimFlagsRefc, echoSrc, "c")
task genbindings_c_abi_echo, "Generate CBOR-free abi=c C bindings for the echo example":
# echoVersion is all-scalar under the abi=c default, so it has no foreign
# codegen yet and is omitted from the bindings; -d:ffiAllowScalarSkip accepts
# that omission instead of failing the build (see genBindings()).
exec genBindingsCmd(
nimFlagsOrc & " -d:ffiEchoAbiC -d:ffiAllowScalarSkip", echoSrc, "c_abi"
)
exec genBindingsCmd(
nimFlagsRefc & " -d:ffiEchoAbiC -d:ffiAllowScalarSkip", echoSrc, "c_abi"
)
# ffiAllowScalarSkip omits echoVersion (all-scalar, no foreign codegen yet);
# abiOut forces output beside the CBOR `c_bindings/` instead of overwriting it.
const abiOut = "examples/echo/c_abi_bindings"
const abiFlags = " -d:ffiEchoAbiC -d:ffiAllowScalarSkip -d:ffiSrcPath=../echo.nim"
exec genBindingsCmd(nimFlagsOrc & abiFlags, echoSrc, "c", abiOut)
exec genBindingsCmd(nimFlagsRefc & abiFlags, echoSrc, "c", abiOut)
task check_bindings_rust, "Verify checked-in Rust bindings match Nim source":
runOrQuit "nimble genbindings_rust"

View File

@ -1,22 +1,15 @@
## C99 binding generator for the nim-ffi framework.
## Emits a header-only C binding plus a CMakeLists.txt. The binding is split
## into three headers so the example reads cleanly: `nim_ffi_prelude.h` (owned
## string/byte types + libc includes), `nim_ffi_cbor.h` (leaf CBOR codecs and
## buffer drivers, includes the prelude), and `<lib>.h` (the library-specific
## structs, codecs and async API, includes the cbor header). Requests/responses
## travel as CBOR (encoded with the same vendored TinyCBOR the C++ backend
## uses, matching the Nim-side cbor_serial codec — both ends speak RFC 8949).
##
## C has neither generics nor overloading, so the codecs the C++ backend gets
## from templates are monomorphised here: every distinct `seq[T]` / `Option[T]`
## becomes its own struct + encode/decode/free triple, and each leaf type has a
## distinctly-named codec emitted by the cbor_helpers template.
## C99 binding generator. The library's ABI format picks the shape: `cbor`
## (default) emits three headers (prelude + cbor codecs + `<lib>.h`) exchanging
## CBOR via vendored TinyCBOR; `c` (`abi = c`) emits one `<lib>.h` whose structs
## are the C ABI directly. C lacks generics, so each distinct `seq[T]`/`Option[T]`
## is monomorphised into its own struct + codec triple (e.g. `seq[uint32]` yields
## an `EchoSeq_U32` struct plus `echo_enc_`/`echo_dec_`/`echo_free_EchoSeq_U32`).
import std/[os, strutils, tables, sets]
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
## Wire-format C type for any Nim `ptr T` / `pointer`. Fixed 64-bit so the CBOR
## payload size is stable regardless of host architecture (mirrors CppPtrType).
## Fixed 64-bit wire type for any Nim `ptr T`/`pointer`, so payload size is
## host-arch independent (mirrors CppPtrType).
const CPtrType* = "uint64_t"
const
@ -24,9 +17,7 @@ const
CborHelpersTpl = staticRead("templates/c/cbor_helpers.h.tpl")
CMakeListsTpl = staticRead("templates/c/CMakeLists.txt.tpl")
# Shared headers written alongside the library header. Their names match the
# include guards baked into the templates and the `#include` the cbor header
# emits for the prelude.
# Shared header names; must match the include guards baked into the templates.
PreludeHeaderName* = "nim_ffi_prelude.h"
CborHeaderName* = "nim_ffi_cbor.h"
@ -45,63 +36,60 @@ const scalarCInfoTable: array[ScalarKind, tuple[cType, suffix: string]] = [
]
func leafSuffix(cType: string): string =
## C type name → leaf codec suffix for the leaf codecs the template provides;
## empty string for composite types. Driven off the shared scalar table so it
## can't drift from the IR's scalar set.
## Leaf codec suffix for `cType`; "" for composites.
for s in ScalarKind:
if scalarCInfoTable[s].cType == cType:
return scalarCInfoTable[s].suffix
case cType
of "NimFfiStr": "str"
of "NimFfiBytes": "bytes"
else: ""
return
case cType
of "NimFfiStr": "str"
of "NimFfiBytes": "bytes"
else: ""
func cToken(cType: string): string =
## Short PascalCase token used to build monomorphised container names and
## codec-adapter symbols. Leaf types reuse their codec suffix (e.g.
## `int64_t`→`I64`); composite C type names are already unique C identifiers,
## so they pass through verbatim.
## PascalCase token for monomorphised names: leaf suffix capitalised, else the
## (already unique) composite C name verbatim.
let suffix = leafSuffix(cType)
if suffix.len > 0:
capitalizeFirstLetter(suffix)
else:
cType
return capitalizeFirstLetter(suffix)
return cType
type CTypeReg = object
libName: string ## snake_case symbol prefix, e.g. "my_timer"
libType: string ## PascalCase container-name prefix, e.g. "MyTimer"
typeTable: Table[string, FFITypeMeta] ## user structs + synthetic Req structs
emitted: HashSet[string] ## composite C type names already emitted
libName: string ## snake_case symbol prefix
libType: string ## PascalCase container-name prefix
typeTable: Table[string, FFITypeMeta]
emitted: HashSet[string]
owns: Table[string, bool] ## C type name → owns-heap-memory
decls: seq[string] ## struct typedefs, dependency order
codecs: seq[string] ## enc/dec/free defs, dependency order
decls: seq[string]
codecs: seq[string]
func encFn(reg: CTypeReg, cType: string): string =
let suffix = leafSuffix(cType)
if suffix.len > 0:
return "nimffi_enc_" & suffix
reg.libName & "_enc_" & cType
return reg.libName & "_enc_" & cType
func decFn(reg: CTypeReg, cType: string): string =
let suffix = leafSuffix(cType)
if suffix.len > 0:
return "nimffi_dec_" & suffix
reg.libName & "_dec_" & cType
return reg.libName & "_dec_" & cType
func freeFn(reg: CTypeReg, cType: string): string =
## Free-function name for `cType`, or "" when the type owns no heap memory.
case cType
of "NimFfiStr":
"nimffi_free_str"
of "NimFfiBytes":
"nimffi_free_bytes"
else:
if leafSuffix(cType).len > 0:
""
elif reg.owns.getOrDefault(cType, false):
reg.libName & "_free_" & cType
## Free-function name for `cType`, or "" when it owns no heap memory.
return
case cType
of "NimFfiStr":
"nimffi_free_str"
of "NimFfiBytes":
"nimffi_free_bytes"
else:
""
if leafSuffix(cType).len > 0:
""
elif reg.owns.getOrDefault(cType, false):
reg.libName & "_free_" & cType
else:
""
proc emitSeqType(reg: var CTypeReg, name, elemC: string) =
let eEnc = encFn(reg, elemC)
@ -256,12 +244,10 @@ proc emitStructType(reg: var CTypeReg, t: FFITypeMeta) =
reg.owns[t.name] = owns
proc ensureCType(reg: var CTypeReg, t: FFIType): tuple[cType: string, owns: bool] =
## Walks the shared type IR into a C type, monomorphising each distinct
## `seq[T]` / `Option[T]` into its own struct + codec triple on first sight.
## `owns` marks a C type that carries heap-allocated payload the caller must
## release with its generated free function (strings, byte buffers, and any
## seq/opt/struct transitively containing one); plain scalars and pointers own
## nothing and need no cleanup.
## Lowers the type intermediate representation (see types_ir.nim) to a C type,
## monomorphising each distinct `seq[T]`/`Option[T]` on first sight. `owns`
## marks a type carrying heap payload the caller must release via its generated
## free function.
case t.kind
of ftPtr:
return (CPtrType, false)
@ -296,31 +282,27 @@ proc ensureCType(reg: var CTypeReg, t: FFIType): tuple[cType: string, owns: bool
return (name, reg.owns.getOrDefault(name, false))
proc ensureCType(reg: var CTypeReg, nimType: string): tuple[cType: string, owns: bool] =
ensureCType(reg, parseFFIType(nimType))
return ensureCType(reg, parseFFIType(nimType))
proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
## Synthesises the per-proc Req struct as an FFITypeMeta so it flows through
## the same monomorphisation path as user-declared types. Pointer/handle
## params ride the wire as the opaque uint64 pointer type.
## Synthesises the per-proc Req struct so it flows through the same
## monomorphisation path as user types; pointer/handle params ride as uint64.
var fields: seq[FFIFieldMeta] = @[]
for ep in p.extraParams:
let typeName = if ep.ridesAsPtr(): "pointer" else: ep.typeName
fields.add(FFIFieldMeta(name: ep.name, typeName: typeName))
FFITypeMeta(name: reqStructName(p), fields: fields)
return FFITypeMeta(name: reqStructName(p), fields: fields)
func paramByValue(nimType: string, ridesAsPtr: bool): bool =
## Scalars / opaque pointers / string views pass by value; composite
## aggregates (seq, Option, user structs) pass by const pointer. Note `ptr T`
## rides by value as the 64-bit wire int (like `pointer`); production params
## reach here as `pointer` since handles are pre-converted upstream.
## Scalars/pointers/string views pass by value; aggregates by const pointer.
if ridesAsPtr:
return true
parseFFIType(nimType).kind in {ftScalar, ftStr, ftPtr}
return parseFFIType(nimType).kind in {ftScalar, ftStr, ftPtr}
proc cReturnType(reg: var CTypeReg, p: FFIProcMeta): string =
if p.returnRidesAsPtr():
return CPtrType
ensureCType(reg, p.returnTypeName).cType
return ensureCType(reg, p.returnTypeName).cType
proc buildReqParams(
reg: var CTypeReg, eps: seq[FFIParamMeta]
@ -340,14 +322,14 @@ proc buildReqParams(
else:
params.add("const " & cType & "* " & ep.name)
assigns.add(" ffi_req." & ep.name & " = *" & ep.name & ";")
(params, assigns)
return (params, assigns)
proc evNames(
libType, libName: string, ev: FFIEventMeta
): tuple[fnType, boxType, tramp, regName: string] =
let pascal = capitalizeFirstLetter(ev.nimProcName)
let snake = camelToSnakeCase(ev.nimProcName)
(
return (
libType & pascal & "Fn",
libType & pascal & "Box",
libName & "_" & snake & "_trampoline",
@ -425,10 +407,8 @@ proc emitCallBox(lines: var seq[string], fnType, boxType: string) =
lines.add("typedef struct { " & fnType & " fn; void* user_data; } " & boxType & ";")
proc emitReplyTrampolineHead(lines: var seq[string], tramp, boxType, fallback: string) =
## Opens a reply trampoline: cast the user-data back to the call box, bail if
## the caller passed no callback (nothing to deliver to, and leaving early
## avoids allocating a result nobody receives), then deliver a non-zero `ret`
## as an error. The error text in msg/len is not NUL-terminated, so copy it.
## Opens a reply trampoline: recover the box, fail if no callback, deliver a
## non-zero `ret` as an error (msg/len isn't NUL-terminated, so copy it).
lines.add(
"static void " & tramp & "(int ret, const char* msg, size_t len, void* ud) {"
)
@ -661,8 +641,8 @@ proc emitMethod(
lines.add(" if (dec != 0) {")
lines.add(" box->fn(-1, NULL, err ? err : \"decode failed\", box->user_data);")
lines.add(" free(err);")
# A partial decode may have allocated some fields; reclaim them (out is
# zeroed, so the typed free skips what was never written).
# Reclaim any fields a partial decode allocated (out is zeroed, so free skips
# what was never written).
if retFree.len > 0:
lines.add(" " & retFree & "(&out);")
lines.add(" free(box);")
@ -734,7 +714,7 @@ proc newCTypeReg(
if p.kind != FFIKind.DTOR:
let rt = reqTypeMeta(p)
reg.typeTable[rt.name] = rt
reg
return reg
proc monomorphiseAll(
reg: var CTypeReg,
@ -742,10 +722,9 @@ proc monomorphiseAll(
procs, methods: seq[FFIProcMeta],
events: seq[FFIEventMeta],
): tuple[reqTypes, respTypes: seq[string]] =
## Walks every user type, per-proc Req envelope, return type and event
## payload through ensureCType, emitting their structs/codecs into `reg` in
## dependency order. Returns the Req and response C type names the buffer
## adapters need.
## Runs every user type, Req envelope, return type and event payload through
## ensureCType, emitting structs/codecs into `reg` in dependency order.
## Returns the Req and response C type names the buffer adapters need.
for t in types:
discard ensureCType(reg, t.name)
var reqTypes: seq[string] = @[]
@ -759,19 +738,17 @@ proc monomorphiseAll(
respTypes.add(cReturnType(reg, m))
for ev in events:
discard ensureCType(reg, ev.payloadTypeName)
(reqTypes, respTypes)
return (reqTypes, respTypes)
func generateCPreludeHeader*(): string =
## The `nim_ffi_prelude.h` shared header: owned string/byte types plus the
## libc/TinyCBOR includes every nim-ffi C binding needs. Identical across
## libraries, so it is emitted verbatim from the template.
HeaderPreludeTpl & "\n"
## The library-agnostic `nim_ffi_prelude.h`: owned string/byte types + libc/
## TinyCBOR includes, emitted verbatim.
return HeaderPreludeTpl & "\n"
func generateCCborHeader*(): string =
## The `nim_ffi_cbor.h` shared header: leaf CBOR codecs and buffer drivers.
## Includes the prelude (its guard is inside the template) and is library-
## agnostic, so it too is emitted verbatim.
CborHelpersTpl & "\n"
## The library-agnostic `nim_ffi_cbor.h`: leaf CBOR codecs and buffer drivers,
## emitted verbatim.
return CborHelpersTpl & "\n"
proc generateCLibHeader*(
procs: seq[FFIProcMeta],
@ -779,8 +756,7 @@ proc generateCLibHeader*(
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
## The `<lib>.h` header: library-specific structs, monomorphised codecs and
## the async API. Pulls the two shared headers in via the cbor header.
## The `<lib>.h` header: library structs, monomorphised codecs and async API.
let classified = classifyProcs(procs)
let ctors = classified.ctors
let methods = classified.methods
@ -875,11 +851,432 @@ proc generateCLibHeader*(
emitMethod(lines, reg, ctxType, libType, libName, m)
lines.add("#endif /* " & guard & " */")
lines.join("\n") & "\n"
return lines.join("\n") & "\n"
proc generateCCMakeLists*(libName, nimSrcRelPath: string): string =
let src = nimSrcRelPath.replace("\\", "/")
CMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
return CMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
# `abi = c` binding: the `_CWire` structs are the C ABI (no CBOR). Layout
# mirrors `wireValueType`/`wireFieldsFor` byte-for-byte: `string`→`const char*`,
# `seq[T]`→`<wireT>* <f>_items` + `ptrdiff_t <f>_len`, `Option[T]`→`<wireT>*`
# (NULL = none), nested type→its `_CWire` struct, `ptr`/`pointer`→`void*`.
const AbiCPtrType = "void*"
const AbiCMakeListsTpl = staticRead("templates/c/CMakeLists_abi.txt.tpl")
func abiLeafCType(t: string): tuple[ok: bool, cType: string] =
## Nim leaf type → `abi = c` wire C type; `ok` is false for composites.
return
case t
of "int", "int64":
(true, "int64_t")
of "int32":
(true, "int32_t")
of "int16":
(true, "int16_t")
of "int8":
(true, "int8_t")
of "uint", "uint64":
(true, "uint64_t")
of "uint32":
(true, "uint32_t")
of "uint16":
(true, "uint16_t")
of "uint8", "byte":
(true, "uint8_t")
of "bool":
(true, "bool")
of "float", "float64":
(true, "double")
of "float32":
(true, "float")
of "pointer":
(true, AbiCPtrType)
of "string", "cstring":
(true, "const char*")
else:
(false, "")
type AbiReg = object
typeTable: Table[string, FFITypeMeta]
emitted: HashSet[string]
decls: seq[string]
proc ensureAbiStruct(reg: var AbiReg, typeName: string)
proc abiWireValueCType(reg: var AbiReg, nimType: string): string =
## `abi = c` C type for a value-position field (a top-level `seq` splits in two).
let t = nimType.strip()
if t.startsWith("ptr ") or t == "pointer":
return AbiCPtrType
let leaf = abiLeafCType(t)
if leaf.ok:
return leaf.cType
var optInner = genericInnerType(t, "Option[")
if optInner.len == 0:
optInner = genericInnerType(t, "Maybe[")
if optInner.len > 0:
return abiWireValueCType(reg, optInner.strip()) & "*"
if genericInnerType(t, "seq[").len > 0:
raise newException(
ValueError, "abi = c: `seq` has no single-field wire form, so it can't nest: " & t
)
if genericInnerType(t, "array[").len > 0:
raise newException(
ValueError, "abi = c: array fields are not yet supported by the C backend: " & t
)
if t in reg.typeTable:
ensureAbiStruct(reg, t)
return t
raise newException(ValueError, "abi = c: unknown field type: " & t)
proc abiFieldDecls(reg: var AbiReg, name, nimType: string): seq[string] =
let seqInner = genericInnerType(nimType.strip(), "seq[")
if seqInner.len > 0:
let elemC = abiWireValueCType(reg, seqInner.strip())
return @[elemC & "* " & name & "_items;", "ptrdiff_t " & name & "_len;"]
return @[abiWireValueCType(reg, nimType) & " " & name & ";"]
proc emitAbiStruct(reg: var AbiReg, t: FFITypeMeta) =
var members: seq[string] = @[]
for f in t.fields:
for line in abiFieldDecls(reg, f.name, f.typeName):
members.add(" " & line)
if members.len == 0:
members.add(" uint8_t _placeholder; /* C forbids empty structs */")
reg.decls.add("typedef struct {\n" & members.join("\n") & "\n} " & t.name & ";")
proc ensureAbiStruct(reg: var AbiReg, typeName: string) =
if typeName in reg.emitted:
return
reg.emitted.incl(typeName)
if typeName in reg.typeTable:
emitAbiStruct(reg, reg.typeTable[typeName])
else:
reg.decls.add("/* unknown type referenced: " & typeName & " */")
proc newAbiReg(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): AbiReg =
var reg = AbiReg()
for t in types:
reg.typeTable[t.name] = t
for p in procs:
if p.kind != FFIKind.DTOR:
let rt = reqTypeMeta(p)
reg.typeTable[rt.name] = rt
return reg
func abiParamByValue(nimType: string, ridesAsPtr: bool): bool =
## Scalars/pointers/string views pass by value; aggregates by const pointer.
if ridesAsPtr:
return true
return abiLeafCType(nimType.strip()).ok
proc abiReqParamsAndAssigns(
reg: var AbiReg, extraParams: seq[FFIParamMeta]
): tuple[params, assigns: seq[string]] =
var params, assigns: seq[string] = @[]
for ep in extraParams:
let rides = ep.ridesAsPtr()
let cType =
if rides:
AbiCPtrType
else:
abiWireValueCType(reg, ep.typeName)
if abiParamByValue(ep.typeName, rides):
params.add(cType & " " & ep.name)
assigns.add(" ffi_req." & ep.name & " = " & ep.name & ";")
else:
params.add("const " & cType & "* " & ep.name)
assigns.add(" ffi_req." & ep.name & " = *" & ep.name & ";")
return (params, assigns)
proc abiMethodReplyInfo(
reg: var AbiReg, libType: string, m: FFIProcMeta
): tuple[fnType, replyParam: string] =
## Reply-callback typedef name plus the C type of its `reply` argument.
let pascal = snakeToPascalCase(stripLibPrefix(m.procName, m.libName))
let fnType = libType & pascal & "ReplyFn"
if m.returnRidesAsPtr():
raise newException(
ValueError,
"abi = c: handle/pointer returns are not yet supported by the C backend: " &
m.procName,
)
let rt = m.returnTypeName.strip()
let leaf = abiLeafCType(rt)
let replyParam =
if rt == "string" or rt == "cstring":
"const char*"
elif leaf.ok:
"const " & leaf.cType & "*"
else:
ensureAbiStruct(reg, rt)
"const " & rt & "*"
return (fnType, replyParam)
proc emitAbiReplyTypedefs(
lines: var seq[string], reg: var AbiReg, libType: string, methods: seq[FFIProcMeta]
) =
for m in methods:
let info = abiMethodReplyInfo(reg, libType, m)
lines.add(
"typedef void (*" & info.fnType & ")(int err_code, " & info.replyParam &
" reply, const char* err_msg, void* user_data);"
)
proc emitAbiExternDecls(
lines: var seq[string],
reg: var AbiReg,
libName, libType: string,
procs: seq[FFIProcMeta],
) =
let createRawFn = libType & "CreateRawFn"
var haveCtor = false
for p in procs:
if p.kind == FFIKind.CTOR:
haveCtor = true
if haveCtor:
lines.add(
"typedef void (*" & createRawFn &
")(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);"
)
lines.add("#ifdef __cplusplus")
lines.add("extern \"C\" {")
lines.add("#endif")
lines.add("")
for p in procs:
let reqStruct = reqStructName(p)
case p.kind
of FFIKind.FFI:
let info = abiMethodReplyInfo(reg, libType, p)
lines.add(
"int " & p.procName & "(void* ctx, " & info.fnType &
" on_reply, void* user_data, const " & reqStruct & "* req);"
)
of FFIKind.CTOR:
lines.add(
"void* " & p.procName & "(const " & reqStruct & "* req, " & createRawFn &
" on_created, void* user_data);"
)
of FFIKind.DTOR:
lines.add("int " & p.procName & "(void* ctx);")
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} /* extern \"C\" */")
lines.add("#endif")
lines.add("")
proc emitAbiCtxAndCtor(
lines: var seq[string],
reg: var AbiReg,
libName, libType, ctxType: string,
ctors: seq[FFIProcMeta],
) =
lines.add("typedef struct {")
lines.add(" void* ptr;")
lines.add("} " & ctxType & ";")
lines.add("")
if ctors.len == 0:
return
let createFn = libType & "CreateFn"
let createBox = libType & "CreateBox"
let createRawFn = libType & "CreateRawFn"
let tramp = libName & "_create_trampoline"
lines.add(
"typedef void (*" & createFn & ")(int err_code, " & ctxType &
"* ctx, const char* err_msg, void* user_data);"
)
lines.add(
"typedef struct { " & createFn & " fn; void* user_data; } " & createBox & ";"
)
lines.add(
"static void " & tramp &
"(int ret, const char* ctx_addr, const char* err_msg, void* ud) {"
)
lines.add(" " & createBox & "* box = (" & createBox & "*)ud;")
lines.add(" if (!box) return;")
lines.add(" if (ret == NIMFFI_RET_STALE_WARN) return;")
lines.add(" if (!box->fn) { free(box); return; }")
lines.add(" if (ret != 0) {")
lines.add(
" box->fn(ret, NULL, err_msg ? err_msg : \"FFI create failed\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" char* endp = NULL;")
lines.add(" unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;")
lines.add(" bool ok = ctx_addr && *ctx_addr && endp && *endp == '\\0';")
lines.add(" if (!ok) {")
lines.add(
" box->fn(-1, NULL, \"FFI create returned non-numeric address\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(
" " & ctxType & "* ctx = (" & ctxType & "*)calloc(1, sizeof(" & ctxType & "));"
)
lines.add(" if (!ctx) {")
lines.add(" box->fn(-1, NULL, \"out of memory\", box->user_data);")
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" ctx->ptr = (void*)(uintptr_t)a;")
lines.add(" box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);")
lines.add(" free(box);")
lines.add("}")
lines.add("")
for ctor in ctors:
let reqStruct = reqStructName(ctor)
let (params, assigns) = abiReqParamsAndAssigns(reg, ctor.extraParams)
let head = "static inline int " & libName & "_ctx_create("
let sig =
if params.len > 0:
head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {"
else:
head & createFn & " on_created, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(
" " & createBox & "* box = (" & createBox & "*)malloc(sizeof(" & createBox &
"));"
)
lines.add(" if (!box) {")
lines.add(
" if (on_created) on_created(-1, NULL, \"out of memory\", user_data);"
)
lines.add(" return -1;")
lines.add(" }")
lines.add(" box->fn = on_created;")
lines.add(" box->user_data = user_data;")
lines.add(" (void)" & ctor.procName & "(&ffi_req, " & tramp & ", box);")
lines.add(" return 0;")
lines.add("}")
lines.add("")
proc emitAbiDestructor(lines: var seq[string], ctxType, libName, dtorProcName: string) =
lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {")
lines.add(" if (!ctx) return;")
if dtorProcName.len > 0:
lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }")
lines.add(" free(ctx);")
lines.add("}")
lines.add("")
proc emitAbiMethod(
lines: var seq[string],
reg: var AbiReg,
ctxType, libName, libType: string,
m: FFIProcMeta,
) =
let stripped = stripLibPrefix(m.procName, m.libName)
let reqStruct = reqStructName(m)
let info = abiMethodReplyInfo(reg, libType, m)
let (params, assigns) = abiReqParamsAndAssigns(reg, m.extraParams)
let head =
"static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, "
let sig =
if params.len > 0:
head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {"
else:
head & info.fnType & " on_reply, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(" return " & m.procName & "(ctx->ptr, on_reply, user_data, &ffi_req);")
lines.add("}")
lines.add("")
proc generateCAbiLibHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
if events.len > 0:
raise newException(
ValueError, "abi = c: the C backend does not yet support {.ffiEvent.} listeners"
)
let classified = classifyProcs(procs)
let libType = libTypeName(classified.ctors, libName)
let ctxType = libType & "Ctx"
var reg = newAbiReg(types, procs)
for t in types:
ensureAbiStruct(reg, t.name)
for p in procs:
if p.kind != FFIKind.DTOR:
ensureAbiStruct(reg, reqStructName(p))
let guard = "NIM_FFI_LIB_" & libName.toUpperAscii() & "_C_ABI_H_INCLUDED"
var lines: seq[string] = @[]
lines.add("#ifndef " & guard)
lines.add("#define " & guard)
lines.add("#include <stdint.h>")
lines.add("#include <stddef.h>")
lines.add("#include <stdbool.h>")
lines.add("#include <stdlib.h>")
lines.add("#include <string.h>")
lines.add("")
lines.add("#define NIMFFI_RET_OK 0")
lines.add("#define NIMFFI_RET_ERR 1")
lines.add("#define NIMFFI_RET_MISSING_CALLBACK 2")
lines.add("/* Non-terminal: the request is still running. Fires every ~5s with `msg`")
lines.add(
" carrying the elapsed milliseconds as decimal text; always followed by a"
)
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(
"/* `abi = c` wire structs — the C ABI. Strings are borrowed, NUL-terminated"
)
lines.add(" `const char*` valid only for the duration of the call they cross. */")
for decl in reg.decls:
lines.add(decl)
lines.add("")
emitAbiReplyTypedefs(lines, reg, libType, classified.methods)
lines.add("")
emitAbiExternDecls(lines, reg, libName, libType, procs)
lines.add("/* High-level context wrapper */")
emitAbiCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors)
emitAbiDestructor(lines, ctxType, libName, classified.dtorProcName)
for m in classified.methods:
emitAbiMethod(lines, reg, ctxType, libName, libType, m)
lines.add("#endif /* " & guard & " */")
return lines.join("\n") & "\n"
proc generateCAbiCMakeLists*(libName, nimSrcRelPath: string): string =
let src = nimSrcRelPath.replace("\\", "/")
return AbiCMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
func libWireFormat(procs: seq[FFIProcMeta], types: seq[FFITypeMeta]): ABIFormat =
## The single wire format the C header targets; a library can't mix `abi = c`
## and `abi = cbor` in one header.
var seen: set[ABIFormat] = {}
for p in procs:
if p.kind != FFIKind.DTOR:
seen.incl(p.abiFormat)
if seen.len == 0:
for t in types:
seen.incl(t.abiFormat)
if seen.len > 1:
raise newException(
ValueError,
"abi = c/cbor mismatch: a C library must use one ABI format for all its " &
"procs and types; a mixed header is not supported",
)
return (if ABIFormat.C in seen: ABIFormat.C else: ABIFormat.Cbor)
proc generateCBindings*(
procs: seq[FFIProcMeta],
@ -889,10 +1286,21 @@ proc generateCBindings*(
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
## Emits the C binding for `libName`, picking the `abi = c` or CBOR shape from
## the library's ABI format.
createDir(outputDir)
writeFile(outputDir / PreludeHeaderName, generateCPreludeHeader())
writeFile(outputDir / CborHeaderName, generateCCborHeader())
writeFile(
outputDir / (libName & ".h"), generateCLibHeader(procs, types, libName, events)
)
writeFile(outputDir / "CMakeLists.txt", generateCCMakeLists(libName, nimSrcRelPath))
case libWireFormat(procs, types)
of ABIFormat.C:
writeFile(
outputDir / (libName & ".h"), generateCAbiLibHeader(procs, types, libName, events)
)
writeFile(
outputDir / "CMakeLists.txt", generateCAbiCMakeLists(libName, nimSrcRelPath)
)
of ABIFormat.Cbor:
writeFile(outputDir / PreludeHeaderName, generateCPreludeHeader())
writeFile(outputDir / CborHeaderName, generateCCborHeader())
writeFile(
outputDir / (libName & ".h"), generateCLibHeader(procs, types, libName, events)
)
writeFile(outputDir / "CMakeLists.txt", generateCCMakeLists(libName, nimSrcRelPath))

View File

@ -1,453 +0,0 @@
## CBOR-free C99 binding generator for the nim-ffi framework (`-d:targetLang=c_abi`).
## Where the `c` backend speaks CBOR on the wire (vendoring TinyCBOR), this one
## emits a single self-contained header whose flat structs *are* the C ABI:
## they mirror the macro-generated `*_CWire` layout byte-for-byte, so the C
## consumer passes native structs and links no CBOR at all. The Nim dylib
## converts flat struct ⇄ Nim object at the boundary (see the `abi = c` dispatch
## in `ffi/internal/c_macro_helpers.nim`) and keeps CBOR purely as an internal
## transport detail.
##
## Layout contract (must stay in lock-step with `c_macro_helpers.wireValueType`
## / `wireFieldsFor`): `string`→`const char*`, `seq[T]`→`<wireT>* <f>_items` +
## `ptrdiff_t <f>_len`, `Option[T]`→`<wireT>*` (NULL = none), nested `{.ffi.}`
## type → its flat struct, `ptr`/`pointer`→`void*`, POD unchanged.
import std/[os, strutils, tables, sets]
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
const CPtrType = "void*"
## Wire C type for any Nim `ptr T` / `pointer` (mirrors the `_CWire` `pointer`).
const CMakeListsTpl = staticRead("templates/c_abi/CMakeLists.txt.tpl")
func leafCTypeAbi(t: string): tuple[ok: bool, cType: string] =
## Maps a Nim leaf type to the flat C type used in a wire struct. `ok` is
## false for composites (seq/Option/user structs), handled separately.
case t
of "int", "int64":
(true, "int64_t")
of "int32":
(true, "int32_t")
of "int16":
(true, "int16_t")
of "int8":
(true, "int8_t")
of "uint", "uint64":
(true, "uint64_t")
of "uint32":
(true, "uint32_t")
of "uint16":
(true, "uint16_t")
of "uint8", "byte":
(true, "uint8_t")
of "bool":
(true, "bool")
of "float", "float64":
(true, "double")
of "float32":
(true, "float")
of "pointer":
(true, CPtrType)
of "string", "cstring":
(true, "const char*")
else:
(false, "")
type AbiReg = object
typeTable: Table[string, FFITypeMeta] ## user structs + synthetic Req structs
emitted: HashSet[string] ## struct names already emitted
decls: seq[string] ## struct typedefs, dependency order
proc ensureAbiStruct(reg: var AbiReg, typeName: string)
proc wireValueCType(reg: var AbiReg, nimType: string): string =
## Flat C type for a value-position field (everything except a top-level
## `seq`, which splits into two fields — see `fieldDecls`).
let t = nimType.strip()
if t.startsWith("ptr ") or t == "pointer":
return CPtrType
let leaf = leafCTypeAbi(t)
if leaf.ok:
return leaf.cType
var optInner = genericInnerType(t, "Option[")
if optInner.len == 0:
optInner = genericInnerType(t, "Maybe[")
if optInner.len > 0:
return wireValueCType(reg, optInner.strip()) & "*"
if genericInnerType(t, "seq[").len > 0:
raise newException(
ValueError,
"abi = c: `seq` has no single-field wire form, so it can't nest inside " &
"another container: " & t,
)
if genericInnerType(t, "array[").len > 0:
raise newException(
ValueError, "abi = c: array fields are not yet supported by the C backend: " & t
)
if t in reg.typeTable:
ensureAbiStruct(reg, t)
return t
raise newException(ValueError, "abi = c: unknown field type: " & t)
proc fieldDecls(reg: var AbiReg, name, nimType: string): seq[string] =
## C struct member line(s) for one Nim field. A `seq[T]` becomes the
## `<name>_items` pointer + `<name>_len` count pair; everything else is one
## member.
let seqInner = genericInnerType(nimType.strip(), "seq[")
if seqInner.len > 0:
let elemC = wireValueCType(reg, seqInner.strip())
return @[elemC & "* " & name & "_items;", "ptrdiff_t " & name & "_len;"]
@[wireValueCType(reg, nimType) & " " & name & ";"]
proc emitAbiStruct(reg: var AbiReg, t: FFITypeMeta) =
var members: seq[string] = @[]
for f in t.fields:
for line in fieldDecls(reg, f.name, f.typeName):
members.add(" " & line)
if members.len == 0:
members.add(" uint8_t _placeholder; /* C forbids empty structs */")
reg.decls.add("typedef struct {\n" & members.join("\n") & "\n} " & t.name & ";")
proc ensureAbiStruct(reg: var AbiReg, typeName: string) =
if typeName in reg.emitted:
return
reg.emitted.incl(typeName)
if typeName in reg.typeTable:
emitAbiStruct(reg, reg.typeTable[typeName])
else:
reg.decls.add("/* unknown type referenced: " & typeName & " */")
proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
## The per-proc Req envelope as an FFITypeMeta, mirroring the Nim macro. A
## pointer/handle param rides as the opaque `pointer` wire type.
var fields: seq[FFIFieldMeta] = @[]
for ep in p.extraParams:
let typeName = if ep.ridesAsPtr(): "pointer" else: ep.typeName
fields.add(FFIFieldMeta(name: ep.name, typeName: typeName))
FFITypeMeta(name: reqStructName(p), fields: fields)
proc newAbiReg(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): AbiReg =
var reg = AbiReg()
for t in types:
reg.typeTable[t.name] = t
for p in procs:
if p.kind != FFIKind.DTOR:
let rt = reqTypeMeta(p)
reg.typeTable[rt.name] = rt
reg
func paramByValue(nimType: string, ridesAsPtr: bool): bool =
## Scalars / opaque pointers / string views pass by value; composite
## aggregates (seq, Option, user structs) pass by const pointer.
if ridesAsPtr:
return true
leafCTypeAbi(nimType.strip()).ok
proc reqParamsAndAssigns(
reg: var AbiReg, extraParams: seq[FFIParamMeta]
): tuple[params, assigns: seq[string]] =
## The C parameter list + `ffi_req` field assignments shared by the ctor and
## method wrappers: by-value params copy straight into the request struct,
## by-const-pointer aggregates are dereferenced in.
var params, assigns: seq[string] = @[]
for ep in extraParams:
let rides = ep.ridesAsPtr()
let cType =
if rides:
CPtrType
else:
wireValueCType(reg, ep.typeName)
if paramByValue(ep.typeName, rides):
params.add(cType & " " & ep.name)
assigns.add(" ffi_req." & ep.name & " = " & ep.name & ";")
else:
params.add("const " & cType & "* " & ep.name)
assigns.add(" ffi_req." & ep.name & " = *" & ep.name & ";")
(params, assigns)
proc methodReplyInfo(
reg: var AbiReg, libType: string, m: FFIProcMeta
): tuple[fnType, replyParam: string] =
## The reply-callback typedef name plus the C type of its `reply` argument.
## An object return hands back a `const <Struct>*`; a string return a
## `const char*`. Both are the raw callback the dylib invokes directly.
let pascal = snakeToPascalCase(stripLibPrefix(m.procName, m.libName))
let fnType = libType & pascal & "ReplyFn"
if m.returnRidesAsPtr():
raise newException(
ValueError,
"abi = c: handle/pointer returns are not yet supported by the C backend: " &
m.procName,
)
let rt = m.returnTypeName.strip()
let replyParam =
if rt == "string" or rt == "cstring":
"const char*"
elif leafCTypeAbi(rt).ok:
"const " & leafCTypeAbi(rt).cType & "*"
else:
ensureAbiStruct(reg, rt)
"const " & rt & "*"
(fnType, replyParam)
proc emitReplyTypedefs(
lines: var seq[string], reg: var AbiReg, libType: string, methods: seq[FFIProcMeta]
) =
for m in methods:
let info = methodReplyInfo(reg, libType, m)
lines.add(
"typedef void (*" & info.fnType & ")(int err_code, " & info.replyParam &
" reply, const char* err_msg, void* user_data);"
)
proc emitExternDecls(
lines: var seq[string],
reg: var AbiReg,
libName, libType: string,
procs: seq[FFIProcMeta],
) =
let createRawFn = libType & "CreateRawFn"
var haveCtor = false
for p in procs:
if p.kind == FFIKind.CTOR:
haveCtor = true
if haveCtor:
lines.add(
"typedef void (*" & createRawFn &
")(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);"
)
lines.add("#ifdef __cplusplus")
lines.add("extern \"C\" {")
lines.add("#endif")
lines.add("")
for p in procs:
let reqStruct = reqStructName(p)
case p.kind
of FFIKind.FFI:
let info = methodReplyInfo(reg, libType, p)
lines.add(
"int " & p.procName & "(void* ctx, " & info.fnType &
" on_reply, void* user_data, const " & reqStruct & "* req);"
)
of FFIKind.CTOR:
lines.add(
"void* " & p.procName & "(const " & reqStruct & "* req, " & createRawFn &
" on_created, void* user_data);"
)
of FFIKind.DTOR:
lines.add("int " & p.procName & "(void* ctx);")
lines.add("")
lines.add("#ifdef __cplusplus")
lines.add("} /* extern \"C\" */")
lines.add("#endif")
lines.add("")
proc emitCtxAndCtor(
lines: var seq[string],
reg: var AbiReg,
libName, libType, ctxType: string,
ctors: seq[FFIProcMeta],
) =
lines.add("typedef struct {")
lines.add(" void* ptr;")
lines.add("} " & ctxType & ";")
lines.add("")
if ctors.len == 0:
return
let createFn = libType & "CreateFn"
let createBox = libType & "CreateBox"
let createRawFn = libType & "CreateRawFn"
let tramp = libName & "_create_trampoline"
lines.add(
"typedef void (*" & createFn & ")(int err_code, " & ctxType &
"* ctx, const char* err_msg, void* user_data);"
)
lines.add(
"typedef struct { " & createFn & " fn; void* user_data; } " & createBox & ";"
)
lines.add(
"static void " & tramp &
"(int ret, const char* ctx_addr, const char* err_msg, void* ud) {"
)
lines.add(" " & createBox & "* box = (" & createBox & "*)ud;")
lines.add(" if (!box) return;")
lines.add(
" /* Non-terminal progress ping: keep the box for the terminal reply. */"
)
lines.add(" if (ret == NIMFFI_RET_STALE_WARN) return;")
lines.add(" if (!box->fn) { free(box); return; }")
lines.add(" if (ret != 0) {")
lines.add(
" box->fn(ret, NULL, err_msg ? err_msg : \"FFI create failed\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" char* endp = NULL;")
lines.add(" unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;")
lines.add(" bool ok = ctx_addr && *ctx_addr && endp && *endp == '\\0';")
lines.add(" if (!ok) {")
lines.add(
" box->fn(-1, NULL, \"FFI create returned non-numeric address\", box->user_data);"
)
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(
" " & ctxType & "* ctx = (" & ctxType & "*)calloc(1, sizeof(" & ctxType & "));"
)
lines.add(" if (!ctx) {")
lines.add(" box->fn(-1, NULL, \"out of memory\", box->user_data);")
lines.add(" free(box);")
lines.add(" return;")
lines.add(" }")
lines.add(" ctx->ptr = (void*)(uintptr_t)a;")
lines.add(" box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);")
lines.add(" free(box);")
lines.add("}")
lines.add("")
for ctor in ctors:
let reqStruct = reqStructName(ctor)
let (params, assigns) = reqParamsAndAssigns(reg, ctor.extraParams)
let head = "static inline int " & libName & "_ctx_create("
let sig =
if params.len > 0:
head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {"
else:
head & createFn & " on_created, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(
" " & createBox & "* box = (" & createBox & "*)malloc(sizeof(" & createBox &
"));"
)
lines.add(" if (!box) {")
lines.add(
" if (on_created) on_created(-1, NULL, \"out of memory\", user_data);"
)
lines.add(" return -1;")
lines.add(" }")
lines.add(" box->fn = on_created;")
lines.add(" box->user_data = user_data;")
lines.add(" (void)" & ctor.procName & "(&ffi_req, " & tramp & ", box);")
lines.add(" return 0;")
lines.add("}")
lines.add("")
proc emitDestructor(lines: var seq[string], ctxType, libName, dtorProcName: string) =
lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {")
lines.add(" if (!ctx) return;")
if dtorProcName.len > 0:
lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }")
lines.add(" free(ctx);")
lines.add("}")
lines.add("")
proc emitMethod(
lines: var seq[string],
reg: var AbiReg,
ctxType, libName, libType: string,
m: FFIProcMeta,
) =
let stripped = stripLibPrefix(m.procName, m.libName)
let reqStruct = reqStructName(m)
let info = methodReplyInfo(reg, libType, m)
let (params, assigns) = reqParamsAndAssigns(reg, m.extraParams)
let head =
"static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, "
let sig =
if params.len > 0:
head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {"
else:
head & info.fnType & " on_reply, void* user_data) {"
lines.add(sig)
lines.add(" " & reqStruct & " ffi_req;")
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
for a in assigns:
lines.add(a)
lines.add(" return " & m.procName & "(ctx->ptr, on_reply, user_data, &ffi_req);")
lines.add("}")
lines.add("")
proc generateCAbiLibHeader*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
events: seq[FFIEventMeta] = @[],
): string =
if events.len > 0:
raise newException(
ValueError, "abi = c: the C backend does not yet support {.ffiEvent.} listeners"
)
let classified = classifyProcs(procs)
let libType = libTypeName(classified.ctors, libName)
let ctxType = libType & "Ctx"
var reg = newAbiReg(types, procs)
for t in types:
ensureAbiStruct(reg, t.name)
for p in procs:
if p.kind != FFIKind.DTOR:
ensureAbiStruct(reg, reqStructName(p))
let guard = "NIM_FFI_LIB_" & libName.toUpperAscii() & "_C_ABI_H_INCLUDED"
var lines: seq[string] = @[]
lines.add("#ifndef " & guard)
lines.add("#define " & guard)
lines.add("#include <stdint.h>")
lines.add("#include <stddef.h>")
lines.add("#include <stdbool.h>")
lines.add("#include <stdlib.h>")
lines.add("#include <string.h>")
lines.add("")
lines.add("#define NIMFFI_RET_OK 0")
lines.add("#define NIMFFI_RET_ERR 1")
lines.add("#define NIMFFI_RET_MISSING_CALLBACK 2")
lines.add("/* Non-terminal: the request is still running. Fires every ~5s with `msg`")
lines.add(
" carrying the elapsed milliseconds as decimal text; always followed by a"
)
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("/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated")
lines.add(" `const char*` valid only for the duration of the call they cross. */")
for decl in reg.decls:
lines.add(decl)
lines.add("")
emitReplyTypedefs(lines, reg, libType, classified.methods)
lines.add("")
emitExternDecls(lines, reg, libName, libType, procs)
lines.add("/* High-level context wrapper */")
emitCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors)
emitDestructor(lines, ctxType, libName, classified.dtorProcName)
for m in classified.methods:
emitMethod(lines, reg, ctxType, libName, libType, m)
lines.add("#endif /* " & guard & " */")
lines.join("\n") & "\n"
proc generateCAbiCMakeLists*(libName, nimSrcRelPath: string): string =
let src = nimSrcRelPath.replace("\\", "/")
CMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
proc generateCAbiBindings*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
events: seq[FFIEventMeta] = @[],
) =
createDir(outputDir)
writeFile(
outputDir / (libName & ".h"), generateCAbiLibHeader(procs, types, libName, events)
)
writeFile(
outputDir / "CMakeLists.txt", generateCAbiCMakeLists(libName, nimSrcRelPath)
)

View File

@ -6,7 +6,7 @@ import std/strutils
type
ABIFormat* {.pure.} = enum
## Wire format for an FFI payload. Only `Cbor` is wired end-to-end; `C`
## (flat C-struct) has a type codec but no proc-dispatch path yet.
## (`abi = c` C-struct) has a type codec but no proc-dispatch path yet.
Cbor = "cbor"
C = "c"
@ -74,7 +74,7 @@ var currentDefaultABIFormat* {.compileTime.}: ABIFormat = ABIFormat.Cbor
proc abiCodegenImplemented*(fmt: ABIFormat): bool =
## Whether `fmt` has a working proc-dispatch path. Both `Cbor` and `C` are
## wired: `Cbor` rides the generic overloads, `C` rides the flat `_CWire`
## wired: `Cbor` rides the generic overloads, `C` rides the `_CWire`
## companions (a CBOR-free foreign surface with CBOR transport internally).
fmt in {ABIFormat.Cbor, ABIFormat.C}

View File

@ -4,8 +4,8 @@ project({{LIB}}_c_abi_bindings C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# The CBOR-free `abi = c` binding links no TinyCBOR — the flat structs in the
# generated header are the ABI. Only the Nim dylib is built.
# The CBOR-free `abi = c` binding links no TinyCBOR — the generated header
# structs are the ABI. Only the Nim dylib is built.
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
set(REPO_ROOT "")

View File

@ -86,7 +86,8 @@ func scalarKind(t: string): Option[ScalarKind] =
none(ScalarKind)
func parseFFIType*(typeName: string): FFIType =
## Single source of truth for turning a Nim type string into the shared IR:
## Single source of truth for turning a Nim type string into the shared
## intermediate representation:
## ptr/pointer, seq[byte]→bytes, seq/Option/Maybe, scalars, string, else struct.
let t = typeName.strip()
if t.startsWith("ptr ") or t == "pointer":

View File

@ -1,4 +1,4 @@
## Compile-time helpers used by `ffi_macro.nim` for the `c` (flat C-struct) ABI.
## Compile-time helpers used by `ffi_macro.nim` for the `c` (`abi = c` C-struct) ABI.
## For each `{.ffi: "abi = c".}` object T, emits a `T_CWire` companion plus
## `cwirePack` / `cwireUnpack` / `cwireFree`. Field mapping: `string`→`cstring`,
## `seq[T]`→`<name>_items`+`<name>_len`, `Option[T]`/`Maybe[T]`→`ptr T_w`
@ -554,7 +554,7 @@ proc flushCWireCompanions*(): NimNode {.compileTime.} =
ensureCWireFor(typeMeta.name, sink)
sink
## abi = c proc dispatch. The foreign surface is CBOR-free — the flat `_CWire`
## abi = c proc dispatch. The foreign surface is CBOR-free — the `_CWire`
## structs are the C ABI — but transport reuses the proven CBOR request path
## internally: the generated exported wrapper `cwireUnpack`s the request into a
## Nim object, `cborEncodeShared`s it onto the FFI thread, and a Nim reply
@ -679,7 +679,7 @@ proc replyTrampProc(trampName, body: NimNode): NimNode =
proc objectTrampBody(boxName, respType, respWire: NimNode): NimNode =
## Reply trampoline for an object return: recover the box, deliver a transport
## error as a copied NUL-terminated string, else CBOR-decode the reply,
## `cwirePack` it into the flat wire struct, hand a pointer to the caller, and
## `cwirePack` it into the `_CWire` struct, hand a pointer to the caller, and
## release the wire. `err_msg` is always a non-nil string; the `reply` struct
## pointer is nil only on error, gated by a non-`RET_OK` `err_code`.
quote:

View File

@ -11,7 +11,6 @@ when defined(ffiGenBindings):
import ../codegen/rust
import ../codegen/cpp
import ../codegen/c
import ../codegen/c_abi
import ../codegen/cddl
proc requireLibraryDeclared(where: string) {.compileTime.} =
@ -92,7 +91,7 @@ proc gateABIFormat(fmt: ABIFormat, where: string) {.compileTime.} =
proc gateFFITypeABIFormat(fmt: ABIFormat, where: string) {.compileTime.} =
## Type annotations only register metadata. `cbor` uses the generic CBOR
## overloads, while `c` emits its flat `_CWire` companion from `genBindings()`.
## overloads, while `c` emits its `_CWire` companion from `genBindings()`.
case fmt
of ABIFormat.Cbor, ABIFormat.C: discard
@ -931,7 +930,7 @@ macro ffi*(args: varargs[untyped]): untyped =
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
# opts in, and only when every wire param + the return is a plain scalar
# (see `isScalarOnly`) and the args fit the inline slots. A non-scalar
# `abi = c` proc rides the flat `_CWire` C-dispatch emitted by `asyncPath`.
# `abi = c` proc rides the `_CWire` C-dispatch emitted by `asyncPath`.
let scalarEligible =
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
extraParamNames.len <= MaxScalarArgs
@ -1061,7 +1060,7 @@ macro ffi*(args: varargs[untyped]): untyped =
ffiProcRegistry.add(procMeta)
if abiFormat == ABIFormat.C:
# The flat-struct exported wrapper + reply trampoline are emitted at
# The `abi = c` exported wrapper + reply trampoline are emitted at
# genBindings() time (see flushCAbiDispatch); the CBOR `ffiProc` is not.
registerCAbiMethod(
cExportName, libTypeName, reqTypeName, extraParamNames, extraParamTypes,
@ -1515,7 +1514,7 @@ macro ffiCtor*(args: varargs[untyped]): untyped =
let stmts =
if abiFormat == ABIFormat.C:
# The flat-struct exported wrapper is emitted at genBindings() time (see
# The `abi = c` exported wrapper is emitted at genBindings() time (see
# flushCAbiDispatch); the CBOR `ffiProc` is not.
registerCAbiCtor(cExportName, libTypeName, reqTypeName, paramNames, paramTypes)
newStmtList(typeDef, ffiNewReqProc, helperProc, processProc, addToReg, poolDecl)
@ -1858,16 +1857,12 @@ when defined(ffiGenBindings):
generateCBindings(
genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry
)
of "c_abi":
generateCAbiBindings(
genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry
)
of "cddl":
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outDir, srcRel)
else:
error(
"genBindings: unknown targetLang '" & lang &
"'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'."
"'. Use 'rust', 'cpp', 'c', or 'cddl'."
)
macro genBindings*(
@ -1886,9 +1881,10 @@ macro genBindings*(
## In a multi-file library, import all sub-modules first and call
## genBindings() once at the bottom of the top-level compilation-root file.
##
## Supported languages (-d:targetLang): "rust" (default), "cpp", "c",
## "c_abi", "cddl". Pass a comma-separated list to emit several at once from
## a single compile — the backend dispatch loops over each language.
## Supported languages (-d:targetLang): "rust" (default), "cpp", "c", "cddl".
## Pass a comma-separated list to emit several at once from a single compile —
## the backend dispatch loops over each language. The `c` target emits the
## `abi = c` or CBOR C shape based on the library's ABI format (`defaultABIFormat`).
##
## Output dir defaults to `<lang>_bindings/` next to the compiled source; the
## embedded nim source path is derived by making that source relative to the

View File

@ -57,7 +57,7 @@ wire-format codecs head-to-head on identical payloads:
- **cbor**`cborEncode` / `cborDecode`, self-describing bytes over
`seq[byte]`. The codec the `cbor` ABI uses on every boundary crossing.
- **c (cwire)**`cwirePack` / `cwireUnpack` / `cwireFree`, flat C-struct
- **c (cwire)**`cwirePack` / `cwireUnpack` / `cwireFree`, `abi = c` C-struct
shared-memory packing. The codec the `c` ABI uses, emitted for every
`{.ffi: "abi = c".}` type as its `<T>_CWire` companion.

View File

@ -1,13 +1,8 @@
/* End-to-end test for the CBOR-free `abi = c` echo bindings. Unlike the CBOR C
* backend, this header links no TinyCBOR: the flat structs in echo.h are the C
* ABI, strings are plain borrowed `const char*`. The test drives the same
* async, callback-per-call surface constructor, an object-returning method
* and teardown copying out what each callback delivers (owned by the binding,
* valid only for the call) and polling a `done` flag to sequence the async
* calls. A string-returning method (echoVersion) rides the CBOR-free scalar
* fast path instead of a flat `_CWire` wrapper, so it has no c_abi binding yet
* (foreign codegen for the scalar shape is a follow-up) and isn't exercised
* here. */
/* End-to-end test for the CBOR-free `abi = c` echo bindings: the `_CWire`
* structs in echo.h are the C ABI, strings are borrowed `const char*`, no
* TinyCBOR. Drives the async callback-per-call surface (ctor, object-returning
* method, teardown). echoVersion rides the scalar fast path (no foreign binding
* yet) and isn't exercised. */
#include "echo.h"
#include <assert.h>
#include <stdatomic.h>

View File

@ -101,7 +101,7 @@ suite "ABI proc-dispatch readiness":
test "both cbor and c proc-dispatch are wired":
# This predicate is what the proc-form macros consult. Both ABIs now have a
# working dispatch path: `cbor` rides the generic overloads, `c` rides the
# flat `_CWire` companions (a CBOR-free foreign surface, CBOR transport
# `_CWire` companions (a CBOR-free foreign surface, CBOR transport
# internally). Events are the one `c` gap, gated separately in the macro.
check abiCodegenImplemented(ABIFormat.Cbor)
check abiCodegenImplemented(ABIFormat.C)

View File

@ -1,11 +1,10 @@
## Unit tests for the CBOR-free `abi = c` C binding generator. Drives
## generateCAbiLibHeader directly against a synthetic registry (no macro
## pipeline, no files written) and asserts on the emitted text — same approach
## as test_c_codegen / test_cddl_codegen.
## Unit tests for the CBOR-free (`abi = c`) C binding shape. Drives
## generateCAbiLibHeader against a synthetic registry (no macro pipeline, no
## files written), asserting on the emitted text.
import std/strutils
import unittest2
import ffi/codegen/[meta, c_abi]
import ffi/codegen/[meta, c]
proc field(n, t: string): FFIFieldMeta =
FFIFieldMeta(name: n, typeName: t)
@ -69,7 +68,7 @@ suite "generateCAbiLibHeader":
check "CborError" notin header
check "tinycbor" notin header.toLowerAscii()
test "flat wire structs mirror the _CWire layout":
test "abi = c wire structs mirror the _CWire layout":
# string -> const char*; POD unchanged.
check "const char* message;" in header
check "int64_t delayMs;" in header
@ -78,14 +77,14 @@ suite "generateCAbiLibHeader":
check "ptrdiff_t messages_len;" in header
# Option[string] -> pointer to the element wire type (NULL = none).
check "const char** note;" in header
# nested {.ffi.} type rides as its flat struct.
# nested {.ffi.} type rides as its _CWire struct.
check "EchoRequest config;" in header
test "per-proc Req envelopes are emitted as structs":
check "} TimerEchoReq;" in header
check "} TimerCreateCtorReq;" in header
test "exported symbols use the flat structs, not CBOR buffers":
test "exported symbols use the _CWire structs, not CBOR buffers":
check "req_cbor" notin header
check "const TimerEchoReq* req" in header
check "void* timer_create(const TimerCreateCtorReq* req," in header

View File

@ -1,4 +1,4 @@
## Round-trip correctness for the `c` (flat C-struct) ABI codec.
## Round-trip correctness for the `c` (`abi = c` C-struct) ABI codec.
##
## Each `{.ffi: "abi = c".}` type gets a `<T>_CWire` companion plus
## `cwirePack` / `cwireUnpack` / `cwireFree`. This asserts

View File

@ -2,7 +2,7 @@
## own scalar table that had drifted from C/C++ — `int8`/`int16`/`uint8`/
## `uint16`/`uint32`/`byte`/`float32` fell through to `capitalizeFirstLetter`
## and emitted invalid Rust. It now renders through the shared `parseFFIType`
## IR, so the full scalar set is pinned here.
## intermediate representation, so the full scalar set is pinned here.
import unittest2
import ffi/codegen/rust

View File

@ -1,6 +1,7 @@
## Unit tests for the shared type IR that the C / C++ / Rust binding generators
## parse Nim type strings through. `parseFFIType` is the single source of truth
## the three backends consume, so its shape mappings are pinned here directly.
## Unit tests for the shared type intermediate representation that the C / C++ /
## Rust binding generators parse Nim type strings through. `parseFFIType` is the
## single source of truth the three backends consume, so its shape mappings are
## pinned here directly.
import unittest2
import ffi/codegen/types_ir
@ -83,7 +84,7 @@ suite "parseFFIType: structs":
check t.kind == ftStruct
check t.name == "EchoRequest"
suite "renderNative: walks the IR with a backend map":
suite "renderNative: walks the intermediate representation with a backend map":
let rustish = NativeTypeMap(
scalar: proc(s: ScalarKind): string =
(