From e12745e85ca2c821ed64d712f393d70b27b27ffa Mon Sep 17 00:00:00 2001 From: Ivan FB <128452529+Ivansete-status@users.noreply.github.com> Date: Mon, 18 May 2026 20:00:57 +0200 Subject: [PATCH] Add cddl generator (#24) --- examples/timer/cddl_bindings/timer.cddl | 45 ++++++ ffi.nimble | 9 ++ ffi/codegen/cddl.nim | 178 ++++++++++++++++++++++++ ffi/codegen/meta.nim | 4 +- ffi/internal/ffi_macro.nim | 16 ++- tests/test_cddl_codegen.nim | 125 +++++++++++++++++ 6 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 examples/timer/cddl_bindings/timer.cddl create mode 100644 ffi/codegen/cddl.nim create mode 100644 tests/test_cddl_codegen.nim diff --git a/examples/timer/cddl_bindings/timer.cddl b/examples/timer/cddl_bindings/timer.cddl new file mode 100644 index 0000000..cf5517c --- /dev/null +++ b/examples/timer/cddl_bindings/timer.cddl @@ -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 diff --git a/ffi.nimble b/ffi.nimble index 3a47b35..753811f 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -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" & diff --git a/ffi/codegen/cddl.nim b/ffi/codegen/cddl.nim new file mode 100644 index 0000000..d583a1e --- /dev/null +++ b/ffi/codegen/cddl.nim @@ -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: {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), + ) diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 9fb9f89..736af55 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -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.} = "" diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 91ba21d..2d22422 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -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() diff --git a/tests/test_cddl_codegen.nim b/tests/test_cddl_codegen.nim new file mode 100644 index 0000000..32fba20 --- /dev/null +++ b/tests/test_cddl_codegen.nim @@ -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