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}