Add cddl generator (#24)

This commit is contained in:
Ivan FB 2026-05-18 20:00:57 +02:00 committed by GitHub
parent ac303a707e
commit e12745e85c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 370 additions and 7 deletions

View File

@ -0,0 +1,45 @@
; CDDL schema for `timer` — auto-generated from ../timer.nim
; Wire format: CBOR (RFC 8949). Errors return raw UTF-8 (not CBOR) and
; are intentionally absent from this schema.
; ─── User-declared FFI types ──────────────────────────────────────
TimerConfig = { name: tstr }
EchoRequest = { message: tstr, delayMs: int }
EchoResponse = { echoed: tstr, timerName: tstr }
ComplexRequest = { messages: [* EchoRequest], tags: [* tstr], note: tstr / nil, retries: int / nil }
ComplexResponse = { summary: tstr, itemCount: int, hasNote: bool }
JobSpec = { name: tstr, payload: [* tstr], priority: int }
RetryPolicy = { maxAttempts: int, backoffMs: int, retryOn: [* tstr] }
ScheduleConfig = { startAtMs: int, intervalMs: int, jitter: int / nil }
ScheduleResult = { jobId: tstr, willRunCount: int, firstRunAtMs: int, effectiveBackoffMs: int }
; ─── Request envelopes (one CBOR blob per request) ────────────────
TimerCreateCtorReq = { config: TimerConfig }
TimerEchoReq = { req: EchoRequest }
TimerVersionReq = { }
TimerComplexReq = { req: ComplexRequest }
TimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig }
; ─── Procs ─────────────────────────────────────────────────────────
; timer_create (ctor)
timer_create-request = TimerCreateCtorReq
timer_create-response = tstr
; timer_echo (ffi)
timer_echo-request = TimerEchoReq
timer_echo-response = EchoResponse
; timer_version (ffi)
timer_version-request = TimerVersionReq
timer_version-response = tstr
; timer_complex (ffi)
timer_complex-request = TimerComplexReq
timer_complex-response = ComplexResponse
; timer_schedule (ffi)
timer_schedule-request = TimerScheduleReq
timer_schedule-response = ScheduleResult
; timer_destroy (dtor)
timer_destroy-response = nil

View File

@ -30,6 +30,7 @@ task test, "Run all tests under --mm:orc and --mm:refc":
exec "nim c -r " & flags & " tests/test_meta.nim"
exec "nim c -r " & flags & " tests/test_string_helpers.nim"
exec "nim c -r " & flags & " tests/test_wire_compat.nim"
exec "nim c -r " & flags & " tests/test_cddl_codegen.nim"
task test_alloc, "Run alloc unit tests under --mm:orc and --mm:refc":
exec "nim c -r " & nimFlagsOrc & " tests/test_alloc.nim"
@ -61,6 +62,14 @@ task genbindings_rust, "Generate Rust bindings for the timer example":
" -d:ffiNimSrcRelPath=../timer.nim" &
" -o:/dev/null examples/timer/timer.nim"
task genbindings_cddl, "Generate CDDL schema for the timer example":
exec "nim c " & nimFlagsOrc &
" --app:lib --noMain --nimMainPrefix:libtimer" &
" -d:ffiGenBindings -d:targetLang=cddl" &
" -d:ffiOutputDir=examples/timer/cddl_bindings" &
" -d:ffiNimSrcRelPath=../timer.nim" &
" -o:/dev/null examples/timer/timer.nim"
task genbindings_cpp, "Generate C++ bindings for the timer example":
exec "nim c " & nimFlagsOrc &
" --app:lib --noMain --nimMainPrefix:libtimer" &

178
ffi/codegen/cddl.nim Normal file
View File

@ -0,0 +1,178 @@
## CDDL (RFC 8610) schema generator for the nim-ffi framework.
## Mirrors the CBOR wire format produced by ffi/cbor_serial.nim: every
## user-declared {.ffi.} type becomes a CDDL rule, every {.ffi.} / {.ffiCtor.}
## proc gets a request envelope rule plus a response shape rule.
import std/[os, strutils, unicode]
import ./meta
proc innerOf(typeName, prefix: string): string =
if typeName.startsWith(prefix) and typeName.endsWith("]"):
return typeName[prefix.len .. ^2]
return ""
proc capitalizeFirstLetter(s: string): string =
if s.len == 0:
return s
return s.capitalize()
proc toCamelCase(s: string): string =
## "testlib_create" → "TestlibCreate"
var parts = s.split('_')
var res = ""
for p in parts:
res.add capitalizeFirstLetter(p)
return res
proc nimTypeToCddl*(typeName: string): string =
## Maps a Nim type name (as recorded in the compile-time registries) to its
## CDDL equivalent. Unknown PascalCase names are passed through as references
## to other CDDL rules in the same document.
let t = typeName.strip()
let seqI = innerOf(t, "seq[")
if seqI.len > 0:
return "[* " & nimTypeToCddl(seqI) & "]"
let arrI = innerOf(t, "array[")
if arrI.len > 0:
# CDDL has no fixed-length array literal as ergonomic as Nim's array; emit
# an unbounded array whose element type is the user-declared element type.
let commaIdx = arrI.find(',')
let elemT =
if commaIdx >= 0:
arrI[commaIdx + 1 .. ^1].strip()
else:
arrI
return "[* " & nimTypeToCddl(elemT) & "]"
let optI = innerOf(t, "Option[")
if optI.len > 0:
return nimTypeToCddl(optI) & " / nil"
let mayI = innerOf(t, "Maybe[")
if mayI.len > 0:
return nimTypeToCddl(mayI) & " / nil"
case t
of "bool": "bool"
of "int", "int64", "int32", "int16", "int8": "int"
of "uint", "uint64", "uint32", "uint16", "uint8": "uint"
of "string", "cstring": "tstr"
of "float", "float64": "float64"
of "float32": "float32"
of "pointer": "uint"
else: t
# reference to another rule in this CDDL document
proc reqStructName(p: FFIProcMeta): string =
## Mirrors the Nim macro: <CamelCase(procName)>{Ctor}Req.
let camel = toCamelCase(p.procName)
if p.kind == FFIKind.CTOR:
camel & "CtorReq"
else:
camel & "Req"
proc emitMap(
fields: openArray[tuple[name: string, typeName: string, isPtr: bool]]
): string =
if fields.len == 0:
return "{ }"
var parts: seq[string] = @[]
for f in fields:
let cddlType =
if f.isPtr:
"uint"
else:
nimTypeToCddl(f.typeName)
parts.add(f.name & ": " & cddlType)
"{ " & parts.join(", ") & " }"
proc emitObjectFields(t: FFITypeMeta): string =
var fields: seq[tuple[name: string, typeName: string, isPtr: bool]] = @[]
for f in t.fields:
fields.add((name: f.name, typeName: f.typeName, isPtr: false))
emitMap(fields)
proc emitReqFields(p: FFIProcMeta): string =
var fields: seq[tuple[name: string, typeName: string, isPtr: bool]] = @[]
for ep in p.extraParams:
fields.add((name: ep.name, typeName: ep.typeName, isPtr: ep.isPtr))
emitMap(fields)
proc responseRule(p: FFIProcMeta): string =
## CDDL shape of the success payload returned by the FFI callback.
## Error payloads stay as raw UTF-8 and are intentionally absent from the schema.
case p.kind
of FFIKind.CTOR:
# The ctor returns the FFI context address as a CBOR-encoded decimal string.
"tstr"
of FFIKind.DTOR:
# The dtor has no meaningful payload — handleRes sends a CBOR null sentinel.
"nil"
of FFIKind.FFI:
if p.returnIsPtr:
"uint"
else:
nimTypeToCddl(p.returnTypeName)
proc generateCddlSchema*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
nimSrcRelPath: string,
): string =
var L: seq[string] = @[]
L.add("; CDDL schema for `" & libName & "` — auto-generated from " & nimSrcRelPath)
L.add("; Wire format: CBOR (RFC 8949). Errors return raw UTF-8 (not CBOR) and")
L.add("; are intentionally absent from this schema.")
L.add("")
if types.len > 0:
L.add(
"; ─── User-declared FFI types ──────────────────────────────────────"
)
for t in types:
L.add(t.name & " = " & emitObjectFields(t))
L.add("")
# Per-proc request envelopes (one CBOR blob per request).
let nonDtor = block:
var r: seq[FFIProcMeta] = @[]
for p in procs:
if p.kind != FFIKind.DTOR:
r.add(p)
r
if nonDtor.len > 0:
L.add(
"; ─── Request envelopes (one CBOR blob per request) ────────────────"
)
for p in nonDtor:
L.add(reqStructName(p) & " = " & emitReqFields(p))
L.add("")
# Per-proc request/response rules.
L.add(
"; ─── Procs ─────────────────────────────────────────────────────────"
)
for p in procs:
let kindTag =
case p.kind
of FFIKind.CTOR: "ctor"
of FFIKind.DTOR: "dtor"
of FFIKind.FFI: "ffi"
L.add("; " & p.procName & " (" & kindTag & ")")
if p.kind != FFIKind.DTOR:
L.add(p.procName & "-request = " & reqStructName(p))
L.add(p.procName & "-response = " & responseRule(p))
L.add("")
return L.join("\n")
proc generateCddlBindings*(
procs: seq[FFIProcMeta],
types: seq[FFITypeMeta],
libName: string,
outputDir: string,
nimSrcRelPath: string,
) =
createDir(outputDir)
writeFile(
outputDir / (libName & ".cddl"),
generateCddlSchema(procs, types, libName, nimSrcRelPath),
)

View File

@ -41,5 +41,5 @@ const targetLang* {.strdefine.} = "rust"
const ffiOutputDir* {.strdefine.} = ""
# Nim source path (relative to outputDir) embedded in generated build files;
# set with -d:ffiNimSrcRelPath=../relative/path.nim
const ffiNimSrcRelPath* {.strdefine.} = ""
# set with -d:ffiSrcPath=../relative/path.nim
const ffiSrcPath* {.strdefine.} = ""

View File

@ -5,6 +5,7 @@ import ../codegen/[meta, string_helpers]
when defined(ffiGenBindings):
import ../codegen/rust
import ../codegen/cpp
import ../codegen/cddl
# ---------------------------------------------------------------------------
# String helpers used by multiple macros
@ -1377,8 +1378,7 @@ macro ffiDtor*(prc: untyped): untyped =
# ---------------------------------------------------------------------------
macro genBindings*(
outputDir: static[string] = ffiOutputDir,
nimSrcRelPath: static[string] = ffiNimSrcRelPath,
outputDir: static[string] = ffiOutputDir, nimSrcRelPath: static[string] = ffiSrcPath
): untyped =
## Emits C++ or Rust binding files from the compile-time FFI registries.
## The foreign-side wrapper encodes one CBOR buffer per request.
@ -1395,14 +1395,14 @@ macro genBindings*(
##
## Supported languages (-d:targetLang): "rust" (default), "cpp".
## Output path and nim source path default to -d:ffiOutputDir and
## -d:ffiNimSrcRelPath, or can be passed as explicit arguments.
## -d:ffiSrcPath, or can be passed as explicit arguments.
## This macro is a no-op unless -d:ffiGenBindings is set.
##
## Example (all via compile flags):
## genBindings()
## # nim c -d:ffiGenBindings -d:targetLang=rust \
## # -d:ffiOutputDir=examples/timer/rust_bindings \
## # -d:ffiNimSrcRelPath=../timer.nim mylib.nim
## # -d:ffiSrcPath=../timer.nim mylib.nim
when defined(ffiGenBindings):
if outputDir.len == 0:
@ -1421,7 +1421,13 @@ macro genBindings*(
generateCppBindings(
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath
)
of "cddl":
generateCddlBindings(
ffiProcRegistry, ffiTypeRegistry, libName, outputDir, nimSrcRelPath
)
else:
error("genBindings: unknown targetLang '" & lang & "'. Use 'rust' or 'cpp'.")
error(
"genBindings: unknown targetLang '" & lang & "'. Use 'rust', 'cpp', or 'cddl'."
)
return newEmptyNode()

125
tests/test_cddl_codegen.nim Normal file
View File

@ -0,0 +1,125 @@
## Unit-tests for the CDDL schema generator. Drives it directly against a
## synthetic `ffiProcRegistry` / `ffiTypeRegistry` so we don't need to invoke
## the macro pipeline (and thus don't write any files).
import std/strutils
import unittest2
import ../ffi/codegen/[meta, cddl]
proc fieldsOf(pairs: openArray[(string, string)]): seq[FFIFieldMeta] =
var res = @[]
for p in pairs:
res.add(FFIFieldMeta(name: p[0], typeName: p[1]))
return res
proc paramsOf(triples: openArray[(string, string, bool)]): seq[FFIParamMeta] =
var res = @[]
for t in triples:
res.add(FFIParamMeta(name: t[0], typeName: t[1], isPtr: t[2]))
return res
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 "nimTypeToCddl primitive mapping":
test "primitives map to CDDL builtins":
check nimTypeToCddl("bool") == "bool"
check nimTypeToCddl("int") == "int"
check nimTypeToCddl("int32") == "int"
check nimTypeToCddl("uint64") == "uint"
check nimTypeToCddl("float64") == "float64"
check nimTypeToCddl("string") == "tstr"
check nimTypeToCddl("cstring") == "tstr"
check nimTypeToCddl("pointer") == "uint"
test "pointer types map to uint":
check nimTypeToCddl("ptr Foo") == "uint"
test "seq[T] becomes [* T]":
check nimTypeToCddl("seq[int]") == "[* int]"
check nimTypeToCddl("seq[string]") == "[* tstr]"
test "Option[T] becomes T / nil":
check nimTypeToCddl("Option[int]") == "int / nil"
check nimTypeToCddl("Maybe[string]") == "tstr / nil"
test "nested generics":
check nimTypeToCddl("seq[Option[int]]") == "[* int / nil]"
test "unknown PascalCase is passed through as a rule reference":
check nimTypeToCddl("EchoRequest") == "EchoRequest"
suite "generateCddlSchema":
setup:
let types = @[
FFITypeMeta(
name: "EchoRequest",
fields: @[field("message", "string"), field("delayMs", "int")],
),
FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]),
]
let procs = @[
FFIProcMeta(
procName: "nimtimer_create",
libName: "nimtimer",
kind: ffiCtorKind,
libTypeName: "NimTimer",
extraParams: @[param("config", "TimerConfig")],
returnTypeName: "NimTimer",
returnIsPtr: false,
isAsync: true,
),
FFIProcMeta(
procName: "nimtimer_echo",
libName: "nimtimer",
kind: ffiFfiKind,
libTypeName: "NimTimer",
extraParams: @[param("req", "EchoRequest")],
returnTypeName: "EchoResponse",
returnIsPtr: false,
isAsync: true,
),
FFIProcMeta(
procName: "nimtimer_destroy",
libName: "nimtimer",
kind: ffiDtorKind,
libTypeName: "NimTimer",
extraParams: @[],
returnTypeName: "",
returnIsPtr: false,
isAsync: false,
),
]
let cddl = generateCddlSchema(procs, types, "nimtimer", "../nim_timer.nim")
test "header references the source file":
check "../nim_timer.nim" in cddl
check "CBOR (RFC 8949)" in cddl
test "user-declared types become CDDL map rules":
check "EchoRequest = { message: tstr, delayMs: int }" in cddl
check "EchoResponse = { echoed: tstr }" in cddl
test "per-proc request envelopes are emitted":
check "NimtimerCreateCtorReq = { config: TimerConfig }" in cddl
check "NimtimerEchoReq = { req: EchoRequest }" in cddl
test "proc request/response rules":
check "nimtimer_create-request = NimtimerCreateCtorReq" in cddl
check "nimtimer_create-response = tstr" in cddl
check "nimtimer_echo-request = NimtimerEchoReq" in cddl
check "nimtimer_echo-response = EchoResponse" in cddl
test "dtor has no request envelope and a nil response":
check "nimtimer_destroy-request" notin cddl
check "nimtimer_destroy-response = nil" in cddl
test "kind tags appear in proc comments":
check "; nimtimer_create (ctor)" in cddl
check "; nimtimer_echo (async)" in cddl
check "; nimtimer_destroy (dtor)" in cddl