Skip to main content

zerodds_wire_codec/
encode.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! One value, one type, the bytes between them.
4//!
5//! The walk follows the layout plan, not a second reading of the
6//! model: whether a structure carries a delimiter, what header each
7//! member takes and which length code it uses are all the plan's
8//! decisions. A probe that decided them again would agree with the
9//! plan only until one of the two changed.
10
11use crate::contract::{self, Consumed};
12use crate::source::{Fields, Source};
13use crate::value::{
14    Emit, MapLengthPolicy, ProbeRequest, encapsulation_header_in, encapsulation_padding,
15    wire_wchar_framing_of, wire_wchar_width_of, wire_wstring_unit_order_of,
16};
17use std::string::String;
18use std::vec::Vec;
19use zerodds_ast::{Ast, Bound, CharWidth, PrimitiveKind, TypeKind, TypeNode};
20use zerodds_ast_core::{Id, Ref, ScopedName};
21use zerodds_ast_layout::{
22    DepCollector, ElementPadding, Endianness, EnumeratorValue, Extent, FixedDigitCount,
23    KeyHashForm, LayoutKey, LengthCode, MapKeyOrdering, MemberHeader, Purpose, TypeLayout,
24    WirePosition, WstringCodeUnits, WstringLengthCounts, WstringLengthPolicy, WstringTerminator,
25    WstringUnitOrder, WstringUnitWidth, array_is_delimited, collection_is_delimited,
26    enumeration_bit_bound, enumerator_wire_value, key_members_with, map_key_ordering, member_key,
27    plan, union_negative_matches_nothing,
28};
29use zerodds_wire::{ParameterRole, WcharPosition, Writer};
30
31/// Why a probe could not produce bytes.
32///
33/// Every variant names where in the value the trouble is, because a
34/// probe that said only "type mismatch" over a fifty-member sample is
35/// a probe nobody uses twice.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ProbeError {
38    /// The requested type is not in the model.
39    NoSuchType {
40        /// The scoped name asked for.
41        name: String,
42    },
43    /// The layout could not be planned.
44    NoLayout {
45        /// What the planner said.
46        detail: String,
47    },
48    /// The value at `path` is not what the type calls for.
49    Mismatch {
50        /// Where, as a dotted path from the sample's root.
51        path: String,
52        /// What the type wanted.
53        expected: String,
54        /// What the value held.
55        found: String,
56    },
57    /// A member the type declares has no value and is not optional.
58    Missing {
59        /// Where.
60        path: String,
61    },
62    /// The stream is a well-formed sample of a compatible type, and no
63    /// object of *this* type can be constructed from it.
64    ///
65    /// The `try_construct` outcome of DDS-XTypes 1.3 §7.2.2.7 —
66    /// a `string<32>` holding twelve characters arriving where the
67    /// receiver declares `string<5>`, a `sequence<long,32>` of eight
68    /// arriving at a bound of four, an enumerator this type does not
69    /// declare — where the governing behaviour is `DISCARD`.
70    ///
71    /// # Why this is not one of the other variants
72    ///
73    /// §7.2.4.1: "the default behavior is to discard the O2 object to
74    /// avoid misinterpretation", and §7.2.3: the sample "can be safely
75    /// ignored (filtered out) by the receiver". A discarded sample is an
76    /// **absence**, not an error: a DDS subscriber's `take()` returns
77    /// nothing and no listener fires. Measured — in Cyclone the
78    /// normalize failure makes `ddsi_serdata_from_ser` return `NULL`,
79    /// `remote_make_sample` passes the `NULL` on and delivery ends at
80    /// `remote_on_delivery_failure_fastpath`, so the sample never
81    /// becomes one.
82    ///
83    /// [`Self::Mismatch`] and the framing errors underneath it say the
84    /// *stream* is wrong and the peer needs fixing. This says one sample
85    /// does not fit a type that is otherwise perfectly compatible, and
86    /// the next sample may well fit. Keeping them apart is the whole
87    /// point: a subscriber that logged every narrowed sample as a
88    /// protocol error would drown, and one that treated a malformed
89    /// stream as a narrowing would never notice a broken peer.
90    Discarded {
91        /// Where, as a dotted path from the sample's root.
92        path: String,
93        /// What this type can hold.
94        expected: String,
95        /// What arrived.
96        found: String,
97    },
98    /// The value holds a key the type does not declare.
99    ///
100    /// An error and not a warning: a misspelled member name would
101    /// otherwise silently test the default value.
102    Unknown {
103        /// Where.
104        path: String,
105        /// The keys the type does declare, so the typo is visible.
106        declared: Vec<String>,
107    },
108    /// A construct this target has no form for.
109    ///
110    /// `kind` carries the whole reason, in the target's terms — "a
111    /// bitmask, which is XTypes 1.3 §7.3.1.2.1.4 and not CORBA IDL",
112    /// "a map, which the IDL-to-C mapping §7.5.1 reuses does not
113    /// have". The rendering used to append "is not serialised by this
114    /// probe yet" to every one of them, which turned a limit of CORBA
115    /// or of a vendor's front end into a backlog item of ours. It was
116    /// read that way: a review of why nine toolchains produced nothing
117    /// for so many corpus cases classified all 127 of TAO's as ours on
118    /// the strength of that suffix, when every one of them names a
119    /// construct CORBA IDL does not have.
120    ///
121    /// Where the limit really is this probe's, [`Self::Unfilled`] says
122    /// so in its own words.
123    Unsupported {
124        /// Where.
125        path: String,
126        /// Which construct, and why this target has no form for it.
127        kind: &'static str,
128    },
129    /// The target took the construct and its own generated code does
130    /// not build.
131    ///
132    /// A measurement of the toolchain and not of its specification,
133    /// which is why it is not [`Self::Unsupported`]: `rosidl`'s IDL
134    /// takes `wchar[3]` without a word, and what
135    /// `rosidl_typesupport_fastrtps_c` 2.2.2 emits calls an undeclared
136    /// `callbacks`. Recorded as a missing form, it said ROS has no
137    /// collection of wchar — the opposite of what was measured.
138    GeneratorDefect {
139        /// Where.
140        path: String,
141        /// What was measured, including the emitted line.
142        kind: &'static str,
143    },
144    /// The target carries the construct and a documented mapping rule
145    /// makes the octets differ.
146    ///
147    /// Also a measurement, and also not a missing form. `omniidl` and
148    /// `tao_idl` both take `long double`; CORBA's C++ mapping makes it
149    /// the platform's x87 extended precision and not §9.3.1.3's
150    /// binary128.
151    MappingDivergence {
152        /// Where.
153        path: String,
154        /// The rule, with its clause.
155        kind: &'static str,
156    },
157    /// A construct this probe has not implemented.
158    ///
159    /// Separate from [`Self::Unsupported`], which is a limit of the
160    /// target. Twenty-one sites reach a catch-all arm and have only
161    /// the construct's own name to give — `union`, `map` — and a bare
162    /// noun as a whole message says neither what is wrong nor whose
163    /// gap it is. This variant supplies the sentence around it.
164    Unfilled {
165        /// Where.
166        path: String,
167        /// The construct's name, as the model spells it.
168        kind: &'static str,
169    },
170    /// The value nests deeper than [`crate::MAX_VALUE_DEPTH`]
171    /// aggregates.
172    ///
173    /// A recursive type has finite values because each one terminates
174    /// itself — a sequence of length zero, an absent `@optional`
175    /// member. A value whose object graph has a cycle, `a.next = a`,
176    /// terminates nowhere, and XTypes 1.3 §7.3.1.2.1.4 says of it "if
177    /// the graph of objects has cycles, it is not serializable".
178    ///
179    /// The walk cannot see the cycle — it is handed one member, or one
180    /// header, at a time — so it counts, and says this. Not a silent
181    /// truncation and not a stack overflow: the caller is told which
182    /// value, how deep it went and what the limit is.
183    ValueTooDeep {
184        /// Where, as a dotted path from the sample's root.
185        path: String,
186        /// The limit that was passed, [`crate::MAX_VALUE_DEPTH`].
187        limit: usize,
188    },
189    /// A KeyHash was asked for on a type that declares no `@key`.
190    ///
191    /// Not "unsupported" and not a zero hash: a keyless topic has one
192    /// instance, so there is no handle to compute, and Cyclone, RTI and
193    /// Fast DDS all decide that before any hash is. See `key_hash`.
194    NoKey {
195        /// The type asked about.
196        type_name: String,
197    },
198}
199
200impl core::fmt::Display for ProbeError {
201    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
202        match self {
203            Self::NoSuchType { name } => write!(f, "`{name}` is not in the model"),
204            Self::NoLayout { detail } => write!(f, "the layout could not be planned: {detail}"),
205            Self::Mismatch {
206                path,
207                expected,
208                found,
209            } => write!(f, "{path}: expected {expected}, found {found}"),
210            Self::Missing { path } => write!(f, "{path}: no value, and the member is not optional"),
211            Self::Discarded {
212                path,
213                expected,
214                found,
215            } => write!(
216                f,
217                "the sample is discarded: at {path} this type holds {expected} and the \
218                 sample carries {found}, and `@try_construct` there is DISCARD"
219            ),
220            Self::Unknown { path, declared } => {
221                write!(f, "{path}: not a member; the type declares {declared:?}")
222            }
223            Self::Unsupported { path, kind } => write!(f, "{path}: {kind}"),
224            Self::GeneratorDefect { path, kind } => write!(f, "{path}: {kind}"),
225            Self::MappingDivergence { path, kind } => write!(f, "{path}: {kind}"),
226            Self::Unfilled { path, kind } => {
227                // "an enum", not "a enum": the article is the only word
228                // this variant adds and getting it wrong reads as a
229                // machine talking.
230                let article = if kind.starts_with(['a', 'e', 'i', 'o', 'u']) {
231                    "an"
232                } else {
233                    "a"
234                };
235                write!(
236                    f,
237                    "{path}: {article} {kind}, which this probe does not carry yet"
238                )
239            }
240            Self::ValueTooDeep { path, limit } => write!(
241                f,
242                "{path}: the value nests more than {limit} aggregates deep; a value whose object \
243                 graph has a cycle is not serializable — XTypes 1.3 §7.3.1.2.1.4"
244            ),
245            Self::NoKey { type_name } => write!(
246                f,
247                "`{type_name}` declares no `@key`, so it has one instance and no KeyHash"
248            ),
249        }
250    }
251}
252
253impl core::error::Error for ProbeError {}
254
255/// What a walk carries down: the key the plan is computed under, and
256/// the one framing question no plan answers.
257///
258/// The layout key is the planner's own input type, so a walk that
259/// consults the plan and a walk that writes the bytes cannot drift
260/// apart. The map policy is separate because the contradiction it
261/// resolves is in the serialisation clause, not in the layout.
262#[derive(Debug, Clone, Copy)]
263struct Frame {
264    key: LayoutKey,
265    map_length: MapLengthPolicy,
266    /// Whether the type about to be written is reached as a member or
267    /// as a collection element.
268    ///
269    /// It rides in the frame rather than as an argument of its own for
270    /// the reason the recorder rides with the writer: there is one thing
271    /// to thread and a new arm of [`write_type`] cannot forget half of
272    /// it. Only `wchar` reads it, and only under TAO's framing — see
273    /// `zerodds_ast_layout::WcharPosition`.
274    wchar: WcharPosition,
275    /// How many aggregates of the *value* the walk is inside.
276    ///
277    /// The type graph's depth is the planner's and is bounded by the
278    /// model; this is the value's, and a recursive type puts no bound
279    /// on it at all. See [`crate::MAX_VALUE_DEPTH`].
280    depth: usize,
281}
282
283impl Frame {
284    /// The frame an element of a collection is written in.
285    fn in_collection(self) -> Self {
286        Self {
287            wchar: WcharPosition::Element,
288            ..self
289        }
290    }
291
292    /// The frame an aggregate's own members are written in.
293    ///
294    /// An aggregate resets the position: a `wchar` member of a struct
295    /// that is itself an array element is reached through the struct's
296    /// marshaller and takes the member's framing.
297    ///
298    /// It is also the one place the value's depth grows, which is why
299    /// the counter rides in the frame rather than beside it: every
300    /// descent into an aggregate goes through here, so no arm of the
301    /// walk can descend without counting.
302    fn in_aggregate(self) -> Self {
303        Self {
304            wchar: WcharPosition::Member,
305            depth: self.depth.saturating_add(1),
306            ..self
307        }
308    }
309
310    /// The refusal for a value that nests past [`crate::MAX_VALUE_DEPTH`],
311    /// or `Ok(())` while it does not.
312    ///
313    /// # Errors
314    /// [`ProbeError::ValueTooDeep`].
315    fn within_depth(self, path: &str) -> Result<(), ProbeError> {
316        if self.depth >= crate::MAX_VALUE_DEPTH {
317            return Err(ProbeError::ValueTooDeep {
318                path: String::from(path),
319                limit: crate::MAX_VALUE_DEPTH,
320            });
321        }
322        Ok(())
323    }
324}
325
326/// The bytes, and a note of every scalar the walk took out of the value.
327///
328/// The recorder rides with the writer rather than beside it so that a
329/// new arm of `write_type` cannot forget to thread it: there is one
330/// mutable thing to pass and it carries both.
331struct Sink {
332    w: Writer,
333    seen: Consumed,
334}
335
336/// Serialise `values` as `request.type_name`.
337///
338/// # Errors
339/// [`ProbeError`], always naming the path in the value.
340pub fn encode<V: Source>(
341    ast: &Ast,
342    request: &ProbeRequest,
343    values: &V,
344) -> Result<Vec<u8>, ProbeError> {
345    encode_recording(ast, request, values, &mut Consumed::new())
346}
347
348/// As [`encode`], recording which scalars of the value were read.
349///
350/// The three probes read one value file, and a file that one of them
351/// reads whole and another reads in part is the defect this exists to
352/// make visible. `tests/probe_agreement.rs` compares the recordings.
353///
354/// # Errors
355/// As [`encode`].
356pub fn encode_recording<V: Source>(
357    ast: &Ast,
358    request: &ProbeRequest,
359    values: &V,
360    seen: &mut Consumed,
361) -> Result<Vec<u8>, ProbeError> {
362    encode_noting_padding(ast, request, values, seen, false, 0).map(|(bytes, _)| bytes)
363}
364
365/// As [`encode`], for a value that continues a sample whose first `start`
366/// octets someone else wrote: alignment counts from the start of the
367/// sample, and only the value's own octets are returned.
368///
369/// DDS-RPC 1.0 §7.5.1.1.6 puts the `Call` union behind a header of
370/// variable length, and under XCDR1 an 8-octet member of it aligns to the
371/// sample. The request must be `Emit::Payload`.
372///
373/// # Errors
374/// As [`encode`]; `ProbeError::Unsupported` for another `emit`.
375pub fn encode_continuing<V: Source>(
376    ast: &Ast,
377    request: &ProbeRequest,
378    values: &V,
379    start: usize,
380) -> Result<Vec<u8>, ProbeError> {
381    if request.emit != Emit::Payload {
382        return Err(ProbeError::Unsupported {
383            path: request.type_name.clone(),
384            kind: "a continuation behind an encapsulation header or as a KeyHash",
385        });
386    }
387    encode_noting_padding(ast, request, values, &mut Consumed::new(), false, start)
388        .map(|(bytes, _)| bytes)
389}
390
391/// As [`encode`], and the offsets of the octets that carry no value.
392///
393/// The second half of the answer is computed by the **layout**, not by
394/// comparing the result with anything: [`Writer::align_to`] is the one
395/// place the walk emits a byte that XTypes 1.3 §7.4.1's alignment rules
396/// put there instead of a value, and the writer notes each span it
397/// emits. The result is a mask over the returned byte string, one entry
398/// per octet, true where the plan says padding.
399///
400/// The interop comparison uses it to check its own octet rule: a peer's
401/// differing octet that lands outside this mask is a difference in a
402/// value, whatever it looks like. See
403/// `tools/interop-results/src/rust_column.rs`.
404///
405/// # Errors
406/// As [`encode`]. `Emit::KeyHash` has no padding mask — the holder is a
407/// separate stream — and yields an all-false one.
408pub fn encode_with_padding<V: Source>(
409    ast: &Ast,
410    request: &ProbeRequest,
411    values: &V,
412) -> Result<(Vec<u8>, Vec<bool>), ProbeError> {
413    let (bytes, spans) =
414        encode_noting_padding(ast, request, values, &mut Consumed::new(), true, 0)?;
415    let mut mask = vec![false; bytes.len()];
416    for (at, length) in spans {
417        for octet in mask.iter_mut().skip(at).take(length) {
418            *octet = true;
419        }
420    }
421    Ok((bytes, mask))
422}
423
424/// A stream and the spans of it that carry no value, as
425/// `(offset, length)` in the finished stream's coordinates.
426type StreamWithPadding = (Vec<u8>, Vec<(usize, usize)>);
427
428fn encode_noting_padding<V: Source>(
429    ast: &Ast,
430    request: &ProbeRequest,
431    values: &V,
432    seen: &mut Consumed,
433    note_padding: bool,
434    start: usize,
435) -> Result<StreamWithPadding, ProbeError> {
436    let type_ = find_type(ast, &request.type_name)?;
437    let root = contract::root_path(ast, type_);
438
439    if request.emit == Emit::KeyHash {
440        return key_hash(ast, type_, values, request, seen).map(|bytes| (bytes, Vec::new()));
441    }
442
443    // A dialect that frames the whole sample one way or the other
444    // decides here, once, for the sample's type; every nested type is
445    // framed by that choice.
446    let frame = Frame {
447        key: request
448            .layout_key(Purpose::Data)
449            .for_sample(ast, type_)
450            .map_err(|e| ProbeError::NoLayout {
451                detail: e.to_string(),
452            })?,
453        map_length: request.map_length,
454        wchar: WcharPosition::Member,
455        depth: 0,
456    };
457    let writer = Writer::continuing(
458        wire_endianness(request.endianness),
459        usize::from(request.encoding.max_alignment()),
460        vec![0; start],
461    )
462    .with_pl_cdr1_framing(request.pl_cdr1_framing())
463    .with_wchar_width(request.wchar_width())
464    .with_wchar_framing(request.wire_wchar_framing())
465    .with_wstring_unit_order(request.wire_wstring_unit_order())
466    // The half of `ShortWhereItFits` a plan cannot answer: a
467    // sequence whose count is in the value. See
468    // `LengthCodePolicy` and `Writer::with_short_length_codes`.
469    .with_short_length_codes(request.length_codes.short_code_where_it_fits());
470    let mut sink = Sink {
471        w: if note_padding {
472            writer.recording_padding()
473        } else {
474            writer
475        },
476        seen: core::mem::take(seen),
477    };
478    let result = write_type(&mut sink, ast, type_, values, frame, &root);
479    *seen = core::mem::take(&mut sink.seen);
480    result?;
481
482    let mut spans = sink.w.padding_spans().to_vec();
483    let mut payload = sink.w.finish();
484    // The octets a continuation was counted from are not the value's.
485    payload.drain(..start);
486    if request.emit == Emit::PayloadWithEncapsulation {
487        // §7.4.1.1 pads the serialized data to the next four-byte
488        // boundary with zeros, and §7.6.3.1.2 puts the count of those
489        // bytes in the encapsulation options so a receiver can find
490        // where the data ended. Both halves or neither: padding without
491        // the count hands a conforming receiver up to three bytes of
492        // zero as data, and the count without the padding tells it to
493        // cut bytes that are data.
494        //
495        // Cyclone, RTI and Fast DDS all write both — measured on the
496        // loopback wire, `encapsulation_header`'s documentation has the
497        // capture. `Emit::Payload` stays unpadded: that is the
498        // serialized data itself, and it is what `vendor_bytes.md`
499        // records for all three.
500        let padding = encapsulation_padding(payload.len());
501        let mut out = Vec::with_capacity(payload.len() + 4 + usize::from(padding));
502        out.extend_from_slice(&encapsulation_header_in(
503            request.encapsulation_ids,
504            request.encoding,
505            request.endianness,
506            request.endianness == Endianness::Little,
507            padding,
508        ));
509        out.extend_from_slice(&payload);
510        // The four-octet encapsulation header moves every span, and the
511        // trailing fill is padding by the same clause.
512        for span in &mut spans {
513            span.0 += 4;
514        }
515        if padding != 0 {
516            spans.push((out.len(), usize::from(padding)));
517        }
518        out.resize(out.len() + usize::from(padding), 0);
519        return Ok((out, spans));
520    }
521    Ok((payload, spans))
522}
523
524/// The sixteen-byte KeyHash. DDSI-RTPS 2.5 §9.6.4.8, XTypes 1.3 §7.6.8.
525///
526/// Big-endian and member-id order whatever the data request asked for:
527/// two peers that disagree about either compute different hashes for
528/// the same instance.
529///
530/// # A type with no `@key` member has no KeyHash
531///
532/// This used to return sixteen bytes for one, and not the same sixteen:
533/// zeroes when the empty holder planned `Extent::Sized` and
534/// `d41d8cd98f00b204e9800998ecf8427e` — the MD5 of nothing — when it
535/// planned `Extent::Unbounded`, which is to say whenever the type was
536/// `@mutable`. Two answers to a question with no answer.
537///
538/// Read literally, Step 1 of §9.6.4.8 says a keyless holder keeps every
539/// member — "If there are any key members, then remove the non-key
540/// members from FooKeyHolder. Otherwise, do not remove any members." No
541/// implementation does that, because the clause it serves does not
542/// arise: a keyless topic has one instance, so there is no handle to
543/// distinguish. Measured against the three: Cyclone, RTI and Fast DDS
544/// all decide on `nkeys == 0` before any hash is computed and produce
545/// none. Cyclone's counterfactual — what its own code would give if the
546/// decision were removed — is MD5 of the empty string for `@final` and
547/// `@mutable` alike, with no split of any kind. The keyed path agrees
548/// with all of them: §9.6.4.8 Example 1 gives
549/// `12345678 00000000 00000000 00000000`, and so do Cyclone, Fast DDS
550/// and this probe.
551///
552/// So the answer is neither of the two this returned. Asking for the
553/// KeyHash of a type that has no instances is a mistake made upstream,
554/// and sixteen bytes of anything hides it. `ProbeError::NoKey` is what
555/// comes back, and `crates/lang-rules/src/encode.rs::key_hash_fn` —
556/// which has emitted no `key_hash` for a keyless type all along — is the
557/// half of the project that was right.
558fn key_hash<V: Source>(
559    ast: &Ast,
560    type_: Id<TypeNode>,
561    values: &V,
562    request: &ProbeRequest,
563    seen: &mut Consumed,
564) -> Result<Vec<u8>, ProbeError> {
565    key_material(ast, type_, values, request, seen, true)
566}
567
568/// Encode the writer's own `serializedKey` (DDSI-RTPS 2.5 §9.4.2.11) — the
569/// key-only payload a dispose/unregister DATA carries behind its K flag —
570/// in the representation and byte order `request` names.
571///
572/// The exact inverse of [`crate::decode::serialized_key`]: the same
573/// `Purpose::Key` plus `request.key_holder`/`key_holder_order`/
574/// `key_holder_nesting` (`AsDeclared` + `Declaration`, `Recursive` nesting
575/// already the default) select the type's own extensibility framing (a
576/// DHEADER for `@appendable`/`@mutable` under XCDR2, a per-member EMHEADER
577/// for `@mutable`, nothing for `@final` or under XCDR1) and declaration
578/// order, in `request`'s own encoding and endianness — not the canonical
579/// big-endian PLAIN_CDR2 form [`key_holder`] always writes.
580///
581/// `values` needs only the `@key` members populated: `write_aggregate`
582/// walks [`plan()`]'s already-filtered member list (the same `Purpose::Key`
583/// "holder" plan [`key_holder`]'s canonical form uses, just under a
584/// different `key_holder`/`key_holder_order`), so a non-key member is
585/// never looked up.
586///
587/// `Emit::PayloadWithEncapsulation` wraps the result behind the four-byte
588/// encapsulation header and zero-pads it to a four-byte boundary, with the
589/// pad count in the header's low two option bits (XTypes 1.3 §7.6.3.1.2) —
590/// the same convention [`encode`] uses for a full sample.
591///
592/// # Errors
593/// [`ProbeError`]: the type is not in the model, [`plan()`] cannot describe
594/// it under this key, or a `@key` member's value is missing or refused.
595pub fn serialized_key<V: Source>(
596    ast: &Ast,
597    request: &ProbeRequest,
598    values: &V,
599) -> Result<Vec<u8>, ProbeError> {
600    let type_ = find_type(ast, &request.type_name)?;
601    let root = contract::root_path(ast, type_);
602    let frame = Frame {
603        key: request.layout_key(Purpose::Key),
604        map_length: request.map_length,
605        wchar: WcharPosition::Member,
606        depth: 0,
607    };
608    let writer = Writer::with_max_align(
609        wire_endianness(request.endianness),
610        usize::from(request.encoding.max_alignment()),
611    )
612    .with_pl_cdr1_framing(request.pl_cdr1_framing())
613    .with_wchar_width(request.wchar_width())
614    .with_wchar_framing(request.wire_wchar_framing())
615    .with_wstring_unit_order(request.wire_wstring_unit_order())
616    .with_short_length_codes(request.length_codes.short_code_where_it_fits());
617    let mut sink = Sink {
618        w: writer,
619        seen: Consumed::new(),
620    };
621    write_type(&mut sink, ast, type_, values, frame, &root)?;
622    let payload = sink.w.finish();
623    if request.emit != Emit::PayloadWithEncapsulation {
624        return Ok(payload);
625    }
626    // §7.4.1.1 pads the serialized data to the next four-byte boundary
627    // with zeros, and §7.6.3.1.2 puts the count of those bytes in the
628    // encapsulation options — the same rule `encode` applies to a full
629    // sample, measured against Cyclone, RTI and Fast DDS there and against
630    // Cyclone's own `serializedKey` octets here (`decode::serialized_key`'s
631    // documentation has the measured vectors).
632    let padding = encapsulation_padding(payload.len());
633    let mut out = Vec::with_capacity(payload.len() + 4 + usize::from(padding));
634    out.extend_from_slice(&encapsulation_header_in(
635        request.encapsulation_ids,
636        request.encoding,
637        request.endianness,
638        request.endianness == Endianness::Little,
639        padding,
640    ));
641    out.extend_from_slice(&payload);
642    out.resize(out.len() + usize::from(padding), 0);
643    Ok(out)
644}
645
646/// Actual serialized holder, before zero padding or hashing.
647///
648/// # Errors
649/// The same layout and value refusals as the KeyHash encoder.
650pub fn key_holder<V: Source>(
651    ast: &Ast,
652    request: &ProbeRequest,
653    values: &V,
654) -> Result<Vec<u8>, ProbeError> {
655    let type_ = find_type(ast, &request.type_name)?;
656    key_material(ast, type_, values, request, &mut Consumed::new(), false)
657}
658
659fn key_material<V: Source>(
660    ast: &Ast,
661    type_: Id<TypeNode>,
662    values: &V,
663    request: &ProbeRequest,
664    seen: &mut Consumed,
665    hash: bool,
666) -> Result<Vec<u8>, ProbeError> {
667    let holder_key = request.key_holder_key();
668    let form = zerodds_ast_layout::keyhash::key_hash_form_for(ast, type_, holder_key).map_err(
669        |error| ProbeError::NoLayout {
670            detail: format!("{error}"),
671        },
672    )?;
673    let frame = Frame {
674        key: holder_key,
675        map_length: request.map_length,
676        wchar: WcharPosition::Member,
677        depth: 0,
678    };
679
680    // The holder is PLAIN_CDR2 big-endian whatever the data encoding
681    // is, so its alignment cap is the one PLAIN_CDR2 has (§7.6.8) — unless
682    // the dialect aligns the holder to its data encoding or packs it
683    // (`KeyHolderAlignment`: Cyclone and RTI under XCDR1, OpenSplice).
684    let mut sink = Sink {
685        w: Writer::with_max_align(
686            zerodds_wire::Endianness::Big,
687            usize::from(
688                holder_key
689                    .key_holder_alignment
690                    .max_alignment(holder_key.encoding),
691            ),
692        )
693        // From the holder's own key, so that the width the writer uses
694        // is the width `primitive_layout` sized the member at. Which
695        // width a key holder should use for a `wchar` is unmeasured —
696        // no vendor row has one in a key — and a writer that disagreed
697        // with its own plan would be wrong whatever the answer is.
698        .with_wchar_width(wire_wchar_width_of(holder_key.wchar_width))
699        // As the width: from the holder's own key, so that the framing
700        // the writer uses is the framing `primitive_layout` sized the
701        // member at.
702        .with_wchar_framing(wire_wchar_framing_of(holder_key.wchar_framing))
703        .with_wstring_unit_order(wire_wstring_unit_order_of(holder_key.wstring_unit_order)),
704        seen: core::mem::take(seen),
705    };
706    let root = contract::root_path(ast, type_);
707    // A union keyed by its discriminator has no `@key` member at all —
708    // Table 21 puts the annotation on the discriminator, which is not a
709    // `MemberNode` — so `key_members_with` answers the empty list and
710    // would report the type keyless. §7.6.8's holder for it is the
711    // discriminator, and the planner above sized it as exactly that.
712    if let Some(discriminator) = zerodds_ast_layout::union_key_discriminator(ast, type_) {
713        let path = contract::join(&root, "_d");
714        let value = values
715            .field("_d")
716            .ok_or_else(|| ProbeError::Missing { path: path.clone() })?;
717        write_type(&mut sink, ast, discriminator, value, frame, &path)?;
718        *seen = core::mem::take(&mut sink.seen);
719        if !hash {
720            return Ok(sink.w.finish());
721        }
722        return Ok(match form {
723            KeyHashForm::ZeroPadded => sink.w.zero_padded().to_vec(),
724            KeyHashForm::Md5 => sink.w.md5().to_vec(),
725        });
726    }
727    let keys = key_members_with(
728        ast,
729        type_,
730        holder_key.key_holder_nesting,
731        holder_key.key_holder_order,
732    )
733    .map_err(|error| ProbeError::NoLayout {
734        detail: format!("{error}"),
735    })?;
736    if keys.is_empty() {
737        return Err(ProbeError::NoKey {
738            type_name: request.type_name.clone(),
739        });
740    }
741    let mut result = Ok(());
742    for node in &keys {
743        let Some((name, member_type, _)) = member_by_node(ast, type_, *node) else {
744            continue;
745        };
746        let path = contract::join(&root, &name);
747        let Some(value) = values.field(&name) else {
748            result = Err(ProbeError::Missing { path });
749            break;
750        };
751        result = write_type(&mut sink, ast, member_type, value, frame, &path);
752        if result.is_err() {
753            break;
754        }
755    }
756    *seen = core::mem::take(&mut sink.seen);
757    result?;
758
759    if !hash {
760        return Ok(sink.w.finish());
761    }
762    Ok(match form {
763        KeyHashForm::ZeroPadded => sink.w.zero_padded().to_vec(),
764        KeyHashForm::Md5 => sink.w.md5().to_vec(),
765    })
766}
767
768/// The two crates name byte order separately — the layout planner does
769/// not depend on the runtime, and the runtime does not depend on the
770/// planner. This is the one place they meet.
771const fn wire_endianness(endianness: Endianness) -> zerodds_wire::Endianness {
772    match endianness {
773        Endianness::Little => zerodds_wire::Endianness::Little,
774        Endianness::Big => zerodds_wire::Endianness::Big,
775    }
776}
777
778fn find_type(ast: &Ast, name: &str) -> Result<Id<TypeNode>, ProbeError> {
779    let segments: Vec<String> = name
780        .split("::")
781        .filter(|segment| !segment.is_empty())
782        .map(std::string::ToString::to_string)
783        .collect();
784    ast.find_by_scoped_name(&ScopedName::absolute(segments))
785        .ok()
786        .and_then(zerodds_ast::AnyId::as_type)
787        .ok_or_else(|| ProbeError::NoSuchType {
788            name: name.to_string(),
789        })
790}
791
792/// The member with this identity, as the contract sees it.
793///
794/// `contract::members` walks `all_members_of`, so a member the
795/// aggregate inherited is found here and not skipped as if the plan had
796/// never named it.
797fn member_by_node(
798    ast: &Ast,
799    aggregate: Id<TypeNode>,
800    node: zerodds_ast_core::NodeId,
801) -> Option<(String, Id<TypeNode>, bool)> {
802    contract::members(ast, aggregate)
803        .into_iter()
804        .find(|member| member.node == node)
805        .map(|member| (member.name, member.type_, member.optional))
806}
807
808/// Write one value of one type.
809///
810/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. Every
811/// descent into an aggregate goes through `Frame::in_aggregate`, and
812/// `write_aggregate` and `write_union` refuse a value past the count with
813/// `ProbeError::ValueTooDeep`. The collection arms descend the value; the
814/// typedef arm does not, and is finite because `write_aggregate` plans the
815/// type first and `zerodds_ast_layout::plan` refuses a graph deeper than
816/// its own `MAX_DEPTH` (`crates/ast-layout/src/plan.rs:388`).
817#[allow(clippy::too_many_lines)]
818fn write_type<V: Source>(
819    s: &mut Sink,
820    ast: &Ast,
821    type_: Id<TypeNode>,
822    value: &V,
823    frame: Frame,
824    path: &str,
825) -> Result<(), ProbeError> {
826    let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
827        detail: format!("{error}"),
828    })?;
829    match &node.kind {
830        TypeKind::Primitive(kind) => write_primitive(s, *kind, value, frame.wchar, path),
831        TypeKind::String { width, bound } => {
832            write_string(s, ast, *width, *bound, value, frame, path)
833        }
834        TypeKind::Fixed { digits, scale } => {
835            write_fixed(s, *digits, *scale, frame.key.fixed_digits, value, path)
836        }
837        TypeKind::Alias { aliased } => {
838            let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
839                detail: format!("{path}: the typedef target is unresolved"),
840            })?;
841            write_type(s, ast, target, value, frame, path)
842        }
843        TypeKind::Enumeration { .. } => write_enumeration(s, ast, type_, value, frame, path),
844        TypeKind::Bitmask { .. } => write_bitmask(s, ast, type_, value, path),
845        TypeKind::Bitset { .. } => write_bitset(s, ast, type_, value, frame, path),
846        TypeKind::Sequence { element, bound } => {
847            let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
848                detail: format!("{path}: the sequence element is unresolved"),
849            })?;
850            let items = contract::bounded_items(ast, value, *bound, "elements", path)?;
851            let delimited = collection_is_delimited(ast, element, frame.key);
852            let dheader = delimited.then(|| s.w.write_dheader());
853            #[allow(clippy::cast_possible_truncation)]
854            s.w.write_u32(items.len() as u32);
855            // `SequenceElementDelimiter::BeforeEachNonFinalElement` writes
856            // an element's own octet count in front of it when the
857            // element's type is not `@final`, under both encoding
858            // versions. Measured on HDDS's `sequence-struct-appendable`
859            // and `empty-struct-in-sequence` — see the axis.
860            let carries_length = sequence_element_carries_length(ast, element, frame.key)?;
861            let packed = frame.key.element_padding == ElementPadding::Packed;
862            for (index, item) in items.iter().enumerate() {
863                let element_path = format!("{path}[{index}]");
864                let saved = packed.then(|| s.w.begin_packed_element());
865                let length = carries_length.then(|| s.w.write_dheader());
866                write_type(s, ast, element, item, frame.in_collection(), &element_path)?;
867                if let Some(length) = length {
868                    s.w.try_finish_dheader(length)
869                        .map_err(|error| refused(path, error))?;
870                }
871                if let Some(saved) = saved {
872                    s.w.finish_packed_element(saved);
873                }
874            }
875            if let Some(dheader) = dheader {
876                s.w.try_finish_dheader(dheader)
877                    .map_err(|error| refused(path, error))?;
878            }
879            Ok(())
880        }
881        TypeKind::Array { element, dims } => {
882            let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
883                detail: format!("{path}: the array element is unresolved"),
884            })?;
885            let counts = contract::array_dimensions(ast, dims, path)?;
886            let delimited = array_is_delimited(ast, element, frame.key);
887            let dheader = delimited.then(|| s.w.write_dheader());
888            write_array_dimension(s, ast, element, value, frame.in_collection(), path, &counts)?;
889            if let Some(dheader) = dheader {
890                s.w.try_finish_dheader(dheader)
891                    .map_err(|error| refused(path, error))?;
892            }
893            Ok(())
894        }
895        TypeKind::Map {
896            key,
897            value: v,
898            bound,
899        } => write_map(s, ast, type_, key, v, *bound, value, frame, path),
900        TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
901            write_aggregate(s, ast, type_, value, frame, path)
902        }
903        TypeKind::Union { .. } => write_union(s, ast, type_, value, frame, path),
904        other => Err(ProbeError::Unfilled {
905            path: path.to_string(),
906            kind: other.as_str(),
907        }),
908    }
909}
910
911/// One dimension of a possibly multi-dimensional array.
912///
913/// XTypes 1.3 §7.4.3.5 treats a multi-dimensional array as one flat run
914/// with the first index varying most slowly, which is what a nested
915/// JSON array in written order already is.
916///
917/// zerodds-lint: recursion-depth 8 — one frame per array dimension.
918/// `counts` drops its head at each frame and the innermost element goes to
919/// `write_type`, which is bounded by `crate::MAX_VALUE_DEPTH`.
920fn write_array_dimension<V: Source>(
921    s: &mut Sink,
922    ast: &Ast,
923    element: Id<TypeNode>,
924    value: &V,
925    frame: Frame,
926    path: &str,
927    counts: &[usize],
928) -> Result<(), ProbeError> {
929    let Some((&count, rest)) = counts.split_first() else {
930        return write_type(s, ast, element, value, frame, path);
931    };
932    let items = contract::array(value, path)?;
933    if items.len() != count {
934        return Err(ProbeError::Mismatch {
935            path: path.to_string(),
936            expected: format!("exactly {count} elements"),
937            found: format!("{}", items.len()),
938        });
939    }
940    for (index, item) in items.iter().enumerate() {
941        // `ElementPadding::Packed`: the element begins where the
942        // previous one ended and aligns from its own first octet. See
943        // `Writer::begin_packed_element`.
944        let saved = (frame.key.element_padding == ElementPadding::Packed)
945            .then(|| s.w.begin_packed_element());
946        write_array_dimension(
947            s,
948            ast,
949            element,
950            item,
951            frame,
952            &format!("{path}[{index}]"),
953            rest,
954        )?;
955        if let Some(saved) = saved {
956            s.w.finish_packed_element(saved);
957        }
958    }
959    Ok(())
960}
961
962/// A structure or a valuetype, framed as its plan says.
963///
964/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`, checked by
965/// the `frame.within_depth` at the top of this function before any member
966/// is written.
967fn write_aggregate<V: Source>(
968    s: &mut Sink,
969    ast: &Ast,
970    type_: Id<TypeNode>,
971    value: &V,
972    frame: Frame,
973    path: &str,
974) -> Result<(), ProbeError> {
975    // Before anything is written: a value that never terminates is a
976    // cyclic object graph, which XTypes 1.3 §7.3.1.2.1.4 does not
977    // serialise, and no prefix of one is a sample.
978    frame.within_depth(path)?;
979
980    // `ElementPadding::Packed`: an aggregate value is its own alignment
981    // block. Its members count from its first octet and the aggregate is
982    // not itself padded up to its own alignment before it starts —
983    // measured on HDDS's `typedef-of-struct`, whose second `Cell` puts
984    // its `long` at offset six where the clause puts it at eight.
985    let packed = frame.key.element_padding == ElementPadding::Packed;
986    let saved_origin = packed.then(|| s.w.begin_packed_element());
987
988    let object = contract::object(value, path)?;
989
990    let mut deps = DepCollector::new();
991    let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
992        detail: format!("{error}"),
993    })?;
994
995    // Every key in the value has to name a member. Checked before
996    // anything is written, so a typo is reported instead of producing
997    // a sample with a default in it.
998    let members = contract::members(ast, type_);
999    let declared: Vec<String> = members.iter().map(|member| member.name.clone()).collect();
1000    contract::reject_unknown_keys(object, &declared, path)?;
1001
1002    let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
1003
1004    // Each nested aggregate is re-planned from the top by the call
1005    // below, so the parent's plan cannot hand its members anything: the
1006    // key they are planned under has to be recomputed here from the same
1007    // rule the planner used, or the two walks disagree about what a key
1008    // holder keeps. XTypes 1.3 §7.6.8 Step 3.
1009    let frame = Frame {
1010        key: member_key(ast, type_, frame.key),
1011        ..frame.in_aggregate()
1012    };
1013
1014    // The plan names members by their stable identity and the value
1015    // file names them by their key; `Member` carries both, so the two
1016    // are matched without a second walk of the model.
1017    for planned in &layout.members {
1018        let Some(member) = members.iter().find(|member| member.node == planned.member) else {
1019            continue;
1020        };
1021        let member_path = contract::join(path, &member.name);
1022        let member_value = contract::member_value(object, member, path)?;
1023
1024        match (planned.header, member_value) {
1025            // Rule (20), §7.4.3.5.2: a BOOLEAN `<is_present>`, then the
1026            // value when there is one. One byte, not an EMHEADER, and
1027            // written whether or not the member is there — which is what
1028            // makes the gap readable. Cyclone, RTI and Fast DDS all
1029            // write exactly this; `vendor_bytes.md` §1.
1030            (MemberHeader::OptionalFlag, present) => {
1031                s.w.write_bool(present.is_some());
1032                if let Some(value) = present {
1033                    write_type(s, ast, member.type_, value, frame, &member_path)?;
1034                }
1035            }
1036            // Rule (19) and §7.4.1.2: the parameter header either way,
1037            // and "if the member takes no value within that particular
1038            // object, the data length indicated by the header shall be
1039            // zero". `finish_pid` declares zero because nothing was
1040            // written between it and the header.
1041            (
1042                MemberHeader::OptionalCdr1 {
1043                    pid,
1044                    extended,
1045                    must_understand,
1046                },
1047                present,
1048            ) => {
1049                let header = if extended {
1050                    s.w.write_pid_extended(
1051                        planned.member_id,
1052                        ParameterRole::Optional,
1053                        must_understand,
1054                    )
1055                } else {
1056                    s.w.write_pid(pid, ParameterRole::Optional, must_understand)
1057                }
1058                .map_err(|error| refused(&member_path, error))?;
1059                if let Some(value) = present {
1060                    write_type(s, ast, member.type_, value, frame, &member_path)?;
1061                }
1062                s.w.finish_pid(header);
1063            }
1064            // An `@optional` member of a `@mutable` type that took no
1065            // value. Nothing goes out: the parameter list itself says
1066            // which members are there, so an absent one leaves no trace.
1067            // `contract::member_value` returns `None` for no other
1068            // member, so the three headers below never lose a value here.
1069            (
1070                MemberHeader::NoHeader
1071                | MemberHeader::PidCdr1 { .. }
1072                | MemberHeader::EmHeader { .. },
1073                None,
1074            ) => {}
1075            // Rule (18): the value and nothing else.
1076            (MemberHeader::NoHeader, Some(value)) => {
1077                write_type(s, ast, member.type_, value, frame, &member_path)?;
1078            }
1079            // Rules (24) and (25).
1080            (
1081                MemberHeader::PidCdr1 {
1082                    pid,
1083                    extended,
1084                    must_understand,
1085                },
1086                Some(value),
1087            ) => {
1088                let header = if extended {
1089                    s.w.write_pid_extended(
1090                        planned.member_id,
1091                        ParameterRole::Member,
1092                        must_understand,
1093                    )
1094                } else {
1095                    s.w.write_pid(pid, ParameterRole::Member, must_understand)
1096                }
1097                .map_err(|error| refused(&member_path, error))?;
1098                write_type(s, ast, member.type_, value, frame, &member_path)?;
1099                s.w.finish_pid(header);
1100            }
1101            // Rule (22).
1102            (
1103                MemberHeader::EmHeader {
1104                    lc,
1105                    must_understand,
1106                },
1107                Some(value),
1108            ) => {
1109                let header =
1110                    s.w.write_emheader(planned.member_id, length_code(lc), must_understand)
1111                        .map_err(|error| refused(&member_path, error))?;
1112                write_type(s, ast, member.type_, value, frame, &member_path)?;
1113                s.w.finish_emheader(header)
1114                    .map_err(|error| refused(&member_path, error))?;
1115            }
1116        }
1117    }
1118
1119    if layout.needs_sentinel {
1120        s.w.write_sentinel();
1121    }
1122    if let Some(dheader) = dheader {
1123        s.w.try_finish_dheader(dheader)
1124            .map_err(|error| refused(path, error))?;
1125    }
1126    if let Some(saved) = saved_origin {
1127        s.w.finish_packed_element(saved);
1128    }
1129    Ok(())
1130}
1131
1132/// One `MMEMBER` of a `@mutable` type, framed as its plan says.
1133///
1134/// Shared by the aggregate walk and the union walk: rules (21) to (25)
1135/// frame a structure's members and rules (27) and (28) frame a union's
1136/// discriminator and selected member the same way, so one function
1137/// writes both.
1138#[allow(clippy::too_many_arguments)]
1139fn write_mmember<V: Source>(
1140    s: &mut Sink,
1141    ast: &Ast,
1142    header: MemberHeader,
1143    member_id: u32,
1144    type_: Id<TypeNode>,
1145    value: &V,
1146    frame: Frame,
1147    path: &str,
1148) -> Result<(), ProbeError> {
1149    match header {
1150        MemberHeader::PidCdr1 {
1151            pid,
1152            extended,
1153            must_understand,
1154        } => {
1155            let header = if extended {
1156                s.w.write_pid_extended(member_id, ParameterRole::Member, must_understand)
1157            } else {
1158                s.w.write_pid(pid, ParameterRole::Member, must_understand)
1159            }
1160            .map_err(|error| refused(path, error))?;
1161            write_type(s, ast, type_, value, frame, path)?;
1162            s.w.finish_pid(header);
1163            Ok(())
1164        }
1165        MemberHeader::EmHeader {
1166            lc,
1167            must_understand,
1168        } => {
1169            let header =
1170                s.w.write_emheader(member_id, length_code(lc), must_understand)
1171                    .map_err(|error| refused(path, error))?;
1172            write_type(s, ast, type_, value, frame, path)?;
1173            s.w.finish_emheader(header)
1174                .map_err(|error| refused(path, error))?;
1175            Ok(())
1176        }
1177        MemberHeader::NoHeader | MemberHeader::OptionalCdr1 { .. } | MemberHeader::OptionalFlag => {
1178            Err(ProbeError::NoLayout {
1179                detail: format!(
1180                    "{path}: a `@mutable` member planned with a header that is not one"
1181                ),
1182            })
1183        }
1184    }
1185}
1186
1187/// A union: the discriminator, then the selected member.
1188///
1189/// Under `@final` and `@appendable` that is rules (26) and (30) — the
1190/// discriminator as a `NOPT_FMEMBER`, then the branch, behind a DHEADER
1191/// where the extensibility calls for one.
1192///
1193/// Under `@mutable` it is rules (27) and (28): the discriminator and the
1194/// selected member each carry an `MMEMBER` header, and under XCDR1 a
1195/// `PID_LIST_END` closes the list. This project used to write neither.
1196/// `08000000 01000000 07000000` is a DHEADER over an unframed
1197/// discriminator and member; a peer parsing it as PL_CDR2 reads
1198/// `01000000` as an EMHEADER with LC0 and hands back a one-byte member
1199/// with no error. RTI and Fast DDS both write the clause's bytes —
1200/// `vendor_bytes.md` §8.
1201///
1202/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`, checked by
1203/// the `frame.within_depth` at the top of this function.
1204fn write_union<V: Source>(
1205    s: &mut Sink,
1206    ast: &Ast,
1207    type_: Id<TypeNode>,
1208    value: &V,
1209    frame: Frame,
1210    path: &str,
1211) -> Result<(), ProbeError> {
1212    // As in `write_aggregate`: a union on a cycle nests a value just as
1213    // a structure does.
1214    frame.within_depth(path)?;
1215
1216    // As in `write_aggregate`: under `ElementPadding::Packed` a union is
1217    // its own alignment block, and `annotation-external-union-branch`
1218    // records one that starts where the previous member ended.
1219    let packed = frame.key.element_padding == ElementPadding::Packed;
1220    let saved_origin = packed.then(|| s.w.begin_packed_element());
1221
1222    let object = contract::object(value, path)?;
1223    let discriminator_value = contract::discriminator(object, path)?;
1224
1225    let mut deps = DepCollector::new();
1226    let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
1227        detail: format!("{error}"),
1228    })?;
1229    let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
1230    // A union's discriminator and its branch are members, whatever the
1231    // union itself was reached as.
1232    let frame = frame.in_aggregate();
1233
1234    let TypeKind::Union { discriminator, .. } = &ast
1235        .type_(type_)
1236        .map_err(|error| ProbeError::NoLayout {
1237            detail: format!("{error}"),
1238        })?
1239        .kind
1240    else {
1241        return Err(ProbeError::Unsupported {
1242            path: path.to_string(),
1243            kind: "a union whose kind changed under the probe",
1244        });
1245    };
1246    let discriminator = discriminator
1247        .resolved()
1248        .ok_or_else(|| ProbeError::NoLayout {
1249            detail: format!("{path}: the discriminator is unresolved"),
1250        })?;
1251    let discriminator_path = contract::join(path, "_d");
1252    match layout.discriminator {
1253        None => write_type(
1254            s,
1255            ast,
1256            discriminator,
1257            discriminator_value,
1258            frame,
1259            &discriminator_path,
1260        )?,
1261        Some(header) => write_mmember(
1262            s,
1263            ast,
1264            header,
1265            0,
1266            discriminator,
1267            discriminator_value,
1268            frame,
1269            &discriminator_path,
1270        )?,
1271    }
1272
1273    // The member the value selects, by name. A union's members hang
1274    // off its cases, not off `members_of` — a case is the label list
1275    // and the member together.
1276    //
1277    // The probe does not evaluate the labels against the
1278    // discriminator: a value that names a member the discriminator
1279    // does not select is a test that wants exactly that, and refusing
1280    // it would make the divergence untestable.
1281    let cases = contract::union_cases(ast, type_);
1282
1283    // Unless a setting says the labels decide. `union_negative_matches_nothing`
1284    // is that setting's one crossing; under the default it is false for
1285    // every discriminator and this walk is the one below, unchanged.
1286    let suppress_negative = union_negative_matches_nothing(ast, type_, frame.key.union_label);
1287    if suppress_negative {
1288        write_selected_branch(
1289            s,
1290            ast,
1291            &cases,
1292            object,
1293            discriminator_value,
1294            &layout,
1295            frame,
1296            path,
1297            suppress_negative,
1298        )?;
1299    } else {
1300        for (name, member_value) in object {
1301            if name == "_d" {
1302                continue;
1303            }
1304            let case =
1305                cases
1306                    .iter()
1307                    .find(|case| case.name == name)
1308                    .ok_or_else(|| ProbeError::Unknown {
1309                        path: contract::join(path, name),
1310                        declared: cases.iter().map(|case| case.name.clone()).collect(),
1311                    })?;
1312            let member_path = contract::join(path, name);
1313            contract::reject_null(member_value, &member_path)?;
1314            match layout.members.iter().find(|plan| plan.member == case.node) {
1315                None => write_type(s, ast, case.type_, member_value, frame, &member_path)?,
1316                Some(plan) => write_mmember(
1317                    s,
1318                    ast,
1319                    plan.header,
1320                    plan.member_id,
1321                    case.type_,
1322                    member_value,
1323                    frame,
1324                    &member_path,
1325                )?,
1326            }
1327        }
1328    }
1329
1330    if layout.needs_sentinel {
1331        s.w.write_sentinel();
1332    }
1333    if let Some(dheader) = dheader {
1334        s.w.try_finish_dheader(dheader)
1335            .map_err(|error| refused(path, error))?;
1336    }
1337    if let Some(saved) = saved_origin {
1338        s.w.finish_packed_element(saved);
1339    }
1340    Ok(())
1341}
1342
1343/// The branch the **labels** select, and the octets a tagless union has
1344/// where they select a member the sample did not name.
1345///
1346/// Reached only under [`zerodds_ast_layout::UnionLabelMatch::NegativeLabelUnmatchedBelow32`]
1347/// and only for a signed discriminator narrower than 32 bits — the
1348/// crossing at the call site decides both, so nothing here re-reads the
1349/// policy.
1350///
1351/// Three outcomes, all three measured on Cyclone DDS 11.0.1:
1352///
1353/// - the labels select the member the sample named — write it, which is
1354///   what the walk above does anyway;
1355/// - the labels select nothing and the union declares no `default:` —
1356///   write no branch at all. `union-discriminator-{int8,short}`, four
1357///   octets shorter than the clause;
1358/// - the labels select the `default:` branch — write the union's
1359///   **storage** read through it. `combined-everything`.
1360///
1361/// # The storage
1362///
1363/// A C union puts every branch at offset zero, so the storage holds the
1364/// named member's octets in the host's order with whatever was above
1365/// them — zeros, because `wireprobe-zerodds-c-gen` declares the sample
1366/// `static`. This column has a tagged representation and no such
1367/// storage, so it is synthesised: the named member written
1368/// little-endian, zero-extended to the selected branch's extent, and
1369/// emitted as one unsigned value of that width in the stream's order.
1370///
1371/// As an unsigned integer and not as the branch's own type: the octets
1372/// are a bit pattern and a `double` that went through
1373/// `f64::from_le_bytes` and back is a value, not a pattern. A signalling
1374/// NaN pattern is the case where the two differ and no test would show
1375/// it until one did.
1376///
1377/// # What it refuses, and why nothing reaches it
1378///
1379/// Every branch has to be a primitive of one, two, four or eight
1380/// octets, and
1381/// [`zerodds_ast_layout::union_negative_matches_nothing`] already
1382/// answers `false` for a union where one is not: a `string` branch has
1383/// no storage a probe can overlay, the peer reads a pointer out of it,
1384/// and `union-member-sequence` is recorded as a segmentation fault
1385/// rather than as octets. So the refusals below are unreachable through
1386/// the crossing and are kept loud rather than removed —
1387/// `write_selected_branch` is not the place to decide that a wire
1388/// question has no answer.
1389#[allow(clippy::too_many_arguments)]
1390fn write_selected_branch<V: Source>(
1391    s: &mut Sink,
1392    ast: &Ast,
1393    cases: &[contract::Case],
1394    object: Fields<'_, V>,
1395    discriminator_value: &V,
1396    layout: &TypeLayout,
1397    frame: Frame,
1398    path: &str,
1399    suppress_negative: bool,
1400) -> Result<(), ProbeError> {
1401    // Through `contract`, which is where the value dialect lives: a
1402    // 64-bit integer arrives as a string and a narrow one as a number,
1403    // and a backend that read the document itself would accept one
1404    // spelling the other two refuse.
1405    let discriminator = contract::integer(discriminator_value, &contract::join(path, "_d"))?;
1406    let named = object.names().into_iter().find(|name| *name != "_d");
1407
1408    // Every key the sample carries still has to name a declared case:
1409    // the suppression decides which branch goes out, not whether a typo
1410    // is a typo.
1411    for name in object.names() {
1412        if name != "_d" && !cases.iter().any(|case| case.name == name) {
1413            return Err(ProbeError::Unknown {
1414                path: contract::join(path, name),
1415                declared: cases.iter().map(|case| case.name.clone()).collect(),
1416            });
1417        }
1418    }
1419
1420    let selection = contract::union_selection(ast, cases, discriminator, suppress_negative)
1421        .map_err(|unevaluated| ProbeError::Mismatch {
1422            path: path.to_string(),
1423            expected: String::from("a union whose case labels all evaluate"),
1424            found: format!(
1425                "the label of case `{}`, which the model cannot evaluate",
1426                cases
1427                    .get(unevaluated.case)
1428                    .map_or("<out of range>", |case| case.name.as_str())
1429            ),
1430        })?;
1431    let Some(index) = selection else {
1432        // No label matched and there is no `default:`. The discriminator
1433        // is already on the wire and nothing follows it.
1434        return Ok(());
1435    };
1436    let case = &cases[index];
1437    let member_path = contract::join(path, &case.name);
1438    let planned = layout.members.iter().find(|plan| plan.member == case.node);
1439
1440    if named == Some(case.name.as_str()) {
1441        let member_value = object.get(&case.name).ok_or_else(|| ProbeError::Missing {
1442            path: member_path.clone(),
1443        })?;
1444        contract::reject_null(member_value, &member_path)?;
1445        return match planned {
1446            None => write_type(s, ast, case.type_, member_value, frame, &member_path),
1447            Some(plan) => write_mmember(
1448                s,
1449                ast,
1450                plan.header,
1451                plan.member_id,
1452                case.type_,
1453                member_value,
1454                frame,
1455                &member_path,
1456            ),
1457        };
1458    }
1459
1460    let width = storage_width(ast, case.type_, frame.key).ok_or(ProbeError::Unsupported {
1461        path: member_path.clone(),
1462        kind: "a union branch with no fixed-width storage to overlay",
1463    })?;
1464    let octets =
1465        match named {
1466            None => vec![0_u8; width],
1467            Some(name) => {
1468                let source = cases.iter().find(|case| case.name == name).ok_or_else(|| {
1469                    ProbeError::Unknown {
1470                        path: contract::join(path, name),
1471                        declared: cases.iter().map(|case| case.name.clone()).collect(),
1472                    }
1473                })?;
1474                let source_width =
1475                    storage_width(ast, source.type_, frame.key).ok_or(ProbeError::Unsupported {
1476                        path: contract::join(path, name),
1477                        kind: "a union branch with no fixed-width storage to overlay",
1478                    })?;
1479                let mut host = Sink {
1480                    w: Writer::little_endian(),
1481                    seen: Consumed::new(),
1482                };
1483                let named_value = object.get(name).ok_or_else(|| ProbeError::Missing {
1484                    path: contract::join(path, name),
1485                })?;
1486                write_type(
1487                    &mut host,
1488                    ast,
1489                    source.type_,
1490                    named_value,
1491                    frame,
1492                    &contract::join(path, name),
1493                )?;
1494                // The scratch sink read the named member out of the value
1495                // document, and that read is the sample's whatever branch
1496                // the octets end up framed as. `Consumed::absorb` says why
1497                // this is not bookkeeping.
1498                s.seen.absorb(&host.seen);
1499                let mut octets = host.w.finish();
1500                if octets.len() != source_width {
1501                    return Err(ProbeError::Unsupported {
1502                        path: contract::join(path, name),
1503                        kind: "a union branch whose octets are not its storage",
1504                    });
1505                }
1506                octets.resize(width, 0);
1507                octets
1508            }
1509        };
1510
1511    let mut pattern = [0_u8; 8];
1512    pattern[..width].copy_from_slice(&octets[..width]);
1513    let pattern = u64::from_le_bytes(pattern);
1514    #[allow(clippy::cast_possible_truncation)]
1515    fn write_pattern(s: &mut Sink, width: usize, pattern: u64) {
1516        match width {
1517            1 => s.w.write_u8(pattern as u8),
1518            2 => s.w.write_u16(pattern as u16),
1519            4 => s.w.write_u32(pattern as u32),
1520            _ => s.w.write_u64(pattern),
1521        }
1522    }
1523    // The branch that goes out, recorded as written and not as read:
1524    // where a member was named, `absorb` above has already recorded the
1525    // read, and where none was, `None => vec![0; width]` reads nothing
1526    // at all.
1527    if named.is_none() {
1528        s.seen.scalar(&member_path);
1529    }
1530    match planned {
1531        None => write_pattern(s, width, pattern),
1532        // Rules (27) and (28) still frame the branch a `@mutable` union
1533        // writes, whichever branch it is.
1534        Some(plan) => match plan.header {
1535            MemberHeader::PidCdr1 {
1536                pid,
1537                extended,
1538                must_understand,
1539            } => {
1540                let header = if extended {
1541                    s.w.write_pid_extended(plan.member_id, ParameterRole::Member, must_understand)
1542                } else {
1543                    s.w.write_pid(pid, ParameterRole::Member, must_understand)
1544                }
1545                .map_err(|error| refused(&member_path, error))?;
1546                write_pattern(s, width, pattern);
1547                s.w.finish_pid(header);
1548            }
1549            MemberHeader::EmHeader {
1550                lc,
1551                must_understand,
1552            } => {
1553                let header =
1554                    s.w.write_emheader(plan.member_id, length_code(lc), must_understand)
1555                        .map_err(|error| refused(&member_path, error))?;
1556                write_pattern(s, width, pattern);
1557                s.w.finish_emheader(header)
1558                    .map_err(|error| refused(&member_path, error))?;
1559            }
1560            MemberHeader::NoHeader
1561            | MemberHeader::OptionalCdr1 { .. }
1562            | MemberHeader::OptionalFlag => {
1563                return Err(ProbeError::NoLayout {
1564                    detail: format!(
1565                        "{member_path}: a `@mutable` member planned with a header that is not one"
1566                    ),
1567                });
1568            }
1569        },
1570    }
1571    Ok(())
1572}
1573
1574/// The octets a primitive branch occupies in a C union's storage, or
1575/// `None` for a branch that has none.
1576///
1577/// The plan's extent and not a table of widths: a `wchar` is two octets
1578/// under one framing and one plus two under another, and a branch whose
1579/// extent is not a power-of-two count of octets has no overlay.
1580fn storage_width(ast: &Ast, type_: Id<TypeNode>, key: LayoutKey) -> Option<usize> {
1581    let mut deps = DepCollector::new();
1582    let layout = plan(ast, type_, key, &mut deps).ok()?;
1583    let width = usize::try_from(layout.extent.sized()?).ok()?;
1584    matches!(width, 1 | 2 | 4 | 8).then_some(width)
1585}
1586
1587/// An enumeration, written as the integer its bit bound calls for.
1588fn write_enumeration<V: Source>(
1589    s: &mut Sink,
1590    ast: &Ast,
1591    type_: Id<TypeNode>,
1592    value: &V,
1593    frame: Frame,
1594    path: &str,
1595) -> Result<(), ProbeError> {
1596    let (name, declared) = contract::enumerator(ast, type_, value, path)?;
1597    s.seen.scalar(path);
1598
1599    // Through the crossing function and not through
1600    // `Ast::effective_bit_bound`: the holder's width is an axis, and a
1601    // site that reads the declared bound straight answers it by
1602    // omission. `zerodds_ast_layout::enumeration_bit_bound` says why.
1603    let bits = enumeration_bit_bound(ast, type_, frame.key.enum_holder);
1604    // And which integer goes into that holder is the other axis, asked
1605    // through the sibling crossing. The contract resolved the name to
1606    // the enumerator the sample means; this decides what that enumerator
1607    // serialises as. `zerodds_ast_layout::enumerator_wire_value`.
1608    // Refused and not defaulted to `declared`: under `Ordinal` the
1609    // declared value is a different integer, and writing it would put
1610    // octets on the wire that no peer under this setting produces while
1611    // reporting success. `wire_value_of` says why.
1612    let value = match wire_value_of(ast, type_, &name, frame.key.enumerator_value) {
1613        Ok(Some(value)) => value,
1614        Ok(None) | Err(StaleEnumerator) => {
1615            return Err(ProbeError::Mismatch {
1616                path: path.to_string(),
1617                expected: format!(
1618                    "an enumerator whose wire value the `{}` setting can compute",
1619                    frame.key.enumerator_value.as_str()
1620                ),
1621                found: format!("{name}, declared {declared}, whose wire value does not evaluate"),
1622            });
1623        }
1624    };
1625    let pattern = enumerator_pattern(value, bits).ok_or_else(|| ProbeError::Mismatch {
1626        path: path.to_string(),
1627        expected: format!("an enumerator a {bits}-bit holder can hold"),
1628        found: format!("{name} = {value}"),
1629    })?;
1630    // The mask in `enumerator_pattern` already narrowed the value to the
1631    // holder's width, so each cast below is exact.
1632    #[allow(clippy::cast_possible_truncation)]
1633    match bits {
1634        1..=8 => s.w.write_u8(pattern as u8),
1635        9..=16 => s.w.write_u16(pattern as u16),
1636        _ => s.w.write_u32(pattern),
1637    }
1638    Ok(())
1639}
1640
1641/// The integer a named enumerator of this type serialises as.
1642///
1643/// The name has already been checked against the declared set by
1644/// `contract::enumerator`; this walks the type once more to reach the
1645/// handle the crossing function takes.
1646///
1647/// # Why `None` is not answered with the declared value
1648///
1649/// It was: `wire_value_of(..).unwrap_or(declared)`. Under
1650/// [`EnumeratorValue::Declared`] that substitution is invisible because
1651/// the declared value *is* the answer; under
1652/// [`EnumeratorValue::Ordinal`] it writes a different integer, and the
1653/// probe reports success over octets no peer under that setting would
1654/// produce. `enumerator_wire_value` refuses to substitute a zero for
1655/// exactly this reason — substituting the declared value is the same
1656/// failure with a different substitute, and it is harder to see because
1657/// it is right half the time. The caller refuses instead.
1658///
1659/// A stale handle inside the walk is not skipped either: skipping it
1660/// would make the enumerator the sample names unreachable and hand the
1661/// walk's `None` back as though the name were undeclared, which
1662/// `contract::enumerator` has already ruled out.
1663fn wire_value_of(
1664    ast: &Ast,
1665    type_: Id<TypeNode>,
1666    name: &str,
1667    policy: EnumeratorValue,
1668) -> Result<Option<i128>, StaleEnumerator> {
1669    for id in ast.enumerators_of(type_) {
1670        let node = ast.enumerator(*id).map_err(|_| StaleEnumerator)?;
1671        if node.name == name {
1672            return Ok(enumerator_wire_value(ast, *id, policy));
1673        }
1674    }
1675    Ok(None)
1676}
1677
1678/// An enumerator handle `enumerators_of` produced and the arena no
1679/// longer holds.
1680///
1681/// A unit type rather than a message, because the caller is the only
1682/// place that knows the path and the enumerator's name.
1683struct StaleEnumerator;
1684
1685/// The bit pattern an enumerator's value has in its holder, or `None`
1686/// where the value does not fit the bound.
1687///
1688/// XTypes 1.3 §7.4.3.5.3 rule (5) is `XCDR << {O : ENUM_TYPE} = XCDR <<
1689/// { O.value : O.holder_type }`, and the ENUM_TYPE entry in the same
1690/// clause's table says "O.holder_type is either Int8, Int16 or Int32
1691/// depending on the value of the @bit_bound annotation". A **signed**
1692/// holder: `@value(-1)` is the two's complement of one, so at the
1693/// default bound it is `ffffffff`. Clamping it to zero, as
1694/// `u32::try_from(value).unwrap_or(0)` did, wrote a different
1695/// enumerator's value and reported success.
1696///
1697/// # What fits
1698///
1699/// The bound is a width, and the two implementations that accept these
1700/// constructs read it as one. Measured on 2026-08-09:
1701///
1702/// - RTI Connext 7.7 takes `@value(-1)` at the default bound and writes
1703///   `ffffffff`. Cyclone's `idlc` refuses the annotation outright
1704///   ("@value(-1) cannot be applied to 'enumerator' element"), so RTI is
1705///   the only oracle for a negative value.
1706/// - Cyclone takes `@value(200)` under `@bit_bound(8)` and writes `c8`.
1707///   RTI refuses any enum bound but 32, so Cyclone is the only oracle
1708///   for a narrow one.
1709///
1710/// Between them the accepted range is everything expressible in the
1711/// bound's bits, signed or unsigned: `-2^(n-1) ..= 2^n - 1`. A stricter
1712/// signed-only range would reject `@value(200)`, which Cyclone writes;
1713/// a wider one would accept values neither implementation can express.
1714///
1715/// §7.3.1.2.1.5 caps the bound itself: "the value member of the
1716/// annotation may take any value from 1 to 32, inclusive, when this
1717/// annotation is applied to an enumerated type." A bound outside that
1718/// has no holder to be written into, so it is refused rather than
1719/// widened to eight bytes.
1720fn enumerator_pattern(value: i128, bits: u16) -> Option<u32> {
1721    if bits == 0 || bits > 32 {
1722        return None;
1723    }
1724    let width = i128::from(bits);
1725    if value < -(1i128 << (width - 1)) || value > (1i128 << width) - 1 {
1726        return None;
1727    }
1728    let mask = (1u128 << width) - 1;
1729    u32::try_from((value as u128) & mask).ok()
1730}
1731
1732/// A `string<N>` or `wstring<N>`, refusing a value the bound cannot hold.
1733///
1734/// The bytes are the same whether the string is bounded or not — the
1735/// bound is a promise about the source and not a difference on the wire
1736/// — so this is the only place it can be enforced. It was not enforced
1737/// anywhere: `let _ = bound;` sat here under a comment claiming the
1738/// front end caught an overrun, and the front end never sees the value.
1739/// Eight characters went into a `string<4>` and the probe said nothing,
1740/// which is the one thing a probe must not do.
1741///
1742/// # What the bound counts
1743///
1744/// Octets for `string<N>` and UTF-16 code units for `wstring<N>`, which
1745/// is what the receiving side has room for: §7.5.1.2.2 maps `string<5>`
1746/// to `char[6]` and `wstring<5>` to a five-unit buffer in every measured
1747/// implementation's C mapping, so a five-scalar value of ten octets is
1748/// one no peer can hold. `runtime/python/zerodds_wire/typesupport.py`
1749/// counts the same two ways — `utf8_octets` and `utf16_units` — and the
1750/// two columns have to agree on which values they refuse.
1751///
1752/// The NUL is not counted. §7.4.1.1.2 puts it on the wire for a narrow
1753/// string and not in the bound: `string<5>` holds five characters.
1754fn write_string<V: Source>(
1755    s: &mut Sink,
1756    ast: &Ast,
1757    width: CharWidth,
1758    bound: Bound,
1759    value: &V,
1760    frame: Frame,
1761    path: &str,
1762) -> Result<(), ProbeError> {
1763    let text = contract::text(value, path)?;
1764    s.seen.scalar(path);
1765    if let Some(limit) = bound.expr().and_then(|expr| ast.eval_integral(expr).ok()) {
1766        let (count, noun) = match width {
1767            CharWidth::Char => (text.len(), "octets"),
1768            CharWidth::WChar => (text.chars().map(utf16_units).sum(), "UTF-16 code units"),
1769        };
1770        if i128::try_from(count).unwrap_or(i128::MAX) > limit {
1771            return Err(ProbeError::Mismatch {
1772                path: path.to_string(),
1773                expected: format!("at most {limit} {noun}, which is what the bound holds"),
1774                found: format!("{count}"),
1775            });
1776        }
1777    }
1778    match width {
1779        CharWidth::Char => {
1780            s.w.write_string(text);
1781            Ok(())
1782        }
1783        CharWidth::WChar => {
1784            if frame.key.wstring_code_units == WstringCodeUnits::Utf8 {
1785                write_wstring_utf8(
1786                    &mut s.w,
1787                    text,
1788                    frame.key.wstring_length,
1789                    layout_position(frame.wchar),
1790                    path,
1791                )
1792            } else {
1793                write_wstring(
1794                    &mut s.w,
1795                    text,
1796                    frame.key.wstring_length,
1797                    frame.key.wstring_unit_order,
1798                    // The position the `wstring` is reached from. Passed and
1799                    // not assumed, for the reason `read_wstring` gives.
1800                    layout_position(frame.wchar),
1801                    path,
1802                )
1803            }
1804        }
1805    }
1806}
1807
1808/// How many UTF-16 code units one scalar takes.
1809///
1810/// Two above the Basic Multilingual Plane, which is a surrogate pair —
1811/// §7.4.1.1.2's own rationale for counting bytes rather than characters.
1812const fn utf16_units(character: char) -> usize {
1813    if (character as u32) > 0xFFFF { 2 } else { 1 }
1814}
1815
1816/// A `wstring`: a four-byte length, then UTF-16 code units.
1817///
1818/// XTypes 1.3 §7.4.3.5.3 rule (4) writes `O.ssize` as a `UInt32` and
1819/// then the `Char16` units, annotated "No NUL" against rule (3)'s
1820/// "includes NUL" for a narrow string. Two things follow, and only the
1821/// first is visible in the rule.
1822///
1823/// What `O.ssize` counts is settled in §7.4.1.1.2: "The serialized
1824/// length of an object of type String<Char16> shall be the number of
1825/// bytes in the CDR buffer taken by the String<Char16> characters. This
1826/// is twice the number of characters in the string". Code units, not
1827/// characters — the clause's own rationale gives the reason, that a
1828/// code point outside the Basic Multilingual Plane takes two UTF-16
1829/// units and a byte count still reaches the end of the string. So a
1830/// `"𝄞"` is a length of four and not of one or two.
1831///
1832/// The same clause forbids the two things a UTF-16 encoder would
1833/// otherwise emit: "The UTF-16 representation ... shall not include a
1834/// Byte Order Mark (BOM). The representation shall also not include any
1835/// terminating NUL character(s)." The order the units go out in is the
1836/// stream's, which is what the encapsulation identifier already
1837/// announced.
1838///
1839/// # The other two forms are written on request
1840///
1841/// Cyclone writes the clause above under both versions and RTI writes
1842/// it under XCDR2; RTI's XCDR1 and Fast DDS's output are two other
1843/// things, and [`WstringLengthPolicy`] carries the measurement and the
1844/// argument. Only the count and the character width change here; the
1845/// text is the same text.
1846/// The layout crate's name for a byte-layer position.
1847///
1848/// The two enumerations are deliberately separate — `zerodds-wire`
1849/// knows nothing about a dialect — and this is the one place the walk
1850/// crosses between them. A `match` and not a cast: a position added to
1851/// either side has to be given a meaning here rather than silently
1852/// becoming `Member`, which is precisely how the framing defect
1853/// survived five emitters.
1854const fn layout_position(position: WcharPosition) -> WirePosition {
1855    match position {
1856        WcharPosition::Member => WirePosition::Member,
1857        WcharPosition::Element => WirePosition::Element,
1858        WcharPosition::Sentinel => WirePosition::Sentinel,
1859        WcharPosition::ExtendedMarker => WirePosition::ExtendedMarker,
1860    }
1861}
1862
1863fn write_wstring(
1864    w: &mut Writer,
1865    text: &str,
1866    policy: WstringLengthPolicy,
1867    order: WstringUnitOrder,
1868    position: WirePosition,
1869    path: &str,
1870) -> Result<(), ProbeError> {
1871    // The coordinates are asked separately because they are separate:
1872    // C1 says what the length counts, C2 whether a terminator joins the
1873    // run, C3 how wide a unit is, and C4 — on its own axis now — in
1874    // what order each one lands. Folding them together is what produced
1875    // `UnswappedUnits`, a byte order wearing a length policy's name,
1876    // and it is what left the cell both ROS type supports write with no
1877    // name at all.
1878    let mut units: Vec<u16> =
1879        if matches!(policy.counts_at(position), WstringLengthCounts::Characters)
1880            && policy.unit_at(position) == WstringUnitWidth::Two
1881        {
1882            // Fast CDR counts `wcslen` characters and then writes each
1883            // four-byte `wchar_t` as two bytes. A code point outside the
1884            // BMP loses its high bits there: `U+1D11E` goes out as `d11e`,
1885            // which is `U+D11E`, a Hangul syllable and a well-formed code
1886            // point. The peer reads a different character and has nothing
1887            // to object to. Reproducing a vendor's length convention is a
1888            // policy; reproducing a data loss no reader can see is not, so
1889            // this refuses.
1890            //
1891            // The refusal is on the pair (characters, two octets) and not
1892            // on a named form: it is the narrow unit that loses the high
1893            // bits, and a character count over four-octet units — the ROS
1894            // cell — carries the surrogate pair intact and is not refused.
1895            if text.chars().any(|c| c as u32 > 0xFFFF) {
1896                return Err(ProbeError::Unsupported {
1897                    path: path.to_string(),
1898                    kind: concat!(
1899                        "a code point outside the BMP under a character count over two-octet ",
1900                        "units, which has no way to carry it — Fast CDR truncates it to ",
1901                        "sixteen bits and sends a different character"
1902                    ),
1903                });
1904            }
1905            #[allow(clippy::cast_possible_truncation)]
1906            text.chars().map(|c| c as u32 as u16).collect()
1907        } else {
1908            text.encode_utf16().collect()
1909        };
1910    if matches!(policy.terminator_at(position), WstringTerminator::Present) {
1911        // CORBA §9.3.1.6's GIOP 1.1 rule, and RTI's XCDR1 form. The NUL
1912        // is a unit of the run and is inside whatever the length counts.
1913        units.push(0);
1914    }
1915
1916    // The byte-order mark is a code unit like any other and its octets
1917    // are counted in the length: omniORB writes `0a000000 fffe 6100
1918    // 34d8 1edd 6200` for `"a𝄞b"`, ten octets over five units.
1919    // §7.4.1.1.2 forbids it outright — "shall not include a Byte Order
1920    // Mark" — so it is here only under `ByteOrderMark`, which no DDS
1921    // dialect selects.
1922    // An **empty** `wstring` carries no mark: omniORB writes `00000000`
1923    // for one, measured 2026-08-10 as `wstring-unbounded-empty`, which
1924    // is byte-identical to every other implementation. A mark on an
1925    // empty string would be a two-octet string that decodes to nothing,
1926    // and no peer writes it.
1927    if order.has_mark() && !units.is_empty() {
1928        units.insert(0, BYTE_ORDER_MARK);
1929    }
1930
1931    let width = policy.character_width(position);
1932    #[allow(clippy::cast_possible_truncation)]
1933    // C1, over units that already include the terminator and the mark.
1934    // An octet count multiplies by C3; a character count is the run's
1935    // length. One expression rather than an arm per named form, so a
1936    // cell nobody has measured computes its length the same way.
1937    let declared = match policy.counts_at(position) {
1938        WstringLengthCounts::Octets => (units.len() * width) as u32,
1939        WstringLengthCounts::Characters => units.len() as u32,
1940    };
1941    w.write_u32(declared);
1942
1943    for unit in units {
1944        write_wstring_unit(w, unit, width, policy, order);
1945    }
1946    Ok(())
1947}
1948
1949/// A `wstring` whose characters go out as their own UTF-8 octets.
1950///
1951/// [`WstringCodeUnits::Utf8`] is the measured departure of one product's
1952/// generated serialiser. XTypes 1.3 Table 31 gives a `wstring`'s code
1953/// unit as `Char16` and §7.4.1.1.2 makes the length "twice the number
1954/// of characters", so the clause has no UTF-8 form at all; the axis
1955/// carries the measurement and the recorded rows.
1956///
1957/// The length is the UTF-8 octet count, not a count of anything a
1958/// two-octet unit could be multiplied by — under this value C1 and C3
1959/// have nothing to say (see [`WstringCodeUnits::counts_octets`]) and
1960/// only the terminator still reaches the wire. `wstring-member` is
1961/// `04 00 00 00 61 62 63 00` for `"abc"`: three octets, a NUL, and the
1962/// four declares all of it.
1963fn write_wstring_utf8(
1964    w: &mut Writer,
1965    text: &str,
1966    policy: WstringLengthPolicy,
1967    position: WirePosition,
1968    path: &str,
1969) -> Result<(), ProbeError> {
1970    let bytes = text.as_bytes();
1971    let terminator = matches!(policy.terminator_at(position), WstringTerminator::Present);
1972    let content = bytes.len() + usize::from(terminator);
1973    let declared = u32::try_from(content).map_err(|_| ProbeError::Unsupported {
1974        path: path.to_string(),
1975        kind: "a wstring whose UTF-8 octet count fits the UInt32 length field",
1976    })?;
1977    w.write_u32(declared);
1978    for byte in bytes {
1979        w.write_u8(*byte);
1980    }
1981    if terminator {
1982        w.write_u8(0);
1983    }
1984    Ok(())
1985}
1986
1987/// Whether an element of a `sequence` is preceded by its own octet
1988/// length under this key.
1989///
1990/// [`SequenceElementDelimiter::BeforeEachNonFinalElement`] is the
1991/// measured departure: a four-octet `UInt32` in front of every element
1992/// whose type is not `@final`, under both encoding versions. A type
1993/// with no extensibility of its own — a primitive, a string, an
1994/// enumeration, a `typedef` — is `@final` by construction and takes
1995/// none; only the four aggregate kinds can be `@appendable` or
1996/// `@mutable`.
1997///
1998/// The alias is **not** seen through, and that is the measurement.
1999/// `recursive-typedef-sequence` is `sequence<NodeRef>` with
2000/// `typedef Node NodeRef` over an `@appendable Node`, and HDDS writes
2001/// no element length: its generated sequence encoder decides on the
2002/// declared element type, and a `typedef` carries no `@final`/
2003/// `@appendable` of its own. A `sequence<Later>` of a forward-declared
2004/// structure, and a `sequence<Node>` of the structure itself, both carry
2005/// it.
2006///
2007/// # Errors
2008/// [`ProbeError::NoLayout`] for an element id the model does not hold:
2009/// whether the element carries a length cannot be told, and guessing
2010/// either way frames every element of the sequence wrong.
2011pub(crate) fn sequence_element_carries_length(
2012    ast: &Ast,
2013    element: Id<TypeNode>,
2014    key: LayoutKey,
2015) -> Result<bool, ProbeError> {
2016    zerodds_ast_layout::xcdr::sequence_element_carries_length(ast, element, key).ok_or_else(|| {
2017        ProbeError::NoLayout {
2018            detail: format!(
2019                "the sequence element {element:?} is not a type the model holds, so whether it \
2020                 carries its own length cannot be told"
2021            ),
2022        }
2023    })
2024}
2025
2026/// The UTF-16 byte-order mark, `U+FEFF`.
2027///
2028/// Named because it appears on both sides of the wire and a bare
2029/// `0xFEFF` in a reader would read as a character of the string.
2030const BYTE_ORDER_MARK: u16 = 0xFEFF;
2031
2032/// One `wstring` code unit, at its width and in its order.
2033///
2034/// The order and the length convention are separate settings and this is
2035/// where the two meet, once, so that a form cannot be written one way in
2036/// the length field and another in the content.
2037fn write_wstring_unit(
2038    w: &mut Writer,
2039    unit: u16,
2040    width: usize,
2041    policy: WstringLengthPolicy,
2042    order: WstringUnitOrder,
2043) {
2044    // C3 picks the writer and C4 picks its endianness. There is no
2045    // longer an arm for a length policy that decides a byte order:
2046    // `HostUnswapped` is a point of the order axis, so Cyclone's form
2047    // and `rosidl_typesupport_fastrtps`'s are the same answer at two
2048    // different widths rather than two unrelated special cases.
2049    let _ = policy;
2050    match (width, order) {
2051        (2, WstringUnitOrder::Stream) => w.write_u16(unit),
2052        (2, WstringUnitOrder::BigEndianAlways) => w.write_u16_big(unit),
2053        (2, WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped) => {
2054            w.write_u16_little(unit);
2055        }
2056        (_, WstringUnitOrder::Stream) => w.write_u32(u32::from(unit)),
2057        (_, WstringUnitOrder::BigEndianAlways) => w.write_u32_big(u32::from(unit)),
2058        (_, WstringUnitOrder::ByteOrderMark | WstringUnitOrder::HostUnswapped) => {
2059            w.write_u32_little(u32::from(unit));
2060        }
2061    }
2062}
2063
2064/// A bitmask, written as the unsigned integer its bit bound calls for.
2065///
2066/// XTypes 1.3 §7.4.3.5.3 rule (6) writes `O.value` as
2067/// `O.holder_type`, and Table 33 of §7.4.1.1.3.2 gives the holder:
2068/// `UInt8` for a bound of 1 to 8, `UInt16` to 16, `UInt32` to 32 and
2069/// `UInt64` to 64. The same clause fixes the direction — "Bit indexes
2070/// are counted from zero starting at the least-significant bit of the
2071/// full byte size of the bitmask" — so bit `n` is `1 << n` in the
2072/// holder whatever the holder's width.
2073///
2074/// A position at or past the bound is refused. §7.4.1.1.3.2 says the
2075/// bits above the bound "are not considered to be part of the
2076/// bitmask", so setting one would put a value on the wire that no peer
2077/// is required to read back.
2078fn write_bitmask<V: Source>(
2079    s: &mut Sink,
2080    ast: &Ast,
2081    type_: Id<TypeNode>,
2082    value: &V,
2083    path: &str,
2084) -> Result<(), ProbeError> {
2085    let set = contract::bit_names(ast, type_, value, path)?;
2086    let bound = ast.effective_bit_bound(type_);
2087
2088    let mut bits: u64 = 0;
2089    for (index, (_, position)) in set.iter().enumerate() {
2090        s.seen.scalar(&format!("{path}[{index}]"));
2091        bits |= 1u64 << position;
2092    }
2093
2094    // `contract::bit_names` refused a bound outside 1..=64, so each
2095    // narrowing below is exact for the bits that survived it.
2096    match bound {
2097        1..=8 => s.w.write_u8(u8::try_from(bits).unwrap_or(0)),
2098        9..=16 => s.w.write_u16(u16::try_from(bits).unwrap_or(0)),
2099        17..=32 => s.w.write_u32(u32::try_from(bits).unwrap_or(0)),
2100        _ => s.w.write_u64(bits),
2101    }
2102    Ok(())
2103}
2104
2105/// A bitset, written as the anchor integer its bitfields pack into.
2106///
2107/// # The specification does not say
2108///
2109/// DDS-XTypes 1.3 has no serialisation rule for a bitset. §7.4.3.5.3
2110/// runs from rule (1) to rule (30) and no left-hand side matches one;
2111/// §7.4.1.1 covers strings, enumerations, bitmasks, maps and structures
2112/// and stops. The only normative text about bitsets at all is the
2113/// TypeObject IDL's `CommonBitfield { unsigned short position;
2114/// BitsetMemberFlag flags; octet bitcount; TypeKind holder_type; }`
2115/// with its `// Ordered by Bitfield.position`.
2116///
2117/// **There is no clause number to cite for the wire form, and this
2118/// comment used to carry a wrong one.** It said §7.3.4.6, which is
2119/// "Classification of TypeIdentifiers" and says nothing about bitsets.
2120/// A wrong citation is worse than none: the next reader follows it,
2121/// finds text that does not support the claim, and has to work out
2122/// whether the claim or the pointer is broken.
2123/// `docs/refused-constructs.md`, "bitset", walks the whole document and
2124/// records where every rule this file follows actually comes from.
2125///
2126/// Two things are therefore taken from elsewhere and are marked as such
2127/// in the tests:
2128///
2129/// - **The positions.** `CommonBitfield.position` has to come from
2130///   somewhere, and the only reading under which an IDL bitset
2131///   round-trips through a TypeObject is that positions run
2132///   cumulatively from zero in declaration order, anonymous padding
2133///   fields included. That is the rule
2134///   `crates/xml/src/typeobject_bridge.rs` already fills the field by.
2135/// - **The anchor.** How wide the integer holding those bits is comes
2136///   from the layout planner, `zerodds_ast_layout::plan`, and is not
2137///   decided a second time here — the same reason the rest of this
2138///   module reads the plan rather than the model.
2139///
2140/// - **Inheritance.** A bitset that inherits takes the base's bitfields
2141///   at the low positions, in declaration order, before its own — the
2142///   same rule §7.4.1.1.5.1 states for an inherited structure. Fast DDS
2143///   is the only implementation that parses `bitset Derived : Base`
2144///   (Cyclone's `idlc` and RTI's `rtiddsgen` reject the keyword), and it
2145///   flattens the chain into one `std::bitset<N>` filled base-first. The
2146///   measurement is in `docs/refused-constructs.md`; the whole-chain
2147///   width comes from the planner through [`Ast::all_bitfields_of`].
2148fn write_bitset<V: Source>(
2149    s: &mut Sink,
2150    ast: &Ast,
2151    type_: Id<TypeNode>,
2152    value: &V,
2153    frame: Frame,
2154    path: &str,
2155) -> Result<(), ProbeError> {
2156    let fields = ast.all_bitfields_of(type_);
2157    let object = contract::object(value, path)?;
2158
2159    let mut deps = DepCollector::new();
2160    let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2161        detail: format!("{error}"),
2162    })?;
2163    let Extent::Sized(anchor) = layout.extent else {
2164        return Err(ProbeError::Unsupported {
2165            path: path.to_string(),
2166            kind: "a bitset whose anchor the planner does not size",
2167        });
2168    };
2169
2170    let declared = contract::bitfield_names(ast, &fields);
2171    contract::reject_unknown_keys(object, &declared, path)?;
2172
2173    let mut bits: u64 = 0;
2174    let mut position: u32 = 0;
2175    for id in &fields {
2176        let Ok(field) = ast.bitfield(*id) else {
2177            continue;
2178        };
2179        let width = u32::from(field.bits);
2180        if position.saturating_add(width) > 64 {
2181            return Err(ProbeError::Unsupported {
2182                path: path.to_string(),
2183                kind: "a bitset wider than the sixty-four bits an anchor holds",
2184            });
2185        }
2186        // An anonymous field is padding: it moves the next field along
2187        // and carries no value of its own.
2188        if let Some(name) = field.name.as_ref() {
2189            let member_path = contract::join(path, name);
2190            let Some(member_value) = object.get(name.as_str()) else {
2191                return Err(ProbeError::Missing { path: member_path });
2192            };
2193            let raw = contract::integer(member_value, &member_path)?;
2194            s.seen.scalar(&member_path);
2195            // A value wider than its bitfield would overwrite the field
2196            // above it. Refused rather than masked: a mask puts a
2197            // different number on the wire than the sample asked for.
2198            let limit = 1i128 << width;
2199            if raw < 0 || raw >= limit {
2200                return Err(ProbeError::Mismatch {
2201                    path: member_path,
2202                    expected: format!("an unsigned value below {limit}, for bitfield<{width}>"),
2203                    found: format!("{raw}"),
2204                });
2205            }
2206            #[allow(clippy::cast_sign_loss)]
2207            let raw = raw as u64;
2208            bits |= raw << position;
2209        }
2210        position += width;
2211    }
2212
2213    match anchor {
2214        1 => s.w.write_u8(u8::try_from(bits).unwrap_or(0)),
2215        2 => s.w.write_u16(u16::try_from(bits).unwrap_or(0)),
2216        4 => s.w.write_u32(u32::try_from(bits).unwrap_or(0)),
2217        8 => s.w.write_u64(bits),
2218        _ => {
2219            return Err(ProbeError::Unsupported {
2220                path: path.to_string(),
2221                kind: "a bitset whose anchor is not one, two, four or eight bytes",
2222            });
2223        }
2224    }
2225    Ok(())
2226}
2227
2228/// A map: the entries, in the shape §7.4.1.1.4 says a map has.
2229///
2230/// §7.4.1.1.4 defines a map as equivalent to a
2231/// `sequence<MapEntry_<key_type>_<value_type>>`, and §7.4.3.5.3 rules
2232/// (14), (15) and (16) write the key and the value of each entry
2233/// consecutively with no framing of their own. The rules are followed
2234/// rather than the equivalence on that point: read literally, the
2235/// `MapEntry` struct in §7.4.1.1.4 carries no extensibility annotation
2236/// and would default to appendable under §7.2.4.4, which would put a
2237/// DHEADER in front of every entry. No rule writes one.
2238///
2239/// Whether the map itself is delimited is the planner's decision and is
2240/// read off the plan, not recomputed: `zerodds_ast_layout` sets
2241/// `needs_dheader` from rule (14), which exempts a map only when both
2242/// halves are primitive, and rule (15), which delimits the rest under
2243/// XCDR2. All of Fast DDS, OpenDDS and this project write `map<long,
2244/// long>` as a bare count and the pairs, so the exemption is measured.
2245/// Where Fast DDS goes further and exempts `map<string, long>` as well
2246/// — its criterion is the value type alone — the broader reading is
2247/// taken here: a string key defeats the clause's own rationale for the
2248/// exemption, that the size can be determined "without iterating over
2249/// each element". OpenDDS reads it the same way.
2250///
2251/// Whether a delimited map also carries an entry count is the one thing
2252/// the specification says twice and differently, and the two
2253/// implementations that can be asked took one reading each; see
2254/// [`MapLengthPolicy`].
2255///
2256/// zerodds-lint: recursion-depth 64 — `crate::MAX_VALUE_DEPTH`. The
2257/// entries go back to `write_type`, whose aggregate arms count and refuse.
2258#[allow(clippy::too_many_arguments)]
2259fn write_map<V: Source>(
2260    s: &mut Sink,
2261    ast: &Ast,
2262    type_: Id<TypeNode>,
2263    key_type: &Ref<TypeNode>,
2264    value_type: &Ref<TypeNode>,
2265    bound: Bound,
2266    value: &V,
2267    frame: Frame,
2268    path: &str,
2269) -> Result<(), ProbeError> {
2270    let key_type = key_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2271        detail: format!("{path}: the map key type is unresolved"),
2272    })?;
2273    let value_type = value_type.resolved().ok_or_else(|| ProbeError::NoLayout {
2274        detail: format!("{path}: the map value type is unresolved"),
2275    })?;
2276
2277    // An array of pairs and not an object: a JSON object keys on a
2278    // string, and `map<long, float>` does not.
2279    let entries = contract::bounded_items(ast, value, bound, "entries", path)?;
2280
2281    let mut deps = DepCollector::new();
2282    let layout = plan(ast, type_, frame.key, &mut deps).map_err(|error| ProbeError::NoLayout {
2283        detail: format!("{error}"),
2284    })?;
2285    let dheader = layout.needs_dheader.then(|| s.w.write_dheader());
2286    if dheader.is_none() || frame.map_length == MapLengthPolicy::SequenceEquivalent {
2287        #[allow(clippy::cast_possible_truncation)]
2288        s.w.write_u32(entries.len() as u32);
2289    }
2290
2291    for index in map_entry_order(ast, type_, key_type, entries, frame, path)? {
2292        let entry = &entries[index];
2293        let entry_path = format!("{path}[{index}]");
2294        let (entry_key, entry_value) = contract::map_entry(entry, &entry_path)?;
2295        write_type(
2296            s,
2297            ast,
2298            key_type,
2299            entry_key,
2300            frame.in_collection(),
2301            &contract::join(&entry_path, "key"),
2302        )?;
2303        write_type(
2304            s,
2305            ast,
2306            value_type,
2307            entry_value,
2308            frame.in_collection(),
2309            &contract::join(&entry_path, "value"),
2310        )?;
2311    }
2312
2313    if let Some(dheader) = dheader {
2314        s.w.try_finish_dheader(dheader)
2315            .map_err(|error| refused(path, error))?;
2316    }
2317    Ok(())
2318}
2319
2320/// The indices of a map's entries, in the order they go on the wire.
2321///
2322/// `0..len` under [`MapEntryOrder::Declaration`], which is every
2323/// dialect but Fast DDS and OpenDDS; a permutation sorted by the key's
2324/// own value under [`MapEntryOrder::AscendingKey`], which is those two.
2325///
2326/// [`zerodds_ast_layout::map_key_ordering`] is the crossing and this is
2327/// its only caller in the Rust column. Where the policy asks for
2328/// ascending keys and the key type has no order, the answer is a
2329/// **refusal** and not the declaration order: a setting that silently
2330/// did nothing for one key type and something for another would be a
2331/// flag that reports success and changed no octet.
2332fn map_entry_order<V: Source>(
2333    ast: &Ast,
2334    type_: Id<TypeNode>,
2335    key_type: Id<TypeNode>,
2336    entries: &[V],
2337    frame: Frame,
2338    path: &str,
2339) -> Result<Vec<usize>, ProbeError> {
2340    if frame.key.map_entries.is_default() {
2341        return Ok((0..entries.len()).collect());
2342    }
2343    let Some(ordering) = map_key_ordering(ast, type_, frame.key.map_entries) else {
2344        return Err(ProbeError::Unsupported {
2345            path: path.to_string(),
2346            kind: "a map whose key type has no value order, under --map-entries ascending-key",
2347        });
2348    };
2349    let mut keyed = Vec::with_capacity(entries.len());
2350    for (index, entry) in entries.iter().enumerate() {
2351        let entry_path = format!("{path}[{index}]");
2352        let (entry_key, _) = contract::map_entry(entry, &entry_path)?;
2353        let sort_key = map_sort_key(
2354            ast,
2355            key_type,
2356            ordering,
2357            entry_key,
2358            &contract::join(&entry_path, "key"),
2359        )?;
2360        keyed.push((sort_key, index));
2361    }
2362    // By the key and then by the declared position, so that a sample
2363    // with two entries carrying the same key keeps their order rather
2364    // than taking whichever one the sort happened to move.
2365    keyed.sort();
2366    Ok(keyed.into_iter().map(|(_, index)| index).collect())
2367}
2368
2369/// One map key reduced to something with an order.
2370///
2371/// Three shapes and not one: an integer order is not an octet order —
2372/// `-1234567` encodes as `ffed2979` and sorts first by value and last by
2373/// octet — and a string's order is over its octets.
2374#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2375enum MapSortKey {
2376    /// Every scalar key: the value, signed or unsigned as the key type
2377    /// says, widened to `i128` so that both fit one comparison.
2378    Integer(i128),
2379    /// A `string` or `wstring` key: the octets of its UTF-8, compared
2380    /// unsigned. UTF-8 is order preserving, so this is also the
2381    /// code-point order a `std::wstring` compares in.
2382    CodeUnits(Vec<u8>),
2383}
2384
2385/// The sort key of one map key value.
2386fn map_sort_key<V: Source>(
2387    ast: &Ast,
2388    key_type: Id<TypeNode>,
2389    ordering: MapKeyOrdering,
2390    value: &V,
2391    path: &str,
2392) -> Result<MapSortKey, ProbeError> {
2393    let resolved = contract::resolve_alias(ast, key_type);
2394    let kind = ast.type_(resolved).map(|node| node.kind.clone()).ok();
2395    match ordering {
2396        MapKeyOrdering::EnumeratorValue => {
2397            let (_, declared) = contract::enumerator(ast, resolved, value, path)?;
2398            Ok(MapSortKey::Integer(declared))
2399        }
2400        MapKeyOrdering::CodeUnits => Ok(MapSortKey::CodeUnits(
2401            contract::text(value, path)?.as_bytes().to_vec(),
2402        )),
2403        MapKeyOrdering::SignedInteger | MapKeyOrdering::UnsignedInteger => {
2404            let raw = match kind {
2405                Some(TypeKind::Primitive(PrimitiveKind::Boolean)) => {
2406                    i128::from(contract::boolean(value, path)?)
2407                }
2408                Some(TypeKind::Primitive(kind @ (PrimitiveKind::Char | PrimitiveKind::WChar))) => {
2409                    i128::from(u32::from(contract::character_for(kind, value, path)?))
2410                }
2411                Some(TypeKind::Primitive(kind)) => contract::integer_for(kind, value, path)?,
2412                _ => contract::integer(value, path)?,
2413            };
2414            // An IDL `char` is a C++ `char`, and `char` is signed on the
2415            // host the corpus was recorded on. Written out here rather
2416            // than left to the column's own compiler, so that the three
2417            // columns agree on a host whose `char` is unsigned.
2418            let signed = ordering == MapKeyOrdering::SignedInteger
2419                && matches!(kind, Some(TypeKind::Primitive(PrimitiveKind::Char)))
2420                && raw >= 0x80;
2421            Ok(MapSortKey::Integer(if signed { raw - 0x100 } else { raw }))
2422        }
2423    }
2424}
2425
2426/// `fixed<P, S>`: the IDL fixed-point decimal, as packed BCD.
2427///
2428/// # The specification does not say
2429///
2430/// DDS-XTypes 1.3 does not carry the type. §7.2.2 has no fixed-point
2431/// kind, the IDL-to-type-system table of §7.3.1 does not map one, and
2432/// §7.4.3.5.3 has no rule whose left-hand side matches. Searching the
2433/// 1.3 text for "fixed" returns only prose about fixed-*size*
2434/// collections. A DDS peer is therefore not required to read this at
2435/// all, and the tests mark the bytes as unverified against any DDS
2436/// vendor.
2437///
2438/// It is nonetheless measured. `fixed<P,S>` came from CORBA and two
2439/// CORBA ORBs still write it: omniORB 4.3.3 produces `12345c` for a
2440/// `fixed<5,2>` holding `123.45`, byte for byte what this function
2441/// writes, over nine shapes covering the sign nibble, the leading zero
2442/// of an even digit count, a zero value, thirty-one digits and
2443/// alignment one — and the same bytes in both byte orders. JacORB 3.9
2444/// agrees on eight of the nine, differing only where Java's
2445/// `BigDecimal` has already lost the declared digit count. The run is
2446/// in `crates/ast-layout/tests/fixtures/vendor_bytes.md`, "Where the
2447/// three cannot answer".
2448///
2449/// The form written here is CORBA's, [CDR] §9.3.2.8, which is where an
2450/// IDL `fixed` came from and the only wire definition it has: `P` digit
2451/// nibbles most-significant first, then a sign nibble — `0xc` for
2452/// positive or zero, `0xd` for negative — with a leading zero nibble
2453/// prepended when `P + 1` is odd so the nibbles pair up. That is
2454/// `ceil((P + 1) / 2)` bytes, and it is the extent
2455/// `zerodds_ast_layout` plans for the type, at alignment one: the
2456/// digits are bytes and no padding precedes them.
2457///
2458/// The JSON is a decimal string, because a JSON number is a double and
2459/// a `fixed<31, 2>` is exactly the case a double cannot carry.
2460fn write_fixed<V: Source>(
2461    s: &mut Sink,
2462    digits: u8,
2463    scale: u8,
2464    policy: FixedDigitCount,
2465    value: &V,
2466    path: &str,
2467) -> Result<(), ProbeError> {
2468    if digits == 0 {
2469        return Err(ProbeError::Unsupported {
2470            path: path.to_string(),
2471            kind: "a fixed with no digits, which can hold no value",
2472        });
2473    }
2474    // OMG IDL 4.2 rule (42) caps the BCD rule at 31 digits, and
2475    // `fixed<31, S>` is the widest declaration IDL admits. A wider one
2476    // has no packing defined for it, so it is refused rather than packed
2477    // into octets no reader can be told the width of. The C runtime
2478    // makes the same check on both sides.
2479    if digits > 31 {
2480        return Err(ProbeError::Mismatch {
2481            path: path.to_string(),
2482            expected: "a fixed of at most 31 digits".to_string(),
2483            found: format!("fixed<{digits}, {scale}>"),
2484        });
2485    }
2486    if scale > digits {
2487        return Err(ProbeError::Mismatch {
2488            path: path.to_string(),
2489            expected: "a scale no larger than the digit count".to_string(),
2490            found: format!("fixed<{digits}, {scale}>"),
2491        });
2492    }
2493    let text = contract::text(value, path).map_err(|_| ProbeError::Mismatch {
2494        path: path.to_string(),
2495        expected: "a decimal, as a string — `\"123.45\"`".to_string(),
2496        found: contract::kind_of(value).to_string(),
2497    })?;
2498    s.seen.scalar(path);
2499    // The value's rule and the packing both live in `zerodds-wire`, which is
2500    // where the twenty-first column reaches them too. What stays here is the
2501    // path: `Writer` says what it expected and at which byte, and a refusal
2502    // over a fifty-member document that cannot say which member it was is one
2503    // nobody can act on.
2504    s.w.write_fixed(text, digits, scale, fixed_digits(policy))
2505        .map_err(|error| refused(path, error))
2506}
2507
2508/// The layout planner's digit-count axis as the byte layer's copy of it.
2509///
2510/// `zerodds-wire` does not depend on `zerodds-ast-layout` and must not — a
2511/// byte layer that needs the layout planner is not a byte layer — so the axis
2512/// exists twice and this is the crossing. `tests/fixed_digits_agree.rs` holds
2513/// the two halves against each other over every argument either can take.
2514pub(crate) const fn fixed_digits(policy: FixedDigitCount) -> zerodds_wire::FixedDigits {
2515    match policy {
2516        FixedDigitCount::Declared => zerodds_wire::FixedDigits::Declared,
2517        FixedDigitCount::ValuePrecision => zerodds_wire::FixedDigits::ValuePrecision,
2518        FixedDigitCount::ValueStringDigits => zerodds_wire::FixedDigits::ValueStringDigits,
2519    }
2520}
2521
2522/// A value the runtime refused, as a probe error that names the path.
2523///
2524/// `Writer` says what it expected and at which byte; what it cannot know
2525/// is where in the sample the value came from, and a refusal without the
2526/// path is one nobody can act on over a fifty-member document.
2527fn refused(path: &str, error: zerodds_wire::WireError) -> ProbeError {
2528    let expected = match error {
2529        zerodds_wire::WireError::OutOfRange { expected, .. } => expected,
2530        other => format!("{other}"),
2531    };
2532    ProbeError::Mismatch {
2533        path: path.to_string(),
2534        expected,
2535        found: "the value the runtime refused".to_string(),
2536    }
2537}
2538
2539/// One primitive, with the range check the dialect makes rather than
2540/// the one `try_from` happens to make.
2541///
2542/// Every narrowing below is exact: `contract::integer_for` has already
2543/// refused a value outside the primitive's range, so nothing here can
2544/// wrap.
2545#[allow(
2546    clippy::cast_possible_truncation,
2547    clippy::cast_precision_loss,
2548    clippy::cast_sign_loss,
2549    clippy::cast_possible_wrap
2550)]
2551fn write_primitive<V: Source>(
2552    s: &mut Sink,
2553    kind: PrimitiveKind,
2554    value: &V,
2555    position: WcharPosition,
2556    path: &str,
2557) -> Result<(), ProbeError> {
2558    match kind {
2559        PrimitiveKind::Boolean => s.w.write_bool(contract::boolean(value, path)?),
2560        PrimitiveKind::Octet | PrimitiveKind::UInt8 => {
2561            s.w.write_u8(contract::integer_for(kind, value, path)? as u8);
2562        }
2563        PrimitiveKind::Int8 => {
2564            s.w.write_i8(contract::integer_for(kind, value, path)? as i8);
2565        }
2566        // `character_for` has already refused a code point the width
2567        // cannot carry, and it did so with the path in hand. The runtime
2568        // refuses the same values, so nothing reaches the `?` today; it
2569        // is there because the runtime is the last thing between a value
2570        // and the wire, and a day when the two stop agreeing has to end
2571        // in a refusal rather than in a byte nobody wrote.
2572        PrimitiveKind::Char => {
2573            let character = contract::character_for(kind, value, path)?;
2574            s.w.write_char(character)
2575                .map_err(|error| refused(path, error))?;
2576        }
2577        PrimitiveKind::WChar => {
2578            let character = contract::character_for(kind, value, path)?;
2579            s.w.write_wchar(character, position)
2580                .map_err(|error| refused(path, error))?;
2581        }
2582        PrimitiveKind::Int16 => {
2583            s.w.write_i16(contract::integer_for(kind, value, path)? as i16);
2584        }
2585        PrimitiveKind::UInt16 => {
2586            s.w.write_u16(contract::integer_for(kind, value, path)? as u16);
2587        }
2588        PrimitiveKind::Int32 => {
2589            s.w.write_i32(contract::integer_for(kind, value, path)? as i32);
2590        }
2591        PrimitiveKind::UInt32 => {
2592            s.w.write_u32(contract::integer_for(kind, value, path)? as u32);
2593        }
2594        PrimitiveKind::Int64 => {
2595            s.w.write_i64(contract::integer_for(kind, value, path)? as i64);
2596        }
2597        PrimitiveKind::UInt64 => {
2598            s.w.write_u64(contract::integer_for(kind, value, path)? as u64);
2599        }
2600        PrimitiveKind::Float32 => s.w.write_f32(contract::float(value, path)? as f32),
2601        PrimitiveKind::Float64 => s.w.write_f64(contract::float(value, path)?),
2602        PrimitiveKind::Float128 => s.w.write_f128(contract::float128(value, path)?),
2603    }
2604    s.seen.scalar(path);
2605    Ok(())
2606}
2607
2608const fn length_code(lc: LengthCode) -> u8 {
2609    match lc {
2610        LengthCode::Lc0 => 0,
2611        LengthCode::Lc1 => 1,
2612        LengthCode::Lc2 => 2,
2613        LengthCode::Lc3 => 3,
2614        LengthCode::Lc4 => 4,
2615        LengthCode::Lc5 => 5,
2616        LengthCode::Lc6 => 6,
2617        LengthCode::Lc7 => 7,
2618    }
2619}