From 7ef58f4bf5cf683cfdf2abbdb3d7688727763d65 Mon Sep 17 00:00:00 2001 From: Gabriel Cruz <8129788+gmelodie@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:13:06 -0300 Subject: [PATCH] feat: shrink the genbindings incantation (#119) --- README.md | 24 ++++----- examples/timer/timer.nimble | 22 ++++---- ffi.nimble | 86 ++++++++++++------------------ ffi/codegen/meta.nim | 13 +++-- ffi/internal/ffi_macro.nim | 102 +++++++++++++++++++++++------------- 5 files changed, 135 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index bbe93ae..87e4cf3 100644 --- a/README.md +++ b/README.md @@ -196,26 +196,26 @@ library and nothing else. nim c --app:lib --noMain --nimMainPrefix:libmylib mylib.nim ``` -**2. Emit the foreign bindings** — same flags, plus the binding defines. This -compile runs the generators as a compile-time side effect and produces no -runnable output, so send the binary to `/dev/null`. The generated files (for -`targetLang=c`/`c_abi`: the `.h` header your host includes, plus a -`CMakeLists.txt`) land in `-d:ffiOutputDir`: +**2. Emit the foreign bindings** — add the binding defines and `--compileOnly`, +which stops after codegen: the binding files are written during macro expansion, +so there's no library to link (no `--app:lib`/`-o:/dev/null` needed). The +generated files (for `targetLang=c`/`c_abi`: the `.h` header your host +includes, plus a `CMakeLists.txt`) land in `-d:ffiOutputDir`: ```sh -nim c --app:lib --noMain --nimMainPrefix:libmylib \ - -d:ffiGenBindings -d:targetLang=c \ - -d:ffiOutputDir=path/to/output -d:ffiSrcPath=../mylib.nim \ - -o:/dev/null mylib.nim +nim c -d:ffiGenBindings -d:targetLang=rust,cpp,c --compileOnly mylib.nim ``` -- `-d:targetLang` — which generator runs. Two kinds: +- `-d:targetLang` — which generator(s) run; pass a comma-separated list to emit + several from one compile. Two kinds: - **Language bindings over the CBOR wire:** `rust` (default), `cpp`, `c`. - **Non-peer generators:** `c_abi` — C bindings that speak the flat `abi = c` wire instead of CBOR; `cddl` — a CDDL schema of the CBOR wire, not a language binding at all. -- `-d:ffiOutputDir` — where the generated files land. -- `-d:ffiSrcPath` — the Nim source path embedded in the generated build files. +- `-d:ffiOutputDir` — override where the generated files land. Defaults to + `_bindings/` next to the compiled source. +- `-d:ffiSrcPath` — override the Nim source path embedded in the generated build + files. Defaults to the compiled source made relative to the output dir. ### The `--nimMainPrefix:lib` rule diff --git a/examples/timer/timer.nimble b/examples/timer/timer.nimble index 9b78c90..149043a 100644 --- a/examples/timer/timer.nimble +++ b/examples/timer/timer.nimble @@ -12,20 +12,24 @@ requires "https://github.com/logos-messaging/nim-ffi >= 0.2.0" const nimFlags = "--mm:orc -d:chronicles_log_level=WARN" +proc genBindingsCmd(langs: string): string = + ## One `nim c` that emits `langs` (comma-separated) from timer.nim, each into + ## `_bindings/`. `--compileOnly` is enough — the files are written during + ## macro expansion, nothing is linked. + "nim c " & nimFlags & " -d:ffiGenBindings -d:targetLang=" & langs & + " --compileOnly timer.nim" + task build, "Compile the timer library": exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libmy_timer timer.nim" +task genbindings, "Generate Rust, C++ and C bindings for the timer example": + exec genBindingsCmd("rust,cpp,c") + task genbindings_rust, "Generate Rust bindings for the timer example": - exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=rust" & " -d:ffiOutputDir=rust_bindings" & - " -d:ffiSrcPath=timer.nim" & " -o:/dev/null timer.nim" + exec genBindingsCmd("rust") task genbindings_cpp, "Generate C++ bindings for the timer example": - exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=cpp" & " -d:ffiOutputDir=cpp_bindings" & - " -d:ffiSrcPath=timer.nim" & " -o:/dev/null timer.nim" + exec genBindingsCmd("cpp") task genbindings_c, "Generate C bindings for the timer example": - exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=c_bindings" & - " -d:ffiSrcPath=timer.nim" & " -o:/dev/null timer.nim" + exec genBindingsCmd("c") diff --git a/ffi.nimble b/ffi.nimble index 7e94dfc..50df29e 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -16,6 +16,9 @@ requires "cbor_serialization == 0.3.0" const nimFlagsOrc = "--mm:orc -d:chronicles_log_level=WARN" const nimFlagsRefc = "--mm:refc -d:chronicles_log_level=WARN" +const timerSrc = "examples/timer/timer.nim" +const echoSrc = "examples/echo/echo.nim" + import std/[algorithm, os, strutils] proc discoverUnitTests(): seq[string] = @@ -93,6 +96,18 @@ proc applyTsanSuppressions() = elif "suppressions=" notin existing: putEnv("TSAN_OPTIONS", existing & ":suppressions=" & suppPath) +proc genBindingsCmd(flags, src: string, langs = "rust", outDir = ""): string = + ## One `nim c` that emits `langs` (comma-separated) from `src`. Output dir and + ## embedded source path default to `_bindings/` next to `src`; `outDir` + ## overrides every language. `--compileOnly` is enough because the binding + ## files are written during macro expansion — nothing is linked. + var cmd = + "nim c " & flags & " -d:ffiGenBindings -d:targetLang=" & langs & " --compileOnly" + if outDir.len > 0: + cmd.add " -d:ffiOutputDir=" & outDir + cmd.add " " & src + cmd + proc removeStaleEchoLib() = ## The CBOR and `abi = c` echo e2e suites both compile examples/echo/echo.nim ## to the same repo-root `libecho.so`, differing only by `-d:ffiEchoAbiC`. @@ -210,75 +225,42 @@ task test_c_abi_e2e_sanitized, runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug" task genbindings_example, "Generate Rust bindings for the timer example": - exec "nim c " & nimFlagsOrc & - " --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiGenBindings -o:/dev/null examples/timer/timer.nim" - exec "nim c " & nimFlagsRefc & - " --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiGenBindings -o:/dev/null examples/timer/timer.nim" + exec genBindingsCmd(nimFlagsOrc, timerSrc) + exec genBindingsCmd(nimFlagsRefc, timerSrc) task genbindings_rust, "Generate Rust bindings for the timer example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=rust" & - " -d:ffiOutputDir=examples/timer/rust_bindings" & " -d:ffiSrcPath=../timer.nim" & - " -o:/dev/null examples/timer/timer.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=rust" & - " -d:ffiOutputDir=examples/timer/rust_bindings" & " -d:ffiSrcPath=../timer.nim" & - " -o:/dev/null examples/timer/timer.nim" + exec genBindingsCmd(nimFlagsOrc, timerSrc, "rust") + exec genBindingsCmd(nimFlagsRefc, timerSrc, "rust") task genbindings_cddl, "Generate CDDL schema for the timer example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=cddl" & - " -d:ffiOutputDir=examples/timer/cddl_bindings" & " -d:ffiSrcPath=../timer.nim" & - " -o:/dev/null examples/timer/timer.nim" + exec genBindingsCmd(nimFlagsOrc, timerSrc, "cddl") task genbindings_cpp, "Generate C++ bindings for the timer example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=cpp" & - " -d:ffiOutputDir=examples/timer/cpp_bindings" & " -d:ffiSrcPath=../timer.nim" & - " -o:/dev/null examples/timer/timer.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=cpp" & - " -d:ffiOutputDir=examples/timer/cpp_bindings" & " -d:ffiSrcPath=../timer.nim" & - " -o:/dev/null examples/timer/timer.nim" + exec genBindingsCmd(nimFlagsOrc, timerSrc, "cpp") + exec genBindingsCmd(nimFlagsRefc, timerSrc, "cpp") task genbindings_cpp_echo, "Generate C++ bindings for the echo example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiGenBindings -d:targetLang=cpp" & - " -d:ffiOutputDir=examples/echo/cpp_bindings" & " -d:ffiSrcPath=../echo.nim" & - " -o:/dev/null examples/echo/echo.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiGenBindings -d:targetLang=cpp" & - " -d:ffiOutputDir=examples/echo/cpp_bindings" & " -d:ffiSrcPath=../echo.nim" & - " -o:/dev/null examples/echo/echo.nim" + exec genBindingsCmd(nimFlagsOrc, echoSrc, "cpp") + exec genBindingsCmd(nimFlagsRefc, echoSrc, "cpp") task genbindings_c, "Generate C bindings for the timer example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/timer/c_bindings" & - " -d:ffiSrcPath=../timer.nim" & " -o:/dev/null examples/timer/timer.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libmy_timer" & - " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/timer/c_bindings" & - " -d:ffiSrcPath=../timer.nim" & " -o:/dev/null examples/timer/timer.nim" + exec genBindingsCmd(nimFlagsOrc, timerSrc, "c") + exec genBindingsCmd(nimFlagsRefc, timerSrc, "c") task genbindings_c_echo, "Generate C bindings for the echo example": - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/echo/c_bindings" & - " -d:ffiSrcPath=../echo.nim" & " -o:/dev/null examples/echo/echo.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/echo/c_bindings" & - " -d:ffiSrcPath=../echo.nim" & " -o:/dev/null examples/echo/echo.nim" + exec genBindingsCmd(nimFlagsOrc, echoSrc, "c") + exec genBindingsCmd(nimFlagsRefc, echoSrc, "c") task genbindings_c_abi_echo, "Generate CBOR-free abi=c C bindings for the echo example": # echoVersion is all-scalar under the abi=c default, so it has no foreign # codegen yet and is omitted from the bindings; -d:ffiAllowScalarSkip accepts # that omission instead of failing the build (see genBindings()). - exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi -d:ffiAllowScalarSkip" & - " -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" & - " -o:/dev/null examples/echo/echo.nim" - exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" & - " -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi -d:ffiAllowScalarSkip" & - " -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" & - " -o:/dev/null examples/echo/echo.nim" + exec genBindingsCmd( + nimFlagsOrc & " -d:ffiEchoAbiC -d:ffiAllowScalarSkip", echoSrc, "c_abi" + ) + exec genBindingsCmd( + nimFlagsRefc & " -d:ffiEchoAbiC -d:ffiAllowScalarSkip", echoSrc, "c_abi" + ) task check_bindings_rust, "Verify checked-in Rust bindings match Nim source": runOrQuit "nimble genbindings_rust" diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 4706915..15eeb14 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -163,14 +163,19 @@ proc returnRidesAsPtr*(p: FFIProcMeta): bool = ## True if the return crosses the wire as an opaque uint64 (raw `ptr` or handle). p.returnIsPtr or p.returnIsHandle -# Target language for binding generation; override with -d:targetLang=cpp +# Target language(s) for binding generation; override with -d:targetLang=cpp. +# Accepts a comma-separated list (e.g. -d:targetLang=rust,cpp,c) to emit +# several languages from a single compile. const targetLang* {.strdefine.} = "rust" -# Output directory for generated bindings; set with -d:ffiOutputDir=path/to/dir +# Output directory override for generated bindings; set with +# -d:ffiOutputDir=path/to/dir. Empty (the default) derives `_bindings/` +# next to the compiled source. const ffiOutputDir* {.strdefine.} = "" -# Nim source path (relative to outputDir) embedded in generated build files; -# set with -d:ffiSrcPath=../relative/path.nim +# Nim source path override (relative to outputDir) embedded in generated build +# files; set with -d:ffiSrcPath=../relative/path.nim. Empty (the default) +# derives it from the compiled source relative to the output dir. const ffiSrcPath* {.strdefine.} = "" # When set to true, scalar-only `abi = c` procs (which have no foreign-binding codegen diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index eee8ab2..b0b9cf5 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -1,4 +1,6 @@ import std/[macros, tables, strutils] +from std/os import `/`, relativePath +from std/compilesettings import querySetting, SingleValueSetting import chronos import ../ffi_types import ../ffi_thread_request @@ -1867,6 +1869,55 @@ proc reportScalarFastPathDrops(procs: seq[FFIProcMeta]) {.compileTime.} = "CBOR wire shape, or\n" & " - pass -d:ffiAllowScalarSkip to accept the omission." ) +proc bindingsOutputDir(lang, explicit: string): string {.compileTime.} = + ## Output dir for `lang`. Defaults to `_bindings/` next to the source + ## file being compiled (`querySetting(projectPath)`); an explicit + ## -d:ffiOutputDir override wins. + if explicit.len > 0: + explicit + else: + return querySetting(SingleValueSetting.projectPath) / (lang & "_bindings") + +proc bindingsSrcPath(outDir, explicit: string): string {.compileTime.} = + ## Nim source path embedded in generated build files, expressed relative to + ## the output dir. Defaults to the compiled file (`querySetting(projectFull)`) + ## made relative to `outDir`; an explicit -d:ffiSrcPath override wins. + if explicit.len > 0: + explicit + else: + relativePath(querySetting(SingleValueSetting.projectFull), outDir) + +when defined(ffiGenBindings): + proc emitBindingsFor( + lang: string, genProcs: seq[FFIProcMeta], libName, outDir, srcRel: string + ) {.compileTime.} = + ## Route one language token to its generator; unknown tokens are a compile + ## error listing the valid set. + case lang + of "rust": + generateRustCrate( + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + ) + of "cpp", "c++": + generateCppBindings( + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + ) + of "c": + generateCBindings( + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + ) + of "c_abi": + generateCAbiBindings( + genProcs, ffiTypeRegistry, libName, outDir, srcRel, ffiEventRegistry + ) + of "cddl": + generateCddlBindings(genProcs, ffiTypeRegistry, libName, outDir, srcRel) + else: + error( + "genBindings: unknown targetLang '" & lang & + "'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'." + ) + macro genBindings*( outputDir: static[string] = ffiOutputDir, nimSrcRelPath: static[string] = ffiSrcPath ): untyped = @@ -1883,54 +1934,35 @@ macro genBindings*( ## In a multi-file library, import all sub-modules first and call ## genBindings() once at the bottom of the top-level compilation-root file. ## - ## Supported languages (-d:targetLang): "rust" (default), "cpp", "c", "cddl". - ## Output path and nim source path default to -d:ffiOutputDir and - ## -d:ffiSrcPath, or can be passed as explicit arguments. + ## Supported languages (-d:targetLang): "rust" (default), "cpp", "c", + ## "c_abi", "cddl". Pass a comma-separated list to emit several at once from + ## a single compile — the backend dispatch loops over each language. + ## + ## Output dir defaults to `_bindings/` next to the compiled source; the + ## embedded nim source path is derived by making that source relative to the + ## output dir. Both can be overridden with -d:ffiOutputDir / -d:ffiSrcPath + ## (or the explicit arguments) — an override applies to every language. ## Foreign-binding file emission is a no-op unless -d:ffiGenBindings is set; ## the `abi = c` `_CWire` companions are emitted unconditionally (runtime ## code, not generated files). ## ## Example (all via compile flags): ## genBindings() - ## # nim c -d:ffiGenBindings -d:targetLang=rust \ - ## # -d:ffiOutputDir=examples/timer/rust_bindings \ - ## # -d:ffiSrcPath=../timer.nim mylib.nim + ## # nim c -d:ffiGenBindings -d:targetLang=rust,cpp,c mylib.nim genBindingsEmitted = true when defined(ffiGenBindings): - if outputDir.len == 0: - error( - "genBindings: output directory is empty." & - " Pass it as an argument or set -d:ffiOutputDir=path/to/output" - ) - let lang = string_helpers.toLower(targetLang) let libName = deriveLibName(ffiProcRegistry) let genProcs = bindableProcs(ffiProcRegistry) reportScalarFastPathDrops(ffiProcRegistry) - case lang - of "rust": - generateRustCrate( - genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry - ) - of "cpp", "c++": - generateCppBindings( - genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry - ) - of "c": - generateCBindings( - genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry - ) - of "c_abi": - generateCAbiBindings( - genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry - ) - of "cddl": - generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath) - else: - error( - "genBindings: unknown targetLang '" & lang & - "'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'." + for rawLang in targetLang.split(','): + let lang = string_helpers.toLower(rawLang.strip()) + if lang.len == 0: + continue + let outDir = bindingsOutputDir(lang, outputDir) + emitBindingsFor( + lang, genProcs, libName, outDir, bindingsSrcPath(outDir, nimSrcRelPath) ) let emitted = flushCWireCompanions()