diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f98751..cd25502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,14 @@ jobs: fi nimble test_cpp_e2e -y + # 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. + - name: Run abi=c C e2e test + if: matrix.label == 'Linux' + shell: bash + run: nimble test_c_abi_e2e -y + check-bindings: # Single OS is enough — codegen output is platform-independent; the Nim # matrix catches version-sensitive output (the PR #39 drift class). diff --git a/.github/workflows/tests-sanitized.yml b/.github/workflows/tests-sanitized.yml index dc8b269..b740d9f 100644 --- a/.github/workflows/tests-sanitized.yml +++ b/.github/workflows/tests-sanitized.yml @@ -101,3 +101,6 @@ jobs: - name: Run C++ e2e tests (${{ inputs.sanitizer }}) run: nimble test_cpp_e2e_sanitized -y + + - name: Run abi=c C e2e test (${{ inputs.sanitizer }}) + run: nimble test_c_abi_e2e_sanitized -y diff --git a/CHANGELOG.md b/CHANGELOG.md index 837f733..c18d679 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,9 +62,22 @@ All notable changes to this project are documented in this file. - `c` (flat C-struct) ABI **codec**: every `{.ffi: "abi = c".}` type gets a `_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 the foreign (C++ / - Rust) generators are still pending, so `c` remains rejected on - proc/ctor/dtor/event annotations for now. + 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 `.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)). - `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. diff --git a/examples/echo/c_abi_bindings/CMakeLists.txt b/examples/echo/c_abi_bindings/CMakeLists.txt new file mode 100644 index 0000000..f007c8a --- /dev/null +++ b/examples/echo/c_abi_bindings/CMakeLists.txt @@ -0,0 +1,72 @@ +cmake_minimum_required(VERSION 3.14) +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. + +set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") +set(REPO_ROOT "") +foreach(_i RANGE 10) + if(EXISTS "${_search_dir}/ffi.nimble") + set(REPO_ROOT "${_search_dir}") + break() + endif() + get_filename_component(_search_dir "${_search_dir}" DIRECTORY) +endforeach() +if("${REPO_ROOT}" STREQUAL "") + message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)") +endif() + +# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to +# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none. +set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib") + +find_program(NIM_EXECUTABLE nim REQUIRED) + +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(NIM_LIB_FILE "${REPO_ROOT}/libecho.dylib") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(NIM_LIB_FILE "${REPO_ROOT}/echo.dll") +else() + set(NIM_LIB_FILE "${REPO_ROOT}/libecho.so") +endif() + +get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../echo.nim" ABSOLUTE) + +add_custom_command( + OUTPUT "${NIM_LIB_FILE}" + COMMAND "${NIM_EXECUTABLE}" c + --mm:orc + -d:chronicles_log_level=WARN + --app:lib + --noMain + "--nimMainPrefix:libecho" + ${NIM_FFI_EXTRA_ARGS} + "-o:${NIM_LIB_FILE}" + "${NIM_SRC}" + WORKING_DIRECTORY "${REPO_ROOT}" + DEPENDS "${NIM_SRC}" + COMMENT "Compiling Nim library libecho (abi = c)" + VERBATIM +) +add_custom_target(echo_nim_lib ALL DEPENDS "${NIM_LIB_FILE}") + +add_library(echo SHARED IMPORTED GLOBAL) +set_target_properties(echo PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}") +add_dependencies(echo echo_nim_lib) + +find_package(Threads REQUIRED) + +add_library(echo_headers INTERFACE) +target_include_directories(echo_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(echo_headers INTERFACE echo Threads::Threads) +target_compile_definitions(echo_headers INTERFACE _POSIX_C_SOURCE=200809L) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c") + add_executable(echo_example main.c) + target_link_libraries(echo_example PRIVATE echo_headers) + add_dependencies(echo_example echo_nim_lib) +endif() diff --git a/examples/echo/c_abi_bindings/README.md b/examples/echo/c_abi_bindings/README.md new file mode 100644 index 0000000..ee39b15 --- /dev/null +++ b/examples/echo/c_abi_bindings/README.md @@ -0,0 +1,27 @@ +# 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). + +| | `../c_bindings/` (`-d:targetLang=c`) | this dir (`-d:targetLang=c_abi`) | +|---|---|---| +| Wire format on the C side | CBOR | flat 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) | + +Both talk to the *same* unmodified Nim dylib API shape (`echo_ctx_create`, +`echo_ctx_shout`, …) and share the async, binding-owned callback contract. The +difference is only the ABI at the boundary: here the macro-generated `_CWire` +structs (`ShoutRequest { const char* text; }`, …) are passed as native C structs +and the Nim side converts them to/from Nim objects. CBOR is still used, but only +as an internal transport between the exported wrapper and the FFI worker thread — +it never appears in this header. + +The library is built with `-d:ffiEchoAbiC`, which flips the shared +`examples/echo/echo.nim` source to `declareLibrary(..., defaultABIFormat = "c")`. +A library that always wants the C ABI would set that in its own `declareLibrary` +call and need no extra flag. + +Run the end-to-end test with `nimble test_c_abi_e2e`. diff --git a/examples/echo/c_abi_bindings/echo.h b/examples/echo/c_abi_bindings/echo.h new file mode 100644 index 0000000..8b9bfdd --- /dev/null +++ b/examples/echo/c_abi_bindings/echo.h @@ -0,0 +1,110 @@ +#ifndef NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED +#define NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED +#include +#include +#include +#include +#include + +#define NIMFFI_RET_OK 0 +#define NIMFFI_RET_ERR 1 +#define NIMFFI_RET_MISSING_CALLBACK 2 + +/* Flat 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; +} EchoConfig; +typedef struct { + const char* text; +} ShoutRequest; +typedef struct { + const char* shouted; + const char* prefix; +} ShoutResponse; +typedef struct { + EchoConfig config; +} EchoCreateCtorReq; +typedef struct { + ShoutRequest req; +} EchoShoutReq; + +typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data); + +typedef void (*EchoCreateRawFn)(int err_code, const char* ctx_addr, const char* err_msg, void* user_data); +#ifdef __cplusplus +extern "C" { +#endif + +void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void* user_data); +int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req); +int echo_destroy(void* ctx); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +/* High-level context wrapper */ +typedef struct { + void* ptr; +} EchoCtx; + +typedef void (*EchoCreateFn)(int err_code, EchoCtx* ctx, const char* err_msg, void* user_data); +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; + if (!box->fn) { free(box); return; } + if (ret != 0) { + box->fn(ret, NULL, err_msg ? err_msg : "FFI create failed", box->user_data); + free(box); + return; + } + char* endp = NULL; + unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0; + bool ok = ctx_addr && *ctx_addr && endp && *endp == '\0'; + if (!ok) { + box->fn(-1, NULL, "FFI create returned non-numeric address", box->user_data); + free(box); + return; + } + EchoCtx* ctx = (EchoCtx*)calloc(1, sizeof(EchoCtx)); + if (!ctx) { + box->fn(-1, NULL, "out of memory", box->user_data); + free(box); + return; + } + ctx->ptr = (void*)(uintptr_t)a; + box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data); + free(box); +} + +static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) { + EchoCreateCtorReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.config = *config; + EchoCreateBox* box = (EchoCreateBox*)malloc(sizeof(EchoCreateBox)); + if (!box) { + if (on_created) on_created(-1, NULL, "out of memory", user_data); + return -1; + } + box->fn = on_created; + box->user_data = user_data; + (void)echo_create(&ffi_req, echo_create_trampoline, box); + return 0; +} + +static inline void echo_ctx_destroy(EchoCtx* ctx) { + if (!ctx) return; + if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; } + free(ctx); +} + +static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) { + EchoShoutReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + return echo_shout(ctx->ptr, on_reply, user_data, &ffi_req); +} + +#endif /* NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED */ diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim index b031925..4dcc1af 100644 --- a/examples/echo/echo.nim +++ b/examples/echo/echo.nim @@ -6,7 +6,13 @@ import ffi, chronos, strutils type Echo = object prefix: string -declareLibrary("echo", Echo) +# `-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. +when defined(ffiEchoAbiC): + declareLibrary("echo", Echo, defaultABIFormat = "c") +else: + declareLibrary("echo", Echo) type EchoConfig {.ffi.} = object prefix: string diff --git a/ffi.nimble b/ffi.nimble index 88ab85b..7988a8f 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -84,6 +84,19 @@ proc applyTsanSuppressions() = elif "suppressions=" notin existing: putEnv("TSAN_OPTIONS", existing & ":suppressions=" & suppPath) +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 CBOR e2e step is silently reused by the + ## abi=c build — the flat-struct C caller then reaches the CBOR entry points + ## (wrong arity/ABI) and segfaults. Delete it first to force a fresh rebuild + ## with the right ABI. + for name in ["libecho.so", "libecho.dylib", "echo.dll"]: + let path = thisDir() / name + if fileExists(path): + rmFile(path) + task buildffi, "Compile the library": exec "nim c " & nimFlagsOrc & " --app:lib --noMain ffi.nim" @@ -138,6 +151,14 @@ task test_c_e2e, "Build and run the C end-to-end tests for the timer example": runOrQuit "cmake --build tests/e2e/c/build --config Debug" 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" + runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug" + runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug" + task test_sanitized, "Run all unit tests under a sanitizer (NIM_FFI_SAN) and mm (NIM_FFI_MM)": let san = getEnv("NIM_FFI_SAN", "none") @@ -169,6 +190,16 @@ task test_c_e2e_sanitized, runOrQuit "cmake --build tests/e2e/c/build --config Debug -j" runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug" +task test_c_abi_e2e_sanitized, + "Build and run the abi=c C e2e test with a sanitizer (NIM_FFI_SAN)": + let san = getEnv("NIM_FFI_SAN", "none") + runOrQuit "nimble genbindings_c_abi_echo" + removeStaleEchoLib() + runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build" & " -DNIM_FFI_SANITIZER=" & + san + runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug -j" + runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug" + task genbindings_example, "Generate Rust bindings for the timer example": exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiGenBindings -o:/dev/null examples/timer/timer.nim" @@ -227,6 +258,16 @@ task genbindings_c_echo, "Generate C bindings for the echo example": " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/echo/c_bindings" & " -d:ffiSrcPath=../echo.nim" & " -o:/dev/null examples/echo/echo.nim" +task genbindings_c_abi_echo, "Generate CBOR-free abi=c C bindings for the echo example": + exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" & + " -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" & + " -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" & + " -o:/dev/null examples/echo/echo.nim" + exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" & + " -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" & + " -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" & + " -o:/dev/null examples/echo/echo.nim" + task check_bindings_rust, "Verify checked-in Rust bindings match Nim source": exec "nimble genbindings_rust" exec "git diff --exit-code --" & " examples/timer/rust_bindings/Cargo.toml" & @@ -250,7 +291,13 @@ task check_bindings_c, "Verify checked-in C bindings match Nim source": " examples/echo/c_bindings/nim_ffi_cbor.h" & " examples/echo/c_bindings/CMakeLists.txt" +task check_bindings_c_abi, "Verify checked-in abi=c C bindings match Nim source": + exec "nimble genbindings_c_abi_echo" + exec "git diff --exit-code --" & " examples/echo/c_abi_bindings/echo.h" & + " examples/echo/c_abi_bindings/CMakeLists.txt" + task check_bindings, "Verify all checked-in example bindings match Nim source": exec "nimble check_bindings_rust" exec "nimble check_bindings_cpp" exec "nimble check_bindings_c" + exec "nimble check_bindings_c_abi" diff --git a/ffi/alloc.nim b/ffi/alloc.nim index 66e688e..07951bf 100644 --- a/ffi/alloc.nim +++ b/ffi/alloc.nim @@ -44,6 +44,18 @@ proc dealloc*(p: cstring) {.inline.} = if not p.isNil(): c_free(cast[pointer](p)) +proc allocBox*(size: int): pointer = + ## `c_malloc` block for a cross-thread callback box (allocated on the foreign + ## caller thread, freed on the FFI thread). Uses libc for the same + ## thread-lifetime safety reason as the rest of this module. Free with + ## `freeBox`. + c_malloc(csize_t(size)) + +proc freeBox*(p: pointer) = + ## Releases a block from `allocBox`. Nil-safe. + if not p.isNil(): + c_free(p) + proc allocSharedSeq*[T](s: seq[T]): SharedSeq[T] = if s.len == 0: return (cast[ptr UncheckedArray[T]](nil), 0) diff --git a/ffi/codegen/c_abi.nim b/ffi/codegen/c_abi.nim new file mode 100644 index 0000000..b58bf42 --- /dev/null +++ b/ffi/codegen/c_abi.nim @@ -0,0 +1,443 @@ +## 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]`→`* _items` + +## `ptrdiff_t _len`, `Option[T]`→`*` (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 + ## `_items` pointer + `_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 *`; 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(" 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 ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + lines.add("#include ") + 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("") + 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) + ) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 8734442..a247aa5 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -73,9 +73,10 @@ var genBindingsEmitted* {.compileTime.}: bool = false var currentDefaultABIFormat* {.compileTime.}: ABIFormat = ABIFormat.Cbor proc abiCodegenImplemented*(fmt: ABIFormat): bool = - ## Whether `fmt` has a working proc-dispatch path. Only `Cbor` does today; the - ## seam a future PR flips once the `c` dispatch path is wired. - fmt == ABIFormat.Cbor + ## Whether `fmt` has a working proc-dispatch path. Both `Cbor` and `C` are + ## wired: `Cbor` rides the generic overloads, `C` rides the flat `_CWire` + ## companions (a CBOR-free foreign surface with CBOR transport internally). + fmt in {ABIFormat.Cbor, ABIFormat.C} proc overrideKey*(override: string): string = ## Lowercased key of a `key = value` pragma override (the text before `=`), diff --git a/ffi/codegen/templates/c_abi/CMakeLists.txt.tpl b/ffi/codegen/templates/c_abi/CMakeLists.txt.tpl new file mode 100644 index 0000000..09c6a2c --- /dev/null +++ b/ffi/codegen/templates/c_abi/CMakeLists.txt.tpl @@ -0,0 +1,72 @@ +cmake_minimum_required(VERSION 3.14) +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. + +set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}") +set(REPO_ROOT "") +foreach(_i RANGE 10) + if(EXISTS "${_search_dir}/ffi.nimble") + set(REPO_ROOT "${_search_dir}") + break() + endif() + get_filename_component(_search_dir "${_search_dir}" DIRECTORY) +endforeach() +if("${REPO_ROOT}" STREQUAL "") + message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)") +endif() + +# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to +# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none. +set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib") + +find_program(NIM_EXECUTABLE nim REQUIRED) + +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.dylib") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows") + set(NIM_LIB_FILE "${REPO_ROOT}/{{LIB}}.dll") +else() + set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.so") +endif() + +get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/{{SRC}}" ABSOLUTE) + +add_custom_command( + OUTPUT "${NIM_LIB_FILE}" + COMMAND "${NIM_EXECUTABLE}" c + --mm:orc + -d:chronicles_log_level=WARN + --app:lib + --noMain + "--nimMainPrefix:lib{{LIB}}" + ${NIM_FFI_EXTRA_ARGS} + "-o:${NIM_LIB_FILE}" + "${NIM_SRC}" + WORKING_DIRECTORY "${REPO_ROOT}" + DEPENDS "${NIM_SRC}" + COMMENT "Compiling Nim library lib{{LIB}} (abi = c)" + VERBATIM +) +add_custom_target({{LIB}}_nim_lib ALL DEPENDS "${NIM_LIB_FILE}") + +add_library({{LIB}} SHARED IMPORTED GLOBAL) +set_target_properties({{LIB}} PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}") +add_dependencies({{LIB}} {{LIB}}_nim_lib) + +find_package(Threads REQUIRED) + +add_library({{LIB}}_headers INTERFACE) +target_include_directories({{LIB}}_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries({{LIB}}_headers INTERFACE {{LIB}} Threads::Threads) +target_compile_definitions({{LIB}}_headers INTERFACE _POSIX_C_SOURCE=200809L) + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c") + add_executable({{LIB}}_example main.c) + target_link_libraries({{LIB}}_example PRIVATE {{LIB}}_headers) + add_dependencies({{LIB}}_example {{LIB}}_nim_lib) +endif() diff --git a/ffi/codegen/types_ir.nim b/ffi/codegen/types_ir.nim index 5771524..fd1b533 100644 --- a/ffi/codegen/types_ir.nim +++ b/ffi/codegen/types_ir.nim @@ -50,7 +50,7 @@ type structName*: proc(name: string): string {.noSideEffect, nimcall.} ## nil ⇒ the user type name passes through unchanged -func genericInnerType(typeName, prefix: string): string = +func genericInnerType*(typeName, prefix: string): string = ## Inner type of a single-parameter generic `Prefix[Inner]`, e.g. ## `genericInnerType("seq[int]", "seq[")` → `"int"`; "" if not that shape. if typeName.startsWith(prefix) and typeName.endsWith("]"): diff --git a/ffi/internal/c_macro_helpers.nim b/ffi/internal/c_macro_helpers.nim index b1c1802..7d4d312 100644 --- a/ffi/internal/c_macro_helpers.nim +++ b/ffi/internal/c_macro_helpers.nim @@ -553,3 +553,377 @@ proc flushCWireCompanions*(): NimNode {.compileTime.} = if typeMeta.abiFormat == ABIFormat.C: ensureCWireFor(typeMeta.name, sink) sink + +## abi = c proc dispatch. The foreign surface is CBOR-free — the flat `_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 +## trampoline `cborDecode`s the reply and `cwirePack`s it back into a `_CWire` +## struct delivered to the caller's typed callback. So the C consumer never +## links CBOR, yet the whole thread/dispatch machinery is unchanged. +## +## All of this is emitted at `genBindings()` time (after `flushCWireCompanions`) +## so the request-envelope companions and their `cwireUnpack` overloads are in +## scope: the exported wrappers reference them, and a proc must follow the +## procs it calls. + +type + CAbiKind = enum + cakMethod + cakCtor + + CAbiSpec = object + kind: CAbiKind + exportName: string ## snake_case C symbol, e.g. "echo_shout" + libType: NimNode ## library value type, e.g. `Echo` + envelope: NimNode ## per-proc Req type, e.g. `EchoShoutReq` + paramNames: seq[string] ## envelope field names (the extra params) + paramTypes: seq[NimNode] ## envelope field types + respType: NimNode ## method result T; empty for a ctor + +var cAbiSpecs {.compileTime.}: seq[CAbiSpec] + +proc copyTypes(types: seq[NimNode]): seq[NimNode] {.compileTime.} = + var res: seq[NimNode] = @[] + for t in types: + res.add(t.copyNimTree()) + res + +proc registerCAbiMethod*( + exportName: string, + libType, envelope: NimNode, + paramNames: seq[string], + paramTypes: seq[NimNode], + respType: NimNode, +) {.compileTime.} = + ## Record an `abi = c` method so `flushCAbiDispatch` can emit its wrapper. + ## Nodes are frozen with `copyNimTree` — the originals are shared with the Req + ## `type` section, which the compiler later binds to `nnkSym`, and a bound + ## type symbol reused in the generated body triggers a compiler ICE. + cAbiSpecs.add( + CAbiSpec( + kind: cakMethod, + exportName: exportName, + libType: libType.copyNimTree(), + envelope: envelope.copyNimTree(), + paramNames: paramNames, + paramTypes: copyTypes(paramTypes), + respType: respType.copyNimTree(), + ) + ) + +proc registerCAbiCtor*( + exportName: string, + libType, envelope: NimNode, + paramNames: seq[string], + paramTypes: seq[NimNode], +) {.compileTime.} = + ## Record an `abi = c` constructor so `flushCAbiDispatch` can emit its wrapper. + ## See `registerCAbiMethod` for why the nodes are frozen with `copyNimTree`. + cAbiSpecs.add( + CAbiSpec( + kind: cakCtor, + exportName: exportName, + libType: libType.copyNimTree(), + envelope: envelope.copyNimTree(), + paramNames: paramNames, + paramTypes: copyTypes(paramTypes), + respType: newEmptyNode(), + ) + ) + +proc cdeclReplyPragma(): NimNode = + nnkPragma.newTree( + ident("cdecl"), + ident("gcsafe"), + nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()), + ) + +proc cAbiCbType(replyType: NimNode): NimNode = + ## `proc(err: cint, reply: , errMsg: cstring, ud: pointer) + ## {.cdecl, gcsafe, raises: [].}` — the caller's typed reply callback. + let fp = nnkFormalParams.newTree( + newEmptyNode(), + newIdentDefs(ident("err"), ident("cint")), + newIdentDefs(ident("reply"), replyType), + newIdentDefs(ident("errMsg"), ident("cstring")), + newIdentDefs(ident("ud"), ident("pointer")), + ) + nnkProcTy.newTree(fp, cdeclReplyPragma()) + +proc boxTypeDef(boxName, cbType: NimNode): NimNode = + ## `type = object` holding the caller's callback + user data, heap + ## boxed across the thread hand-off. + let recList = nnkRecList.newTree( + newIdentDefs(ident("fn"), cbType), newIdentDefs(ident("ud"), ident("pointer")) + ) + let objTy = nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), recList) + nnkTypeSection.newTree(nnkTypeDef.newTree(boxName, newEmptyNode(), objTy)) + +proc replyTrampProc(trampName, body: NimNode): NimNode = + ## A `FFICallBack`-shaped Nim proc: it runs on the FFI thread inside + ## `handleRes`' `foreignThreadGc`, converts the reply, and frees the box. + newProc( + name = trampName, + params = @[ + newEmptyNode(), + newIdentDefs(ident("ret"), ident("cint")), + newIdentDefs(ident("msg"), nnkPtrTy.newTree(ident("cchar"))), + newIdentDefs(ident("len"), ident("csize_t")), + newIdentDefs(ident("ud"), ident("pointer")), + ], + body = body, + pragmas = cdeclReplyPragma(), + ) + +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 + ## 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: + let box = cast[ptr `boxName`](ud) + if box.isNil(): + return + defer: + freeBox(box) + if box.fn.isNil(): + return + try: + if ret != RET_OK: + var em = newString(int(len)) + if int(len) > 0: + copyMem(addr em[0], msg, int(len)) + box.fn(ret, nil, em.cstring, box.ud) + return + let decoded = + cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), `respType`) + if decoded.isErr(): + box.fn(RET_ERR, nil, decoded.error.cstring, box.ud) + else: + var wire: `respWire` + cwirePack(wire, decoded.get()) + box.fn(RET_OK, addr wire, "".cstring, box.ud) + cwireFree(wire) + except CatchableError as e: + box.fn(RET_ERR, nil, e.msg.cstring, box.ud) + +proc stringTrampBody(boxName: NimNode): NimNode = + ## Reply trampoline for a `string` return (and the ctor's address string): + ## CBOR-decode the reply into a Nim string and hand its (NUL-terminated) + ## `cstring` to the caller for the duration of the call. Reply and error + ## strings are always non-nil empty strings on the paths they don't apply to, + ## so a consumer can `strlen`/print either unconditionally without a nil deref. + quote: + let box = cast[ptr `boxName`](ud) + if box.isNil(): + return + defer: + freeBox(box) + if box.fn.isNil(): + return + try: + if ret != RET_OK: + var em = newString(int(len)) + if int(len) > 0: + copyMem(addr em[0], msg, int(len)) + box.fn(ret, "".cstring, em.cstring, box.ud) + return + let decoded = cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), string) + if decoded.isErr(): + box.fn(RET_ERR, "".cstring, decoded.error.cstring, box.ud) + else: + let replyStr = decoded.get() + box.fn(RET_OK, replyStr.cstring, "".cstring, box.ud) + except CatchableError as e: + box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud) + +proc exportedMethodProc( + spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode +): NimNode = + # `cwireUnpack` and `cborEncodeShared` run on the *calling* thread and allocate + # GC memory, so the caller must be a GC-registered thread — which the dylib's + # load thread already is. Deliberately NOT wrapped in `foreignThreadGc`: its + # `tearDownForeignThreadGc` would destroy that thread's live ORC heap (a + # use-after-free / nil-read crash), and unlike the CBOR path — which only + # memcpy's bytes here — this path genuinely needs the heap intact. + let envName = spec.envelope + let libFFICtx = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType)) + # A string reply is an empty (non-nil) cstring on the error path, matching the + # trampoline; an object reply is a nil struct pointer gated by `err_code`. + let emptyReply = + if isStringType(spec.respType): + newDotExpr(newLit(""), ident("cstring")) + else: + newNilLit() + let body = quote: + if onReply.isNil(): + return RET_MISSING_CALLBACK + if not `poolIdent`.isValidCtx(cast[pointer](ctx)): + onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData) + return RET_ERR + var reqObj: `envName` = cwireUnpack(req[]) + let enc = cborEncodeShared(reqObj) + let reqBuf = enc.data + let reqBufLen = enc.len + let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`))) + box.fn = onReply + box.ud = userData + let typeStr = $`envName` + let reqPtr = FFIThreadRequest.initFromOwnedShared( + `trampName`, box, typeStr.cstring, reqBuf, reqBufLen + ) + let sendRes = + try: + ffi_context.sendRequestToFFIThread(ctx, reqPtr) + except Exception as e: + Result[void, string].err("sendRequestToFFIThread exception: " & e.msg) + if sendRes.isErr(): + onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData) + return RET_ERR + return RET_OK + newProc( + name = ident($envName & "CAbiExport"), + params = @[ + ident("cint"), + newIdentDefs(ident("ctx"), libFFICtx), + newIdentDefs(ident("onReply"), cbType), + newIdentDefs(ident("userData"), ident("pointer")), + newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)), + ], + body = body, + pragmas = nnkPragma.newTree( + ident("dynlib"), + nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)), + ident("cdecl"), + nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()), + ), + ) + +proc exportedCtorProc( + spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode +): NimNode = + let envName = spec.envelope + # See exportedMethodProc: the request conversion allocates GC on the calling + # thread, so no `foreignThreadGc` (its teardown would free that thread's heap). + # `when declared(initializeLibrary): initializeLibrary()` — built as raw AST; + # a `when` with an undeclared symbol inside `quote` trips a compiler ICE. + let initGuard = nnkWhenStmt.newTree( + nnkElifBranch.newTree( + newCall(ident("declared"), ident("initializeLibrary")), + newStmtList(newCall(ident("initializeLibrary"))), + ) + ) + let body = quote: + let ctxRes = `poolIdent`.createFFIContext() + if ctxRes.isErr(): + if not onCreated.isNil(): + onCreated( + RET_ERR, + "".cstring, + ("ffiCtor: failed to create FFIContext: " & $ctxRes.error).cstring, + userData, + ) + return nil + let ctx = ctxRes.get() + var reqObj: `envName` = cwireUnpack(req[]) + let enc = cborEncodeShared(reqObj) + let reqBuf = enc.data + let reqBufLen = enc.len + let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`))) + box.fn = onCreated + box.ud = userData + let typeStr = $`envName` + let reqPtr = FFIThreadRequest.initFromOwnedShared( + `trampName`, box, typeStr.cstring, reqBuf, reqBufLen + ) + let sendRes = + try: + ctx.sendRequestToFFIThread(reqPtr) + except Exception as e: + Result[void, string].err("sendRequestToFFIThread exception: " & e.msg) + if sendRes.isErr(): + if not onCreated.isNil(): + onCreated(RET_ERR, "".cstring, sendRes.error.cstring, userData) + return nil + return cast[pointer](ctx) + body.insert(0, initGuard) + newProc( + name = ident($envName & "CAbiExport"), + params = @[ + ident("pointer"), + newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)), + newIdentDefs(ident("onCreated"), cbType), + newIdentDefs(ident("userData"), ident("pointer")), + ], + body = body, + pragmas = nnkPragma.newTree( + ident("dynlib"), + nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)), + ident("cdecl"), + nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()), + ), + ) + +proc ensureCWireForFields( + sink: NimNode, typeName: string, names: seq[string], types: seq[NimNode] +) {.compileTime.} = + ## Emit the `_CWire` companion + conversion procs for a synthetic per-proc Req + ## envelope (not a user `{.ffi.}` type, so it isn't in `ffiTypeRegistry`). + ## Nested user-type deps are already emitted by `flushCWireCompanions`. + if isCWireEmitted(typeName): + return + var deps: seq[string] = @[] + collectNestedFFITypes(types, deps) + for dep in deps: + ensureCWireFor(dep, sink) + markCWireEmitted(typeName) + let section = newNimNode(nnkTypeSection) + section.add(buildCWireTypeDef(typeName, names, types)) + sink.add(section) + for p in buildCWireProcs(typeName, names, types): + sink.add(p) + +proc flushCAbiDispatch*(): NimNode {.compileTime.} = + ## Emit the exported wrappers + reply trampolines for every registered + ## `abi = c` proc. Runs after `flushCWireCompanions` so nested companions and + ## their `cwireUnpack`/`cwirePack` overloads are already defined. + let sink = newStmtList() + for spec in cAbiSpecs: + let envName = spec.envelope + ensureCWireForFields(sink, $envName, spec.paramNames, spec.paramTypes) + let envWire = ident(cwireTypeName($envName)) + let boxName = ident($envName & "CBox") + let trampName = ident($envName & "CReply") + let poolIdent = ident($spec.libType & "FFIPool") + case spec.kind + of cakCtor: + let cbType = cAbiCbType(ident("cstring")) + sink.add(boxTypeDef(boxName, cbType)) + sink.add(replyTrampProc(trampName, stringTrampBody(boxName))) + sink.add(exportedCtorProc(spec, boxName, envWire, trampName, poolIdent, cbType)) + of cakMethod: + let rt = spec.respType + if isStringType(rt): + let cbType = cAbiCbType(ident("cstring")) + sink.add(boxTypeDef(boxName, cbType)) + sink.add(replyTrampProc(trampName, stringTrampBody(boxName))) + sink.add( + exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType) + ) + elif rt.kind == nnkIdent: + let respWire = ident(cwireTypeName($rt)) + let cbType = cAbiCbType(nnkPtrTy.newTree(respWire)) + sink.add(boxTypeDef(boxName, cbType)) + sink.add(replyTrampProc(trampName, objectTrampBody(boxName, rt, respWire))) + sink.add( + exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType) + ) + else: + error( + "abi = c: unsupported response type for proc '" & spec.exportName & "': " & + rt.repr & " (only object and string returns are wired)" + ) + sink diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 6b4b675..69be972 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -9,6 +9,7 @@ when defined(ffiGenBindings): import ../codegen/rust import ../codegen/cpp import ../codegen/c + import ../codegen/c_abi import ../codegen/cddl proc requireLibraryDeclared(where: string) {.compileTime.} = @@ -954,9 +955,10 @@ macro ffi*(args: varargs[untyped]): untyped = abiFormat: abiFormat, ) - # Qualifies for the CBOR-free fast path only if `abi = c`, every wire param + - # return is scalar (`isScalarOnly`), and the args fit the inline slots. A - # non-scalar `abi = c` proc stays gated — full C-wire dispatch is a follow-up. + # 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`. let scalarEligible = abiFormat == ABIFormat.C and isScalarOnly(procMeta) and extraParamNames.len <= MaxScalarArgs @@ -1085,6 +1087,17 @@ macro ffi*(args: varargs[untyped]): untyped = ffiProcRegistry.add(procMeta) + if abiFormat == ABIFormat.C: + # The flat-struct exported wrapper + reply trampoline are emitted at + # genBindings() time (see flushCAbiDispatch); the CBOR `ffiProc` is not. + registerCAbiMethod( + cExportName, libTypeName, reqTypeName, extraParamNames, extraParamTypes, + resultRetType, + ) + return newStmtList( + helperProc, registerReq, registerRequestTimeout(reqTypeName, timeoutMs) + ) + return newStmtList( helperProc, registerReq, ffiProc, registerRequestTimeout(reqTypeName, timeoutMs) ) @@ -1109,9 +1122,6 @@ macro ffi*(args: varargs[untyped]): untyped = procMeta = procMeta, ) - if abiFormat == ABIFormat.C and not scalarEligible: - gateABIFormat(abiFormat, "`.ffi.` proc") - let stmts = if scalarEligible: scalarPath() @@ -1534,16 +1544,31 @@ macro ffiCtor*(args: varargs[untyped]): untyped = when not declared(`poolIdent`): var `poolIdent`: FFIContextPool[`libTypeName`] - let stmts = newStmtList( - typeDef, - ffiNewReqProc, - helperProc, - processProc, - addToReg, - poolDecl, - ffiProc, - registerRequestTimeout(reqTypeName, timeoutMs), - ) + let stmts = + if abiFormat == ABIFormat.C: + # The flat-struct 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, + registerRequestTimeout(reqTypeName, timeoutMs), + ) + else: + newStmtList( + typeDef, + ffiNewReqProc, + helperProc, + processProc, + addToReg, + poolDecl, + ffiProc, + registerRequestTimeout(reqTypeName, timeoutMs), + ) when defined(ffiDumpMacros): echo stmts.repr @@ -1720,6 +1745,11 @@ macro ffiEvent*(args: varargs[untyped]): untyped = let (wireName, abiSpecStart) = resolveEventWireName(leading, userProcName) let abiFormat = resolveABIFormat(leading[abiSpecStart ..^ 1]) gateABIFormat(abiFormat, "`.ffiEvent.` proc") + if abiFormat == ABIFormat.C: + error( + "`.ffiEvent.` proc: the `c` ABI does not yet support events; declare the " & + "event with `abi = cbor` (events still ride CBOR internally)" + ) let formalParams = prc[3] @@ -1839,15 +1869,21 @@ macro genBindings*( generateCBindings( genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry ) + of "c_abi": + generateCAbiBindings( + genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry + ) of "cddl": generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) else: error( "genBindings: unknown targetLang '" & lang & - "'. Use 'rust', 'cpp', 'c', or 'cddl'." + "'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'." ) - let cwireCompanions = flushCWireCompanions() + let emitted = flushCWireCompanions() + for node in flushCAbiDispatch(): + emitted.add(node) when defined(ffiDumpMacros): - echo cwireCompanions.repr - cwireCompanions + echo emitted.repr + emitted diff --git a/tests/e2e/c_abi/CMakeLists.txt b/tests/e2e/c_abi/CMakeLists.txt new file mode 100644 index 0000000..0b7ddd9 --- /dev/null +++ b/tests/e2e/c_abi/CMakeLists.txt @@ -0,0 +1,65 @@ +cmake_minimum_required(VERSION 3.14) +project(echo_c_abi_e2e C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +# Sanitizer plumbing (mirrors tests/e2e/c). asan-ubsan / tsan flow to both the +# Nim dylib (via NIM_FFI_EXTRA_ARGS) and the C consumer (via compile/link opts). +set(NIM_SAN_ARGS "") +set(SAN_CFLAGS "") +if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan") + set(NIM_SAN_ARGS -d:useMalloc "--passC:-fsanitize=address,undefined" "--passL:-fsanitize=address,undefined") + set(SAN_CFLAGS -fsanitize=address,undefined -fno-omit-frame-pointer) +elseif(NIM_FFI_SANITIZER STREQUAL "tsan") + set(NIM_SAN_ARGS "--passC:-fsanitize=thread" "--passL:-fsanitize=thread") + set(SAN_CFLAGS -fsanitize=thread -fno-omit-frame-pointer) +endif() + +# Force the shared echo example source into its `abi = c` variant and thread the +# sanitizer args through to the dylib build. Must be `CACHE ... FORCE`: the +# bindings subdir declares `NIM_FFI_EXTRA_ARGS` as a `CACHE` variable, and under +# CMP0126 OLD (our `cmake_minimum_required(VERSION 3.14)`) that `set(... CACHE)` +# deletes any same-named normal variable from this scope — so a plain `set` here +# would be wiped and the dylib would build with the default CBOR ABI, mismatching +# the flat `abi = c` header. Forcing the cache entry makes the child's non-FORCE +# cache set a no-op instead. +set(NIM_FFI_EXTRA_ARGS "-d:ffiEchoAbiC" ${NIM_SAN_ARGS} + CACHE STRING "Extra nim c args when building the dylib" FORCE) +set(ECHO_BINDINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../../examples/echo/c_abi_bindings") +add_subdirectory("${ECHO_BINDINGS}" echo_c_abi_build) + +enable_testing() +add_executable(test_echo_c_abi test_echo_c_abi.c) +target_link_libraries(test_echo_c_abi PRIVATE echo_headers) +add_dependencies(test_echo_c_abi echo_nim_lib) +if(SAN_CFLAGS) + target_compile_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS}) + target_link_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS}) +endif() + +# Nim-built dylibs use `@rpath/lib*.so|dylib`; embed the imported lib's dir so +# the test runs without LD_LIBRARY_PATH. +get_target_property(_echo_loc echo IMPORTED_LOCATION) +get_filename_component(_echo_dir "${_echo_loc}" DIRECTORY) +set_target_properties(test_echo_c_abi PROPERTIES + BUILD_RPATH "${_echo_dir}" + INSTALL_RPATH "${_echo_dir}") + +set(_san_test_env "") +if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan") + list(APPEND _san_test_env + "ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:detect_leaks=1" + "UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1" + "LSAN_OPTIONS=suppressions=${CMAKE_CURRENT_SOURCE_DIR}/lsan.supp:print_suppressions=0") +elseif(NIM_FFI_SANITIZER STREQUAL "tsan") + get_filename_component(_repo_tsan_supp + "${CMAKE_CURRENT_SOURCE_DIR}/../../../tsan.supp" ABSOLUTE) + list(APPEND _san_test_env + "TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:history_size=7:suppressions=${_repo_tsan_supp}") +endif() + +add_test(NAME echo_c_abi_e2e COMMAND test_echo_c_abi) +if(_san_test_env) + set_tests_properties(echo_c_abi_e2e PROPERTIES ENVIRONMENT "${_san_test_env}") +endif() diff --git a/tests/e2e/c_abi/lsan.supp b/tests/e2e/c_abi/lsan.supp new file mode 100644 index 0000000..94add2f --- /dev/null +++ b/tests/e2e/c_abi/lsan.supp @@ -0,0 +1,22 @@ +# LeakSanitizer suppressions for the Nim runtime. +# +# These are process-lifetime allocations freed implicitly at exit — +# not real leaks. Add new entries here only with a comment justifying +# why the leak is unavoidable, and only for symbols inside the Nim +# standard library, chronos, or chronicles. Anything in our own code +# (ffi/*) or the generated bindings must be fixed, not suppressed. + +# Nim runtime initialisation — allocates global state freed at exit. +leak:NimMain +leak:PreMain +leak:systemDatInit + +# GC bootstrap — registers stack bottom / TLS slots once per thread. +leak:nimGC_setStackBottom +leak:initStackBottomWith +leak:setupForeignThreadGc + +# Async / logging library globals (event loop singletons, logger +# registries) — owned for the process lifetime. +leak:chronos +leak:chronicles diff --git a/tests/e2e/c_abi/test_echo_c_abi.c b/tests/e2e/c_abi/test_echo_c_abi.c new file mode 100644 index 0000000..87d6f97 --- /dev/null +++ b/tests/e2e/c_abi/test_echo_c_abi.c @@ -0,0 +1,99 @@ +/* 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. */ +#include "echo.h" +#include +#include +#include +#include +#include + +/* The `done` flag is a C11 atomic: the callback fires on the FFI thread and + * stores it with release ordering after filling the waiter's fields, and the + * poller loads it with acquire ordering — so the field writes are visible + * (and race-free under TSan) once `done` is seen set. */ +static void wait_done(atomic_int* done) { + for (int i = 0; i < 500 && !atomic_load_explicit(done, memory_order_acquire); i++) { + struct timespec t = {0, 10 * 1000 * 1000}; /* 10ms */ + nanosleep(&t, NULL); + } + assert(atomic_load_explicit(done, memory_order_acquire)); +} + +typedef struct { + atomic_int done; + int err_code; + EchoCtx* ctx; + char err[256]; +} CreateWaiter; + +static void on_created(int ec, EchoCtx* ctx, const char* em, void* ud) { + CreateWaiter* w = (CreateWaiter*)ud; + w->err_code = ec; + w->ctx = ctx; + if (em) { + snprintf(w->err, sizeof(w->err), "%s", em); + } + atomic_store_explicit(&w->done, 1, memory_order_release); +} + +static EchoCtx* make_ctx(void) { + CreateWaiter w; + memset(&w, 0, sizeof(w)); + EchoConfig config = {"c-abi"}; + echo_ctx_create(&config, on_created, &w); + wait_done(&w.done); + if (w.err_code != 0) { + fprintf(stderr, "create failed: %s\n", w.err[0] ? w.err : "?"); + } + assert(w.err_code == 0); + assert(w.ctx != NULL); + return w.ctx; +} + +typedef struct { + atomic_int done; + int err_code; + char err[256]; + char text_a[256]; + char text_b[256]; +} ReplyWaiter; + +static void on_shout(int ec, const ShoutResponse* reply, const char* em, void* ud) { + ReplyWaiter* w = (ReplyWaiter*)ud; + w->err_code = ec; + if (reply) { + if (reply->shouted) + snprintf(w->text_a, sizeof(w->text_a), "%s", reply->shouted); + if (reply->prefix) + snprintf(w->text_b, sizeof(w->text_b), "%s", reply->prefix); + } + if (em) snprintf(w->err, sizeof(w->err), "%s", em); + atomic_store_explicit(&w->done, 1, memory_order_release); +} + +static void test_shout(EchoCtx* ctx) { + ReplyWaiter w; + memset(&w, 0, sizeof(w)); + ShoutRequest req = {"hello"}; + echo_ctx_shout(ctx, &req, on_shout, &w); + wait_done(&w.done); + assert(w.err_code == 0); + assert(strcmp(w.text_a, "c-abi: HELLO") == 0); + assert(strcmp(w.text_b, "c-abi") == 0); +} + +int main(void) { + EchoCtx* ctx = make_ctx(); + test_shout(ctx); + echo_ctx_destroy(ctx); + printf("all abi=c echo e2e checks passed\n"); + return 0; +} diff --git a/tests/unit/test_abi_format.nim b/tests/unit/test_abi_format.nim index f6cf8f2..4e486f4 100644 --- a/tests/unit/test_abi_format.nim +++ b/tests/unit/test_abi_format.nim @@ -122,10 +122,11 @@ suite "handler-timeout spec parsing (issue #93)": check requestTimeoutsMs["AbitestSlowReq".cstring] == 30000 check not requestTimeoutsMs.hasKey("AbitestPingReq".cstring) -suite "ABI proc-dispatch readiness (why c is still gated on procs)": - test "cbor proc-dispatch is wired; c proc-dispatch is gated": - # This predicate is what the proc-form macros consult: `cbor` is wired - # end-to-end, while `c` is recognized but gated pending its codec. It is the - # single seam a future PR flips when the c codec and dispatch path land. +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 + # internally). Events are the one `c` gap, gated separately in the macro. check abiCodegenImplemented(ABIFormat.Cbor) - check not abiCodegenImplemented(ABIFormat.C) + check abiCodegenImplemented(ABIFormat.C) diff --git a/tests/unit/test_c_abi_codegen.nim b/tests/unit/test_c_abi_codegen.nim new file mode 100644 index 0000000..7a10630 --- /dev/null +++ b/tests/unit/test_c_abi_codegen.nim @@ -0,0 +1,125 @@ +## 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. + +import std/strutils +import unittest2 +import ffi/codegen/[meta, c_abi] + +proc field(n, t: string): FFIFieldMeta = + FFIFieldMeta(name: n, typeName: t) + +proc param(n, t: string, isPtr = false): FFIParamMeta = + FFIParamMeta(name: n, typeName: t, isPtr: isPtr) + +suite "generateCAbiLibHeader": + setup: + let types = @[ + FFITypeMeta( + name: "EchoRequest", + fields: @[field("message", "string"), field("delayMs", "int")], + ), + FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]), + FFITypeMeta( + name: "ComplexRequest", + fields: + @[field("messages", "seq[EchoRequest]"), field("note", "Option[string]")], + ), + ] + let procs = @[ + FFIProcMeta( + procName: "timer_create", + libName: "timer", + kind: FFIKind.CTOR, + libTypeName: "Timer", + extraParams: @[param("config", "EchoRequest")], + returnTypeName: "Timer", + ), + FFIProcMeta( + procName: "timer_echo", + libName: "timer", + kind: FFIKind.FFI, + libTypeName: "Timer", + extraParams: @[param("req", "ComplexRequest")], + returnTypeName: "EchoResponse", + ), + FFIProcMeta( + procName: "timer_version", + libName: "timer", + kind: FFIKind.FFI, + libTypeName: "Timer", + extraParams: @[], + returnTypeName: "string", + ), + FFIProcMeta( + procName: "timer_destroy", + libName: "timer", + kind: FFIKind.DTOR, + libTypeName: "Timer", + extraParams: @[], + returnTypeName: "", + ), + ] + let header = generateCAbiLibHeader(procs, types, "timer") + + test "the header is self-contained and links no CBOR": + check "#include " in header + check "nim_ffi_cbor.h" notin header + check "CborError" notin header + check "tinycbor" notin header.toLowerAscii() + + test "flat wire structs mirror the _CWire layout": + # string -> const char*; POD unchanged. + check "const char* message;" in header + check "int64_t delayMs;" in header + # seq[T] -> * _items + ptrdiff_t _len (two fields). + check "EchoRequest* messages_items;" in header + 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. + 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": + check "req_cbor" notin header + check "const TimerEchoReq* req" in header + check "void* timer_create(const TimerCreateCtorReq* req," in header + check "int timer_destroy(void* ctx);" in header + + test "object returns hand back a const struct pointer; string returns a cstring": + check "typedef void (*TimerEchoReplyFn)(int err_code, const EchoResponse* reply," in + header + check "typedef void (*TimerVersionReplyFn)(int err_code, const char* reply," in + header + + test "constructor delivers its context address through a raw string callback": + check "TimerCreateRawFn)(int err_code, const char* ctx_addr," in header + check "strtoull(ctx_addr" in header + + test "high-level context wrapper is emitted": + check "} TimerCtx;" in header + check "timer_ctx_create(" in header + check "timer_ctx_echo(" in header + check "timer_ctx_version(" in header + check "timer_ctx_destroy(" in header + + test "events are rejected (CBOR-only for now)": + expect ValueError: + discard generateCAbiLibHeader( + procs, + types, + "timer", + @[ + FFIEventMeta( + wireName: "on_tick", + nimProcName: "onTick", + libName: "timer", + payloadTypeName: "EchoResponse", + ) + ], + )