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/// The member rosidl gives a struct that declares none (issue 1293).
123///
124/// `rosidl_adapter` emits `uint8 structure_needs_at_least_one_member;` for an
125/// empty `.msg` / `.srv` half, so every stock typesupport has one member to
126/// serialize and an empty message is ONE byte on the wire, not zero. Measured
127/// on humble: `rmw_zenoh_cpp`, `rmw_cyclonedds_cpp` and `rmw_fastrtps_cpp` all
128/// serialize `std_msgs/Empty` with that byte, and all three REFUSE to
129/// deserialize the encapsulation header alone ("Not enough memory in the
130/// buffer stream" / "invalid data size").
131pub const EMPTY_STRUCT_MEMBER: &str = "structure_needs_at_least_one_member";
132
133/// The schema of a struct that declares no fields: the one padding member
134/// rosidl adds ([`EMPTY_STRUCT_MEMBER`]). Codegen emits this for every empty
135/// message, so the schema describes the byte the serializer writes and a size
136/// bound, a Cyclone descriptor or a schema walk derived from `FIELDS` agrees
137/// with the wire. It has no host storage, so its offset is 0.
138pub const EMPTY_STRUCT_FIELDS: &[Field] = &[Field {
139    name: EMPTY_STRUCT_MEMBER,
140    ty: FieldType::Uint8,
141    offset: 0,
142}];
143
144/// Trait implemented by every generated ROS message type for runtime
145/// introspection.
146///
147/// Provides the ROS type name plus the static field schema. Implementors
148/// also typically implement [`crate::Serialize`] + [`crate::Deserialize`]
149/// for the CDR fast path; this trait is the *introspection* surface used
150/// by RMW backends that build type descriptors at runtime.
151///
152/// All items are `&'static`, so the trait is fully usable in `no_std` +
153/// alloc-free environments. The blanket bound is just `Sized` — no
154/// `Serialize` / `Deserialize` super-bound, so verification-only mirror
155/// types (`nros-ghost-types`) and CycloneDDS-only "descriptor probe"
156/// types can implement `Message` without dragging the CDR codecs in.
157pub trait Message: Sized {
158    /// ROS topic-type name in `package/msg/Type` form
159    /// (e.g. `"std_msgs/msg/String"`).
160    ///
161    /// Wire-level DDS encoding (`"std_msgs::msg::dds_::String_"`) is the
162    /// concern of the per-RMW topic-name renderer, *not* this trait.
163    const TYPE_NAME: &'static str;
164
165    /// Field schema in declaration order.
166    const FIELDS: &'static [Field];
167
168    /// Phase 380 — the largest this type can serialize to, per encoding, or
169    /// `None` when it is unbounded.
170    ///
171    /// PROVIDED, computed from [`Self::FIELDS`] by `crate::size`, so a generated
172    /// message gets it for free and cannot state a number that disagrees with
173    /// its own schema. There are two because there are genuinely two: the
174    /// encodings pad 8-byte primitives differently and XCDR2 adds a DHEADER per
175    /// struct, so one constant would be silently wrong for one of them.
176    ///
177    /// `None` means "no bound exists", never "unknown" — do not size a buffer
178    /// from a fallback. For an unbounded type ask `size::serialized_size` about
179    /// the message in hand instead.
180    const MAX_SERIALIZED_SIZE_XCDR1: Option<usize> =
181        crate::size::max_serialized_size(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr1);
182
183    /// See [`Self::MAX_SERIALIZED_SIZE_XCDR1`].
184    const MAX_SERIALIZED_SIZE_XCDR2: Option<usize> =
185        crate::size::max_serialized_size(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr2);
186
187    /// No variable-length member anywhere: the size above is EXACT and the type
188    /// is loan-eligible (phase-380 W5).
189    ///
190    /// Encoding-independent — "has a `String` or an unbounded sequence" does not
191    /// depend on how it is packed — so unlike the sizes there is only one. Note
192    /// the FLAG is encoding-independent, not the size it qualifies: a plain
193    /// type's bound still differs per encoding, because XCDR2 adds a
194    /// fixed-width DHEADER.
195    ///
196    /// **Diagnostic today: no dispatch reads it** (issues 1422 / 1369). It exists
197    /// so that "fixed layout" has exactly one definition in the tree, reachable
198    /// as [`crate::size::is_loan_eligible`]; issue 0814 step 5 is the wiring that
199    /// would consume it, and 0814 argues for not building it yet. See
200    /// [`crate::size::SizeBound::plain`] for the full note.
201    const IS_PLAIN: bool =
202        crate::size::size_bound(Self::FIELDS, crate::cdr::EncodingVersion::Xcdr1, 0).plain;
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use core::mem::offset_of;
209
210    // ── Fixtures: hand-rolled mirrors of real ROS messages ──────────────
211    //
212    // These stand in for what the codegen template (in the standalone
213    // `nros-cli` repo, K.7.1) will eventually emit for every msg crate.
214    // They exist only to exercise the trait surface in isolation; the
215    // real generated msg crates pick up the same impls automatically once
216    // the codegen template is updated.
217
218    /// Mirrors `std_msgs/msg/Int32` (one primitive field).
219    #[repr(C)]
220    struct Int32 {
221        data: i32,
222    }
223
224    impl Message for Int32 {
225        const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
226        const FIELDS: &'static [Field] = &[Field {
227            name: "data",
228            ty: FieldType::Int32,
229            offset: offset_of!(Int32, data),
230        }];
231    }
232
233    /// Mirrors `builtin_interfaces/msg/Time` (two primitives).
234    #[repr(C)]
235    struct Time {
236        sec: i32,
237        nanosec: u32,
238    }
239
240    impl Message for Time {
241        const TYPE_NAME: &'static str = "builtin_interfaces/msg/Time";
242        const FIELDS: &'static [Field] = &[
243            Field {
244                name: "sec",
245                ty: FieldType::Int32,
246                offset: offset_of!(Time, sec),
247            },
248            Field {
249                name: "nanosec",
250                ty: FieldType::Uint32,
251                offset: offset_of!(Time, nanosec),
252            },
253        ];
254    }
255
256    /// `Time`'s schema, re-exposed as a nested-type descriptor for
257    /// recursion testing.
258    const TIME_NESTED: NestedType = NestedType {
259        type_name: <Time as Message>::TYPE_NAME,
260        fields: <Time as Message>::FIELDS,
261    };
262
263    /// Mirrors `std_msgs/msg/Header` (nested struct + string + bounded fields).
264    #[repr(C)]
265    #[allow(dead_code)]
266    struct Header {
267        stamp: Time,
268        frame_id: &'static str, // representative, not real layout
269    }
270
271    impl Message for Header {
272        const TYPE_NAME: &'static str = "std_msgs/msg/Header";
273        const FIELDS: &'static [Field] = &[
274            Field {
275                name: "stamp",
276                ty: FieldType::Nested(&TIME_NESTED),
277                offset: offset_of!(Header, stamp),
278            },
279            Field {
280                name: "frame_id",
281                ty: FieldType::String,
282                offset: offset_of!(Header, frame_id),
283            },
284        ];
285    }
286
287    /// Mirrors a message with every collection-shape variant the runtime
288    /// descriptor builder needs to handle.
289    #[repr(C)]
290    #[allow(dead_code)]
291    struct Collections {
292        fixed: [i32; 4],
293        bytes: &'static [u8],
294        bounded_seq: &'static [u8],
295        bounded_str: &'static str,
296        bounded_wstr: &'static str,
297        wide: &'static str,
298    }
299
300    const FIXED_I32: FieldType = FieldType::Int32;
301    const SEQ_U8: FieldType = FieldType::Uint8;
302
303    impl Message for Collections {
304        const TYPE_NAME: &'static str = "test_msgs/msg/Collections";
305        const FIELDS: &'static [Field] = &[
306            Field {
307                name: "fixed",
308                ty: FieldType::Array(4, &FIXED_I32),
309                offset: offset_of!(Collections, fixed),
310            },
311            Field {
312                name: "bytes",
313                ty: FieldType::Sequence(&SEQ_U8),
314                offset: offset_of!(Collections, bytes),
315            },
316            Field {
317                name: "bounded_seq",
318                ty: FieldType::BoundedSequence(16, &SEQ_U8),
319                offset: offset_of!(Collections, bounded_seq),
320            },
321            Field {
322                name: "bounded_str",
323                ty: FieldType::BoundedString(32),
324                offset: offset_of!(Collections, bounded_str),
325            },
326            Field {
327                name: "bounded_wstr",
328                ty: FieldType::BoundedWString(8),
329                offset: offset_of!(Collections, bounded_wstr),
330            },
331            Field {
332                name: "wide",
333                ty: FieldType::WString,
334                offset: offset_of!(Collections, wide),
335            },
336        ];
337    }
338
339    // ── Tests: shape of the public surface ──────────────────────────────
340
341    #[test]
342    fn message_consts_visible_in_const_context() {
343        // If `TYPE_NAME` / `FIELDS` weren't `const`, this wouldn't compile.
344        const NAME: &str = <Int32 as Message>::TYPE_NAME;
345        const FIELDS: &[Field] = <Int32 as Message>::FIELDS;
346        assert_eq!(NAME, "std_msgs/msg/Int32");
347        assert_eq!(FIELDS.len(), 1);
348    }
349
350    #[test]
351    fn primitive_field_round_trip() {
352        let f = Int32::FIELDS[0];
353        assert_eq!(f.name, "data");
354        assert!(matches!(f.ty, FieldType::Int32));
355        assert_eq!(f.offset, 0);
356    }
357
358    #[test]
359    fn multi_field_offsets_match_struct_layout() {
360        let fields = Time::FIELDS;
361        assert_eq!(fields.len(), 2);
362        assert_eq!(fields[0].name, "sec");
363        assert_eq!(fields[1].name, "nanosec");
364        // sec is at offset 0, nanosec immediately after on a #[repr(C)] {i32, u32}.
365        assert_eq!(fields[0].offset, 0);
366        assert_eq!(fields[1].offset, 4);
367    }
368
369    #[test]
370    fn nested_field_recurses_into_child_schema() {
371        let fields = Header::FIELDS;
372        assert_eq!(fields.len(), 2);
373        match fields[0].ty {
374            FieldType::Nested(nested) => {
375                assert_eq!(nested.type_name, "builtin_interfaces/msg/Time");
376                assert_eq!(nested.fields.len(), 2);
377                assert_eq!(nested.fields[0].name, "sec");
378            }
379            _ => panic!("expected Nested variant"),
380        }
381        assert!(matches!(fields[1].ty, FieldType::String));
382    }
383
384    #[test]
385    fn collection_variants_cover_array_sequence_bounded_string() {
386        let fields = Collections::FIELDS;
387        assert_eq!(fields.len(), 6);
388
389        assert!(matches!(
390            fields[0].ty,
391            FieldType::Array(4, inner) if matches!(*inner, FieldType::Int32),
392        ));
393        assert!(matches!(
394            fields[1].ty,
395            FieldType::Sequence(inner) if matches!(*inner, FieldType::Uint8),
396        ));
397        assert!(matches!(
398            fields[2].ty,
399            FieldType::BoundedSequence(16, inner) if matches!(*inner, FieldType::Uint8),
400        ));
401        assert!(matches!(fields[3].ty, FieldType::BoundedString(32)));
402        assert!(matches!(fields[4].ty, FieldType::BoundedWString(8)));
403        assert!(matches!(fields[5].ty, FieldType::WString));
404    }
405
406    #[test]
407    fn field_and_fieldtype_are_copy_and_eq() {
408        // Compile-time check via trait bound: forces Copy + Eq.
409        fn assert_copy_eq<T: Copy + Eq>() {}
410        assert_copy_eq::<Field>();
411        assert_copy_eq::<FieldType>();
412        assert_copy_eq::<NestedType>();
413
414        // Spot-check Eq equality.
415        let a = Field {
416            name: "x",
417            ty: FieldType::Int32,
418            offset: 4,
419        };
420        let b = a; // Copy
421        assert_eq!(a, b);
422    }
423
424    #[test]
425    fn all_primitive_variants_constructible() {
426        // Smoke: walking a slice of every primitive variant compiles
427        // and matches end-to-end. If any variant is removed the match
428        // becomes non-exhaustive.
429        const PRIMS: &[FieldType] = &[
430            FieldType::Bool,
431            FieldType::Uint8,
432            FieldType::Int8,
433            FieldType::Uint16,
434            FieldType::Int16,
435            FieldType::Uint32,
436            FieldType::Int32,
437            FieldType::Uint64,
438            FieldType::Int64,
439            FieldType::Float32,
440            FieldType::Float64,
441            FieldType::String,
442            FieldType::WString,
443        ];
444        assert_eq!(PRIMS.len(), 13);
445
446        let mut seen = 0u32;
447        for ty in PRIMS {
448            seen += match ty {
449                FieldType::Bool => 1 << 0,
450                FieldType::Uint8 => 1 << 1,
451                FieldType::Int8 => 1 << 2,
452                FieldType::Uint16 => 1 << 3,
453                FieldType::Int16 => 1 << 4,
454                FieldType::Uint32 => 1 << 5,
455                FieldType::Int32 => 1 << 6,
456                FieldType::Uint64 => 1 << 7,
457                FieldType::Int64 => 1 << 8,
458                FieldType::Float32 => 1 << 9,
459                FieldType::Float64 => 1 << 10,
460                FieldType::String => 1 << 11,
461                FieldType::WString => 1 << 12,
462                FieldType::BoundedString(_)
463                | FieldType::BoundedWString(_)
464                | FieldType::Nested(_)
465                | FieldType::Array(..)
466                | FieldType::Sequence(_)
467                | FieldType::BoundedSequence(..) => 0,
468            };
469        }
470        assert_eq!(seen, (1u32 << 13) - 1, "every primitive variant matched");
471    }
472}