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
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use core::mem::offset_of;
151
152 // ── Fixtures: hand-rolled mirrors of real ROS messages ──────────────
153 //
154 // These stand in for what the codegen template (in the standalone
155 // `nros-cli` repo, K.7.1) will eventually emit for every msg crate.
156 // They exist only to exercise the trait surface in isolation; the
157 // real generated msg crates pick up the same impls automatically once
158 // the codegen template is updated.
159
160 /// Mirrors `std_msgs/msg/Int32` (one primitive field).
161 #[repr(C)]
162 struct Int32 {
163 data: i32,
164 }
165
166 impl Message for Int32 {
167 const TYPE_NAME: &'static str = "std_msgs/msg/Int32";
168 const FIELDS: &'static [Field] = &[Field {
169 name: "data",
170 ty: FieldType::Int32,
171 offset: offset_of!(Int32, data),
172 }];
173 }
174
175 /// Mirrors `builtin_interfaces/msg/Time` (two primitives).
176 #[repr(C)]
177 struct Time {
178 sec: i32,
179 nanosec: u32,
180 }
181
182 impl Message for Time {
183 const TYPE_NAME: &'static str = "builtin_interfaces/msg/Time";
184 const FIELDS: &'static [Field] = &[
185 Field {
186 name: "sec",
187 ty: FieldType::Int32,
188 offset: offset_of!(Time, sec),
189 },
190 Field {
191 name: "nanosec",
192 ty: FieldType::Uint32,
193 offset: offset_of!(Time, nanosec),
194 },
195 ];
196 }
197
198 /// `Time`'s schema, re-exposed as a nested-type descriptor for
199 /// recursion testing.
200 const TIME_NESTED: NestedType = NestedType {
201 type_name: <Time as Message>::TYPE_NAME,
202 fields: <Time as Message>::FIELDS,
203 };
204
205 /// Mirrors `std_msgs/msg/Header` (nested struct + string + bounded fields).
206 #[repr(C)]
207 #[allow(dead_code)]
208 struct Header {
209 stamp: Time,
210 frame_id: &'static str, // representative, not real layout
211 }
212
213 impl Message for Header {
214 const TYPE_NAME: &'static str = "std_msgs/msg/Header";
215 const FIELDS: &'static [Field] = &[
216 Field {
217 name: "stamp",
218 ty: FieldType::Nested(&TIME_NESTED),
219 offset: offset_of!(Header, stamp),
220 },
221 Field {
222 name: "frame_id",
223 ty: FieldType::String,
224 offset: offset_of!(Header, frame_id),
225 },
226 ];
227 }
228
229 /// Mirrors a message with every collection-shape variant the runtime
230 /// descriptor builder needs to handle.
231 #[repr(C)]
232 #[allow(dead_code)]
233 struct Collections {
234 fixed: [i32; 4],
235 bytes: &'static [u8],
236 bounded_seq: &'static [u8],
237 bounded_str: &'static str,
238 bounded_wstr: &'static str,
239 wide: &'static str,
240 }
241
242 const FIXED_I32: FieldType = FieldType::Int32;
243 const SEQ_U8: FieldType = FieldType::Uint8;
244
245 impl Message for Collections {
246 const TYPE_NAME: &'static str = "test_msgs/msg/Collections";
247 const FIELDS: &'static [Field] = &[
248 Field {
249 name: "fixed",
250 ty: FieldType::Array(4, &FIXED_I32),
251 offset: offset_of!(Collections, fixed),
252 },
253 Field {
254 name: "bytes",
255 ty: FieldType::Sequence(&SEQ_U8),
256 offset: offset_of!(Collections, bytes),
257 },
258 Field {
259 name: "bounded_seq",
260 ty: FieldType::BoundedSequence(16, &SEQ_U8),
261 offset: offset_of!(Collections, bounded_seq),
262 },
263 Field {
264 name: "bounded_str",
265 ty: FieldType::BoundedString(32),
266 offset: offset_of!(Collections, bounded_str),
267 },
268 Field {
269 name: "bounded_wstr",
270 ty: FieldType::BoundedWString(8),
271 offset: offset_of!(Collections, bounded_wstr),
272 },
273 Field {
274 name: "wide",
275 ty: FieldType::WString,
276 offset: offset_of!(Collections, wide),
277 },
278 ];
279 }
280
281 // ── Tests: shape of the public surface ──────────────────────────────
282
283 #[test]
284 fn message_consts_visible_in_const_context() {
285 // If `TYPE_NAME` / `FIELDS` weren't `const`, this wouldn't compile.
286 const NAME: &str = <Int32 as Message>::TYPE_NAME;
287 const FIELDS: &[Field] = <Int32 as Message>::FIELDS;
288 assert_eq!(NAME, "std_msgs/msg/Int32");
289 assert_eq!(FIELDS.len(), 1);
290 }
291
292 #[test]
293 fn primitive_field_round_trip() {
294 let f = Int32::FIELDS[0];
295 assert_eq!(f.name, "data");
296 assert!(matches!(f.ty, FieldType::Int32));
297 assert_eq!(f.offset, 0);
298 }
299
300 #[test]
301 fn multi_field_offsets_match_struct_layout() {
302 let fields = Time::FIELDS;
303 assert_eq!(fields.len(), 2);
304 assert_eq!(fields[0].name, "sec");
305 assert_eq!(fields[1].name, "nanosec");
306 // sec is at offset 0, nanosec immediately after on a #[repr(C)] {i32, u32}.
307 assert_eq!(fields[0].offset, 0);
308 assert_eq!(fields[1].offset, 4);
309 }
310
311 #[test]
312 fn nested_field_recurses_into_child_schema() {
313 let fields = Header::FIELDS;
314 assert_eq!(fields.len(), 2);
315 match fields[0].ty {
316 FieldType::Nested(nested) => {
317 assert_eq!(nested.type_name, "builtin_interfaces/msg/Time");
318 assert_eq!(nested.fields.len(), 2);
319 assert_eq!(nested.fields[0].name, "sec");
320 }
321 _ => panic!("expected Nested variant"),
322 }
323 assert!(matches!(fields[1].ty, FieldType::String));
324 }
325
326 #[test]
327 fn collection_variants_cover_array_sequence_bounded_string() {
328 let fields = Collections::FIELDS;
329 assert_eq!(fields.len(), 6);
330
331 assert!(matches!(
332 fields[0].ty,
333 FieldType::Array(4, inner) if matches!(*inner, FieldType::Int32),
334 ));
335 assert!(matches!(
336 fields[1].ty,
337 FieldType::Sequence(inner) if matches!(*inner, FieldType::Uint8),
338 ));
339 assert!(matches!(
340 fields[2].ty,
341 FieldType::BoundedSequence(16, inner) if matches!(*inner, FieldType::Uint8),
342 ));
343 assert!(matches!(fields[3].ty, FieldType::BoundedString(32)));
344 assert!(matches!(fields[4].ty, FieldType::BoundedWString(8)));
345 assert!(matches!(fields[5].ty, FieldType::WString));
346 }
347
348 #[test]
349 fn field_and_fieldtype_are_copy_and_eq() {
350 // Compile-time check via trait bound: forces Copy + Eq.
351 fn assert_copy_eq<T: Copy + Eq>() {}
352 assert_copy_eq::<Field>();
353 assert_copy_eq::<FieldType>();
354 assert_copy_eq::<NestedType>();
355
356 // Spot-check Eq equality.
357 let a = Field {
358 name: "x",
359 ty: FieldType::Int32,
360 offset: 4,
361 };
362 let b = a; // Copy
363 assert_eq!(a, b);
364 }
365
366 #[test]
367 fn all_primitive_variants_constructible() {
368 // Smoke: walking a slice of every primitive variant compiles
369 // and matches end-to-end. If any variant is removed the match
370 // becomes non-exhaustive.
371 const PRIMS: &[FieldType] = &[
372 FieldType::Bool,
373 FieldType::Uint8,
374 FieldType::Int8,
375 FieldType::Uint16,
376 FieldType::Int16,
377 FieldType::Uint32,
378 FieldType::Int32,
379 FieldType::Uint64,
380 FieldType::Int64,
381 FieldType::Float32,
382 FieldType::Float64,
383 FieldType::String,
384 FieldType::WString,
385 ];
386 assert_eq!(PRIMS.len(), 13);
387
388 let mut seen = 0u32;
389 for ty in PRIMS {
390 seen += match ty {
391 FieldType::Bool => 1 << 0,
392 FieldType::Uint8 => 1 << 1,
393 FieldType::Int8 => 1 << 2,
394 FieldType::Uint16 => 1 << 3,
395 FieldType::Int16 => 1 << 4,
396 FieldType::Uint32 => 1 << 5,
397 FieldType::Int32 => 1 << 6,
398 FieldType::Uint64 => 1 << 7,
399 FieldType::Int64 => 1 << 8,
400 FieldType::Float32 => 1 << 9,
401 FieldType::Float64 => 1 << 10,
402 FieldType::String => 1 << 11,
403 FieldType::WString => 1 << 12,
404 FieldType::BoundedString(_)
405 | FieldType::BoundedWString(_)
406 | FieldType::Nested(_)
407 | FieldType::Array(..)
408 | FieldType::Sequence(_)
409 | FieldType::BoundedSequence(..) => 0,
410 };
411 }
412 assert_eq!(seen, (1u32 << 13) - 1, "every primitive variant matched");
413 }
414}