Skip to main content

nros_macros/
lib.rs

1//! Proc macros for nros message type generation
2//!
3//! Provides `#[derive(RosMessage)]` and `#[derive(RosService)]` macros.
4
5use proc_macro::TokenStream;
6use quote::quote;
7use syn::{DeriveInput, Fields, LitByteStr, LitStr, Path, parse_macro_input};
8
9// Phase 212.N.9 — `nros::main!()` proc-macro family. Replaces today's
10// Entry-pkg `build.rs + include!(concat!(env!("OUT_DIR"), "/run_plan.rs"))`
11// shape with a one-line `main.rs`. See `main_macro.rs` for the impl.
12mod main_macro;
13
14/// One-line `fn main()` for Entry pkgs. Four forms:
15///
16/// ```ignore
17/// nros::main!();                                          // single-node self-bringup
18/// nros::main!(board = NativeBoard);                       // single-node, explicit board
19/// nros::main!(launch = "demo_bringup");                   // multi-node, default launch
20/// nros::main!(launch = "demo_bringup:sim.launch.xml");    // multi-node, explicit file
21/// nros::main!(
22///     board  = NativeBoard,
23///     launch = "demo_bringup:sim.launch.xml",
24///     args   = [("use_sim", "true")],
25/// );
26/// ```
27///
28/// Reads `[package.metadata.nros.entry] deploy = "<board>"` from the
29/// Entry pkg's own `Cargo.toml` when `board = …` is absent; consults
30/// the workspace pkg-index (Phase 212.N.10) to resolve the bringup
31/// pkg's launch file (Phase 212.N.11 parser).
32///
33/// Emits `fn main()` that delegates to
34/// `<Board as ::nros::__macro_support::nros_platform::BoardEntry>::run(...)`,
35/// dispatching one `<pkg>::register(runtime)?;` call per
36/// launch-XML `<node>` entry. See `docs/design/0024-multi-node-workspace-layout.md`
37/// §11.6 for the design lock.
38#[proc_macro]
39pub fn main(input: TokenStream) -> TokenStream {
40    main_macro::expand(input)
41}
42
43/// Sanitise a cargo package name into a C-identifier-safe symbol component.
44///
45/// Cargo allows `-` in package names; C identifiers don't. Each non
46/// `[A-Za-z0-9_]` byte is replaced with `_` so the result is a valid
47/// suffix for the per-pkg register symbol emitted by [`node!`].
48///
49/// Crate-private (proc-macro crates can't export non-macro items); the
50/// `sanitize_tests` module exercises it directly.
51fn sanitize_pkg_name_for_symbol(pkg: &str) -> String {
52    let mut out = String::with_capacity(pkg.len());
53    for c in pkg.chars() {
54        if c.is_ascii_alphanumeric() || c == '_' {
55            out.push(c);
56        } else {
57            out.push('_');
58        }
59    }
60    out
61}
62
63/// Derive macro for ROS message types
64///
65/// Generates `Serialize`, `Deserialize`, and `RosMessage` implementations.
66///
67/// # Attributes
68///
69/// - `#[ros(type_name = "...")]` - Full ROS type name (required)
70/// - `#[ros(hash = "...")]` - RIHS type hash (required)
71///
72/// # Example
73///
74/// ```ignore
75/// use nros_macros::RosMessage;
76///
77/// #[derive(RosMessage)]
78/// #[ros(type_name = "std_msgs::msg::dds_::String_")]
79/// #[ros(hash = "abc123...")]
80/// pub struct StringMsg {
81///     pub data: heapless::String<256>,
82/// }
83/// ```
84#[proc_macro_derive(RosMessage, attributes(ros))]
85pub fn derive_ros_message(input: TokenStream) -> TokenStream {
86    let input = parse_macro_input!(input as DeriveInput);
87    let name = &input.ident;
88    let generics = &input.generics;
89    let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
90
91    // Extract attributes
92    let mut type_name: Option<String> = None;
93    let mut type_hash: Option<String> = None;
94
95    for attr in &input.attrs {
96        if attr.path().is_ident("ros") {
97            let _ = attr.parse_nested_meta(|meta| {
98                if meta.path.is_ident("type_name") {
99                    let value: LitStr = meta.value()?.parse()?;
100                    type_name = Some(value.value());
101                } else if meta.path.is_ident("hash") {
102                    let value: LitStr = meta.value()?.parse()?;
103                    type_hash = Some(value.value());
104                }
105                Ok(())
106            });
107        }
108    }
109
110    let type_name = type_name.unwrap_or_else(|| format!("{}::msg::dds_::{}_", "unknown", name));
111    let type_hash = type_hash.unwrap_or_else(|| "0".repeat(64));
112
113    // Get fields
114    let fields = match &input.data {
115        syn::Data::Struct(data) => match &data.fields {
116            Fields::Named(fields) => &fields.named,
117            Fields::Unit => {
118                // Unit struct (no fields)
119                return generate_unit_struct_impl(
120                    name,
121                    &impl_generics,
122                    &ty_generics,
123                    where_clause,
124                    &type_name,
125                    &type_hash,
126                );
127            }
128            _ => {
129                return syn::Error::new_spanned(&input, "RosMessage only supports named fields")
130                    .to_compile_error()
131                    .into();
132            }
133        },
134        _ => {
135            return syn::Error::new_spanned(&input, "RosMessage can only be derived for structs")
136                .to_compile_error()
137                .into();
138        }
139    };
140
141    // Generate serialize calls for each field
142    let serialize_fields = fields.iter().map(|f| {
143        let field_name = &f.ident;
144        quote! {
145            self.#field_name.serialize(writer)?;
146        }
147    });
148
149    // Generate deserialize calls for each field
150    let deserialize_fields = fields.iter().map(|f| {
151        let field_name = &f.ident;
152        quote! {
153            #field_name: Deserialize::deserialize(reader)?,
154        }
155    });
156
157    let expanded = quote! {
158        impl #impl_generics nros_serdes::Serialize for #name #ty_generics #where_clause {
159            fn serialize(&self, writer: &mut nros_serdes::CdrWriter) -> Result<(), nros_serdes::SerError> {
160                use nros_serdes::Serialize;
161                #(#serialize_fields)*
162                Ok(())
163            }
164        }
165
166        impl #impl_generics nros_serdes::Deserialize for #name #ty_generics #where_clause {
167            fn deserialize(reader: &mut nros_serdes::CdrReader) -> Result<Self, nros_serdes::DeserError> {
168                use nros_serdes::Deserialize;
169                Ok(Self {
170                    #(#deserialize_fields)*
171                })
172            }
173        }
174
175        impl #impl_generics nros_core::RosMessage for #name #ty_generics #where_clause {
176            const TYPE_NAME: &'static str = #type_name;
177            const TYPE_HASH: &'static str = #type_hash;
178        }
179    };
180
181    TokenStream::from(expanded)
182}
183
184/// Export a Rust type implementing `nros::Node` as the package node.
185///
186/// Phase 212.N.12 — `nros::node!()` is the canonical name (matches the
187/// rclcpp_components / ROS 2 launch.xml `<node pkg=…>` convention). The
188/// legacy `nros::node!()` macro and `Component*` trait family were
189/// retired in the same phase.
190///
191/// # Emitted items
192///
193/// Per invocation the macro currently emits:
194///
195/// 1. `pub fn register(runtime: &mut RuntimeCtx<'_>) -> Result<(), RuntimeError>`
196///    — the Entry-pkg-callable wrapper that registers the four typed
197///    `register` / `init` / `dispatch` / `tick` trampolines with the
198///    runtime by stable pkg name (Phase 212.N.7 step-3.4).
199/// 2. `#[unsafe(no_mangle)] pub extern "C" fn
200///    __nros_node_<pkg>_dispatch_strategy() -> u8` — Phase 216.A.5
201///    ABI export of the Node's [`DispatchStrategy`] discriminant
202///    (`<Type as Node>::DISPATCH as u8`). Read out-of-tree by
203///    `nros check` (216.D.1) and consumed from a separate compilation
204///    unit by the RTIC (216.B.3) / Embassy (216.C.3) dispatch tasks.
205///    `<pkg>` is the value of `CARGO_PKG_NAME` after
206///    `sanitize_pkg_name_for_symbol` (hyphens → underscores).
207///
208/// [`DispatchStrategy`]: ../nros/enum.DispatchStrategy.html
209///
210/// # Example
211///
212/// ```ignore
213/// struct Talker;
214///
215/// impl nros::Node for Talker {
216///     const NAME: &'static str = "talker";
217///
218///     fn register(ctx: &mut nros::NodeContext<'_>) -> nros::NodeResult<()> {
219///         let mut node = ctx.create_node(
220///             nros::NodeId::new("node"),
221///             nros::NodeOptions::new("talker"),
222///         )?;
223///         let _pub = node.create_publisher::<std_msgs::msg::String>(
224///             nros::EntityId::new("pub_chatter"),
225///             "chatter",
226///         )?;
227///         Ok(())
228///     }
229/// }
230///
231/// nros::node!(Talker);
232/// ```
233#[proc_macro]
234pub fn node(input: TokenStream) -> TokenStream {
235    node_impl(input)
236}
237
238fn node_impl(input: TokenStream) -> TokenStream {
239    let node_ty = parse_macro_input!(input as Path);
240
241    // Phase 212.N.7 step-3.4 — the package-name string handed to
242    // `register_dispatch_slot_dyn` (diagnostics) +
243    // `RuntimeError::ComponentRegister` is the sanitised pkg-name used by
244    // the codegen-emitted `run_plan` to reference each Node pkg, so
245    // the two strings round-trip 1:1.
246    //
247    // `proc_macro::tracked_env::var` is still unstable, so we use plain
248    // `std::env::var`. Cargo sets `CARGO_PKG_NAME` for every compilation
249    // (proc-macro crates inherit the parent crate's env at expansion).
250    // The fallback "unknown" only triggers in toolchains that don't set
251    // it (none today); it keeps the macro robust against future hosts.
252    let pkg_raw = std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "unknown".to_string());
253    let pkg_sym = sanitize_pkg_name_for_symbol(&pkg_raw);
254    let pkg_name_lit = pkg_sym.clone();
255
256    // Phase 216.A.5 — per-Node-pkg `DispatchStrategy` ABI export. The
257    // identifier is `__nros_node_<pkg>_dispatch_strategy`, sharing the
258    // same sanitised `<pkg>` substring as the diagnostics string above
259    // so out-of-tree tools (`nros check`, RTIC/Embassy dispatch tasks)
260    // can resolve the symbol from `CARGO_PKG_NAME` directly.
261    let dispatch_fn_name = quote::format_ident!("__nros_node_{}_dispatch_strategy", pkg_sym);
262    // Phase 257 (W0-B / D6) — the uniform cross-language install seam.
263    let component_install_fn_name = quote::format_ident!("__nros_component_{}_install", pkg_sym);
264    let component_present_name = quote::format_ident!("__NROS_NODE_PKG_{}_EXPORT_PRESENT", pkg_sym);
265    let component_class_name = quote::format_ident!("__nros_component_{}_class_name", pkg_sym);
266    let node_class_leaf = node_ty
267        .segments
268        .last()
269        .map(|segment| segment.ident.to_string())
270        .unwrap_or_else(|| "Node".to_string());
271    let component_class = format!("{}::{}\0", pkg_sym, node_class_leaf);
272    let component_class_len = component_class.len();
273    let component_class_bytes =
274        LitByteStr::new(component_class.as_bytes(), proc_macro2::Span::call_site());
275
276    // Phase 216.A.5 follow-up — the on_callback trampoline the B.3 RTIC /
277    // C.3 Embassy dispatch tasks invoke after dequeuing a
278    // `SignaledCallback<'static>` from the SPSC ring. The chosen signature
279    // is `extern "C"` with `(state, cb_id_ptr, cb_id_len, ctx)` because:
280    //
281    //   1. `SignaledCallback.cb_id` is `&'a str` (Phase 216.A.2 layout,
282    //      `nros-platform/src/board/runtime.rs:47`) — a Rust fat pointer.
283    //      The dispatch task already has `(ptr, len)` in hand from the
284    //      `&str` it pulls off the ring, so passing them as separate
285    //      `*const u8 + usize` args costs zero packing/unpacking.
286    //   2. Sibling export `__nros_node_<pkg>_dispatch_strategy` uses
287    //      `extern "C"` — keeping the same ABI across the two
288    //      macro-emitted symbols means out-of-tree tools (`nros check`,
289    //      the RTIC/Embassy dispatch tasks themselves) resolve both via
290    //      the same `dlsym` / objdump path with no cfg-driven ABI
291    //      branching.
292    //   3. `extern "C"` with raw pointers is `no_std`-clean and matches
293    //      the spec block in Phase 216.A.5 verbatim.
294    let on_callback_fn_name = quote::format_ident!("__nros_node_{}_on_callback", pkg_sym);
295
296    // Phase 212.N.7 step-6 — the legacy `#[unsafe(no_mangle)] extern
297    // "Rust" fn __nros_component_<pkg>_{register,init,dispatch,tick}`
298    // symbols and the `__NROS_COMPONENT_<PKG>_EXPORT_PRESENT` `#[used]`
299    // marker are gone. They existed for the Phase 212.M.5.a BSP baker
300    // (`freertos-qemu-mps2-an385-bsp` — retired in step-4), which
301    // walked the mangled names from a generated `system_main.rs`. The
302    // Phase 212.N Entry pkg path calls `<pkg>::register(runtime)`
303    // directly through the path API, so the four typed fns now live
304    // as local items inside the `register(runtime)` wrapper. The
305    // macro emits ONE public item: the wrapper itself.
306    let expanded = quote! {
307        // Phase 212.N.7 step-3.4 — Entry-pkg-callable `register(runtime)`
308        // wrapper. The codegen-emitted `run_plan(runtime)` body
309        // (`nros-build::generate_run_plan`) dispatches one
310        // `<pkg>::register(runtime)?` call per launch-XML `<node>` entry,
311        // so every Node pkg whose `lib.rs` invokes `nros::node!()`
312        // gets a stable per-pkg API here.
313        //
314        // SAFETY (transmutes below): typed `fn(args...) -> ret` /
315        // `unsafe fn(args...) -> ret` and the zero-arg
316        // `extern "Rust" fn()` aliases share the same ABI representation
317        // (one pointer); the transmute is purely a type-level
318        // reinterpretation. The impl-side transmute on the other side
319        // (`nros::node_runtime`) recovers the same typed signature
320        // before invoking — the round-trip is type-preserving so long
321        // as both sides agree on the typed signature, which they do
322        // (both live in `nros`).
323        //
324        // Phase 212.M-F.13 path (b): emit references go through
325        // `::nros::__macro_support::nros_platform::*` rather than the
326        // bare `::nros_platform::*` path so Node pkgs only need
327        // a single `nros` dep in their `Cargo.toml`. The
328        // `__macro_support` module is a `#[doc(hidden)]` re-export
329        // alias maintained by `packages/core/nros/src/lib.rs`.
330
331        // Phase 257 (W0-B / D6) — the uniform cross-language component-install seam.
332        // A typed Entry of ANY language calls
333        // `__nros_component_<pkg>_install(node, executor, self)` to install this Node
334        // onto the shared `Executor` it hands in (`executor` = the entry's
335        // `nros::global_handle()` / a node's `executor_handle()` = `*mut Executor`).
336        // For a Rust node `_node`/`_self` are unused — it self-creates its node (its
337        // `Node::NAME`) on the shared executor (phase-257 D7 Option C). Returns 0 on
338        // success, nonzero on a registration error (or -1 without the cffi runtime).
339        // `not_unsafe_ptr_arg_deref`: this is a C-ABI export — the raw-ptr
340        // params are mandatory (the foreign typed entry hands in the executor
341        // handle) and the body is `unsafe`. The deref-safety contract lives on
342        // `install_node_typed`'s caller (the entry), not this thin trampoline.
343        #[allow(clippy::not_unsafe_ptr_arg_deref)]
344        #[unsafe(no_mangle)]
345        pub extern "C" fn #component_install_fn_name(
346            _node: *const ::core::ffi::c_void,
347            executor: *mut ::core::ffi::c_void,
348            _self: *mut ::core::ffi::c_void,
349        ) -> i32 {
350            unsafe { ::nros::install_node_typed::<#node_ty>(executor) }
351        }
352
353        #[unsafe(no_mangle)]
354        pub static #component_present_name: u8 = 1;
355
356        #[unsafe(no_mangle)]
357        pub static #component_class_name: [u8; #component_class_len] = *#component_class_bytes;
358
359        #[unsafe(no_mangle)]
360        pub extern "C" fn #dispatch_fn_name() -> u8 {
361            <#node_ty as ::nros::Node>::DISPATCH as u8
362        }
363
364        // Phase 216.A.5 follow-up — extern "C" trampoline the B.3 RTIC
365        // and C.3 Embassy dispatch tasks call after dequeuing a
366        // `SignaledCallback<'static>` from the SPSC ring + resolving the
367        // owning Node-pkg by `CallbackId` (the registry is a separate
368        // follow-up). The call sequence on the dispatch-task side is:
369        //
370        //   let cb = ring.pop()?;                       // SignaledCallback<'static>
371        //   let pkg = lookup(cb.cb_id)?;                // Node-by-cb_id registry (TBD)
372        //   let f = dlsym(pkg, "__nros_node_<pkg>_on_callback")?;
373        //   let state = node_state_table[pkg];          // *mut State, from i()
374        //   f(state, cb.cb_id.as_ptr(), cb.cb_id.len(),
375        //     cb.ctx_ptr);
376        //
377        // The trampoline reconstitutes the `&'static str` from
378        // `(cb_id_ptr, cb_id_len)` — both ends agree on UTF-8 (every
379        // `CallbackId` is built from a Rust `&str`), so the
380        // `from_utf8_unchecked` is sound by construction. The `state`
381        // and `ctx` re-cast mirrors the existing `unsafe fn d()` inside
382        // `register()` below — the only delta is the extern "C" surface
383        // + the (ptr, len) split for `cb_id`.
384        //
385        // SAFETY at the call site (documented for future
386        // dispatch-task authors — the trampoline does NOT recheck):
387        //   * `state` must point to a live `<NodeTy as ExecutableNode>::State`
388        //     produced by this pkg's `i()` (= same as the existing
389        //     `unsafe fn d()` contract).
390        //   * `cb_id_ptr` must point to `cb_id_len` valid UTF-8 bytes
391        //     with `'static` lifetime — produced by the codegen-emitted
392        //     `CallbackId(&'static str)` literals.
393        //   * `ctx` must point to a live `CallbackCtx<'static>` the
394        //     dispatch task owns for the duration of the call.
395        //   * Concurrent calls against the same `state` are forbidden
396        //     (same dispatch-slot non-reentrancy as `unsafe fn d()`).
397        #[unsafe(no_mangle)]
398        pub unsafe extern "C" fn #on_callback_fn_name(
399            state: *mut ::core::ffi::c_void,
400            cb_id_ptr: *const u8,
401            cb_id_len: usize,
402            ctx: *mut ::core::ffi::c_void,
403        ) {
404            // SAFETY: caller upholds the four bullets above.
405            let cb_id = unsafe {
406                let bytes = ::core::slice::from_raw_parts(cb_id_ptr, cb_id_len);
407                let s: &'static str = ::core::str::from_utf8_unchecked(bytes);
408                ::nros::CallbackId(s)
409            };
410            let state_mut = unsafe {
411                &mut *(state as *mut <#node_ty as ::nros::ExecutableNode>::State)
412            };
413            let ctx_mut = unsafe {
414                &mut *(ctx as *mut ::nros::CallbackCtx<'static>)
415            };
416            <#node_ty as ::nros::ExecutableNode>::on_callback(
417                state_mut,
418                ::nros::Callback::__from_id(cb_id),
419                ctx_mut,
420            );
421        }
422
423        /// Phase 216 final wave — framework-side per-Node registration.
424        ///
425        /// The `nros::main!()` proc-macro emits one
426        /// `<pkg>::register_dispatch(&mut executor)?;` call per
427        /// declared Node pkg in the RTIC / Embassy `#[init]` body
428        /// (see `packages/core/nros-macros/src/main_macro.rs`
429        /// `Framework::Rtic` / `Framework::Embassy` emit branches).
430        /// The call:
431        ///
432        ///   1. Constructs the Node's per-pkg `State` blob via
433        ///      `<NodeTy as ExecutableNode>::init()`.
434        ///   2. Leaks the state into a raw `*mut c_void` pointer via
435        ///      `__private_node_state_into_raw` (the same shape the
436        ///      `register(runtime)` wrapper's local `i()` fn uses).
437        ///   3. Pushes `(state_ptr, __nros_node_<pkg>_on_callback)`
438        ///      onto the `Executor`'s dispatch-slot registry, where
439        ///      the framework dispatch task's
440        ///      `executor.dispatch_callback(cb_id, ctx)` calls scan
441        ///      and invoke it.
442        ///
443        /// Returns `Err(())` if the Executor's slot table is full
444        /// (`MAX_NODES` entries — raise via
445        /// `NROS_EXECUTOR_MAX_NODES` at build time). The Entry pkg
446        /// surfaces the failure with `expect("register dispatch")`
447        /// so a too-many-Nodes misconfig fails loud at boot rather
448        /// than dropping dispatch silently.
449        ///
450        /// References `::nros::Executor` which is gated on `rmw-cffi`
451        /// inside the `nros` crate. The Node pkg must depend on `nros`
452        /// with the `rmw-cffi` feature enabled (every shipped 216
453        /// example pkg does). A consumer w/o `rmw-cffi` enabled gets a
454        /// hard `cannot find type Executor` error at expansion site —
455        /// surfaces the misconfig loud rather than silently producing
456        /// a Node pkg that can't be deployed to a framework target.
457        pub fn register_dispatch(
458            executor: &mut ::nros::Executor,
459        ) -> ::core::result::Result<(), ()> {
460            let state: <#node_ty as ::nros::ExecutableNode>::State =
461                <#node_ty as ::nros::ExecutableNode>::init();
462            let state_ptr =
463                ::nros::__private_node_state_into_raw::<#node_ty>(state)
464                    as *mut ::core::ffi::c_void;
465            executor.register_dispatch_slot(state_ptr, #on_callback_fn_name)
466        }
467
468        /// Phase 258 (Track 2, 2a) — owned-spin entry registration. The
469        /// codegen-emitted `run_plan(runtime)` body + `nros::main!`
470        /// owned-spin loop call `<pkg>::register(runtime)?` once per Node.
471        ///
472        /// This now installs through the **uniform `install` seam** — the
473        /// same `nros::install_node_typed` path the C / C++ typed entries
474        /// use — via a raw `*mut Executor` the runtime sink exposes
475        /// (`executor_handle()`), replacing the legacy opaque four-fn-ptr
476        /// `register_dispatch_slot_dyn` bridge. The installed component
477        /// enrolls in the executor's tick registry, so service-client /
478        /// action poll nodes tick (phase-257 D2 closed for owned-spin too).
479        ///
480        /// References `::nros::install_node_typed` → `::nros::Executor`,
481        /// gated on `rmw-cffi` inside `nros`; the Node pkg must depend on
482        /// `nros` with `rmw-cffi` (every owned-spin example pkg does). A
483        /// null handle (a sink with no live executor) maps to
484        /// `RuntimeError::NodeRegister`.
485        pub fn register(
486            runtime: &mut ::nros::__macro_support::nros_platform::RuntimeCtx<'_>,
487        ) -> ::core::result::Result<(), ::nros::__macro_support::nros_platform::RuntimeError> {
488            let executor = runtime.runtime.executor_handle();
489            // SAFETY: `executor` is the runtime sink's live `*mut Executor`
490            // (or null, which `install_node_typed` rejects as an error).
491            match unsafe { ::nros::install_node_typed::<#node_ty>(executor) } {
492                0 => ::core::result::Result::Ok(()),
493                _ => ::core::result::Result::Err(
494                    ::nros::__macro_support::nros_platform::RuntimeError::NodeRegister(#pkg_name_lit),
495                ),
496            }
497        }
498    };
499
500    TokenStream::from(expanded)
501}
502
503fn generate_unit_struct_impl(
504    name: &syn::Ident,
505    impl_generics: &syn::ImplGenerics,
506    ty_generics: &syn::TypeGenerics,
507    where_clause: Option<&syn::WhereClause>,
508    type_name: &str,
509    type_hash: &str,
510) -> TokenStream {
511    let expanded = quote! {
512        impl #impl_generics nros_serdes::Serialize for #name #ty_generics #where_clause {
513            fn serialize(&self, _writer: &mut nros_serdes::CdrWriter) -> Result<(), nros_serdes::SerError> {
514                Ok(())
515            }
516        }
517
518        impl #impl_generics nros_serdes::Deserialize for #name #ty_generics #where_clause {
519            fn deserialize(_reader: &mut nros_serdes::CdrReader) -> Result<Self, nros_serdes::DeserError> {
520                Ok(Self {})
521            }
522        }
523
524        impl #impl_generics nros_core::RosMessage for #name #ty_generics #where_clause {
525            const TYPE_NAME: &'static str = #type_name;
526            const TYPE_HASH: &'static str = #type_hash;
527        }
528    };
529    TokenStream::from(expanded)
530}
531
532/// Derive macro for ROS service types
533///
534/// # Attributes
535///
536/// - `#[ros(service_name = "...")]` - Full ROS service name (required)
537/// - `#[ros(hash = "...")]` - RIHS service hash (required)
538/// - `#[ros(request = "RequestType")]` - Request type name (required)
539/// - `#[ros(reply = "ReplyType")]` - Reply type name (required)
540///
541/// # Example
542///
543/// ```ignore
544/// use nros_macros::RosService;
545///
546/// #[derive(RosService)]
547/// #[ros(service_name = "std_srvs::srv::dds_::Empty_")]
548/// #[ros(hash = "abc123...")]
549/// #[ros(request = "EmptyRequest")]
550/// #[ros(reply = "EmptyReply")]
551/// pub struct Empty;
552/// ```
553#[proc_macro_derive(RosService, attributes(ros))]
554pub fn derive_ros_service(input: TokenStream) -> TokenStream {
555    let input = parse_macro_input!(input as DeriveInput);
556    let name = &input.ident;
557
558    // Extract attributes
559    let mut service_name: Option<String> = None;
560    let mut service_hash: Option<String> = None;
561    let mut request_type: Option<syn::Ident> = None;
562    let mut reply_type: Option<syn::Ident> = None;
563
564    for attr in &input.attrs {
565        if attr.path().is_ident("ros") {
566            let _ = attr.parse_nested_meta(|meta| {
567                if meta.path.is_ident("service_name") {
568                    let value: LitStr = meta.value()?.parse()?;
569                    service_name = Some(value.value());
570                } else if meta.path.is_ident("hash") {
571                    let value: LitStr = meta.value()?.parse()?;
572                    service_hash = Some(value.value());
573                } else if meta.path.is_ident("request") {
574                    let value: LitStr = meta.value()?.parse()?;
575                    request_type = Some(syn::Ident::new(
576                        &value.value(),
577                        proc_macro2::Span::call_site(),
578                    ));
579                } else if meta.path.is_ident("reply") {
580                    let value: LitStr = meta.value()?.parse()?;
581                    reply_type = Some(syn::Ident::new(
582                        &value.value(),
583                        proc_macro2::Span::call_site(),
584                    ));
585                }
586                Ok(())
587            });
588        }
589    }
590
591    let service_name =
592        service_name.unwrap_or_else(|| format!("{}::srv::dds_::{}_", "unknown", name));
593    let service_hash = service_hash.unwrap_or_else(|| "0".repeat(64));
594
595    let request_type = match request_type {
596        Some(t) => t,
597        None => {
598            return syn::Error::new_spanned(
599                &input,
600                "RosService requires #[ros(request = \"...\")]",
601            )
602            .to_compile_error()
603            .into();
604        }
605    };
606
607    let reply_type = match reply_type {
608        Some(t) => t,
609        None => {
610            return syn::Error::new_spanned(&input, "RosService requires #[ros(reply = \"...\")]")
611                .to_compile_error()
612                .into();
613        }
614    };
615
616    let expanded = quote! {
617        impl nros_core::RosService for #name {
618            type Request = #request_type;
619            type Reply = #reply_type;
620
621            const SERVICE_NAME: &'static str = #service_name;
622            const SERVICE_HASH: &'static str = #service_hash;
623        }
624    };
625
626    TokenStream::from(expanded)
627}
628
629#[cfg(test)]
630mod sanitize_tests {
631    use super::sanitize_pkg_name_for_symbol;
632
633    #[test]
634    fn plain_pkg_name_is_passthrough() {
635        assert_eq!(sanitize_pkg_name_for_symbol("talker_pkg"), "talker_pkg");
636    }
637
638    #[test]
639    fn hyphens_become_underscores() {
640        assert_eq!(sanitize_pkg_name_for_symbol("my-cool-pkg"), "my_cool_pkg");
641    }
642
643    #[test]
644    fn mixed_specials_become_underscores() {
645        assert_eq!(sanitize_pkg_name_for_symbol("a.b+c-d"), "a_b_c_d");
646    }
647
648    #[test]
649    fn empty_is_empty() {
650        assert_eq!(sanitize_pkg_name_for_symbol(""), "");
651    }
652}