diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7f56fa..d0f316f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,18 @@ jobs: nim-versions: ${{ needs.versions.outputs.nim-versions }} nimble-version: ${{ needs.versions.outputs.nimble }} + c-wire: + # The `c`-ABI cwire codec is layout-/allocator-sensitive (malloc/free, flat + # struct packing, allocShared for seq/Option), so cover it across the full + # OS matrix, not just Linux. + name: C Wire Codec + needs: versions + uses: ./.github/workflows/test.yml + with: + test: test_c_wire + nim-versions: ${{ needs.versions.outputs.nim-versions }} + nimble-version: ${{ needs.versions.outputs.nimble }} + cpp-e2e: # Codegen output doesn't vary with mm, so we matrix over OS and Nim only. # Windows runs MSVC by default and may surface codegen tweaks needed in diff --git a/ffi/internal/c_macro_helpers.nim b/ffi/internal/c_macro_helpers.nim index 00e728e..b1c1802 100644 --- a/ffi/internal/c_macro_helpers.nim +++ b/ffi/internal/c_macro_helpers.nim @@ -1,10 +1,19 @@ ## Compile-time helpers used by `ffi_macro.nim` for the `c` (flat C-struct) ABI. -## For each `{.ffi: "abi = c".}` object T, emits a `T_CWire` companion (`string` -## → `cstring`, POD unchanged) plus `cwirePack` / `cwireUnpack` / `cwireFree`. +## For each `{.ffi: "abi = c".}` object T, emits a `T_CWire` companion plus +## `cwirePack` / `cwireUnpack` / `cwireFree`. Field mapping: `string`→`cstring`, +## `seq[T]`→`_items`+`_len`, `Option[T]`/`Maybe[T]`→`ptr T_w` +## (nil=none), nested {.ffi.}→`T_CWire`, `array[N, T]`→inline `array[N, T_w]`, +## `tuple[a: T, ...]`→`tuple[a: T_w, ...]`, POD unchanged. seq/Option/array/tuple +## nest to any depth, but a `seq` may not nest inside another container (it has +## no single-field wire form — only the top-level `_items`/`_len` split). import std/macros import ../codegen/meta +const + cwireItemsSuffix = "_items" + cwireLenSuffix = "_len" + var emittedCWireTypes {.compileTime.}: seq[string] proc isCWireEmitted(typeName: string): bool {.compileTime.} = @@ -23,41 +32,113 @@ proc cwireTypeName(userTypeName: string): string = ## Companion-type naming convention; stable so generated tests reach in by name. userTypeName & "_CWire" +proc seqItemsField(obj, field: NimNode): NimNode = + ## `obj._items` — the buffer half of a seq's two-field wire split. + newDotExpr(obj, ident($field & cwireItemsSuffix)) + +proc seqLenField(obj, field: NimNode): NimNode = + ## `obj._len` — the count half of a seq's two-field wire split. + newDotExpr(obj, ident($field & cwireLenSuffix)) + proc isStringType(t: NimNode): bool = t.kind == nnkIdent and ($t == "string" or $t == "cstring") -const cWireSupportedTypes = [ - "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", - "uint64", "float", "float32", "float64", "bool", "char", "byte", "string", "cstring", -] +proc isBracketOf(t: NimNode, heads: openArray[string]): bool = + t.kind == nnkBracketExpr and t.len >= 2 and t[0].kind == nnkIdent and $t[0] in heads -proc assertCWireFieldSupported( - typeName, fieldName: string, fieldType: NimNode -) {.compileTime.} = - ## Gate the `abi = c` whitelist: the flat C codec handles only fixed-width - ## scalars, `bool`, `char`/`byte`, and `string`. Composite types (`seq[T]`, - ## `Opt[T]`, nested objects, ...) must use `abi = cbor`, which supports every - ## type. Future PRs widen this list as the codec grows. - if fieldType.kind == nnkIdent and $fieldType in cWireSupportedTypes: - return +proc isSeqType(t: NimNode): bool = + isBracketOf(t, ["seq"]) + +proc isOptionType(t: NimNode): bool = + isBracketOf(t, ["Option", "Maybe"]) + +proc isArrayType(t: NimNode): bool = + t.kind == nnkBracketExpr and t.len == 3 and t[0].kind == nnkIdent and $t[0] == "array" + +proc isTupleType(t: NimNode): bool = + t.kind == nnkTupleTy + +proc tupleComponents(t: NimNode): seq[tuple[name: string, typ: NimNode]] = + ## Flatten a named-tuple type into `(name, type)` pairs, expanding grouped + ## declarations like `tuple[a, b: int]` into one entry per name. + var comps: seq[tuple[name: string, typ: NimNode]] = @[] + for defs in t: + if defs.kind != nnkIdentDefs: + error("cwire: only named tuples are supported: " & t.repr) + let typ = defs[^2] + for i in 0 ..< defs.len - 2: + comps.add((name: $defs[i], typ: typ)) + comps + +proc isKnownFFIType(name: string): bool {.compileTime.} = + for typeMeta in ffiTypeRegistry: + if typeMeta.name == name: + return true + false + +proc isNestedFFIType(t: NimNode): bool = + t.kind == nnkIdent and isKnownFFIType($t) + +proc cwireNeedsFree(t: NimNode): bool = + ## Whether the wire form of `t` owns shared-memory allocations that + ## `cwireFree` must release. POD scalars (and aggregates entirely of POD) + ## own nothing, so their free is elided. + if isStringType(t) or isNestedFFIType(t) or isOptionType(t) or isSeqType(t): + return true + if isArrayType(t): + return cwireNeedsFree(t[2]) + if isTupleType(t): + for c in tupleComponents(t): + if cwireNeedsFree(c.typ): + return true + return false + false + +proc rejectNestedSeq(t: NimNode) = + ## `seq` has no single-field wire form (only the top-level `_items`/`_len` + ## split), so it can't sit inside another container. One message, one place. error( - "ffi 'abi = c' type '" & typeName & "': field '" & fieldName & "' of type '" & - repr(fieldType) & - "' is not supported by the flat C codec (supported: scalars, bool, char, " & - "string). Use 'abi = cbor' for composite types." + "cwire: `seq` has no single-field wire form, so it can't nest inside " & + "another container (use it only as a top-level field): " & t.repr ) -proc wireFieldType(userType: NimNode): NimNode = - ## Map a user field type AST → its wire-form AST: `string` → `cstring`, - ## everything else unchanged. - if isStringType(userType): +proc wireValueType(t: NimNode): NimNode = + ## Single-field wire form of value type `t`: `string`→`cstring`, nested + ## {.ffi.}→`T_CWire`, `Option[T]`→`ptr `, `array[N, T]`→ + ## `array[N, ]`, `tuple[a: T, ...]`→`tuple[a: , ...]`, + ## POD unchanged. `seq` has no single-field form, so it errors here. + if isStringType(t): return ident("cstring") - userType + if isNestedFFIType(t): + return ident(cwireTypeName($t)) + if isOptionType(t): + return nnkPtrTy.newTree(wireValueType(t[1])) + if isArrayType(t): + return + nnkBracketExpr.newTree(ident("array"), t[1].copyNimTree(), wireValueType(t[2])) + if isTupleType(t): + let wireTup = nnkTupleTy.newTree() + for c in tupleComponents(t): + wireTup.add(newIdentDefs(ident(c.name), wireValueType(c.typ))) + return wireTup + if isSeqType(t): + rejectNestedSeq(t) + t proc wireFieldsFor(fieldName: string, fieldType: NimNode): seq[NimNode] = - ## IdentDefs for `fieldName: fieldType` in the wire object. A seq because - ## composite fields later split into two physical fields. - @[newIdentDefs(ident(fieldName), wireFieldType(fieldType), newEmptyNode())] + ## IdentDefs for one field. `seq[T]` splits into `_items: ptr + ## UncheckedArray[]` + `_len: int`; else a single IdentDef. + if isSeqType(fieldType): + let elemWire = wireValueType(fieldType[1]) + let itemsField = newIdentDefs( + ident(fieldName & cwireItemsSuffix), + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("UncheckedArray"), elemWire)), + newEmptyNode(), + ) + let lenField = + newIdentDefs(ident(fieldName & cwireLenSuffix), ident("int"), newEmptyNode()) + return @[itemsField, lenField] + @[newIdentDefs(ident(fieldName), wireValueType(fieldType), newEmptyNode())] proc buildCWireTypeDef( userTypeName: string, fieldNames: seq[string], fieldTypes: seq[NimNode] @@ -79,32 +160,276 @@ proc buildCWireTypeDef( let objTy = newTree(nnkObjectTy, newEmptyNode(), newEmptyNode(), recList) newTree(nnkTypeDef, postfix(wireName, "*"), newEmptyNode(), objTy) +proc emitOptionPack(dstAccess, srcAccess, userType: NimNode): NimNode +proc emitOptionUnpack(dstAccess, srcAccess, userType: NimNode): NimNode +proc emitOptionFree(dstAccess, userType: NimNode): NimNode +proc emitArrayPack(dstAccess, srcAccess, arrType: NimNode): NimNode +proc emitArrayUnpack(dstAccess, srcAccess, arrType: NimNode): NimNode +proc emitArrayFree(dstAccess, arrType: NimNode): NimNode +proc emitTuplePack(dstAccess, srcAccess, tupType: NimNode): NimNode +proc emitTupleUnpack(dstAccess, srcAccess, tupType: NimNode): NimNode +proc emitTupleFree(dstAccess, tupType: NimNode): NimNode + +proc emitElemPack(dstElem, srcElem, elemType: NimNode): NimNode = + ## Pack one value: cstring for `string`, recursive `cwirePack` for nested + ## ffi types, recursive Option/array/tuple handling, direct copy for POD. + if isStringType(elemType): + return newAssignment(dstElem, newCall(ident("cwireAllocStr"), srcElem)) + if isNestedFFIType(elemType): + return newCall(ident("cwirePack"), dstElem, srcElem) + if isOptionType(elemType): + return emitOptionPack(dstElem, srcElem, elemType) + if isArrayType(elemType): + return emitArrayPack(dstElem, srcElem, elemType) + if isTupleType(elemType): + return emitTuplePack(dstElem, srcElem, elemType) + if isSeqType(elemType): + rejectNestedSeq(elemType) + newAssignment(dstElem, srcElem) + +proc emitElemUnpack(dstElem, srcElem, elemType: NimNode): NimNode = + ## Inverse of `emitElemPack`: copy one value back into Nim-managed memory. + if isStringType(elemType): + return newAssignment(dstElem, newCall(ident("$"), srcElem)) + if isNestedFFIType(elemType): + return newAssignment(dstElem, newCall(ident("cwireUnpack"), srcElem)) + if isOptionType(elemType): + return emitOptionUnpack(dstElem, srcElem, elemType) + if isArrayType(elemType): + return emitArrayUnpack(dstElem, srcElem, elemType) + if isTupleType(elemType): + return emitTupleUnpack(dstElem, srcElem, elemType) + if isSeqType(elemType): + rejectNestedSeq(elemType) + newAssignment(dstElem, srcElem) + +proc emitElemFree(elemAccess, elemType: NimNode): NimNode = + ## Free one value, or `nnkEmpty` for POD (nothing to free). + if isStringType(elemType): + return newCall(ident("cwireFreeStr"), elemAccess) + if isNestedFFIType(elemType): + return newCall(ident("cwireFree"), elemAccess) + if isOptionType(elemType): + return emitOptionFree(elemAccess, elemType) + if isArrayType(elemType): + return emitArrayFree(elemAccess, elemType) + if isTupleType(elemType): + return emitTupleFree(elemAccess, elemType) + if isSeqType(elemType): + rejectNestedSeq(elemType) + newEmptyNode() + +proc maybeStmt(n: NimNode): NimNode = + ## `n` as a one-statement list, or an empty list when `n` is `nnkEmpty` + ## (nothing to do) — keeps the surrounding `quote` block well-formed. + if n.kind == nnkEmpty: + return newStmtList() + newStmtList(n) + +proc indexLoop(access, idx, body: NimNode): NimNode = + ## `for in low(access) .. high(access): body` — `low`/`high` so any + ## array index range (not just 0-based) is covered. + nnkForStmt.newTree( + idx, + nnkInfix.newTree( + ident(".."), newCall(ident("low"), access), newCall(ident("high"), access) + ), + newStmtList(body), + ) + +proc emitArrayPack(dstAccess, srcAccess, arrType: NimNode): NimNode = + ## Pack a fixed `array[N, T]` element-by-element into the inline wire array; + ## the array itself needs no allocation, only its GC'd element contents do. + let idx = genSym(nskForVar, "i") + let body = emitElemPack( + nnkBracketExpr.newTree(dstAccess, idx), + nnkBracketExpr.newTree(srcAccess, idx), + arrType[2], + ) + indexLoop(srcAccess, idx, body) + +proc emitArrayUnpack(dstAccess, srcAccess, arrType: NimNode): NimNode = + ## Inverse of `emitArrayPack`: copy each wire element back into the Nim array. + let idx = genSym(nskForVar, "i") + let body = emitElemUnpack( + nnkBracketExpr.newTree(dstAccess, idx), + nnkBracketExpr.newTree(srcAccess, idx), + arrType[2], + ) + indexLoop(srcAccess, idx, body) + +proc emitArrayFree(dstAccess, arrType: NimNode): NimNode = + ## Free each array element; `nnkEmpty` when the element type owns nothing. + if not cwireNeedsFree(arrType[2]): + return newEmptyNode() + let idx = genSym(nskForVar, "i") + let body = emitElemFree(nnkBracketExpr.newTree(dstAccess, idx), arrType[2]) + indexLoop(dstAccess, idx, body) + +proc emitTuplePack(dstAccess, srcAccess, tupType: NimNode): NimNode = + ## Pack each named tuple component into the matching wire component. + let body = newStmtList() + for c in tupleComponents(tupType): + let nm = ident(c.name) + body.add(emitElemPack(newDotExpr(dstAccess, nm), newDotExpr(srcAccess, nm), c.typ)) + body + +proc emitTupleUnpack(dstAccess, srcAccess, tupType: NimNode): NimNode = + ## Inverse of `emitTuplePack`: copy each wire component back out. + let body = newStmtList() + for c in tupleComponents(tupType): + let nm = ident(c.name) + body.add( + emitElemUnpack(newDotExpr(dstAccess, nm), newDotExpr(srcAccess, nm), c.typ) + ) + body + +proc emitTupleFree(dstAccess, tupType: NimNode): NimNode = + ## Free each tuple component that owns allocations; `nnkEmpty` when none do. + if not cwireNeedsFree(tupType): + return newEmptyNode() + let body = newStmtList() + for c in tupleComponents(tupType): + body.add(maybeStmt(emitElemFree(newDotExpr(dstAccess, ident(c.name)), c.typ))) + body + +proc emitSeqPack(dstObj, srcAccess, fieldNameIdent, userType: NimNode): NimNode = + ## Pack a seq field into a freshly `allocShared`'d `UncheckedArray`; an empty + ## seq encodes as nil items + 0 len. + let elemType = userType[1] + let wireElem = wireValueType(elemType) + let items = seqItemsField(dstObj, fieldNameIdent) + let count = seqLenField(dstObj, fieldNameIdent) + let bufType = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("UncheckedArray"), wireElem)) + let idx = genSym(nskForVar, "i") + let elemPack = emitElemPack( + nnkBracketExpr.newTree(items, idx), nnkBracketExpr.newTree(srcAccess, idx), elemType + ) + let forLoop = nnkForStmt.newTree( + idx, + nnkInfix.newTree( + ident("..<"), newLit(0), newCall(newDotExpr(srcAccess, ident("len"))) + ), + newStmtList(elemPack), + ) + quote: + if `srcAccess`.len() == 0: + `items` = nil + `count` = 0 + else: + `items` = cast[`bufType`](allocShared(sizeof(`wireElem`) * `srcAccess`.len())) + `forLoop` + `count` = `srcAccess`.len() + +proc emitOptionPack(dstAccess, srcAccess, userType: NimNode): NimNode = + ## Pack an Option into a `ptr`: some → `allocShared` a box and pack into it, + ## none → nil. The payload is read into a local once, so a composite inner + ## type (e.g. `array`/`tuple`) isn't re-`get()`-copied per element. + let innerType = userType[1] + let wireInner = wireValueType(innerType) + let bufType = nnkPtrTy.newTree(wireInner) + let innerVal = genSym(nskLet, "innerVal") + let elemPack = emitElemPack(nnkBracketExpr.newTree(dstAccess), innerVal, innerType) + quote: + if `srcAccess`.isSome(): + `dstAccess` = cast[`bufType`](allocShared(sizeof(`wireInner`))) + let `innerVal` = `srcAccess`.get() + `elemPack` + else: + `dstAccess` = nil + proc emitPackStmt(dstObj, srcObj, fieldNameIdent, userType: NimNode): seq[NimNode] = - ## Populate `dstObj.` from `srcObj.`: cstring allocation for - ## strings, direct copy for POD. + ## Populate `dstObj.` from `srcObj.`, allocating shared-memory + ## cstrings/arrays as the field's natural type requires. let srcAccess = newDotExpr(srcObj, fieldNameIdent) let dstAccess = newDotExpr(dstObj, fieldNameIdent) - if isStringType(userType): - return @[newAssignment(dstAccess, newCall(ident("cwireAllocStr"), srcAccess))] - @[newAssignment(dstAccess, srcAccess)] + if isSeqType(userType): + return @[emitSeqPack(dstObj, srcAccess, fieldNameIdent, userType)] + @[emitElemPack(dstAccess, srcAccess, userType)] + +proc emitSeqUnpack(dstAccess, srcObj, fieldNameIdent, userType: NimNode): NimNode = + ## Rebuild a Nim seq from the `_items`/`_len` wire pair. + let elemType = userType[1] + let items = seqItemsField(srcObj, fieldNameIdent) + let count = seqLenField(srcObj, fieldNameIdent) + let elemVar = genSym(nskVar, "elem") + let idx = genSym(nskForVar, "i") + let elemUnpack = emitElemUnpack(elemVar, nnkBracketExpr.newTree(items, idx), elemType) + quote: + `dstAccess` = @[] + for `idx` in 0 ..< `count`: + var `elemVar`: `elemType` + `elemUnpack` + `dstAccess`.add(`elemVar`) + +proc emitOptionUnpack(dstAccess, srcAccess, userType: NimNode): NimNode = + ## Rebuild an Option from a wire `ptr`: nil → none, else unpack the pointee. + let innerType = userType[1] + let elemVar = genSym(nskVar, "innerVal") + let elemUnpack = emitElemUnpack(elemVar, nnkBracketExpr.newTree(srcAccess), innerType) + quote: + if `srcAccess`.isNil(): + `dstAccess` = none(`innerType`) + else: + var `elemVar`: `innerType` + `elemUnpack` + `dstAccess` = some(`elemVar`) proc emitUnpackStmt( resultObj, srcObj, fieldNameIdent, userType: NimNode ): seq[NimNode] = - ## Fill `resultObj.` from `srcObj.`: `$cstring` copies into - ## Nim-managed memory, POD is a direct copy. + ## Fill `resultObj.` from `srcObj.`, copying back into + ## Nim-managed memory. let srcAccess = newDotExpr(srcObj, fieldNameIdent) let dstAccess = newDotExpr(resultObj, fieldNameIdent) - if isStringType(userType): - return @[newAssignment(dstAccess, newCall(ident("$"), srcAccess))] - @[newAssignment(dstAccess, srcAccess)] + if isSeqType(userType): + return @[emitSeqUnpack(dstAccess, srcObj, fieldNameIdent, userType)] + @[emitElemUnpack(dstAccess, srcAccess, userType)] + +proc emitSeqFree(dstObj, fieldNameIdent, userType: NimNode): NimNode = + ## Free a seq field: free each element (skipped entirely for POD), then the + ## shared buffer. + let elemType = userType[1] + let items = seqItemsField(dstObj, fieldNameIdent) + let count = seqLenField(dstObj, fieldNameIdent) + let idx = genSym(nskForVar, "i") + let elemFree = emitElemFree(nnkBracketExpr.newTree(items, idx), elemType) + let freeLoop = + if elemFree.kind == nnkEmpty: + newStmtList() + else: + newStmtList( + nnkForStmt.newTree( + idx, nnkInfix.newTree(ident("..<"), newLit(0), count), newStmtList(elemFree) + ) + ) + quote: + if not `items`.isNil(): + `freeLoop` + deallocShared(`items`) + `items` = nil + `count` = 0 + +proc emitOptionFree(dstAccess, userType: NimNode): NimNode = + ## Free an Option field: free the pointee (skipped for POD), then the box. + let innerType = userType[1] + let freeInner = maybeStmt(emitElemFree(nnkBracketExpr.newTree(dstAccess), innerType)) + quote: + if not `dstAccess`.isNil(): + `freeInner` + deallocShared(`dstAccess`) + `dstAccess` = nil proc emitFreeStmt(dstObj, fieldNameIdent, userType: NimNode): seq[NimNode] = - ## Release `dstObj.`: free the cstring for strings, nothing for POD. + ## Release `dstObj.`: free cstrings/arrays/pointers; POD frees nothing. let dstAccess = newDotExpr(dstObj, fieldNameIdent) - if isStringType(userType): - return @[newCall(ident("cwireFreeStr"), dstAccess)] - @[] + if isSeqType(userType): + return @[emitSeqFree(dstObj, fieldNameIdent, userType)] + let elemFree = emitElemFree(dstAccess, userType) + if elemFree.kind == nnkEmpty: + return @[] + @[elemFree] proc buildCWireProcs( userTypeName: string, fieldNames: seq[string], fieldTypes: seq[NimNode] @@ -184,15 +509,36 @@ proc fieldInfoForType( return (names, types) error("fieldInfoForType: ffi type '" & typeName & "' not in registry") +proc collectNestedFFITypes( + fieldTypes: seq[NimNode], deps: var seq[string] +) {.compileTime.} = + ## Append (deduped) the names of nested ffi types referenced anywhere in + ## `fieldTypes`, recursing through `seq`/`Option`/`array`/`tuple` to any depth. + for t in fieldTypes: + if isNestedFFIType(t): + let n = $t + if n notin deps: + deps.add(n) + elif isSeqType(t) or isOptionType(t): + collectNestedFFITypes(@[t[1]], deps) + elif isArrayType(t): + collectNestedFFITypes(@[t[2]], deps) + elif isTupleType(t): + for c in tupleComponents(t): + collectNestedFFITypes(@[c.typ], deps) + proc ensureCWireFor(typeName: string, sink: NimNode) {.compileTime.} = ## Idempotent: if `typeName`'s cwire companion has not yet been emitted, ## append its TypeSection and conversion procs to `sink` and mark it emitted. + ## Nested ffi deps are ensured first so the resulting AST is self-contained. if isCWireEmitted(typeName): return - markCWireEmitted(typeName) let info = fieldInfoForType(typeName) - for i in 0 ..< info.names.len: - assertCWireFieldSupported(typeName, info.names[i], info.types[i]) + var deps: seq[string] = @[] + collectNestedFFITypes(info.types, deps) + for dep in deps: + ensureCWireFor(dep, sink) + markCWireEmitted(typeName) let section = newNimNode(nnkTypeSection) section.add(buildCWireTypeDef(typeName, info.names, info.types)) sink.add(section) diff --git a/ffi/internal/c_wire.nim b/ffi/internal/c_wire.nim index 973da35..9628508 100644 --- a/ffi/internal/c_wire.nim +++ b/ffi/internal/c_wire.nim @@ -1,5 +1,6 @@ ## Runtime helpers for the macro-generated `*_CWire` companion types: only the -## `cstring` fields need allocation, packed on pack and released on free. +## `cstring` fields need allocation here (seq/Option are alloc'd inline by the +## macro), packed on pack and released on free. import ../alloc diff --git a/tests/unit/test_c_wire.nim b/tests/unit/test_c_wire.nim index 4ecc0f3..8b561c4 100644 --- a/tests/unit/test_c_wire.nim +++ b/tests/unit/test_c_wire.nim @@ -1,54 +1,195 @@ -## Round-trip correctness for the `c` (flat C-struct) ABI codec — flat path. +## Round-trip correctness for the `c` (flat C-struct) ABI codec. ## ## Each `{.ffi: "abi = c".}` type gets a `_CWire` companion plus ## `cwirePack` / `cwireUnpack` / `cwireFree`. This asserts -## `cwireUnpack(cwirePack(x)) == x` for the flat scalar+string field shapes, -## including the empty-string edge case the cstring encoding must handle. +## `cwireUnpack(cwirePack(x)) == x` across the supported field shapes — +## scalars, strings, `seq`, `Option`, `array`, named `tuple`, nested {.ffi.} +## structs, and nested composites (`seq[Option[T]]`, `array[N, tuple[...]]`, +## `Option[array[...]]`, ...) — including the empty/none/empty-string edge +## cases the cstring/pointer encoding must handle. ## ## `genBindings()` flushes the cwire companions for every abi=c type declared ## above it (a type-pragma macro can't splice them in at the type site). +import std/options import unittest2 import ffi -type Flat {.ffi: "abi = c".} = object - name: string +type Inner {.ffi: "abi = c".} = object label: string + weight: int + +type Outer {.ffi: "abi = c".} = object + name: string count: int - size: uint32 - ratio: float64 flag: bool + inner: Inner + items: seq[Inner] + tags: seq[string] + note: Option[string] + retries: Option[int] + blob: seq[byte] + maybeInner: Option[Inner] + optItems: seq[Option[Inner]] + optTags: seq[Option[string]] + +type Shapes {.ffi: "abi = c".} = object + ## Exercises array/tuple wire shapes and their cross-nestings (array of + ## array, tuple in array, array in Option, tuple in seq) alongside GC'd and + ## nested-{.ffi.} element types. + coords: array[3, int] + labels: array[2, string] + cells: array[2, Inner] + matrix: array[2, array[2, int]] + point: tuple[x: int, y: int] + tagged: tuple[label: string, kv: Inner] + grid: array[2, tuple[a: string, b: int]] + optBox: Option[array[2, string]] + pairs: seq[tuple[name: string, n: int]] genBindings() -proc roundTrip(o: Flat): Flat = - ## Pack into the flat wire struct, copy back out, then release the wire +proc roundTrip(o: Outer): Outer = + ## Pack into the wire struct, copy back out, then release the wire ## allocations — the exact lifecycle a boundary crossing would use. - var wire: Flat_CWire + var wire: Outer_CWire cwirePack(wire, o) let back = cwireUnpack(wire) cwireFree(wire) return back -suite "c-ABI cwire round-trip (flat)": - test "populated scalars and strings survive pack/unpack/free": - let o = Flat( - name: "hello", - label: "world", - count: -42, - size: 4_000_000_000'u32, - ratio: 3.5, +proc roundTrip(o: Shapes): Shapes = + ## Same pack/unpack/free lifecycle as the `Outer` overload, for the + ## array/tuple shapes. + var wire: Shapes_CWire + cwirePack(wire, o) + let back = cwireUnpack(wire) + cwireFree(wire) + return back + +suite "c-ABI cwire round-trip": + test "fully-populated value survives pack/unpack/free": + let o = Outer( + name: "outer", + count: 42, flag: true, + inner: Inner(label: "core", weight: 7), + items: @[Inner(label: "a", weight: 1), Inner(label: "b", weight: 2)], + tags: @["x", "y", "z"], + note: some("a note"), + retries: some(3), + blob: @[1'u8, 2, 3, 255], + maybeInner: some(Inner(label: "opt", weight: 99)), + optItems: @[some(Inner(label: "p", weight: 5)), none(Inner)], + optTags: @[some("kept"), none(string), some("")], + ) + check roundTrip(o) == o + + test "empty strings, empty seqs, and none Options survive": + let o = Outer( + name: "", + count: 0, + flag: false, + inner: Inner(label: "", weight: 0), + items: @[], + tags: @[], + note: none(string), + retries: none(int), + blob: @[], + maybeInner: none(Inner), + optItems: @[], + optTags: @[], ) let back = roundTrip(o) check back == o - # Distinct strings must not alias one another. - check back.name == "hello" - check back.label == "world" + check back.note.isNone + check back.retries.isNone + check back.maybeInner.isNone + check back.items.len == 0 + check back.blob.len == 0 + check back.optItems.len == 0 - test "empty strings survive pack/unpack/free": - let o = Flat(name: "", label: "", count: 0, size: 0, ratio: 0.0, flag: false) + test "nested seq elements keep their string fields": + let o = Outer( + name: "n", + inner: Inner(label: "i", weight: 9), + items: @[Inner(label: "alpha", weight: 10), Inner(label: "beta", weight: 20)], + note: some(""), # some-of-empty-string must stay `some`, not collapse to none + ) + let back = roundTrip(o) + check back.items.len == 2 + check back.items[1].label == "beta" + check back.note.isSome + check back.note.get == "" + + test "seq[Option[T]] elements round-trip per-element some/none": + let o = Outer( + name: "rec", + optItems: @[ + some(Inner(label: "x", weight: 1)), + none(Inner), + some(Inner(label: "z", weight: 3)), + ], + optTags: @[none(string), some("mid"), some("")], + ) + let back = roundTrip(o) + check back.optItems.len == 3 + check back.optItems[0].isSome + check back.optItems[0].get.label == "x" + check back.optItems[1].isNone + check back.optItems[2].get.weight == 3 + check back.optTags[0].isNone + check back.optTags[1].get == "mid" + check back.optTags[2].isSome + check back.optTags[2].get == "" + +suite "c-ABI cwire array/tuple round-trip": + test "fully-populated array & tuple fields survive pack/unpack/free": + let o = Shapes( + coords: [1, 2, 3], + labels: ["alpha", "beta"], + cells: [Inner(label: "a", weight: 1), Inner(label: "b", weight: 2)], + matrix: [[1, 2], [3, 4]], + point: (x: 10, y: 20), + tagged: (label: "lbl", kv: Inner(label: "k", weight: 9)), + grid: [(a: "g0", b: 0), (a: "g1", b: 1)], + optBox: some(["p", "q"]), + pairs: @[(name: "n0", n: 0), (name: "n1", n: 1)], + ) + check roundTrip(o) == o + + test "empty seq, none Option, and empty-string elements survive": + let o = Shapes( + coords: [0, 0, 0], + labels: ["", ""], + cells: [Inner(label: "", weight: 0), Inner(label: "", weight: 0)], + matrix: [[0, 0], [0, 0]], + point: (x: 0, y: 0), + tagged: (label: "", kv: Inner(label: "", weight: 0)), + grid: [(a: "", b: 0), (a: "", b: 0)], + optBox: none(array[2, string]), + pairs: @[], + ) let back = roundTrip(o) check back == o - check back.name == "" - check back.label == "" + check back.optBox.isNone + check back.pairs.len == 0 + + test "GC'd contents inside array/tuple keep their values": + let o = Shapes( + labels: ["kept", "also kept"], + cells: [Inner(label: "x", weight: 5), Inner(label: "y", weight: 6)], + tagged: (label: "tag", kv: Inner(label: "deep", weight: 7)), + grid: [(a: "first", b: 1), (a: "second", b: 2)], + optBox: some(["box0", ""]), + pairs: @[(name: "alpha", n: 10), (name: "", n: 20)], + ) + let back = roundTrip(o) + check back.labels[1] == "also kept" + check back.cells[0].label == "x" + check back.tagged.kv.label == "deep" + check back.grid[1].a == "second" + check back.optBox.get[0] == "box0" + check back.optBox.get[1] == "" + check back.pairs[1].name == "" + check back.pairs[0].n == 10