Skip to main content

zerodds_wire_probe/
dump_cpp.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The value-printing code the Fast DDS probe needs to answer with.
4//!
5//! `dump.rs` writes C, for the two generators whose output is a plain
6//! `struct`. Fast DDS generates a C++ class, so every member is read
7//! through its getter and the expressions have a shape of their own —
8//! the same split `fill.rs` and `fill_cpp.rs` have in the other
9//! direction.
10//!
11//! ```text
12//! sample.x                       // Cyclone, RTI
13//! sample.x()                     // Fast DDS
14//! sample.location.x              // Cyclone, RTI
15//! sample.location().x()          // Fast DDS
16//! ```
17//!
18//! ## Why this is not a self-oracle
19//!
20//! The statements read the *vendor's* object, which the *vendor's*
21//! deserialiser filled. A printer that read the wrong member shows up as
22//! a disagreement between the implementations, never as a false
23//! agreement.
24//!
25//! ## What the C++ binding has that the C one does not
26//!
27//! Three constructs `dump.rs` refuses are printable here, because the
28//! C++ mapping does define them: a bitset is a struct of public C++
29//! bit-fields, a `map` is a `std::map`, and a `wstring` is a
30//! `std::wstring` whose element is `wchar_t`. Only `fixed<P, S>` stays
31//! refused, and there because `fastddsgen` rejects it in the IDL before
32//! any accessor exists to read.
33
34use crate::contract;
35use crate::encode::ProbeError;
36use crate::fill_cpp::cpp_name;
37use zerodds_ast::{Ast, CharWidth, PrimitiveKind, TypeKind, TypeNode};
38use zerodds_ast_core::Id;
39
40/// Where one value is read from, in the two shapes Fast DDS's generated
41/// code offers.
42enum Place {
43    /// A member of an aggregate, read through its getter.
44    Accessor {
45        /// The expression naming the aggregate.
46        object: String,
47        /// The member's name, which is also the accessor's name.
48        name: String,
49    },
50    /// An element of a `std::array`, a `std::vector` or a `std::map`,
51    /// which is a plain lvalue.
52    Element(String),
53}
54
55impl Place {
56    fn read(&self) -> String {
57        match self {
58            Self::Accessor { object, name } => format!("{object}.{name}()"),
59            Self::Element(expression) => expression.clone(),
60        }
61    }
62}
63
64#[derive(Debug, Default)]
65struct Dump {
66    body: String,
67    depth: usize,
68    next: usize,
69    /// The aggregates on a cycle, each of which gets a function of its
70    /// own — `zd_rec::dump_<position>` — instead of an inline
71    /// expansion, which for a cycle has no end. See
72    /// [`crate::recursion::recursive_aggregates`].
73    rec: Vec<Id<TypeNode>>,
74}
75
76impl Dump {
77    fn line(&mut self, text: &str) {
78        for _ in 0..self.depth {
79            self.body.push_str("    ");
80        }
81        self.body.push_str(text);
82        self.body.push('\n');
83    }
84
85    fn open(&mut self, text: &str) {
86        self.line(text);
87        self.depth += 1;
88    }
89
90    fn close(&mut self, text: &str) {
91        self.depth = self.depth.saturating_sub(1);
92        self.line(text);
93    }
94
95    fn raw(&mut self, literal: &str) {
96        let escaped = literal.replace('\\', "\\\\").replace('"', "\\\"");
97        self.line(&format!("zdj_raw(\"{escaped}\");"));
98    }
99
100    /// A distinct name, so two nested loops do not shadow each other in
101    /// a way that reads as working and iterates the wrong collection.
102    fn temporary(&mut self, stem: &str) -> String {
103        self.next += 1;
104        format!("zd_{stem}_{}", self.next)
105    }
106}
107
108/// Emit the C++ statements that print `sample` as canonical JSON.
109///
110/// # Errors
111/// [`ProbeError`], naming the path in the type.
112pub fn dump_fastdds(ast: &Ast, type_: Id<TypeNode>) -> Result<String, ProbeError> {
113    // Before `recursive_aggregates`; see `contract::reject_alias_cycle`.
114    contract::reject_alias_cycle(ast)?;
115    let mut out = Dump {
116        rec: crate::recursion::recursive_aggregates(ast, type_),
117        ..Dump::default()
118    };
119    if !out.rec.is_empty() {
120        // A local struct, because `dump.inc` lands inside `main` and a
121        // plain function cannot be declared there; static members can,
122        // and they see each other regardless of order, which is what a
123        // mutual cycle needs — the same shape `corba_cpp.rs` emits.
124        out.open("struct zd_rec {");
125        for position in 0..out.rec.len() {
126            let id = out.rec[position];
127            let name = crate::fill_cpp::cpp_name(ast, id);
128            out.open(&format!(
129                "static void dump_{position}(const {name} &zdv) {{"
130            ));
131            emit_rec_body(ast, id, &mut out, &format!("<{name}>"))?;
132            out.close("}");
133        }
134        out.close("};");
135    }
136    emit(
137        ast,
138        type_,
139        &Place::Element(String::from("sample")),
140        &mut out,
141        "",
142    )?;
143    out.line("zdj_raw(\"\\n\");");
144    Ok(out.body)
145}
146
147/// The body of one cycle function: the aggregate's own expansion, with
148/// the value behind the reference parameter.
149fn emit_rec_body(
150    ast: &Ast,
151    type_: Id<TypeNode>,
152    out: &mut Dump,
153    path: &str,
154) -> Result<(), ProbeError> {
155    let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
156        detail: format!("{error}"),
157    })?;
158    match &node.kind {
159        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
160            emit_aggregate(ast, type_, "zdv", out, path)
161        }
162        TypeKind::Union { .. } => emit_union(ast, type_, "zdv", out, path),
163        _ => Err(ProbeError::NoLayout {
164            detail: format!("{path}: a type on a cycle is not an aggregate"),
165        }),
166    }
167}
168
169/// zerodds-lint: recursion-depth 64 — the type graph's own nesting. There
170/// is no sample here to end the walk, so the cycle is cut instead: an
171/// aggregate named by `crate::recursion::recursive_aggregates` becomes a
172/// call to the function emitted for it and is a leaf of this walk, which
173/// therefore descends an acyclic graph. The typedef arm is finite because
174/// `contract::reject_alias_cycle` at the entry point has refused a chain
175/// that returns to itself. The corpus's deepest nesting is four.
176#[allow(clippy::too_many_lines)]
177fn emit(
178    ast: &Ast,
179    type_: Id<TypeNode>,
180    place: &Place,
181    out: &mut Dump,
182    path: &str,
183) -> Result<(), ProbeError> {
184    let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
185        detail: format!("{error}"),
186    })?;
187    let here = place.read();
188    match &node.kind {
189        // As in `fill_cpp.rs`: printing it as a `double` would report
190        // agreement for a value neither side carried.
191        TypeKind::Primitive(PrimitiveKind::Float128) => Err(ProbeError::Unsupported {
192            path: path.to_string(),
193            kind: "a long double, whose Fast DDS bytes differ between x86-64 and aarch64",
194        }),
195        TypeKind::Primitive(kind) => {
196            out.line(&primitive_call(*kind, &here));
197            Ok(())
198        }
199        TypeKind::String {
200            width: CharWidth::Char,
201            ..
202        } => {
203            out.line(&format!("zdj_string({here}.c_str());"));
204            Ok(())
205        }
206        TypeKind::String {
207            width: CharWidth::WChar,
208            ..
209        } => {
210            // §7.5.2 fixes `wstring` as `std::wstring`, whose element is
211            // a code point and not a UTF-16 code unit. The helper
212            // re-splits a code point above the BMP into the pair the
213            // canonical form expects, which is what the reader on the
214            // other side will have joined.
215            out.line(&format!("zdj_wstring_cpp({here});"));
216            Ok(())
217        }
218        TypeKind::Enumeration { .. } => {
219            emit_enumeration(ast, type_, &here, out);
220            Ok(())
221        }
222        TypeKind::Bitmask { .. } => {
223            emit_bitmask(ast, type_, &here, out);
224            Ok(())
225        }
226        TypeKind::Bitset { .. } => {
227            out.open("{");
228            out.line("int zd_first = 1;");
229            out.raw("{");
230            // `contract::bitfield_names` already drops the anonymous
231            // padding fields, which carry no value. `all_bitfields_of`
232            // adds the base's, because `fastddsgen` flattens an
233            // inherited bitset into one struct and an inherited
234            // bitfield is addressed like a declared one.
235            for name in contract::bitfield_names(ast, &ast.all_bitfields_of(type_)) {
236                out.line(&format!("zdj_key(&zd_first, \"{name}\");"));
237                out.line(&format!("zdj_u64((unsigned long long)({here}.{name}));"));
238            }
239            out.raw("}");
240            out.close("}");
241            Ok(())
242        }
243        TypeKind::Alias { aliased } => {
244            let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
245                detail: format!("{path}: the typedef target is unresolved"),
246            })?;
247            emit(ast, target, place, out, path)
248        }
249        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } | TypeKind::Union { .. } => {
250            // A type on a cycle is a call, never an inline expansion —
251            // the expansion of a cycle does not end.
252            if let Some(position) = out.rec.iter().position(|id| *id == type_) {
253                out.line(&format!("zd_rec::dump_{position}({here});"));
254                return Ok(());
255            }
256            if matches!(&node.kind, TypeKind::Union { .. }) {
257                emit_union(ast, type_, &here, out, path)
258            } else {
259                emit_aggregate(ast, type_, &here, out, path)
260            }
261        }
262        TypeKind::Array { element, dims } => {
263            let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
264                detail: format!("{path}: the array element is unresolved"),
265            })?;
266            let counts = contract::array_dimensions(ast, dims, path)?;
267            emit_array(ast, element, &here, out, path, &counts)
268        }
269        TypeKind::Sequence { element, .. } => {
270            let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
271                detail: format!("{path}: the sequence element is unresolved"),
272            })?;
273            let index = out.temporary("i");
274            let count = out.temporary("n");
275            out.open("{");
276            out.line(&format!("const size_t {count} = {here}.size();"));
277            out.raw("[");
278            out.open(&format!(
279                "for (size_t {index} = 0; {index} < {count}; {index}++) {{"
280            ));
281            out.line(&format!("if ({index} > 0) {{ zdj_raw(\",\"); }}"));
282            emit(
283                ast,
284                element,
285                &Place::Element(format!("{here}[{index}]")),
286                out,
287                &format!("{path}[]"),
288            )?;
289            out.close("}");
290            out.raw("]");
291            out.close("}");
292            Ok(())
293        }
294        TypeKind::Map {
295            key, value: entry, ..
296        } => {
297            let key_type = key.resolved().ok_or_else(|| ProbeError::NoLayout {
298                detail: format!("{path}: the map key type is unresolved"),
299            })?;
300            let value_type = entry.resolved().ok_or_else(|| ProbeError::NoLayout {
301                detail: format!("{path}: the map value type is unresolved"),
302            })?;
303            let first = out.temporary("first");
304            let pair = out.temporary("entry");
305            // An array of pairs and not an object, the same shape every
306            // other probe reads and writes: a JSON object keys on a
307            // string and `map<long, float>` does not.
308            out.open("{");
309            out.line(&format!("int {first} = 1;"));
310            out.raw("[");
311            out.open(&format!("for (const auto &{pair} : {here}) {{"));
312            out.line(&format!("if (!{first}) {{ zdj_raw(\",\"); }} {first} = 0;"));
313            out.raw("[");
314            emit(
315                ast,
316                key_type,
317                &Place::Element(format!("{pair}.first")),
318                out,
319                &format!("{path}[].key"),
320            )?;
321            out.raw(",");
322            emit(
323                ast,
324                value_type,
325                &Place::Element(format!("{pair}.second")),
326                out,
327                &format!("{path}[].value"),
328            )?;
329            out.raw("]");
330            out.close("}");
331            out.raw("]");
332            out.close("}");
333            Ok(())
334        }
335        TypeKind::Fixed { .. } => Err(ProbeError::Unsupported {
336            path: path.to_string(),
337            kind: "a fixed, which fastddsgen rejects in the IDL before any \
338                   accessor could be read from it",
339        }),
340        other => Err(ProbeError::Unfilled {
341            path: path.to_string(),
342            kind: other.as_str(),
343        }),
344    }
345}
346
347/// A structure or a valuetype, as a JSON object in declaration order.
348///
349/// `fastddsgen` maps inheritance to C++ inheritance, so a base's
350/// accessors are reachable through the derived object under their own
351/// names and the path stays flat — the same walk `fill_cpp.rs` makes.
352///
353/// zerodds-lint: recursion-depth 64 — the type graph's own nesting, below
354/// `emit`, which carries the argument: every member goes back through it,
355/// and an aggregate on a cycle is a call to `zd_rec::dump_<n>` there
356/// rather than an expansion here.
357fn emit_aggregate(
358    ast: &Ast,
359    type_: Id<TypeNode>,
360    here: &str,
361    out: &mut Dump,
362    path: &str,
363) -> Result<(), ProbeError> {
364    out.open("{");
365    out.line("int zd_first = 1;");
366    out.raw("{");
367    for member in contract::members(ast, type_) {
368        let member_type = member.type_;
369        let name = member.name.clone();
370        let member_path = contract::join(path, &name);
371        // `fastddsgen` renders `@external` as
372        // `eprosima::fastcdr::external<T>`, which dereferences to the
373        // value. Always present, unlike an `@optional`; the getter is
374        // the holder and not the value, so reading it as one would print
375        // the holder's address.
376        if is_external(ast, type_, &name) && !member.optional {
377            out.line(&format!("zdj_key(&zd_first, \"{name}\");"));
378            emit(
379                ast,
380                member_type,
381                &Place::Element(format!("(*{here}.{name}())")),
382                out,
383                &member_path,
384            )?;
385            continue;
386        }
387        // `fastddsgen` renders `@optional` as `eprosima::fastcdr::
388        // optional<T>`. An absent one leaves the key out; writing `null`
389        // would be a value the canonical form refuses.
390        if member.optional {
391            // Behind `@optional @external` the engaged value is the
392            // `external` holder, and the sample sits one dereference
393            // further in — the mirror of the fill's stacked-wrapper
394            // arm.
395            let engaged = if is_external(ast, type_, &name) {
396                format!("(*{here}.{name}().value())")
397            } else {
398                format!("{here}.{name}().value()")
399            };
400            out.open(&format!("if ({here}.{name}().has_value()) {{"));
401            out.line(&format!("zdj_key(&zd_first, \"{name}\");"));
402            emit(
403                ast,
404                member_type,
405                &Place::Element(engaged),
406                out,
407                &member_path,
408            )?;
409            out.close("}");
410            continue;
411        }
412        out.line(&format!("zdj_key(&zd_first, \"{name}\");"));
413        emit(
414            ast,
415            member_type,
416            &Place::Accessor {
417                object: here.to_string(),
418                name,
419            },
420            out,
421            &member_path,
422        )?;
423    }
424    out.raw("}");
425    out.close("}");
426    Ok(())
427}
428
429/// Whether a member of `type_` carries `@external`.
430///
431/// `contract::Member` does not report it, and the C++ binding needs it:
432/// XTypes 1.3 §7.5.2.4 gives an `@external` member a holder rather than
433/// the value, so a printer that read the accessor directly would print
434/// the holder.
435pub(crate) fn is_external(ast: &Ast, type_: Id<TypeNode>, name: &str) -> bool {
436    // A union's branches are its case members, which `all_members_of`
437    // does not list; `fastddsgen` wraps an `@external` branch in
438    // `eprosima::fastcdr::external<T>` as it wraps a structure member.
439    let cases = ast
440        .cases_of(type_)
441        .iter()
442        .filter_map(|case| ast.case(*case).ok().map(|case| case.member));
443    for id in ast.all_members_of(type_).into_iter().chain(cases) {
444        let Ok(member) = ast.member(id) else { continue };
445        if member.name == name {
446            return member
447                .facets
448                .xtypes
449                .as_ref()
450                .is_some_and(|facet| facet.is_external);
451        }
452    }
453    false
454}
455
456/// A union, as `{"_d": …}` plus the member the discriminator selects.
457///
458/// Only the selected member's getter is called. Fast DDS's generated
459/// accessors throw `BadParamException` when asked for a member the
460/// discriminator does not select, so a printer that read them all would
461/// abort on every sample instead of printing one.
462///
463/// zerodds-lint: recursion-depth 64 — the type graph's own nesting, below
464/// `emit`, which carries the argument: every case member and the
465/// discriminator go back through it.
466fn emit_union(
467    ast: &Ast,
468    type_: Id<TypeNode>,
469    here: &str,
470    out: &mut Dump,
471    path: &str,
472) -> Result<(), ProbeError> {
473    let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
474        detail: format!("{error}"),
475    })?;
476    let TypeKind::Union { discriminator, .. } = &node.kind else {
477        return Err(ProbeError::Unsupported {
478            path: path.to_string(),
479            kind: "a union whose kind changed under the emitter",
480        });
481    };
482    let discriminator = discriminator
483        .resolved()
484        .ok_or_else(|| ProbeError::NoLayout {
485            detail: format!("{path}: the discriminator is unresolved"),
486        })?;
487
488    out.open("{");
489    out.line("int zd_first = 1;");
490    out.raw("{");
491    out.line("zdj_key(&zd_first, \"_d\");");
492    emit(
493        ast,
494        discriminator,
495        &Place::Element(format!("{here}._d()")),
496        out,
497        &format!("{path}._d"),
498    )?;
499    // The width is the discriminator's, not always `long long`:
500    // `union Choice switch (unsigned long long) { case 16045690984833335998: … }`
501    // is a corpus case whose label does not fit a signed 64-bit
502    // constant, and a `switch` cast to `long long` would compare it
503    // wrongly even where the constant is accepted. `discriminator_c_width`
504    // answers both halves at once.
505    let (width, suffix) = contract::discriminator_c_width(ast, discriminator);
506    out.open(&format!("switch (({width})({here}._d())) {{"));
507    for id in ast.cases_of(type_) {
508        let Ok(case) = ast.case(*id) else { continue };
509        let Ok(member) = ast.member(case.member) else {
510            continue;
511        };
512        let Some(member_type) = member.type_.resolved() else {
513            continue;
514        };
515        for label in &case.labels {
516            match label.expr() {
517                Some(expr) => match ast.eval_integral(expr) {
518                    Ok(value) => out.line(&format!("case {value}{suffix}:")),
519                    Err(error) => {
520                        return Err(ProbeError::NoLayout {
521                            detail: format!("{path}: a case label does not evaluate: {error}"),
522                        });
523                    }
524                },
525                None => out.line("default:"),
526            }
527        }
528        out.depth += 1;
529        out.line(&format!("zdj_key(&zd_first, \"{}\");", member.name));
530        // An `@external` branch is read through the wrapper's pointee.
531        let place = if is_external(ast, type_, &member.name) {
532            Place::Element(format!("(*{here}.{}())", member.name))
533        } else {
534            Place::Accessor {
535                object: here.to_string(),
536                name: member.name.clone(),
537            }
538        };
539        emit(
540            ast,
541            member_type,
542            &place,
543            out,
544            &format!("{path}.{}", member.name),
545        )?;
546        out.line("break;");
547        out.depth = out.depth.saturating_sub(1);
548    }
549    out.close("}");
550    out.raw("}");
551    out.close("}");
552    Ok(())
553}
554
555/// zerodds-lint: recursion-depth 8 — one frame per array dimension.
556/// `counts` drops its head at each frame and the innermost element goes to
557/// `emit`.
558fn emit_array(
559    ast: &Ast,
560    element: Id<TypeNode>,
561    here: &str,
562    out: &mut Dump,
563    path: &str,
564    counts: &[usize],
565) -> Result<(), ProbeError> {
566    let Some((&count, rest)) = counts.split_first() else {
567        return emit(ast, element, &Place::Element(here.to_string()), out, path);
568    };
569    out.raw("[");
570    for index in 0..count {
571        if index > 0 {
572            out.raw(",");
573        }
574        emit_array(
575            ast,
576            element,
577            &format!("{here}[{index}]"),
578            out,
579            &format!("{path}[{index}]"),
580            rest,
581        )?;
582    }
583    out.raw("]");
584    Ok(())
585}
586
587/// An enumeration, printed as the enumerator's name.
588///
589/// `enum class`, so the enumerator is `Colour::RED` and never a bare
590/// `RED` — the same scope `fill_cpp.rs` writes it under.
591fn emit_enumeration(ast: &Ast, type_: Id<TypeNode>, here: &str, out: &mut Dump) {
592    let scope = cpp_name(ast, type_);
593    out.open(&format!("switch ((long long)({here})) {{"));
594    for id in ast.enumerators_of(type_) {
595        let Ok(node) = ast.enumerator(*id) else {
596            continue;
597        };
598        out.line(&format!("case (long long){scope}::{}:", node.name));
599        out.line(&format!("    zdj_raw(\"\\\"{}\\\"\"); break;", node.name));
600    }
601    out.line(&format!(
602        "default: zdj_enum_unknown((long long)({here})); break;"
603    ));
604    out.close("}");
605}
606
607/// A bitmask, printed as the set bits' names in declaration order.
608///
609/// `fastddsgen` emits `enum <TypeName>Bits : <holder>` beside the
610/// typedef, and that enumeration is not an `enum class`, so a flag is
611/// named at the enclosing namespace — §7.5.2.2, and the same rule
612/// `fill_cpp.rs` writes the flags under.
613fn emit_bitmask(ast: &Ast, type_: Id<TypeNode>, here: &str, out: &mut Dump) {
614    let prefix = namespace_prefix(ast, type_);
615    out.open("{");
616    out.line("int zd_first = 1;");
617    out.raw("[");
618    for id in ast.bits_of(type_) {
619        let Ok(node) = ast.bit_value(*id) else {
620            continue;
621        };
622        out.open(&format!(
623            "if (((unsigned long long)({here}) & (unsigned long long){prefix}{}) != 0) {{",
624            node.name
625        ));
626        out.line("if (!zd_first) { zdj_raw(\",\"); }");
627        out.line("zd_first = 0;");
628        out.line(&format!("zdj_raw(\"\\\"{}\\\"\");", node.name));
629        out.close("}");
630    }
631    out.raw("]");
632    out.close("}");
633}
634
635fn primitive_call(kind: PrimitiveKind, here: &str) -> String {
636    match kind {
637        PrimitiveKind::Boolean => format!("zdj_bool({here});"),
638        PrimitiveKind::Octet
639        | PrimitiveKind::UInt8
640        | PrimitiveKind::UInt16
641        | PrimitiveKind::UInt32 => format!("zdj_u64((unsigned long long)({here}));"),
642        PrimitiveKind::Int8 | PrimitiveKind::Int16 | PrimitiveKind::Int32 => {
643            format!("zdj_i64((long long)({here}));")
644        }
645        PrimitiveKind::Char => format!("zdj_char((char)({here}));"),
646        PrimitiveKind::WChar => format!("zdj_wchar((unsigned long)({here}));"),
647        PrimitiveKind::Int64 => format!("zdj_i64_string((long long)({here}));"),
648        PrimitiveKind::UInt64 => format!("zdj_u64_string((unsigned long long)({here}));"),
649        PrimitiveKind::Float32 => format!("zdj_f32((float)({here}));"),
650        PrimitiveKind::Float64 | PrimitiveKind::Float128 => {
651            format!("zdj_f64((double)({here}));")
652        }
653    }
654}
655
656/// The namespace a type's members are named through, with the trailing
657/// `::` when there is one.
658fn namespace_prefix(ast: &Ast, type_: Id<TypeNode>) -> String {
659    let scoped = match ast.any_of_type(type_) {
660        Ok(any) => ast.scoped_name_of(any),
661        Err(_) => None,
662    };
663    let Some(scoped) = scoped else {
664        return String::new();
665    };
666    let segments = scoped.segments();
667    if segments.len() <= 1 {
668        return String::new();
669    }
670    let mut prefix = segments[..segments.len() - 1].join("::");
671    prefix.push_str("::");
672    prefix
673}
674
675#[cfg(test)]
676mod tests {
677    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
678    use super::*;
679    use crate::encode::model_from_idl;
680
681    fn emitted(idl: &str, type_name: &str) -> String {
682        let ast = model_from_idl(idl, "dump_cpp.idl").expect("the fixture parses");
683        let type_ = ast
684            .find_by_scoped_name(&zerodds_ast_core::ScopedName::absolute(vec![
685                type_name.to_string(),
686            ]))
687            .ok()
688            .and_then(zerodds_ast::AnyId::as_type)
689            .expect("the type is in the model");
690        dump_fastdds(&ast, type_).expect("the type has a C++ shape")
691    }
692
693    #[test]
694    fn a_member_is_read_through_its_getter() {
695        let cpp = emitted("@final struct S { long a; string text; };", "S");
696        assert!(cpp.contains("zdj_i64((long long)(sample.a()));"), "{cpp}");
697        assert!(cpp.contains("zdj_string(sample.text().c_str());"), "{cpp}");
698    }
699
700    #[test]
701    fn a_nested_aggregate_chains_the_getters() {
702        let cpp = emitted(
703            "@final struct Inner { long x; };
704             @final struct S { Inner nested; };",
705            "S",
706        );
707        assert!(cpp.contains("sample.nested().x()"), "{cpp}");
708    }
709
710    #[test]
711    fn an_optional_member_is_read_only_when_it_has_a_value() {
712        let cpp = emitted("@mutable struct S { @optional long b; };", "S");
713        assert!(cpp.contains("sample.b().has_value()"), "{cpp}");
714        assert!(cpp.contains("sample.b().value()"), "{cpp}");
715    }
716
717    #[test]
718    fn a_map_prints_as_an_array_of_pairs() {
719        let cpp = emitted("@final struct S { map<long, double> table; };", "S");
720        assert!(cpp.contains(".first"), "{cpp}");
721        assert!(cpp.contains(".second"), "{cpp}");
722        assert!(!cpp.contains("zdj_key(&zd_first, \"table\");\n    zdj_raw(\"{\")"));
723    }
724
725    #[test]
726    fn a_bitset_prints_its_named_fields_and_not_its_padding() {
727        let cpp = emitted(
728            "@bit_bound(16) bitset Packed { bitfield<3> lo; bitfield<2>; bitfield<3> hi; };
729             @final struct S { Packed bits; };",
730            "S",
731        );
732        assert!(cpp.contains("sample.bits().lo"), "{cpp}");
733        assert!(cpp.contains("sample.bits().hi"), "{cpp}");
734    }
735
736    #[test]
737    fn a_fixed_is_refused_because_fastddsgen_rejects_the_idl() {
738        let ast = model_from_idl("@final struct S { fixed<7, 2> amount; };", "dump_cpp.idl")
739            .expect("the fixture parses");
740        let type_ = ast
741            .find_by_scoped_name(&zerodds_ast_core::ScopedName::absolute(vec![
742                "S".to_string(),
743            ]))
744            .ok()
745            .and_then(zerodds_ast::AnyId::as_type)
746            .expect("the type is in the model");
747        assert!(matches!(
748            dump_fastdds(&ast, type_),
749            Err(ProbeError::Unsupported { .. })
750        ));
751    }
752}