From c58e2bcdd296c6a8b9df38f5d90dda9d7802332c Mon Sep 17 00:00:00 2001 From: Gabriel Cruz <8129788+gmelodie@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:04:35 -0300 Subject: [PATCH] feat: propagate comments (#136) --- CHANGELOG.md | 8 + README.md | 22 +++ examples/echo/c_abi_bindings/echo.h | 8 + examples/echo/c_bindings/echo.h | 8 + examples/echo/cpp_bindings/echo.hpp | 10 ++ examples/echo/echo.nim | 4 + examples/timer/c_bindings/my_timer.h | 11 ++ examples/timer/cddl_bindings/my_timer.cddl | 5 + examples/timer/cpp_bindings/my_timer.hpp | 14 ++ examples/timer/rust_bindings/src/api.rs | 9 + examples/timer/rust_bindings/src/ffi.rs | 5 + examples/timer/timer.nim | 8 +- ffi/codegen/c.nim | 24 ++- ffi/codegen/c_cpp_common.nim | 8 +- ffi/codegen/cddl.nim | 3 +- ffi/codegen/cpp.nim | 6 + ffi/codegen/meta.nim | 2 + ffi/codegen/rust.nim | 6 + ffi/codegen/string_helpers.nim | 43 ++++- ffi/internal/ffi_macro.nim | 14 ++ tests/unit/test_doc_extraction.nim | 98 +++++++++++ tests/unit/test_doc_propagation.nim | 195 +++++++++++++++++++++ tests/unit/test_string_helpers.nim | 50 ++++++ 23 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_doc_extraction.nim create mode 100644 tests/unit/test_doc_propagation.nim diff --git a/CHANGELOG.md b/CHANGELOG.md index 24d0abc..ed79344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,14 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### Added +- Doc comments (`##`) on `{.ffi.}` / `{.ffiCtor.}` / `{.ffiDtor.}` procs are now + propagated to the generated bindings — `/** ... */` on the C declarations, + `///` on the C++ class methods and Rust `pub fn`s, and `;` comments in the + CDDL schema — so the exported API is documented once, in the Nim source + ([#127](https://github.com/logos-messaging/nim-ffi/issues/127)). Editing a + `##` comment now changes the generated bindings, so `nimble check_bindings` + flags them stale until regenerated; an undocumented proc still generates + byte-identical output. - `{.ffiEvent.}` no longer requires an explicit wire-name string: when omitted it is derived from the proc name via `camelToSnakeCase` (`onPeerConnected` → `on_peer_connected`), matching how `{.ffi.}` derives its diff --git a/README.md b/README.md index 00637cd..5451f8b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ The generated C export names are the snake_case form of the proc names, e.g. | `{.ffiHandle.}` | `ref object` | Marks a type as an opaque handle: it stays server-side and crosses the wire as a `uint64` id. | | `genBindings()` | call | Emits the bindings. Must be the **last** FFI call in the compilation root. | +### Doc comments + +A `##` doc comment on an annotated proc is carried through to every generated +binding, so the exported API is documented once, at the source: + +```nim +proc myTimerEcho*( + timer: MyTimer, req: EchoRequest +): Future[Result[EchoResponse, string]] {.ffi.} = + ## Sleeps `delayMs` then echoes the message back. + ... +``` + +becomes ``/** Sleeps `delayMs` then echoes the message back. */`` above both the +exported symbol and the `_ctx_echo` wrapper in the C header, `/// ...` on +the C++ class method and the Rust `pub fn`, and a `;` comment in the CDDL +schema. Multi-line doc comments are preserved as-is. + +Only `##` doc comments are propagated, and only on procs — a plain `#` comment +above the proc, and any comment on a `{.ffi.}` type or its fields, is dropped by +Nim's parser before the macro can see it. + ### The return-type contract Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit diff --git a/examples/echo/c_abi_bindings/echo.h b/examples/echo/c_abi_bindings/echo.h index c49b507..db044d3 100644 --- a/examples/echo/c_abi_bindings/echo.h +++ b/examples/echo/c_abi_bindings/echo.h @@ -58,9 +58,13 @@ static inline char* nimffi_abi_dup_cstr_n(const char* s, size_t n) { extern "C" { #endif +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req); +/** Returns the library's version string. */ int echo_version(void* ctx, EchoScalarRawFn callback, void* user_data); +/** Releases the echo context. */ int echo_destroy(void* ctx); #ifdef __cplusplus @@ -103,6 +107,7 @@ static void echo_create_trampoline(int ret, const char* ctx_addr, const char* er free(box); } +/** Creates an echo context that prefixes every reply with `config.prefix`. */ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) { EchoCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -118,6 +123,7 @@ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_crea return 0; } +/** Releases the echo context. */ static inline int echo_ctx_destroy(EchoCtx* ctx) { if (!ctx) return NIMFFI_RET_OK; int rc = NIMFFI_RET_OK; @@ -126,6 +132,7 @@ static inline int echo_ctx_destroy(EchoCtx* ctx) { return rc; } +/** Upper-cases `req.text` and returns it behind the context's prefix. */ static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) { EchoShoutReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -156,6 +163,7 @@ static void echo_version_scalar_reply(int caller_ret, char* msg, size_t len, voi free(reply); } +/** Returns the library's version string. */ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_reply, void* user_data) { EchoVersionScalarBox* box = (EchoVersionScalarBox*)malloc(sizeof(EchoVersionScalarBox)); if (!box) { diff --git a/examples/echo/c_bindings/echo.h b/examples/echo/c_bindings/echo.h index 13e8021..c222bf4 100644 --- a/examples/echo/c_bindings/echo.h +++ b/examples/echo/c_bindings/echo.h @@ -193,9 +193,13 @@ static inline CborError echo_dec_EchoVersionReq( extern "C" { #endif +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int echo_remove_event_listener(void* ctx, uint64_t listener_id); @@ -264,6 +268,7 @@ static void echo_create_trampoline(int ret, const char* msg, size_t len, void* u free(box); } +/** Creates an echo context that prefixes every reply with `config.prefix`. */ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) { EchoCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -289,6 +294,7 @@ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_crea return 0; } +/** Releases the echo context. */ static inline int echo_ctx_destroy(EchoCtx* ctx) { if (!ctx) return NIMFFI_RET_OK; int rc = NIMFFI_RET_OK; @@ -329,6 +335,7 @@ static void echo_shout_reply_trampoline(int ret, const char* msg, size_t len, vo echo_free_ShoutResponse(&out); free(box); } +/** Upper-cases `req.text` and returns it behind the context's prefix. */ static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) { EchoShoutReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -391,6 +398,7 @@ static void echo_version_reply_trampoline(int ret, const char* msg, size_t len, nimffi_free_str(&out); free(box); } +/** Returns the library's version string. */ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_reply, void* user_data) { EchoVersionReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index 4c47cf3..c17a244 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -431,9 +431,13 @@ inline CborError decode_cbor(CborValue& it, EchoVersionReq&) { extern "C" { typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data); +/** Creates an echo context that prefixes every reply with `config.prefix`. */ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Upper-cases `req.text` and returns it behind the context's prefix. */ int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int echo_remove_event_listener(void* ctx, uint64_t listener_id); @@ -512,6 +516,7 @@ inline Result> ffi_call_( class EchoCtx { public: + /// Creates an echo context that prefixes every reply with `config.prefix`. static Result> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = EchoCreateCtorReq{config}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -535,6 +540,7 @@ public: return Result>::ok(std::unique_ptr(new EchoCtx(reinterpret_cast(static_cast(addr)), timeout))); } + /// Creates an echo context that prefixes every reply with `config.prefix`. static std::future>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -560,6 +566,7 @@ public: EchoCtx(EchoCtx&&) = delete; EchoCtx& operator=(EchoCtx&&) = delete; + /// Upper-cases `req.text` and returns it behind the context's prefix. Result shout(const ShoutRequest& req) const { const auto ffi_req_ = EchoShoutReq{req}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -572,10 +579,12 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Upper-cases `req.text` and returns it behind the context's prefix. std::future> shoutAsync(const ShoutRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->shout(req); }); } + /// Returns the library's version string. Result version() const { const auto ffi_req_ = EchoVersionReq{}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -588,6 +597,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Returns the library's version string. std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim index cb689f1..5471252 100644 --- a/examples/echo/echo.nim +++ b/examples/echo/echo.nim @@ -23,20 +23,24 @@ type ShoutResponse {.ffi.} = object prefix: string proc echoCreate*(config: EchoConfig): Future[Result[Echo, string]] {.ffiCtor.} = + ## Creates an echo context that prefixes every reply with `config.prefix`. await sleepAsync(1.milliseconds) return ok(Echo(prefix: config.prefix)) proc echoShout*( e: Echo, req: ShoutRequest ): Future[Result[ShoutResponse, string]] {.ffi.} = + ## Upper-cases `req.text` and returns it behind the context's prefix. await sleepAsync(1.milliseconds) let upper = req.text.toUpperAscii return ok(ShoutResponse(shouted: e.prefix & ": " & upper, prefix: e.prefix)) proc echoVersion*(e: Echo): Future[Result[string, string]] {.ffi.} = + ## Returns the library's version string. return ok("nim-echo v0.1.0") proc echo_destroy*(e: Echo) {.ffiDtor.} = + ## Releases the echo context. discard genBindings() diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index 38d45df..09d996a 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -766,11 +766,16 @@ static inline void my_timer_free_MyTimerScheduleReq(MyTimerScheduleReq* v) { extern "C" { #endif +/** Creates the FFIContext + MyTimer; async via chronos. */ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ int my_timer_destroy(void* ctx); uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int my_timer_remove_event_listener(void* ctx, uint64_t listener_id); @@ -871,6 +876,7 @@ static void my_timer_create_trampoline(int ret, const char* msg, size_t len, voi free(box); } +/** Creates the FFIContext + MyTimer; async via chronos. */ static inline int my_timer_ctx_create(const TimerConfig* config, MyTimerCreateFn on_created, void* user_data) { MyTimerCreateCtorReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -896,6 +902,7 @@ static inline int my_timer_ctx_create(const TimerConfig* config, MyTimerCreateFn return 0; } +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ static inline int my_timer_ctx_destroy(MyTimerCtx* ctx) { if (!ctx) return NIMFFI_RET_OK; int rc = NIMFFI_RET_OK; @@ -908,6 +915,7 @@ static inline int my_timer_ctx_destroy(MyTimerCtx* ctx) { return rc; } +/** Fired by `myTimerEcho` once the reply is ready. */ static inline uint64_t my_timer_ctx_add_on_echo_fired_listener(MyTimerCtx* ctx, MyTimerOnEchoFiredFn fn, void* user_data) { MyTimerOnEchoFiredBox* box = (MyTimerOnEchoFiredBox*)malloc(sizeof(MyTimerOnEchoFiredBox)); if (!box) return 0; @@ -974,6 +982,7 @@ static void my_timer_echo_reply_trampoline(int ret, const char* msg, size_t len, my_timer_free_EchoResponse(&out); free(box); } +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ static inline int my_timer_ctx_echo(const MyTimerCtx* ctx, const EchoRequest* req, MyTimerEchoReplyFn on_reply, void* user_data) { MyTimerEchoReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -1036,6 +1045,7 @@ static void my_timer_version_reply_trampoline(int ret, const char* msg, size_t l nimffi_free_str(&out); free(box); } +/** Returns the library's version string. */ static inline int my_timer_ctx_version(const MyTimerCtx* ctx, MyTimerVersionReplyFn on_reply, void* user_data) { MyTimerVersionReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); @@ -1159,6 +1169,7 @@ static void my_timer_schedule_reply_trampoline(int ret, const char* msg, size_t my_timer_free_ScheduleResult(&out); free(box); } +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ static inline int my_timer_ctx_schedule(const MyTimerCtx* ctx, const JobSpec* job, const RetryPolicy* retry, const ScheduleConfig* schedule, MyTimerScheduleReplyFn on_reply, void* user_data) { MyTimerScheduleReq ffi_req; memset(&ffi_req, 0, sizeof(ffi_req)); diff --git a/examples/timer/cddl_bindings/my_timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl index 295441f..3497664 100644 --- a/examples/timer/cddl_bindings/my_timer.cddl +++ b/examples/timer/cddl_bindings/my_timer.cddl @@ -23,14 +23,17 @@ MyTimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfi ; ─── Procs ───────────────────────────────────────────────────────── ; my_timer_create (ctor) +; Creates the FFIContext + MyTimer; async via chronos. my_timer_create-request = MyTimerCreateCtorReq my_timer_create-response = tstr ; my_timer_echo (ffi) +; Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. my_timer_echo-request = MyTimerEchoReq my_timer_echo-response = EchoResponse ; my_timer_version (ffi) +; Returns the library's version string. my_timer_version-request = MyTimerVersionReq my_timer_version-response = tstr @@ -39,8 +42,10 @@ my_timer_complex-request = MyTimerComplexReq my_timer_complex-response = ComplexResponse ; my_timer_schedule (ffi) +; Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. my_timer_schedule-request = MyTimerScheduleReq my_timer_schedule-response = ScheduleResult ; my_timer_destroy (dtor) +; Tears down the FFI context; blocks until FFI + watchdog threads join. my_timer_destroy-response = nil diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 9190cfb..6c89b02 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -729,11 +729,16 @@ inline CborError decode_cbor(CborValue& it, MyTimerScheduleReq& v) { extern "C" { typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data); +/** Creates the FFIContext + MyTimer; async via chronos. */ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data); +/** Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. */ int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +/** Tears down the FFI context; blocks until FFI + watchdog threads join. */ int my_timer_destroy(void* ctx); uint64_t my_timer_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); int my_timer_remove_event_listener(void* ctx, uint64_t listener_id); @@ -812,6 +817,7 @@ inline Result> ffi_call_( class MyTimerCtx { public: + /// Creates the FFIContext + MyTimer; async via chronos. static Result> create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = MyTimerCreateCtorReq{config}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -835,6 +841,7 @@ public: return Result>::ok(std::unique_ptr(new MyTimerCtx(reinterpret_cast(static_cast(addr)), timeout))); } + /// Creates the FFIContext + MyTimer; async via chronos. static std::future>> createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -863,6 +870,7 @@ public: // ── Event listener API ────────────────────────────────── struct ListenerHandle { std::uint64_t id = 0; }; + /// Fired by `myTimerEcho` once the reply is ready. ListenerHandle addOnEchoFiredListener(std::function handler) { auto owned = std::make_unique>(std::move(handler)); auto* raw = owned.get(); @@ -880,6 +888,7 @@ public: return rc == 0; } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. Result echo(const EchoRequest& req) const { const auto ffi_req_ = MyTimerEchoReq{req}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -892,10 +901,12 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. std::future> echoAsync(const EchoRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->echo(req); }); } + /// Returns the library's version string. Result version() const { const auto ffi_req_ = MyTimerVersionReq{}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -908,6 +919,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Returns the library's version string. std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } @@ -928,6 +940,7 @@ public: return std::async(std::launch::async, [this, req]() { return this->complex(req); }); } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. Result schedule(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { const auto ffi_req_ = MyTimerScheduleReq{job, retry, schedule}; auto ffi_enc_ = encodeCborFFI(ffi_req_); @@ -940,6 +953,7 @@ public: return decodeCborFFI(ffi_raw_.value()); } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. std::future> scheduleAsync(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { return std::async(std::launch::async, [this, job, retry, schedule]() { return this->schedule(job, retry, schedule); }); } diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index 829113b..fb05b7c 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -159,6 +159,7 @@ impl Drop for MyTimerCtx { } impl MyTimerCtx { + /// Creates the FFIContext + MyTimer; async via chronos. pub fn create(config: TimerConfig, timeout: Duration) -> Result { let req = MyTimerCreateCtorReq { config }; let req_bytes = encode_cbor(&req)?; @@ -171,6 +172,7 @@ impl MyTimerCtx { Ok(Self { ptr: addr as *mut c_void, timeout, listeners: std::sync::Mutex::new(std::collections::HashMap::new()) }) } + /// Creates the FFIContext + MyTimer; async via chronos. pub async fn new_async(config: TimerConfig, timeout: Duration) -> Result { let req = MyTimerCreateCtorReq { config }; let req_bytes = encode_cbor(&req)?; @@ -199,6 +201,7 @@ impl MyTimerCtx { ListenerHandle { id } } + /// Fired by `myTimerEcho` once the reply is ready. /// Register a typed listener for `on_echo_fired`. The returned handle can be /// passed to `remove_event_listener` to unregister. pub fn add_on_echo_fired_listener(&self, handler: F) -> ListenerHandle @@ -220,6 +223,7 @@ impl MyTimerCtx { rc == 0 } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub fn echo(&self, req: EchoRequest) -> Result { let req = MyTimerEchoReq { req }; let req_bytes = encode_cbor(&req)?; @@ -229,6 +233,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub async fn echo_async(&self, req: EchoRequest) -> Result { let req = MyTimerEchoReq { req }; let req_bytes = encode_cbor(&req)?; @@ -239,6 +244,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Returns the library's version string. pub fn version(&self) -> Result { let req = MyTimerVersionReq {}; let req_bytes = encode_cbor(&req)?; @@ -248,6 +254,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Returns the library's version string. pub async fn version_async(&self) -> Result { let req = MyTimerVersionReq {}; let req_bytes = encode_cbor(&req)?; @@ -277,6 +284,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub fn schedule(&self, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig) -> Result { let req = MyTimerScheduleReq { job, retry, schedule }; let req_bytes = encode_cbor(&req)?; @@ -286,6 +294,7 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub async fn schedule_async(&self, job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig) -> Result { let req = MyTimerScheduleReq { job, retry, schedule }; let req_bytes = encode_cbor(&req)?; diff --git a/examples/timer/rust_bindings/src/ffi.rs b/examples/timer/rust_bindings/src/ffi.rs index 905acf4..3a6cf73 100644 --- a/examples/timer/rust_bindings/src/ffi.rs +++ b/examples/timer/rust_bindings/src/ffi.rs @@ -9,11 +9,16 @@ pub type FFICallback = unsafe extern "C" fn( #[link(name = "my_timer")] extern "C" { + /// Creates the FFIContext + MyTimer; async via chronos. pub fn my_timer_create(req_cbor: *const u8, req_cbor_len: usize, callback: FFICallback, user_data: *mut c_void) -> *mut c_void; + /// Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. pub fn my_timer_echo(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Returns the library's version string. pub fn my_timer_version(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + /// Tears down the FFI context; blocks until FFI + watchdog threads join. pub fn my_timer_destroy(ctx: *mut c_void) -> c_int; pub fn my_timer_add_event_listener(ctx: *mut c_void, event_name: *const c_char, callback: FFICallback, user_data: *mut c_void) -> u64; pub fn my_timer_remove_event_listener(ctx: *mut c_void, listener_id: u64) -> c_int; diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index eb52568..88810fb 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -36,23 +36,25 @@ type EchoEvent {.ffi.} = object message: string echoCount: int -proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} +proc onEchoFired*(evt: EchoEvent) {.ffiEvent: "on_echo_fired".} = + ## Fired by `myTimerEcho` once the reply is ready. -# Constructor: creates the FFIContext + MyTimer; async via chronos. proc myTimerCreate*(config: TimerConfig): Future[Result[MyTimer, string]] {.ffiCtor.} = + ## Creates the FFIContext + MyTimer; async via chronos. await sleepAsync(1.milliseconds) # proves chronos is live on the FFI thread return ok(MyTimer(name: config.name)) -# Async method: sleeps `delayMs` then echoes the message back. proc myTimerEcho*( timer: MyTimer, req: EchoRequest ): Future[Result[EchoResponse, string]] {.ffi.} = + ## Sleeps `delayMs` then echoes the message back, firing `on_echo_fired`. await sleepAsync(req.delayMs.milliseconds) onEchoFired(EchoEvent(message: req.message, echoCount: 1)) return ok(EchoResponse(echoed: req.message, timerName: timer.name)) # Sync method: no await, so the macro fires the callback inline. proc myTimerVersion*(timer: MyTimer): Future[Result[string, string]] {.ffi.} = + ## Returns the library's version string. return ok("nim-timer v0.1.0") proc myTimerComplex*( diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index 64ae619..77dea25 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -2,7 +2,7 @@ ## `abi = c` emits one header whose structs are the C ABI directly. Lacking ## generics, each distinct `seq[T]`/`Option[T]` is monomorphised per type. -import std/[os, strutils, tables, sets] +import std/[os, strutils, tables, sets, options] import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir ## Fixed 64-bit wire type for any Nim `ptr T`/`pointer` (mirrors CppPtrType). @@ -487,6 +487,7 @@ proc emitConstructors( head & params.join(", ") & ", " & fnType & " on_created, void* user_data) {" else: head & fnType & " on_created, void* user_data) {" + lines.add(renderBlockDocComment(ctor.doc)) lines.add(sig) lines.add(" " & reqName & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -525,15 +526,19 @@ proc emitConstructors( proc emitDestructor( lines: var seq[string], - ctxType, libName, dtorProcName: string, + ctxType, libName: string, + dtor: Option[FFIProcMeta], events: seq[FFIEventMeta], ) = + if dtor.isSome(): + lines.add(renderBlockDocComment(dtor.get().doc)) lines.add("static inline int " & libName & "_ctx_destroy(" & ctxType & "* ctx) {") lines.add(" if (!ctx) return NIMFFI_RET_OK;") lines.add(" int rc = NIMFFI_RET_OK;") - if dtorProcName.len > 0: + if dtor.isSome(): lines.add( - " if (ctx->ptr) { rc = " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }" + " if (ctx->ptr) { rc = " & dtor.get().procName & + "(ctx->ptr); ctx->ptr = NULL; }" ) if events.len > 0: # A failed teardown leaves the worker threads live (ffi_context.nim: @@ -557,6 +562,7 @@ proc emitListenerApi( return for ev in events: let n = evNames(libType, libName, ev) + lines.add(renderBlockDocComment(ev.doc)) lines.add( "static inline uint64_t " & n.regName & "(" & ctxType & "* ctx, " & n.fnType & " fn, void* user_data) {" @@ -661,6 +667,7 @@ proc emitMethod( head & params.join(", ") & ", " & fnType & " on_reply, void* user_data) {" else: head & fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add(" " & reqName & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -789,6 +796,7 @@ proc generateCLibHeader*( lines.add("#endif") lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: lines.add( @@ -842,7 +850,7 @@ proc generateCLibHeader*( emitEventMachinery(lines, reg, libType, libName, events) emitContextStruct(lines, ctxType, events) emitConstructors(lines, reg, ctxType, libType, libName, ctors) - emitDestructor(lines, ctxType, libName, classified.dtorProcName, events) + emitDestructor(lines, ctxType, libName, classified.dtor, events) emitListenerApi(lines, ctxType, libType, libName, events) for m in methods: emitMethod(lines, reg, ctxType, libType, libName, m) @@ -1085,6 +1093,7 @@ proc emitAbiExternDecls( lines.add("#endif") lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: if p.scalarFastPath: @@ -1181,6 +1190,7 @@ proc emitAbiCtxAndCtor( head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {" else: head & createFn & " on_created, void* user_data) {" + lines.add(renderBlockDocComment(ctor.doc)) lines.add(sig) lines.add(" " & reqStruct & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -1220,6 +1230,7 @@ proc emitAbiMethod( head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {" else: head & info.fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add(" " & reqStruct & " ffi_req;") lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") @@ -1326,6 +1337,7 @@ proc emitAbiScalarMethod( head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {" else: head & info.fnType & " on_reply, void* user_data) {" + lines.add(renderBlockDocComment(m.doc)) lines.add(sig) lines.add( " " & boxType & "* box = (" & boxType & "*)malloc(sizeof(" & boxType & "));" @@ -1401,7 +1413,7 @@ proc generateCAbiLibHeader*( lines.add("/* High-level context wrapper */") emitAbiCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors) # abi = c has no events, so the destructor is the CBOR one minus the listener sweep. - emitDestructor(lines, ctxType, libName, classified.dtorProcName, @[]) + emitDestructor(lines, ctxType, libName, classified.dtor, @[]) for m in classified.methods: if m.scalarFastPath: emitAbiScalarMethod(lines, reg, ctxType, libName, libType, m) diff --git a/ffi/codegen/c_cpp_common.nim b/ffi/codegen/c_cpp_common.nim index 5ca3ce0..b23484c 100644 --- a/ffi/codegen/c_cpp_common.nim +++ b/ffi/codegen/c_cpp_common.nim @@ -1,6 +1,6 @@ ## Helpers shared by the C/C++ binding generators (cpp.nim, c.nim). -import std/strutils +import std/[strutils, options] import ./meta, ./string_helpers proc stripLibPrefix*(procName, libName: string): string = @@ -21,7 +21,7 @@ proc reqStructName*(p: FFIProcMeta): string = type ClassifiedProcs* = object ctors*: seq[FFIProcMeta] methods*: seq[FFIProcMeta] - dtorProcName*: string + dtor*: Option[FFIProcMeta] proc classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = ## Splits the registry into constructors, methods and the first destructor. @@ -33,8 +33,8 @@ proc classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = of FFIKind.FFI: c.methods.add(p) of FFIKind.DTOR: - if c.dtorProcName.len == 0: - c.dtorProcName = p.procName + if c.dtor.isNone(): + c.dtor = some(p) c proc libTypeName*(ctors: seq[FFIProcMeta], libName: string): string = diff --git a/ffi/codegen/cddl.nim b/ffi/codegen/cddl.nim index c2e34f2..1589d85 100644 --- a/ffi/codegen/cddl.nim +++ b/ffi/codegen/cddl.nim @@ -2,7 +2,7 @@ ## ffi/cbor_serial.nim: types become rules, procs get request/response rules. import std/[os, strutils, unicode] -import ./meta +import ./meta, ./string_helpers proc innerOf(typeName, prefix: string): string = if typeName.startsWith(prefix) and typeName.endsWith("]"): @@ -154,6 +154,7 @@ proc generateCddlSchema*( of FFIKind.DTOR: "dtor" of FFIKind.FFI: "ffi" L.add("; " & p.procName & " (" & kindTag & ")") + L.add(renderDocComment(p.doc, "", "; ")) if p.kind != FFIKind.DTOR: L.add(p.procName & "-request = " & reqStructName(p)) L.add(p.procName & "-response = " & responseRule(p)) diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index b75d76c..29089f6 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -112,6 +112,7 @@ proc emitEventDispatcher( for ev in events: let methodName = "addOn" & capitalizeFirstLetter(ev.nimProcName).substr(2) & "Listener" + lines.add(renderMemberDocComment(ev.doc)) lines.add( " ListenerHandle $1(std::function handler) {" % [methodName, ev.payloadTypeName] @@ -251,6 +252,7 @@ proc generateCppHeader*( ) lines.add("") for p in procs: + lines.add(renderBlockDocComment(p.doc)) case p.kind of FFIKind.FFI: lines.add( @@ -312,6 +314,7 @@ proc generateCppHeader*( # `create` yields the ctx via the callback's CBOR address (sync void* return discarded), owned as a unique_ptr since the class forbids copy/move. let createRet = "Result>" % [ctxTypeName] + lines.add(renderMemberDocComment(ctor.doc)) lines.add(" static $1 create($2) {" % [createRet, ctorParamsWithTimeout]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") @@ -363,6 +366,7 @@ proc generateCppHeader*( epNames.join(", ") & ", timeout" else: "timeout" + lines.add(renderMemberDocComment(ctor.doc)) lines.add( " static std::future>> createAsync($2) {" % [ctxTypeName, ctorParamsWithTimeout] @@ -405,6 +409,7 @@ proc generateCppHeader*( let reqInit = cppBracedInit(reqName, methParamNames) let methRet = "Result<$1>" % [retCppType] + lines.add(renderMemberDocComment(m.doc)) lines.add(" $1 $2($3) const {" % [methRet, methodName, methParamsStr]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") @@ -425,6 +430,7 @@ proc generateCppHeader*( lines.add(" }") lines.add("") # `this->methodName(...)` so a same-named param can't shadow the call target. + lines.add(renderMemberDocComment(m.doc)) if methParamsStr.len > 0: lines.add( " std::future<$1> $2Async($3) const {" % [methRet, methodName, methParamsStr] diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 1aa7314..cad7150 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -26,6 +26,7 @@ type libName*: string kind*: FFIKind libTypeName*: string + doc*: string extraParams*: seq[FFIParamMeta] # all params except the lib param returnTypeName*: string returnIsPtr*: bool @@ -52,6 +53,7 @@ type libName*: string payloadTypeName*: string abiFormat*: ABIFormat + doc*: string var ffiProcRegistry* {.compileTime.}: seq[FFIProcMeta] var ffiTypeRegistry* {.compileTime.}: seq[FFITypeMeta] diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index f07d655..545c593 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -183,6 +183,7 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string = for p in procs: var params: seq[string] = @[] + lines.add(renderMemberDocComment(p.doc)) case p.kind of FFIKind.FFI: # Method-style: ctx first. @@ -544,6 +545,7 @@ proc generateApiRs*( else: reqName & " {}" + lines.add(renderMemberDocComment(ctor.doc)) lines.add(" pub fn create($1) -> Result {" % [ctorParamsStr]) lines.add(" let req = $1;" % [reqLit]) lines.add(" let req_bytes = encode_cbor(&req)?;") @@ -569,6 +571,7 @@ proc generateApiRs*( lines.add(" }") lines.add("") + lines.add(renderMemberDocComment(ctor.doc)) lines.add( " pub async fn new_async($1) -> Result {" % [ctorParamsStr] ) @@ -621,6 +624,7 @@ proc generateApiRs*( let methodName = "add_" & camelToSnakeCase(ev.nimProcName) & "_listener" let handlerStruct = capitalizeFirstLetter(ev.nimProcName) & "Handler" let trampolineName = camelToSnakeCase(ev.nimProcName) & "_trampoline" + lines.add(renderMemberDocComment(ev.doc)) lines.add( " /// Register a typed listener for `$1`. The returned handle can be" % [ev.wireName] @@ -690,6 +694,7 @@ proc generateApiRs*( let retTypeForApi = if m.returnRidesAsPtr(): RustPtrType else: retRustType + lines.add(renderMemberDocComment(m.doc)) lines.add( " pub fn $1(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] @@ -707,6 +712,7 @@ proc generateApiRs*( lines.add("") # async method: ptr cast to usize (Copy + Send) keeps the move closure and returned future Send for multi-threaded tokio runtimes. + lines.add(renderMemberDocComment(m.doc)) lines.add( " pub async fn $1_async(&self$2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] diff --git a/ffi/codegen/string_helpers.nim b/ffi/codegen/string_helpers.nim index a44eef5..681eb77 100644 --- a/ffi/codegen/string_helpers.nim +++ b/ffi/codegen/string_helpers.nim @@ -1,7 +1,48 @@ -## Unicode-aware identifier-casing helpers shared by codegen and the FFI macro. +## Unicode-aware identifier casing and doc-comment rendering, shared by codegen +## and the FFI macro. import std/[strutils, unicode] +func docLines(doc: string): seq[string] = + ## `doc` split into lines, trailing blank ones dropped. + if doc.strip().len == 0: + return @[] + var lines = doc.splitLines() + while lines.len > 0 and lines[^1].strip().len == 0: + lines.setLen(lines.len - 1) + return lines + +func renderDocComment*(doc, indent, prefix: string): seq[string] = + ## `doc` as one `prefix`-led line comment per source line, at `indent`. + var rendered: seq[string] = @[] + for line in docLines(doc): + # A trailing `\` would splice the next generated line into a `//` comment. + rendered.add( + indent & (prefix & line).strip(leading = false, chars = Whitespace + {'\\'}) + ) + return rendered + +func renderMemberDocComment*(doc: string): seq[string] = + ## `///` at the indent C++ class members and Rust `impl` items sit at. + return doc.renderDocComment(" ", "/// ") + +func escapeBlockComment(line: string): string = + ## `*/` would close the comment early and splice the rest in as code. + return line.replace("*/", "* /") + +func renderBlockDocComment*(doc: string, indent = ""): seq[string] = + ## `doc` as a `/** ... */` block at `indent`; one-liners stay on one line. + let lines = docLines(doc) + if lines.len == 0: + return @[] + if lines.len == 1: + return @[indent & "/** " & escapeBlockComment(lines[0].strip()) & " */"] + var rendered = @[indent & "/**"] + for line in lines: + rendered.add((indent & " * " & escapeBlockComment(line)).strip(leading = false)) + rendered.add(indent & " */") + return rendered + proc toLower*(s: string): string = ## Unicode-aware lowercase for an entire string. var buf = "" diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index 5911ea0..6bf5bdf 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -143,6 +143,16 @@ proc registerFFITypeInfo( ) return typeDef +func extractDocComment(prc: NimNode): string {.compileTime.} = + ## The proc's leading `##`, or "". Nim drops comments outside a proc body, so + ## types and fields are unreachable from here. + let body = prc[^1] + if body.kind != nnkStmtList or body.len == 0: + return "" + if body[0].kind != nnkCommentStmt: + return "" + return body[0].strVal + proc nimTypeNameRepr(typ: NimNode): string = ## Stringifies a parameter or field type for the registry. case typ.kind @@ -760,6 +770,7 @@ macro ffi*(args: varargs[untyped]): untyped = returnIsPtr: retIsPtr, returnIsHandle: retIsHandle, abiFormat: abiFormat, + doc: extractDocComment(prc), ) # CBOR-free scalar fast path: only `abi = c` with all-scalar params/return that fit the inline slots; non-scalar `abi = c` rides the `_CWire` C-dispatch. @@ -1294,6 +1305,7 @@ macro ffiCtor*(args: varargs[untyped]): untyped = returnTypeName: $libTypeName, returnIsPtr: false, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) @@ -1422,6 +1434,7 @@ macro ffiDtor*(args: varargs[untyped]): untyped = returnTypeName: "", returnIsPtr: false, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) @@ -1511,6 +1524,7 @@ macro ffiEvent*(args: varargs[untyped]): untyped = libName: currentLibName, payloadTypeName: payloadTypeNameStr, abiFormat: abiFormat, + doc: extractDocComment(prc), ) ) diff --git a/tests/unit/test_doc_extraction.nim b/tests/unit/test_doc_extraction.nim new file mode 100644 index 0000000..5450bf9 --- /dev/null +++ b/tests/unit/test_doc_extraction.nim @@ -0,0 +1,98 @@ +## `##` comments on annotated procs → the registry. Codegen: test_doc_propagation.nim. + +import unittest2 +import results +import ffi +import ffi/codegen/meta + +type DocLib = object + +# Stub the importc NimMain declareLibrary emits (plain-exe link). +{.emit: "void libdoctestNimMain(void) {}".} + +declareLibrary("doctest", DocLib) + +type DocConfig {.ffi.} = object + v: int + +type Tocked {.ffi.} = object + n: int + +proc doctest_create*(c: DocConfig): Future[Result[DocLib, string]] {.ffiCtor.} = + ## Builds the lib. + return ok(DocLib()) + +proc doctest_documented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + ## First line. + ## Second line. + return ok("ok") + +proc doctest_undocumented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + return ok("ok") + +proc doctest_hash_commented*(lib: DocLib): Future[Result[string, string]] {.ffi.} = + # not a doc comment + return ok("ok") + +proc doctest_spec_documented*( + lib: DocLib, n: int +): Future[Result[int, string]] {.ffi: "abi = cbor".} = + ## Doc survives alongside a pragma spec. + return ok(n) + +proc doctest_destroy*(lib: DocLib) {.ffiDtor.} = + ## Tears it down. + discard + +proc doctest_tocked*(p: Tocked) {.ffiEvent: "on_tocked".} = + ## Fires on every tock. + +proc doctest_plain*(p: Tocked) {.ffiEvent: "on_plain".} + +# The registries are compile-time only, so snapshot them into consts. +proc docOf(procName: string): string {.compileTime.} = + for p in ffiProcRegistry: + if p.procName == procName: + return p.doc + doAssert false, "no proc " & procName & " in ffiProcRegistry" + +proc eventDocOf(wireName: string): string {.compileTime.} = + for e in ffiEventRegistry: + if e.wireName == wireName: + return e.doc + doAssert false, "no event " & wireName & " in ffiEventRegistry" + +const + CreateDoc = docOf("doctest_create") + DocumentedDoc = docOf("doctest_documented") + UndocumentedDoc = docOf("doctest_undocumented") + HashCommentedDoc = docOf("doctest_hash_commented") + SpecDocumentedDoc = docOf("doctest_spec_documented") + DestroyDoc = docOf("doctest_destroy") + TockedDoc = eventDocOf("on_tocked") + PlainEventDoc = eventDocOf("on_plain") + +suite "extractDocComment: what lands on the registry": + test "a single-line doc comment is captured": + check CreateDoc == "Builds the lib." + + test "a multi-line doc comment keeps its line breaks": + check DocumentedDoc == "First line.\nSecond line." + + test "an undocumented proc gets an empty doc": + check UndocumentedDoc == "" + + test "a plain `#` comment is not captured": + check HashCommentedDoc == "" + + test "a doc comment survives a pragma spec argument": + check SpecDocumentedDoc == "Doc survives alongside a pragma spec." + + test "the destructor's doc is captured": + check DestroyDoc == "Tears it down." + + test "an event proc's doc comment is captured": + check TockedDoc == "Fires on every tock." + + test "an undocumented event proc gets an empty doc": + check PlainEventDoc == "" diff --git a/tests/unit/test_doc_propagation.nim b/tests/unit/test_doc_propagation.nim new file mode 100644 index 0000000..4294d47 --- /dev/null +++ b/tests/unit/test_doc_propagation.nim @@ -0,0 +1,195 @@ +## FFIProcMeta.doc → each target's comment syntax. Extraction: test_doc_extraction.nim. + +import std/strutils +import unittest2 +import ffi/codegen/[meta, c, cpp, rust, cddl] + +const + CtorDoc = "Builds a widget from `config`." + MethodDoc = "Echoes `req` back.\nSecond line." + DtorDoc = "Releases the widget." + EventDoc = "Fires once the echo lands." + ScalarDoc = "Adds two numbers, no CBOR in sight." + +let types = @[ + FFITypeMeta( + name: "EchoRequest", fields: @[FFIFieldMeta(name: "message", typeName: "string")] + ), + FFITypeMeta( + name: "EchoResponse", fields: @[FFIFieldMeta(name: "echoed", typeName: "string")] + ), +] + +let procs = @[ + FFIProcMeta( + procName: "widget_create", + libName: "widget", + kind: FFIKind.CTOR, + libTypeName: "Widget", + extraParams: @[FFIParamMeta(name: "config", typeName: "EchoRequest")], + returnTypeName: "Widget", + doc: CtorDoc, + ), + FFIProcMeta( + procName: "widget_echo", + libName: "widget", + kind: FFIKind.FFI, + libTypeName: "Widget", + extraParams: @[FFIParamMeta(name: "req", typeName: "EchoRequest")], + returnTypeName: "EchoResponse", + doc: MethodDoc, + ), + FFIProcMeta( + procName: "widget_destroy", + libName: "widget", + kind: FFIKind.DTOR, + libTypeName: "Widget", + returnTypeName: "", + doc: DtorDoc, + ), +] + +let events = @[ + FFIEventMeta( + wireName: "on_echoed", + nimProcName: "onEchoed", + libName: "widget", + payloadTypeName: "EchoResponse", + doc: EventDoc, + ) +] + +let undocumented = block: + var stripped = procs + for p in stripped.mitems: + p.doc = "" + stripped + +func withoutDocLines(s: string): string = + ## Drops `///` lines by content, so a generator's own hardcoded ones survive. + var rendered: seq[string] = @[] + for doc in [CtorDoc, MethodDoc, DtorDoc]: + for line in doc.splitLines(): + rendered.add(("/// " & line).strip()) + var kept: seq[string] = @[] + for line in s.splitLines(): + if line.strip() notin rendered: + kept.add(line) + return kept.join("\n") + +proc checkNoDocText(rendered: string) = + for doc in [CtorDoc, MethodDoc, DtorDoc]: + for line in doc.splitLines(): + check line notin rendered + +suite "doc comments reach the C header": + setup: + let header = generateCLibHeader(procs, types, "widget") + + test "the exported symbol carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "the high-level wrapper carries the doc": + check "/** " & CtorDoc & " */\nstatic inline int widget_ctx_create(" in header + check "/** " & DtorDoc & " */\nstatic inline int widget_ctx_destroy(" in header + + test "a multi-line doc becomes a star block": + check "/**\n * Echoes `req` back.\n * Second line.\n */\nstatic inline int widget_ctx_echo(" in + header + + test "the listener registration carries the event's doc": + let withEvents = generateCLibHeader(procs, types, "widget", events) + check "/** " & EventDoc & + " */\nstatic inline uint64_t widget_ctx_add_on_echoed_listener(" in withEvents + + test "an undocumented registry leaks no doc text": + checkNoDocText(generateCLibHeader(undocumented, types, "widget")) + +suite "doc comments reach the abi = c header": + setup: + var abiProcs = procs + abiProcs.add( + FFIProcMeta( + procName: "widget_add", + libName: "widget", + kind: FFIKind.FFI, + libTypeName: "Widget", + extraParams: @[ + FFIParamMeta(name: "a", typeName: "int"), + FFIParamMeta(name: "b", typeName: "int"), + ], + returnTypeName: "int", + scalarFastPath: true, + doc: ScalarDoc, + ) + ) + for p in abiProcs.mitems: + p.abiFormat = ABIFormat.C + var abiTypes = types + for t in abiTypes.mitems: + t.abiFormat = ABIFormat.C + let header = generateCAbiLibHeader(abiProcs, abiTypes, "widget") + + test "the exported symbol carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "the high-level wrapper carries the doc": + check "/** " & CtorDoc & " */\nstatic inline int widget_ctx_create(" in header + check "/** " & DtorDoc & " */\nstatic inline int widget_ctx_destroy(" in header + + test "the scalar fast path carries the doc": + check "/** " & ScalarDoc & " */\nstatic inline int widget_ctx_add(" in header + +suite "doc comments reach the C++ header": + setup: + let header = generateCppHeader(procs, types, "widget") + + test "the extern \"C\" declaration carries the doc": + check "/** " & CtorDoc & " */\nvoid* widget_create(" in header + + test "class members carry an indented /// doc": + check " /// " & CtorDoc & + "\n static Result> create(" in header + check " /// Echoes `req` back.\n /// Second line.\n Result echo(" in + header + + test "the async variant repeats the doc": + check " /// Echoes `req` back.\n /// Second line.\n std::future> echoAsync(" in + header + + test "the listener registration carries the event's doc": + check " /// " & EventDoc & "\n ListenerHandle addOnEchoedListener(" in + generateCppHeader(procs, types, "widget", events) + + test "an undocumented registry leaks no doc text": + checkNoDocText(generateCppHeader(undocumented, types, "widget")) + +suite "doc comments reach the Rust bindings": + test "the extern \"C\" block carries the doc": + let ffiRs = generateFFIRs(procs) + check " /// " & CtorDoc & "\n pub fn widget_create(" in ffiRs + check " /// Echoes `req` back.\n /// Second line.\n pub fn widget_echo(" in + ffiRs + + test "the high-level api carries the doc": + let apiRs = generateApiRs(procs, "widget") + check " /// " & CtorDoc & "\n pub fn create(" in apiRs + check " /// Echoes `req` back.\n /// Second line.\n pub fn echo(" in apiRs + check " /// Echoes `req` back.\n /// Second line.\n pub async fn echo_async(" in + apiRs + + test "the listener registration carries the event's doc above the generated one": + check " /// " & EventDoc & "\n /// Register a typed listener for `on_echoed`" in + generateApiRs(procs, "widget", events) + + test "docs are purely additive — stripping them reproduces the undocumented output": + check withoutDocLines(generateFFIRs(procs)) == generateFFIRs(undocumented) + check withoutDocLines(generateApiRs(procs, "widget")) == + generateApiRs(undocumented, "widget") + +suite "doc comments reach the CDDL schema": + test "each documented rule gets a `;` comment under its header": + let schema = generateCddlSchema(procs, types, "widget", "widget.nim") + check "; widget_create (ctor)\n; " & CtorDoc & "\n" in schema + check "; widget_echo (ffi)\n; Echoes `req` back.\n; Second line.\n" in schema + check "; widget_destroy (dtor)\n; " & DtorDoc & "\n" in schema diff --git a/tests/unit/test_string_helpers.nim b/tests/unit/test_string_helpers.nim index a3f318a..6b9d1bf 100644 --- a/tests/unit/test_string_helpers.nim +++ b/tests/unit/test_string_helpers.nim @@ -81,3 +81,53 @@ suite "snakeToPascalCase": test "already-mixed parts preserve their existing case after the first": # Existing caps after the first letter of each part are preserved. check snakeToPascalCase("already_HasCaps") == "AlreadyHasCaps" + +suite "renderDocComment": + test "empty doc renders nothing": + check renderDocComment("", "", "/// ").len == 0 + + test "blank doc renders nothing": + check renderDocComment(" \n ", "", "/// ").len == 0 + + test "single line gets the prefix": + check renderDocComment("does a thing", "", "/// ") == @["/// does a thing"] + + test "each line gets prefix and indent": + check renderDocComment("one\ntwo", " ", "/// ") == @[ + " /// one", " /// two" + ] + + test "a blank interior line keeps no trailing whitespace": + check renderDocComment("one\n\ntwo", "", "/// ") == @["/// one", "///", "/// two"] + + test "trailing blank lines are dropped": + check renderDocComment("one\n\n", "", "; ") == @["; one"] + + test "a trailing backslash cannot splice the next line into the comment": + check renderDocComment("one \\\ntwo", "", "/// ") == @["/// one", "/// two"] + +suite "renderBlockDocComment": + test "empty doc renders nothing": + check renderBlockDocComment("", "").len == 0 + + test "single line collapses to a one-line block": + check renderBlockDocComment("does a thing", "") == @["/** does a thing */"] + + test "multiple lines render a star block": + check renderBlockDocComment("one\ntwo", "") == @["/**", " * one", " * two", " */"] + + test "indent applies to every line of the block": + check renderBlockDocComment("one\ntwo", " ") == + @[" /**", " * one", " * two", " */"] + + test "a blank interior line keeps no trailing whitespace": + check renderBlockDocComment("one\n\ntwo", "") == + @["/**", " * one", " *", " * two", " */"] + + test "a one-liner cannot close its own comment early": + check renderBlockDocComment("Frees it. */ #define OOPS 1 /* x", "") == + @["/** Frees it. * / #define OOPS 1 /* x */"] + + test "a star block cannot close its own comment early": + check renderBlockDocComment("one */ #define OOPS 1\ntwo", "") == + @["/**", " * one * / #define OOPS 1", " * two", " */"]