Skip to main content

nros_serdes/
schema.rs

1//! Static field schema for runtime introspection.
2//!
3//! Each generated message type exposes its CDR field layout as a
4//! `&'static [Field]` slice plus a `&'static str` ROS type name via the
5//! [`Message`] trait. Backends that need to construct type descriptors at
6//! runtime (Cyclone DDS dynamic types, FastRTPS DynamicTypeBuilder, …) walk
7//! this static metadata instead of pulling in per-RMW codegen at compile
8//! time.
9//!
10//! All schema items are `&'static` / [`Copy`] / contain no allocations,
11//! keeping the surface usable on `no_std` + alloc-free embedded targets.
12//!
13//! # Example
14//!
15//! ```
16//! use nros_serdes::schema::{Field, FieldType, Message};
17//!
18//! /// Hand-rolled mirror of `std_msgs/msg/Int32`.
19//! pub struct Int32 {
20//!     pub data: i32,
21//! }
22//!
23//! impl Message for Int32 {
24//!     const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
25//!     const FIELDS: &'static [Field] = &[Field {
26//!         name: "data",
27//!         ty: FieldType::Int32,
28//!         offset: 0,
29//!     }];
30//! }
31//!
32//! assert_eq!(Int32::FIELDS.len(), 1);
33//! assert!(matches!(Int32::FIELDS[0].ty, FieldType::Int32));
34//! ```
35
36/// One field of a ROS message, in declaration order.
37///
38/// `offset` is the byte offset of the field within the host Rust struct
39/// (typically derived from `core::mem::offset_of!`). Backends that build
40/// runtime descriptors use it to compute serializer per-field strides;
41/// pure schema consumers (e.g. type-name renderers) may ignore it.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Field {
44    /// Field name as written in the `.msg` IDL.
45    pub name: &'static str,
46    /// CDR / IDL type of the field.
47    pub ty: FieldType,
48    /// Byte offset of the field within the host Rust struct.
49    pub offset: usize,
50}
51
52/// CDR / ROS-IDL type of a single field.
53///
54/// Covers every variant Cyclone DDS' dynamic-type C API needs for
55/// constructing a `dds_topic_descriptor_t` at runtime:
56///
57/// * primitives (bool, [iu]{8,16,32,64}, f{32,64})
58/// * strings (unbounded / bounded; narrow / wide)
59/// * nested structs (recurse into a child `&'static [Field]`)
60/// * fixed-size arrays (`T[N]`)
61/// * unbounded sequences (`sequence<T>`)
62/// * bounded sequences (`sequence<T, N>`)
63///
64/// The recursive variants (`Nested`, `Array`, `Sequence`, `BoundedSequence`)
65/// take a `&'static` reference so the entire schema graph stays in `.rodata`
66/// with no heap touch.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum FieldType {
69    /// IDL `boolean` — 1 byte, no alignment.
70    Bool,
71    /// IDL `octet` / `uint8` — 1 byte, no alignment.
72    Uint8,
73    /// IDL `int8` — 1 byte, no alignment.
74    Int8,
75    /// IDL `uint16` — 2 bytes, 2-byte aligned.
76    Uint16,
77    /// IDL `int16` — 2 bytes, 2-byte aligned.
78    Int16,
79    /// IDL `uint32` — 4 bytes, 4-byte aligned.
80    Uint32,
81    /// IDL `int32` — 4 bytes, 4-byte aligned.
82    Int32,
83    /// IDL `uint64` — 8 bytes, 8-byte aligned.
84    Uint64,
85    /// IDL `int64` — 8 bytes, 8-byte aligned.
86    Int64,
87    /// IDL `float` / `float32` — 4 bytes, 4-byte aligned.
88    Float32,
89    /// IDL `double` / `float64` — 8 bytes, 8-byte aligned.
90    Float64,
91    /// Unbounded `string` (UTF-8 narrow).
92    String,
93    /// Unbounded `wstring` (UTF-16 wide).
94    WString,
95    /// Bounded `string<N>` (UTF-8 narrow, max `N` bytes excluding null).
96    BoundedString(usize),
97    /// Bounded `wstring<N>` (UTF-16 wide, max `N` code units).
98    BoundedWString(usize),
99    /// Nested struct field; the inner slice is the child's schema.
100    Nested(&'static NestedType),
101    /// Fixed-size array `T[N]`.
102    Array(usize, &'static FieldType),
103    /// Unbounded `sequence<T>`.
104    Sequence(&'static FieldType),
105    /// Bounded `sequence<T, N>`.
106    BoundedSequence(usize, &'static FieldType),
107}
108
109/// Metadata for a nested struct field.
110///
111/// Carried by [`FieldType::Nested`] so the runtime descriptor builder can
112/// recurse into the child with the correct ROS type name (Cyclone DDS uses
113/// it to dedupe identical nested types in the registry).
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct NestedType {
116    /// Full ROS type name of the nested struct, e.g. `"builtin_interfaces/msg/Time"`.
117    pub type_name: &'static str,
118    /// Schema of the nested struct's fields.
119    pub fields: &'static [Field],
120}
121
122/// Trait implemented by every generated ROS message type for runtime
123/// introspection.
124///
125/// Provides the ROS type name plus the static field schema. Implementors
126/// also typically implement [`crate::Serialize`] + [`crate::Deserialize`]
127/// for the CDR fast path; this trait is the *introspection* surface used
128/// by RMW backends that build type descriptors at runtime.
129///
130/// All items are `&'static`, so the trait is fully usable in `no_std` +
131/// alloc-free environments. The blanket bound is just `Sized` — no
132/// `Serialize` / `Deserialize` super-bound, so verification-only mirror
133/// types (`nros-ghost-types`) and CycloneDDS-only "descriptor probe"
134/// types can implement `Message` without dragging the CDR codecs in.
135pub trait Message: Sized {
136    /// ROS topic-type name in `package/msg/Type` form
137    /// (e.g. `"std_msgs/msg/String"`).
138    ///
139    /// Wire-level DDS encoding (`"std_msgs::msg::dds_::String_"`) is the
140    /// concern of the per-RMW topic-name renderer, *not* this trait.
141    const TYPE_NAME: &'static str;
142
143    /// Field schema in declaration order.
144    const FIELDS: &'static [Field];
145
146    /// Phase 380 — the largest this type can serialize to, per encoding, or
147    /// `None` when it is unbounded.
148    ///
149    /// PROVIDED, computed from [`Self::FIELDS`] by `crate::size`, so a generated
150    /// message gets it for free and cannot state a number that disagrees with
151    /// its own schema. There are two because there are genuinely two: the
152    /// encodings pad 8-byte primitives differently and XCDR2 adds a DHEADER per
153    /// struct, so one constant would be silently wrong for one of them.
154    ///
155    /// `None` means "no bound exists", never "unknown" — do not size a buffer
156    /// from a fallback. For an unbounded type ask `size::serialized_size` about
157    /// the message in hand instead.
158    const MAX_SERIALIZED_SIZE_XCDR1: Option<usize> =
159        crate::size::max_serialized_size(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr1);
160
161    /// See [`Self::MAX_SERIALIZED_SIZE_XCDR1`].
162    const MAX_SERIALIZED_SIZE_XCDR2: Option<usize> =
163        crate::size::max_serialized_size(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr2);
164
165    /// No variable-length member anywhere: the size above is EXACT and the type
166    /// is loan-eligible (phase-380 W5).
167    ///
168    /// Encoding-independent — "has a `String` or an unbounded sequence" does not
169    /// depend on how it is packed — so unlike the sizes there is only one.
170    const IS_PLAIN: bool =
171        crate::size::size_bound(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr1, 0).plain;
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use core::mem::offset_of;
178
179    // ── Fixtures: hand-rolled mirrors of real ROS messages ──────────────
180    //
181    // These stand in for what the codegen template (in the standalone
182    // `nros-cli` repo, K.7.1) will eventually emit for every msg crate.
183    // They exist only to exercise the trait surface in isolation; the
184    // real generated msg crates pick up the same impls automatically once
185    // the codegen template is updated.
186
187    /// Mirrors `std_msgs/msg/Int32` (one primitive field).
188    #[repr(C)]
189    struct Int32 {
190        data: i32,
191    }
192
193    impl Message for Int32 {
194        const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
195        const FIELDS: &'static [Field] = &[Field {
196            name: "data",
197            ty: FieldType::Int32,
198            offset: offset_of!(Int32, data),
199        }];
200    }
201
202    /// Mirrors `builtin_interfaces/msg/Time` (two primitives).
203    #[repr(C)]
204    struct Time {
205        sec: i32,
206        nanosec: u32,
207    }
208
209    impl Message for Time {
210        const TYPE_NAME: &'static str = "builtin_interfaces/msg/Time";
211        const FIELDS: &'static [Field] = &[
212            Field {
213                name: "sec",
214                ty: FieldType::Int32,
215                offset: offset_of!(Time, sec),
216            },
217            Field {
218                name: "nanosec",
219                ty: FieldType::Uint32,
220                offset: offset_of!(Time, nanosec),
221            },
222        ];
223    }
224
225    /// `Time`'s schema, re-exposed as a nested-type descriptor for
226    /// recursion testing.
227    const TIME_NESTED: NestedType = NestedType {
228        type_name: <Time as Message>::TYPE_NAME,
229        fields: <Time as Message>::FIELDS,
230    };
231
232    /// Mirrors `std_msgs/msg/Header` (nested struct + string + bounded fields).
233    #[repr(C)]
234    #[allow(dead_code)]
235    struct Header {
236        stamp: Time,
237        frame_id: &'static str, // representative, not real layout
238    }
239
240    impl Message for Header {
241        const TYPE_NAME: &'static str = "std_msgs/msg/Header";
242        const FIELDS: &'static [Field] = &[
243            Field {
244                name: "stamp",
245                ty: FieldType::Nested(&TIME_NESTED),
246                offset: offset_of!(Header, stamp),
247            },
248            Field {
249                name: "frame_id",
250                ty: FieldType::String,
251                offset: offset_of!(Header, frame_id),
252            },
253        ];
254    }
255
256    /// Mirrors a message with every collection-shape variant the runtime
257    /// descriptor builder needs to handle.
258    #[repr(C)]
259    #[allow(dead_code)]
260    struct Collections {
261        fixed: [i32; 4],
262        bytes: &'static [u8],
263        bounded_seq: &'static [u8],
264        bounded_str: &'static str,
265        bounded_wstr: &'static str,
266        wide: &'static str,
267    }
268
269    const FIXED_I32: FieldType = FieldType::Int32;
270    const SEQ_U8: FieldType = FieldType::Uint8;
271
272    impl Message for Collections {
273        const TYPE_NAME: &'static str = "test_msgs/msg/Collections";
274        const FIELDS: &'static [Field] = &[
275            Field {
276                name: "fixed",
277                ty: FieldType::Array(4, &FIXED_I32),
278                offset: offset_of!(Collections, fixed),
279            },
280            Field {
281                name: "bytes",
282                ty: FieldType::Sequence(&SEQ_U8),
283                offset: offset_of!(Collections, bytes),
284            },
285            Field {
286                name: "bounded_seq",
287                ty: FieldType::BoundedSequence(16, &SEQ_U8),
288                offset: offset_of!(Collections, bounded_seq),
289            },
290            Field {
291                name: "bounded_str",
292                ty: FieldType::BoundedString(32),
293                offset: offset_of!(Collections, bounded_str),
294            },
295            Field {
296                name: "bounded_wstr",
297                ty: FieldType::BoundedWString(8),
298                offset: offset_of!(Collections, bounded_wstr),
299            },
300            Field {
301                name: "wide",
302                ty: FieldType::WString,
303                offset: offset_of!(Collections, wide),
304            },
305        ];
306    }
307
308    // ── Tests: shape of the public surface ──────────────────────────────
309
310    #[test]
311    fn message_consts_visible_in_const_context() {
312        // If `TYPE_NAME` / `FIELDS` weren't `const`, this wouldn't compile.
313        const NAME: &str = <Int32 as Message>::TYPE_NAME;
314        const FIELDS: &[Field] = <Int32 as Message>::FIELDS;
315        assert_eq!(NAME, "std_msgs/msg/Int32");
316        assert_eq!(FIELDS.len(), 1);
317    }
318
319    #[test]
320    fn primitive_field_round_trip() {
321        let f = Int32::FIELDS[0];
322        assert_eq!(f.name, "data");
323        assert!(matches!(f.ty, FieldType::Int32));
324        assert_eq!(f.offset, 0);
325    }
326
327    #[test]
328    fn multi_field_offsets_match_struct_layout() {
329        let fields = Time::FIELDS;
330        assert_eq!(fields.len(), 2);
331        assert_eq!(fields[0].name, "sec");
332        assert_eq!(fields[1].name, "nanosec");
333        // sec is at offset 0, nanosec immediately after on a #[repr(C)] {i32, u32}.
334        assert_eq!(fields[0].offset, 0);
335        assert_eq!(fields[1].offset, 4);
336    }
337
338    #[test]
339    fn nested_field_recurses_into_child_schema() {
340        let fields = Header::FIELDS;
341        assert_eq!(fields.len(), 2);
342        match fields[0].ty {
343            FieldType::Nested(nested) => {
344                assert_eq!(nested.type_name, "builtin_interfaces/msg/Time");
345                assert_eq!(nested.fields.len(), 2);
346                assert_eq!(nested.fields[0].name, "sec");
347            }
348            _ => panic!("expected Nested variant"),
349        }
350        assert!(matches!(fields[1].ty, FieldType::String));
351    }
352
353    #[test]
354    fn collection_variants_cover_array_sequence_bounded_string() {
355        let fields = Collections::FIELDS;
356        assert_eq!(fields.len(), 6);
357
358        assert!(matches!(
359            fields[0].ty,
360            FieldType::Array(4, inner) if matches!(*inner, FieldType::Int32),
361        ));
362        assert!(matches!(
363            fields[1].ty,
364            FieldType::Sequence(inner) if matches!(*inner, FieldType::Uint8),
365        ));
366        assert!(matches!(
367            fields[2].ty,
368            FieldType::BoundedSequence(16, inner) if matches!(*inner, FieldType::Uint8),
369        ));
370        assert!(matches!(fields[3].ty, FieldType::BoundedString(32)));
371        assert!(matches!(fields[4].ty, FieldType::BoundedWString(8)));
372        assert!(matches!(fields[5].ty, FieldType::WString));
373    }
374
375    #[test]
376    fn field_and_fieldtype_are_copy_and_eq() {
377        // Compile-time check via trait bound: forces Copy + Eq.
378        fn assert_copy_eq<T: Copy + Eq>() {}
379        assert_copy_eq::<Field>();
380        assert_copy_eq::<FieldType>();
381        assert_copy_eq::<NestedType>();
382
383        // Spot-check Eq equality.
384        let a = Field {
385            name: "x",
386            ty: FieldType::Int32,
387            offset: 4,
388        };
389        let b = a; // Copy
390        assert_eq!(a, b);
391    }
392
393    #[test]
394    fn all_primitive_variants_constructible() {
395        // Smoke: walking a slice of every primitive variant compiles
396        // and matches end-to-end. If any variant is removed the match
397        // becomes non-exhaustive.
398        const PRIMS: &[FieldType] = &[
399            FieldType::Bool,
400            FieldType::Uint8,
401            FieldType::Int8,
402            FieldType::Uint16,
403            FieldType::Int16,
404            FieldType::Uint32,
405            FieldType::Int32,
406            FieldType::Uint64,
407            FieldType::Int64,
408            FieldType::Float32,
409            FieldType::Float64,
410            FieldType::String,
411            FieldType::WString,
412        ];
413        assert_eq!(PRIMS.len(), 13);
414
415        let mut seen = 0u32;
416        for ty in PRIMS {
417            seen += match ty {
418                FieldType::Bool => 1 << 0,
419                FieldType::Uint8 => 1 << 1,
420                FieldType::Int8 => 1 << 2,
421                FieldType::Uint16 => 1 << 3,
422                FieldType::Int16 => 1 << 4,
423                FieldType::Uint32 => 1 << 5,
424                FieldType::Int32 => 1 << 6,
425                FieldType::Uint64 => 1 << 7,
426                FieldType::Int64 => 1 << 8,
427                FieldType::Float32 => 1 << 9,
428                FieldType::Float64 => 1 << 10,
429                FieldType::String => 1 << 11,
430                FieldType::WString => 1 << 12,
431                FieldType::BoundedString(_)
432                | FieldType::BoundedWString(_)
433                | FieldType::Nested(_)
434                | FieldType::Array(..)
435                | FieldType::Sequence(_)
436                | FieldType::BoundedSequence(..) => 0,
437            };
438        }
439        assert_eq!(seen, (1u32 << 13) - 1, "every primitive variant matched");
440    }
441}