refactor(codegen): shared FFIType IR + single type parser for C/C++/Rust (#104)

This commit is contained in:
Gabriel Cruz 2026-07-06 11:43:34 -03:00 committed by GitHub
parent 08509cc74f
commit 8c1343aaf2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 429 additions and 161 deletions

View File

@ -13,7 +13,7 @@
## distinctly-named codec emitted by the cbor_helpers template.
import std/[os, strutils, tables, sets]
import ./meta, ./string_helpers, ./c_cpp_common
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
## Wire-format C type for any Nim `ptr T` / `pointer`. Fixed 64-bit so the CBOR
## payload size is stable regardless of host architecture (mirrors CppPtrType).
@ -30,81 +30,43 @@ const
PreludeHeaderName* = "nim_ffi_prelude.h"
CborHeaderName* = "nim_ffi_cbor.h"
type LeafInfo = tuple[ok: bool, cType: string, suffix: string, owns: bool]
func leafCType(t: string): LeafInfo =
## Maps a Nim leaf type to its C type, codec suffix and whether a decoded
## value owns heap memory. `ok` is false for composite types (seq/Option/
## user structs), which are monomorphised separately.
case t
of "int", "int64":
(true, "int64_t", "i64", false)
of "int32":
(true, "int32_t", "i32", false)
of "int16":
(true, "int16_t", "i16", false)
of "int8":
(true, "int8_t", "i8", false)
of "uint", "uint64":
(true, "uint64_t", "u64", false)
of "uint32":
(true, "uint32_t", "u32", false)
of "uint16":
(true, "uint16_t", "u16", false)
of "uint8", "byte":
(true, "uint8_t", "u8", false)
of "bool":
(true, "bool", "bool", false)
of "float", "float64":
(true, "double", "f64", false)
of "float32":
(true, "float", "f32", false)
of "pointer":
(true, CPtrType, "u64", false)
of "string", "cstring":
(true, "NimFfiStr", "str", true)
else:
(false, "", "", false)
func cToken(cType: string): string =
## Short PascalCase token used to build monomorphised container names and
## codec-adapter symbols. Composite C type names are already unique C
## identifiers, so they pass through verbatim.
case cType
of "int64_t": "I64"
of "int32_t": "I32"
of "int16_t": "I16"
of "int8_t": "I8"
of "uint64_t": "U64"
of "uint32_t": "U32"
of "uint16_t": "U16"
of "uint8_t": "U8"
of "bool": "Bool"
of "double": "F64"
of "float": "F32"
of "NimFfiStr": "Str"
of "NimFfiBytes": "Bytes"
else: cType
const scalarCInfoTable: array[ScalarKind, tuple[cType, suffix: string]] = [
skBool: ("bool", "bool"),
skI8: ("int8_t", "i8"),
skI16: ("int16_t", "i16"),
skI32: ("int32_t", "i32"),
skI64: ("int64_t", "i64"),
skU8: ("uint8_t", "u8"),
skU16: ("uint16_t", "u16"),
skU32: ("uint32_t", "u32"),
skU64: ("uint64_t", "u64"),
skF32: ("float", "f32"),
skF64: ("double", "f64"),
]
func leafSuffix(cType: string): string =
## Inverse of leafCType's cType→suffix for the leaf codecs the template
## provides; empty string for composite types.
## C type name → leaf codec suffix for the leaf codecs the template provides;
## empty string for composite types. Driven off the shared scalar table so it
## can't drift from the IR's scalar set.
for s in ScalarKind:
if scalarCInfoTable[s].cType == cType:
return scalarCInfoTable[s].suffix
case cType
of "int64_t": "i64"
of "int32_t": "i32"
of "int16_t": "i16"
of "int8_t": "i8"
of "uint64_t": "u64"
of "uint32_t": "u32"
of "uint16_t": "u16"
of "uint8_t": "u8"
of "bool": "bool"
of "double": "f64"
of "float": "f32"
of "NimFfiStr": "str"
of "NimFfiBytes": "bytes"
else: ""
func cToken(cType: string): string =
## Short PascalCase token used to build monomorphised container names and
## codec-adapter symbols. Leaf types reuse their codec suffix (e.g.
## `int64_t`→`I64`); composite C type names are already unique C identifiers,
## so they pass through verbatim.
let suffix = leafSuffix(cType)
if suffix.len > 0:
capitalizeFirstLetter(suffix)
else:
cType
type CTypeReg = object
libName: string ## snake_case symbol prefix, e.g. "my_timer"
libType: string ## PascalCase container-name prefix, e.g. "MyTimer"
@ -293,44 +255,48 @@ proc emitStructType(reg: var CTypeReg, t: FFITypeMeta) =
reg.codecs.add(body.join("\n"))
reg.owns[t.name] = owns
proc ensureCType(reg: var CTypeReg, nimType: string): tuple[cType: string, owns: bool] =
let t = nimType.strip()
if t.startsWith("ptr ") or t == "pointer":
proc ensureCType(reg: var CTypeReg, t: FFIType): tuple[cType: string, owns: bool] =
## Walks the shared type IR into a C type, monomorphising each distinct
## `seq[T]` / `Option[T]` into its own struct + codec triple on first sight.
## `owns` marks a C type that carries heap-allocated payload the caller must
## release with its generated free function (strings, byte buffers, and any
## seq/opt/struct transitively containing one); plain scalars and pointers own
## nothing and need no cleanup.
case t.kind
of ftPtr:
return (CPtrType, false)
let leaf = leafCType(t)
if leaf.ok:
return (leaf.cType, leaf.owns)
let seqInner = genericInnerType(t, "seq[")
if seqInner.len > 0:
let inner = seqInner.strip()
if inner == "byte" or inner == "uint8":
return ("NimFfiBytes", true)
let (elemC, _) = ensureCType(reg, inner)
of ftScalar:
return (scalarCInfoTable[t.scalar].cType, false)
of ftStr:
return ("NimFfiStr", true)
of ftBytes:
return ("NimFfiBytes", true)
of ftSeq:
let (elemC, _) = ensureCType(reg, t.elem)
let name = reg.libType & "Seq_" & cToken(elemC)
if name notin reg.emitted:
reg.emitted.incl(name)
emitSeqType(reg, name, elemC)
return (name, true)
var optInner = genericInnerType(t, "Option[")
if optInner.len == 0:
optInner = genericInnerType(t, "Maybe[")
if optInner.len > 0:
let (elemC, elemOwns) = ensureCType(reg, optInner.strip())
of ftOpt:
let (elemC, elemOwns) = ensureCType(reg, t.elem)
let name = reg.libType & "Opt_" & cToken(elemC)
if name notin reg.emitted:
reg.emitted.incl(name)
emitOptType(reg, name, elemC, elemOwns)
return (name, reg.owns.getOrDefault(name, false))
of ftStruct:
let name = t.name
if name notin reg.emitted:
reg.emitted.incl(name)
if name in reg.typeTable:
emitStructType(reg, reg.typeTable[name])
else:
reg.decls.add("/* unknown type referenced: " & name & " */")
return (name, reg.owns.getOrDefault(name, false))
if t notin reg.emitted:
reg.emitted.incl(t)
if t in reg.typeTable:
emitStructType(reg, reg.typeTable[t])
else:
reg.decls.add("/* unknown type referenced: " & t & " */")
(t, reg.owns.getOrDefault(t, false))
proc ensureCType(reg: var CTypeReg, nimType: string): tuple[cType: string, owns: bool] =
ensureCType(reg, parseFFIType(nimType))
proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
## Synthesises the per-proc Req struct as an FFITypeMeta so it flows through
@ -344,10 +310,12 @@ proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
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.
## aggregates (seq, Option, user structs) pass by const pointer. Note `ptr T`
## rides by value as the 64-bit wire int (like `pointer`); production params
## reach here as `pointer` since handles are pre-converted upstream.
if ridesAsPtr:
return true
leafCType(nimType.strip()).ok
parseFFIType(nimType).kind in {ftScalar, ftStr, ftPtr}
proc cReturnType(reg: var CTypeReg, p: FFIProcMeta): string =
if p.returnRidesAsPtr():

View File

@ -6,16 +6,6 @@
import std/strutils
import ./meta, ./string_helpers
proc genericInnerType*(typeName, prefix: string): string =
## Inner type of a single-parameter generic written `Prefix[Inner]`, e.g.
## `genericInnerType("seq[int]", "seq[")` → `"int"`. Empty string when
## `typeName` is not of that shape.
if typeName.startsWith(prefix) and typeName.endsWith("]"):
let start = prefix.len
let lastIndex = typeName.len - 2
return typeName[start .. lastIndex]
return ""
proc stripLibPrefix*(procName, libName: string): string =
## Drops the `<lib>_` prefix from an exported C symbol, e.g.
## `stripLibPrefix("timer_echo", "timer")` → `"echo"`.

View File

@ -4,7 +4,7 @@
## the Nim-side cbor_serial codec on the wire — both ends speak RFC 8949).
import std/[os, strutils]
import ./meta, ./string_helpers, ./c_cpp_common
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
## Wire-format C++ type used for any Nim `ptr T` / `pointer`. Fixed 64-bit so
## the CBOR payload size is stable regardless of host architecture.
@ -21,35 +21,37 @@ const
ContextRuleOf5Tpl = staticRead("templates/cpp/context_rule_of_5.hpp.tpl")
CMakeListsTpl = staticRead("templates/cpp/CMakeLists.txt.tpl")
func cppScalar(s: ScalarKind): string =
case s
of skBool: "bool"
of skI8: "int8_t"
of skI16: "int16_t"
of skI32: "int32_t"
of skI64: "int64_t"
of skU8: "uint8_t"
of skU16: "uint16_t"
of skU32: "uint32_t"
of skU64: "uint64_t"
of skF32: "float"
of skF64: "double"
func cppSeq(elem: string): string =
"std::vector<" & elem & ">"
func cppOpt(elem: string): string =
"std::optional<" & elem & ">"
const cppMap = NativeTypeMap(
scalar: cppScalar,
str: "std::string",
bytes: "std::vector<uint8_t>",
ptrType: CppPtrType,
seqOf: cppSeq,
optOf: cppOpt,
) ## structName omitted: C++ uses the user type name verbatim
proc nimTypeToCpp*(typeName: string): string =
let trimmed = typeName.strip()
if trimmed.startsWith("ptr "):
return CppPtrType
else:
let seqInner = genericInnerType(trimmed, "seq[")
if seqInner.len > 0:
return "std::vector<" & nimTypeToCpp(seqInner) & ">"
let optionInner = genericInnerType(trimmed, "Option[")
if optionInner.len > 0:
return "std::optional<" & nimTypeToCpp(optionInner) & ">"
let maybeInner = genericInnerType(trimmed, "Maybe[")
if maybeInner.len > 0:
return "std::optional<" & nimTypeToCpp(maybeInner) & ">"
case trimmed
of "string", "cstring": "std::string"
of "int", "int64": "int64_t"
of "int32": "int32_t"
of "int16": "int16_t"
of "int8": "int8_t"
of "uint", "uint64": "uint64_t"
of "uint32": "uint32_t"
of "uint16": "uint16_t"
of "uint8", "byte": "uint8_t"
of "bool": "bool"
of "float", "float32": "float"
of "float64": "double"
of "pointer": CppPtrType
else: trimmed
renderNative(cppMap, parseFFIType(typeName))
proc emitStructCborCodec(
lines: var seq[string], structName: string, fields: seq[(string, string)]

View File

@ -1,38 +1,47 @@
## Rust binding generator for the nim-ffi framework.
## Generates a complete Rust crate that uses CBOR (ciborium) on the wire.
import std/[os, strutils, sequtils]
import ./meta, ./string_helpers
import std/[os, strutils]
import ./meta, ./string_helpers, ./types_ir
## Wire-format Rust type used for any Nim `ptr T` / `pointer`. Fixed 64-bit so
## the CBOR payload size is stable regardless of host architecture (mirrors
## CppPtrType in cpp.nim).
const RustPtrType* = "u64"
func rustScalar(s: ScalarKind): string =
case s
of skBool: "bool"
of skI8: "i8"
of skI16: "i16"
of skI32: "i32"
of skI64: "i64"
of skU8: "u8"
of skU16: "u16"
of skU32: "u32"
of skU64: "u64"
of skF32: "f32"
of skF64: "f64"
func rustSeq(elem: string): string =
"Vec<" & elem & ">"
func rustOpt(elem: string): string =
"Option<" & elem & ">"
const rustMap = NativeTypeMap(
scalar: rustScalar,
str: "String",
bytes: "Vec<u8>",
ptrType: RustPtrType,
seqOf: rustSeq,
optOf: rustOpt,
structName: capitalizeFirstLetter,
)
proc nimTypeToRust*(typeName: string): string =
## Maps Nim type names to Rust type names, including generics.
let t = typeName.strip()
if t.startsWith("seq[") and t.endsWith("]"):
return "Vec<" & nimTypeToRust(t[4 .. ^2]) & ">"
if t.startsWith("Option[") and t.endsWith("]"):
return "Option<" & nimTypeToRust(t[7 .. ^2]) & ">"
if t.startsWith("Maybe[") and t.endsWith("]"):
return "Option<" & nimTypeToRust(t[6 .. ^2]) & ">"
case t
of "string", "cstring":
"String"
of "int", "int64":
"i64"
of "int32":
"i32"
of "bool":
"bool"
of "float", "float64":
"f64"
of "pointer":
RustPtrType
else:
capitalizeFirstLetter(t)
renderNative(rustMap, parseFFIType(typeName))
proc deriveLibName*(procs: seq[FFIProcMeta]): string =
## Extracts the common prefix before the first `_` from proc names.

View File

@ -25,7 +25,7 @@ proc camelToSnakeCase*(s: string): string =
first = false
return snake
proc capitalizeFirstLetter*(s: string): string =
func capitalizeFirstLetter*(s: string): string =
## Returns `s` with its first rune uppercased; the rest is left unchanged.
## e.g. "abc" → "Abc", "" → "", "Abc" → "Abc"
if s.len == 0:

134
ffi/codegen/types_ir.nim Normal file
View File

@ -0,0 +1,134 @@
## Structured type model shared by the C / C++ / Rust binding generators: one
## parser (`parseFFIType`) for the Nim type strings each backend used to slice
## by hand, plus `renderNative` to walk the result into a backend's type string.
import std/[strutils, options]
type
ScalarKind* {.pure.} = enum
skBool
skI8
skI16
skI32
skI64
skU8
skU16
skU32
skU64
skF32
skF64
FFITypeKind* {.pure.} = enum
ftScalar
ftStr
ftBytes
ftSeq
ftOpt
ftPtr
ftStruct
FFIType* = ref object
case kind*: FFITypeKind
of ftScalar:
scalar*: ScalarKind
of ftSeq, ftOpt:
elem*: FFIType
of ftStruct:
name*: string
else:
discard
NativeTypeMap* = object
## A backend's answer to "what do you call this kind?". `seqOf`/`optOf`
## wrap an already-rendered element; `structName` maps a user type name.
scalar*: proc(s: ScalarKind): string {.noSideEffect, nimcall.}
str*: string
bytes*: string
ptrType*: string
seqOf*: proc(elem: string): string {.noSideEffect, nimcall.}
optOf*: proc(elem: string): string {.noSideEffect, nimcall.}
structName*: proc(name: string): string {.noSideEffect, nimcall.}
## nil ⇒ the user type name passes through unchanged
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("]"):
return typeName[prefix.len .. ^2]
return ""
func scalarKind(t: string): Option[ScalarKind] =
## Single source of truth for the scalar leaf set every backend shares.
case t
of "bool":
some(skBool)
of "int8":
some(skI8)
of "int16":
some(skI16)
of "int32":
some(skI32)
of "int", "int64":
some(skI64)
of "uint8", "byte":
some(skU8)
of "uint16":
some(skU16)
of "uint32":
some(skU32)
of "uint", "uint64":
some(skU64)
of "float32":
some(skF32)
of "float", "float64":
some(skF64)
else:
none(ScalarKind)
func parseFFIType*(typeName: string): FFIType =
## Single source of truth for turning a Nim type string into the shared IR:
## ptr/pointer, seq[byte]→bytes, seq/Option/Maybe, scalars, string, else struct.
let t = typeName.strip()
if t.startsWith("ptr ") or t == "pointer":
return FFIType(kind: ftPtr)
let seqInner = genericInnerType(t, "seq[")
if seqInner.len > 0:
let inner = seqInner.strip()
if inner == "byte" or inner == "uint8":
return FFIType(kind: ftBytes)
return FFIType(kind: ftSeq, elem: parseFFIType(inner))
var optInner = genericInnerType(t, "Option[")
if optInner.len == 0:
optInner = genericInnerType(t, "Maybe[")
if optInner.len > 0:
return FFIType(kind: ftOpt, elem: parseFFIType(optInner.strip()))
let sc = scalarKind(t)
if sc.isSome():
return FFIType(kind: ftScalar, scalar: sc.get())
if t == "string" or t == "cstring":
return FFIType(kind: ftStr)
FFIType(kind: ftStruct, name: t)
func renderNative*(m: NativeTypeMap, t: FFIType): string =
## Recursively walks `t` into a native type string for the backend `m`.
case t.kind
of ftScalar:
m.scalar(t.scalar)
of ftStr:
m.str
of ftBytes:
m.bytes
of ftPtr:
m.ptrType
of ftSeq:
m.seqOf(renderNative(m, t.elem))
of ftOpt:
m.optOf(renderNative(m, t.elem))
of ftStruct:
if m.structName.isNil():
t.name
else:
m.structName(t.name)

View File

@ -0,0 +1,43 @@
## Regression tests for the Rust type mapping. `nimTypeToRust` used to carry its
## own scalar table that had drifted from C/C++ — `int8`/`int16`/`uint8`/
## `uint16`/`uint32`/`byte`/`float32` fell through to `capitalizeFirstLetter`
## and emitted invalid Rust. It now renders through the shared `parseFFIType`
## IR, so the full scalar set is pinned here.
import unittest2
import ffi/codegen/rust
suite "nimTypeToRust: scalar set":
test "every scalar maps to its Rust primitive (the drift that regressed)":
check nimTypeToRust("bool") == "bool"
check nimTypeToRust("int8") == "i8"
check nimTypeToRust("int16") == "i16"
check nimTypeToRust("int32") == "i32"
check nimTypeToRust("int") == "i64"
check nimTypeToRust("int64") == "i64"
check nimTypeToRust("uint8") == "u8"
check nimTypeToRust("byte") == "u8"
check nimTypeToRust("uint16") == "u16"
check nimTypeToRust("uint32") == "u32"
check nimTypeToRust("uint") == "u64"
check nimTypeToRust("uint64") == "u64"
check nimTypeToRust("float32") == "f32"
check nimTypeToRust("float") == "f64"
check nimTypeToRust("float64") == "f64"
suite "nimTypeToRust: strings, pointers and containers":
test "string types render to String":
check nimTypeToRust("string") == "String"
check nimTypeToRust("cstring") == "String"
test "seq[byte] collapses to Vec<u8> and ptr/pointer to the wire int":
check nimTypeToRust("seq[byte]") == "Vec<u8>"
check nimTypeToRust("ptr Foo") == RustPtrType
check nimTypeToRust("pointer") == RustPtrType
test "generics nest and Maybe aliases Option":
check nimTypeToRust("seq[Option[int8]]") == "Vec<Option<i8>>"
check nimTypeToRust("Maybe[uint16]") == "Option<u16>"
test "an unknown user type is capitalised, not mistaken for a scalar":
check nimTypeToRust("echoRequest") == "EchoRequest"

View File

@ -0,0 +1,122 @@
## Unit tests for the shared type IR that the C / C++ / Rust binding generators
## parse Nim type strings through. `parseFFIType` is the single source of truth
## the three backends consume, so its shape mappings are pinned here directly.
import unittest2
import ffi/codegen/types_ir
suite "parseFFIType: scalars":
test "the full scalar set round-trips to its ScalarKind":
let cases = {
"bool": skBool,
"int8": skI8,
"int16": skI16,
"int32": skI32,
"int": skI64,
"int64": skI64,
"uint8": skU8,
"byte": skU8,
"uint16": skU16,
"uint32": skU32,
"uint": skU64,
"uint64": skU64,
"float32": skF32,
"float": skF64,
"float64": skF64,
}
for (name, kind) in cases:
let t = parseFFIType(name)
check t.kind == ftScalar
check t.scalar == kind
test "surrounding whitespace is stripped":
let t = parseFFIType(" int ")
check t.kind == ftScalar
check t.scalar == skI64
suite "parseFFIType: strings, bytes and pointers":
test "string and cstring are ftStr":
check parseFFIType("string").kind == ftStr
check parseFFIType("cstring").kind == ftStr
test "seq[byte] and seq[uint8] collapse to ftBytes":
check parseFFIType("seq[byte]").kind == ftBytes
check parseFFIType("seq[uint8]").kind == ftBytes
test "ptr T and pointer are ftPtr":
check parseFFIType("ptr Foo").kind == ftPtr
check parseFFIType("pointer").kind == ftPtr
suite "parseFFIType: containers":
test "seq[T] wraps the parsed element":
let t = parseFFIType("seq[int]")
check t.kind == ftSeq
check t.elem.kind == ftScalar
check t.elem.scalar == skI64
test "Maybe[T] is the same as Option[T]":
let opt = parseFFIType("Option[string]")
let maybe = parseFFIType("Maybe[string]")
check opt.kind == ftOpt
check maybe.kind == ftOpt
check opt.elem.kind == ftStr
check maybe.elem.kind == ftStr
test "nested containers parse all the way down":
let t = parseFFIType("seq[Option[seq[int]]]")
check t.kind == ftSeq
check t.elem.kind == ftOpt
check t.elem.elem.kind == ftSeq
check t.elem.elem.elem.kind == ftScalar
check t.elem.elem.elem.scalar == skI64
test "seq[Option[byte]] keeps the inner byte as a scalar, not bytes":
let t = parseFFIType("seq[Option[byte]]")
check t.kind == ftSeq
check t.elem.kind == ftOpt
check t.elem.elem.kind == ftScalar
check t.elem.elem.scalar == skU8
suite "parseFFIType: structs":
test "an unknown name is an ftStruct carrying the name":
let t = parseFFIType("EchoRequest")
check t.kind == ftStruct
check t.name == "EchoRequest"
suite "renderNative: walks the IR with a backend map":
let rustish = NativeTypeMap(
scalar: proc(s: ScalarKind): string =
(
case s
of skI64: "i64"
of skU8: "u8"
else: "?"
),
str: "String",
bytes: "Vec<u8>",
ptrType: "u64",
seqOf: proc(e: string): string =
"Vec<" & e & ">",
optOf: proc(e: string): string =
"Option<" & e & ">",
structName: proc(n: string): string =
n,
)
test "scalars, strings, bytes and pointers map to the leaf entries":
check renderNative(rustish, parseFFIType("int")) == "i64"
check renderNative(rustish, parseFFIType("string")) == "String"
check renderNative(rustish, parseFFIType("seq[byte]")) == "Vec<u8>"
check renderNative(rustish, parseFFIType("ptr Foo")) == "u64"
test "nested containers render recursively":
check renderNative(rustish, parseFFIType("seq[Option[int]]")) == "Vec<Option<i64>>"
test "a struct renders through structName when set":
check renderNative(rustish, parseFFIType("EchoRequest")) == "EchoRequest"
test "a nil structName passes the user type name through unchanged":
let noStructMap = NativeTypeMap(
scalar: rustish.scalar, str: "String", bytes: "Vec<u8>", ptrType: "u64"
)
check renderNative(noStructMap, parseFFIType("EchoRequest")) == "EchoRequest"