mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-06-20 08:19:55 +00:00
Add cddl generator (#24)
This commit is contained in:
parent
ac303a707e
commit
e12745e85c
45
examples/timer/cddl_bindings/timer.cddl
Normal file
45
examples/timer/cddl_bindings/timer.cddl
Normal 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
|
||||
@ -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
178
ffi/codegen/cddl.nim
Normal 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),
|
||||
)
|
||||
@ -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.} = ""
|
||||
|
||||
@ -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
125
tests/test_cddl_codegen.nim
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user