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}