Skip to main content

zerodds_wire_codec/
contract.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! What a value file means, in one place.
4//!
5//! Three backends read the same value file: [`crate::encode()`] turns it
6//! into bytes, `zerodds_wire_probe::fill` into C for Cyclone and RTI,
7//! and `zerodds_wire_probe::fill_cpp` into C++ for Fast DDS. Every
8//! question about the file — is this key a member, may it be `null`, is
9//! this integer expressible, which members does this aggregate have —
10//! has to be answered the same way by all three, because a difference is
11//! a difference in *the sample* and the matrix would read it as a
12//! difference in the serialisers.
13//!
14//! The two backends that are not here still call these functions: the
15//! rules are generic over [`Source`] and every one of them is
16//! monomorphised at the same `serde_json::Value` the encoder uses. A
17//! backend that lived in a different crate and re-derived the rules
18//! would be back where defect 11 was.
19//!
20//! They agreed once by inspection and diverged twice by edit. Defect 11
21//! of `docs/silent-drop-sweep.md` is one half of that divergence: the C
22//! emitter walked `members_of` where the other two walked
23//! `all_members_of`, so a derived type's base members were never filled
24//! and the vendor serialised a structure of the right length with zeros
25//! in it. Nothing said so, because each backend held its own copy of the
26//! rule.
27//!
28//! So the rules live here and the backends call them. A rule that is
29//! only in one backend is a rule the other two can contradict.
30//!
31//! ## What is recorded
32//!
33//! Sharing the decisions is half of it; the other half is checking that
34//! the backends actually reach the same parts of the value. Each backend
35//! threads a [`Consumed`] and pushes the path of every **scalar** it
36//! reads out of the document. Two backends that accept the same file and
37//! consumed different paths disagree about what the sample is, whatever
38//! their exit codes said. `tests/probe_agreement.rs` is what compares
39//! them.
40
41use crate::encode::ProbeError;
42use crate::source::{Fields, Source};
43use std::string::String;
44use std::vec::Vec;
45use zerodds_ast::{Ast, Bound, PrimitiveKind, TypeKind, TypeNode};
46use zerodds_ast_core::Id;
47use zerodds_float128::Float128;
48
49/// Where each backend read a scalar out of the value document.
50///
51/// Paths only, in visit order. The comparison sorts, because the
52/// backends visit a union's discriminator at different points and that
53/// is not a disagreement about the sample.
54#[derive(Debug, Default, Clone, PartialEq, Eq)]
55pub struct Consumed(Vec<String>);
56
57impl Consumed {
58    /// A fresh recorder.
59    #[must_use]
60    pub const fn new() -> Self {
61        Self(Vec::new())
62    }
63
64    /// Record that a scalar at `path` was read.
65    pub fn scalar(&mut self, path: &str) {
66        self.0.push(path.to_string());
67    }
68
69    /// The paths, sorted, for comparison between backends.
70    #[must_use]
71    pub fn sorted(&self) -> Vec<String> {
72        let mut out = self.0.clone();
73        out.sort();
74        out
75    }
76
77    /// The paths in visit order.
78    #[must_use]
79    pub fn paths(&self) -> &[String] {
80        &self.0
81    }
82
83    /// Take another recorder's paths into this one.
84    ///
85    /// One caller: the union storage a suppressed case label makes the
86    /// writer synthesise. It writes the **named** member into a scratch
87    /// sink to get its octets, and the scratch sink has a recorder of
88    /// its own — so the read of that member was happening and no
89    /// recorder said so. `probe_agreement.rs` found it the day
90    /// `Dialect::Cyclone` took the suppressed label: the Rust backend
91    /// reported reading `picked.fraction`, which is the branch it wrote,
92    /// and the two fill backends reported `picked.whole`, which is the
93    /// member the sample names and the one all three actually read.
94    pub fn absorb(&mut self, other: &Self) {
95        self.0.extend(other.0.iter().cloned());
96    }
97}
98
99/// One member of an aggregate, as all three backends see it.
100#[derive(Debug, Clone)]
101pub struct Member {
102    /// The member's stable identity, which is how the layout plan names
103    /// it. Carried here so the serialiser can match a planned member to
104    /// its JSON key without a second walk of the model.
105    pub node: zerodds_ast_core::NodeId,
106    /// The member's simple name, which is also its JSON key.
107    pub name: String,
108    /// The member's type, resolved.
109    pub type_: Id<TypeNode>,
110    /// Whether the key may be left out.
111    pub optional: bool,
112}
113
114/// The path the root of a value document is named by.
115///
116/// The three backends report a refusal by path, and a path that differs
117/// between them cannot be compared. Taken from the model rather than
118/// from the caller's spelling, so `::common::Robot` and `common::Robot`
119/// name the same place.
120#[must_use]
121pub fn root_path(ast: &Ast, type_: Id<TypeNode>) -> String {
122    ast.any_of_type(type_)
123        .ok()
124        .and_then(|any| ast.scoped_name_of(any))
125        .map_or_else(String::new, |scoped| scoped.segments().join("::"))
126}
127
128/// Every member an aggregate has, a base's before its own.
129///
130/// `Ast::members_of` answers with a type's **own** members. A backend
131/// that walks it and treats the answer as the whole one writes half a
132/// sample: XTypes 1.3 §7.4.1.1.5.1 puts the base's members on the wire
133/// first, "as if all of the members had been defined, in the same
134/// order, in the most-derived type". Defect 11 of the sweep.
135#[must_use]
136pub fn members(ast: &Ast, type_: Id<TypeNode>) -> Vec<Member> {
137    ast.all_members_of(type_)
138        .into_iter()
139        .filter_map(|id| {
140            let member = ast.member(id).ok()?;
141            Some(Member {
142                node: member.node_id,
143                name: member.name.clone(),
144                type_: member.type_.resolved()?,
145                optional: member.facets.is_optional(),
146            })
147        })
148        .collect()
149}
150
151/// A union's members, by name, in case order.
152///
153/// A union's members hang off its cases; `members_of` does not answer
154/// for one.
155#[must_use]
156pub fn union_members(ast: &Ast, type_: Id<TypeNode>) -> Vec<(String, Id<TypeNode>)> {
157    union_cases(ast, type_)
158        .into_iter()
159        .map(|case| (case.name, case.type_))
160        .collect()
161}
162
163/// One case member of a union.
164///
165/// The identity as well as the name, because a `@mutable` union's plan
166/// names its members the way an aggregate's does — by node — and a walk
167/// that had only the name would have to match on a string.
168#[derive(Debug, Clone)]
169pub struct Case {
170    /// The member's stable identity.
171    pub node: zerodds_ast_core::NodeId,
172    /// Its simple name, which is also its JSON key.
173    pub name: String,
174    /// Its type, resolved.
175    pub type_: Id<TypeNode>,
176    /// `@try_construct` as the case member wrote it. §7.2.2.7 lists
177    /// union members alongside structure members.
178    pub try_construct: Option<zerodds_ast::TryConstruct>,
179    /// The labels selecting this case, in declaration order.
180    ///
181    /// Carried because [`union_selection`] compares them: the probe
182    /// evaluates labels only where a setting says it must, and it needs
183    /// them in the same walk that gives it the member.
184    pub labels: Vec<zerodds_ast::CaseLabel>,
185}
186
187impl Case {
188    /// Whether one of this case's labels is `default:`.
189    #[must_use]
190    pub fn is_default(&self) -> bool {
191        self.labels.iter().any(|label| label.is_default())
192    }
193}
194
195/// The case a discriminator value selects, under
196/// [`zerodds_ast_layout::UnionLabelMatch`].
197///
198/// `suppress_negative` is
199/// [`zerodds_ast_layout::union_negative_matches_nothing`] for this
200/// union's discriminator — the crossing, asked once by the caller and
201/// passed in, so that a walk cannot ask it of the wrong type.
202///
203/// IDL 4.2 §7.4.1.4.4.2.4: the `default:` member is the one selected by
204/// any value no label names. A union with no `default:` and no matching
205/// label selects nothing, which is a case the corpus carries
206/// (`union-default-unselected`) and not an error.
207///
208/// # Errors
209/// [`UnevaluatedLabel`] for a label whose constant expression the model
210/// cannot evaluate. Skipping it would turn a label that should have
211/// matched into one that matches nothing, and the walk would select a
212/// different branch — or none — and say nothing.
213pub fn union_selection(
214    ast: &Ast,
215    cases: &[Case],
216    discriminator: i128,
217    suppress_negative: bool,
218) -> Result<Option<usize>, UnevaluatedLabel> {
219    let mut fallback = None;
220    for (index, case) in cases.iter().enumerate() {
221        for label in &case.labels {
222            match label.expr() {
223                None => fallback = Some(index),
224                Some(expr) => {
225                    // Reported and not skipped. A skipped label is a
226                    // label that matches nothing, so the walk goes on to
227                    // `default:` or off the end and the writer emits a
228                    // branch the sample did not select — or emits none —
229                    // and returns success. The Python column refuses the
230                    // same construct in `emit.rs::label_value`; this is
231                    // the same refusal on the probe's side.
232                    let value = ast
233                        .eval_integral(expr)
234                        .map_err(|_| UnevaluatedLabel { case: index })?;
235                    if suppress_negative && value < 0 {
236                        continue;
237                    }
238                    if value == discriminator {
239                        return Ok(Some(index));
240                    }
241                }
242            }
243        }
244    }
245    Ok(fallback)
246}
247
248/// A case label whose constant expression the model cannot evaluate.
249///
250/// Carries the case's index and not a message: the caller holds the
251/// path and the case's name, which is what makes the refusal
252/// actionable.
253#[derive(Debug, Clone, Copy)]
254pub struct UnevaluatedLabel {
255    /// Which case of the union the label belongs to.
256    pub case: usize,
257}
258
259/// A union's case members, in case order.
260///
261/// A union's members hang off its cases; `members_of` does not answer
262/// for one.
263#[must_use]
264pub fn union_cases(ast: &Ast, type_: Id<TypeNode>) -> Vec<Case> {
265    ast.cases_of(type_)
266        .iter()
267        .filter_map(|id| ast.case(*id).ok())
268        .filter_map(|case| {
269            let member = ast.member(case.member).ok()?;
270            Some(Case {
271                node: member.node_id,
272                name: member.name.clone(),
273                type_: member.type_.resolved()?,
274                try_construct: member.facets.xtypes.as_ref().and_then(|x| x.try_construct),
275                labels: case.labels.clone(),
276            })
277        })
278        .collect()
279}
280
281/// The names of a bitset's own bitfields, anonymous padding excluded.
282#[must_use]
283pub fn bitfield_names(ast: &Ast, fields: &[Id<zerodds_ast::BitfieldNode>]) -> Vec<String> {
284    fields
285        .iter()
286        .filter_map(|id| ast.bitfield(*id).ok())
287        .filter_map(|field| field.name.clone())
288        .collect()
289}
290
291/// Refuse a key that names nothing the type declares.
292///
293/// An error and not a warning: a misspelled member name would otherwise
294/// silently test the default value, and the two emitters that skipped it
295/// produced a sample the third one refused.
296///
297/// # Errors
298/// [`ProbeError::Unknown`], naming the offending key's path.
299pub fn reject_unknown_keys<V: Source>(
300    object: Fields<'_, V>,
301    declared: &[String],
302    path: &str,
303) -> Result<(), ProbeError> {
304    for name in object.names() {
305        if !declared.iter().any(|known| known == name) {
306            return Err(ProbeError::Unknown {
307                path: join(path, name),
308                declared: declared.to_vec(),
309            });
310        }
311    }
312    Ok(())
313}
314
315/// The value for one member, or `None` where an `@optional` is absent.
316///
317/// The two rules the corpus README states, in the one place all three
318/// backends ask:
319///
320/// - a member with no key is absent, which only an `@optional` may be;
321/// - `null` is never a value and never an absence. An absent optional
322///   member is a key that is **left out**. `null` would read as "present
323///   and empty" to one backend and as "absent" to another, and the
324///   vendors write nothing at all for an absent optional — measured
325///   across all three, `vendor_bytes.md` §"Everything else all three
326///   agree on".
327///
328/// # Errors
329/// [`ProbeError::Missing`] for a required member with no key,
330/// [`ProbeError::Mismatch`] for `null`.
331pub fn member_value<'a, V: Source>(
332    object: Fields<'a, V>,
333    member: &Member,
334    path: &str,
335) -> Result<Option<&'a V>, ProbeError> {
336    let member_path = join(path, &member.name);
337    match object.get(&member.name) {
338        Some(value) if value.is_null() => Err(ProbeError::Mismatch {
339            path: member_path,
340            expected: "a value, or the key left out for an absent @optional".to_string(),
341            found: "null".to_string(),
342        }),
343        Some(value) => Ok(Some(value)),
344        None if member.optional => Ok(None),
345        None => Err(ProbeError::Missing { path: member_path }),
346    }
347}
348
349/// Refuse `null` wherever a value belongs.
350///
351/// The counterpart of [`member_value`] for a place that has no
352/// optionality to consider — a union's selected member, an element of a
353/// collection. Same rule, same message, so the three backends report
354/// the same refusal.
355///
356/// # Errors
357/// [`ProbeError::Mismatch`].
358pub fn reject_null<V: Source>(value: &V, path: &str) -> Result<(), ProbeError> {
359    if value.is_null() {
360        return Err(ProbeError::Mismatch {
361            path: path.to_string(),
362            expected: "a value, or the key left out for an absent @optional".to_string(),
363            found: "null".to_string(),
364        });
365    }
366    Ok(())
367}
368
369/// A union's discriminator value, which is never absent.
370///
371/// # Errors
372/// [`ProbeError::Missing`] when `_d` is not in the object.
373pub fn discriminator<'a, V: Source>(
374    object: Fields<'a, V>,
375    path: &str,
376) -> Result<&'a V, ProbeError> {
377    object.get("_d").ok_or_else(|| ProbeError::Missing {
378        path: join(path, "_d"),
379    })
380}
381
382/// `<path>.<name>`, or `<name>` at the root of a nameless walk.
383#[must_use]
384pub fn join(path: &str, name: &str) -> String {
385    if path.is_empty() {
386        name.to_string()
387    } else {
388        format!("{path}.{name}")
389    }
390}
391
392// -- scalars ---------------------------------------------------------
393
394/// An object.
395///
396/// # Errors
397/// [`ProbeError::Mismatch`].
398pub fn object<'a, V: Source>(value: &'a V, path: &str) -> Result<Fields<'a, V>, ProbeError> {
399    if value.is_object() {
400        return Ok(Fields::of(value));
401    }
402    Err(ProbeError::Mismatch {
403        path: path.to_string(),
404        expected: "an object".to_string(),
405        found: kind_of(value).to_string(),
406    })
407}
408
409/// An array.
410///
411/// # Errors
412/// [`ProbeError::Mismatch`].
413pub fn array<'a, V: Source>(value: &'a V, path: &str) -> Result<&'a [V], ProbeError> {
414    value.as_items().ok_or_else(|| ProbeError::Mismatch {
415        path: path.to_string(),
416        expected: "an array".to_string(),
417        found: kind_of(value).to_string(),
418    })
419}
420
421/// A string.
422///
423/// # Errors
424/// [`ProbeError::Mismatch`].
425pub fn text<'a, V: Source>(value: &'a V, path: &str) -> Result<&'a str, ProbeError> {
426    value.as_text().ok_or_else(|| ProbeError::Mismatch {
427        path: path.to_string(),
428        expected: "a string".to_string(),
429        found: kind_of(value).to_string(),
430    })
431}
432
433/// `true` or `false`.
434///
435/// # Errors
436/// [`ProbeError::Mismatch`].
437pub fn boolean<V: Source>(value: &V, path: &str) -> Result<bool, ProbeError> {
438    value.as_bool().ok_or_else(|| ProbeError::Mismatch {
439        path: path.to_string(),
440        expected: "true or false".to_string(),
441        found: kind_of(value).to_string(),
442    })
443}
444
445/// A floating-point value.
446///
447/// # Errors
448/// [`ProbeError::Mismatch`].
449pub fn float<V: Source>(value: &V, path: &str) -> Result<f64, ProbeError> {
450    value.as_float().ok_or_else(|| ProbeError::Mismatch {
451        path: path.to_string(),
452        expected: "a number".to_string(),
453        found: kind_of(value).to_string(),
454    })
455}
456
457/// A `long double`.
458///
459/// The spelling is the representation's rule and is stated by
460/// [`Source::as_float128`]; this is where the type asks for one, so that a
461/// caller does not reach past the contract to get it.
462///
463/// # Errors
464/// [`ProbeError::Mismatch`] for a value the representation cannot read
465/// as a `long double`.
466pub fn float128<V: Source>(value: &V, path: &str) -> Result<Float128, ProbeError> {
467    value.as_float128(path)
468}
469
470/// Exactly one character.
471///
472/// A `char` field is one character, and a longer string is refused
473/// rather than cut down to its first: two backends took `.next()` and
474/// put `'a'` on the wire for `"ab"`, which is a sample nobody wrote.
475///
476/// # Errors
477/// [`ProbeError::Mismatch`].
478pub fn character<V: Source>(value: &V, path: &str) -> Result<char, ProbeError> {
479    let text = text(value, path)?;
480    let mut chars = text.chars();
481    match (chars.next(), chars.next()) {
482        (Some(ch), None) => Ok(ch),
483        _ => Err(ProbeError::Mismatch {
484            path: path.to_string(),
485            expected: "exactly one character".to_string(),
486            found: format!("`{text}`"),
487        }),
488    }
489}
490
491/// Exactly one character, of a width that can carry it.
492///
493/// The width check belongs beside `integer_for`'s range check and for
494/// the same reason: a `char` holding `š` is a value file that says
495/// something the type cannot hold, and the two C emitters wrote
496/// `'\u{161}'` into a one-byte field where the compiler narrows it
497/// without a word. DDS-XTypes 1.3 §7.2.2.2.1.2.2 gives `CHAR_8_TYPE`
498/// eight bits and no encoding; §7.2.2.2.1.2.5 restricts `CHAR_16_TYPE`
499/// to the Basic Multilingual Plane, "Unicode codepoints from 0x0000 to
500/// U+FFFF".
501///
502/// # Errors
503/// [`ProbeError::Mismatch`].
504pub fn character_for<V: Source>(
505    kind: PrimitiveKind,
506    value: &V,
507    path: &str,
508) -> Result<char, ProbeError> {
509    let found = character(value, path)?;
510    let point = u32::from(found);
511    let (limit, expected) = match kind {
512        PrimitiveKind::Char => (0xff, "a character below U+0100"),
513        PrimitiveKind::WChar => (0xffff, "a character in the Basic Multilingual Plane"),
514        // Not characters. A caller that routed one here is asking the
515        // wrong question, and answering it would be the silent part.
516        PrimitiveKind::Boolean
517        | PrimitiveKind::Octet
518        | PrimitiveKind::UInt8
519        | PrimitiveKind::Int8
520        | PrimitiveKind::Int16
521        | PrimitiveKind::UInt16
522        | PrimitiveKind::Int32
523        | PrimitiveKind::UInt32
524        | PrimitiveKind::Int64
525        | PrimitiveKind::UInt64
526        | PrimitiveKind::Float32
527        | PrimitiveKind::Float64
528        | PrimitiveKind::Float128 => {
529            return Err(ProbeError::Mismatch {
530                path: path.to_string(),
531                expected: "a character type".to_string(),
532                found: format!("{kind:?}"),
533            });
534        }
535    };
536    if point > limit {
537        return Err(ProbeError::Mismatch {
538            path: path.to_string(),
539            expected: expected.to_string(),
540            found: format!("U+{point:04X}"),
541        });
542    }
543    Ok(found)
544}
545
546/// An integer, in whatever spelling the representation admits.
547///
548/// Which spellings those are is the representation's own rule and is
549/// stated by [`Source::as_integer`] — a JSON number is a double and stops
550/// carrying a 64-bit integer exactly past 2^53, which is a fact about
551/// JSON and not about IDL. This is where the *type* asks for an
552/// integer, so that no caller reaches past the contract to get one and
553/// no backend accepts a spelling the other two refuse.
554///
555/// # Errors
556/// [`ProbeError::Mismatch`].
557pub fn integer<V: Source>(value: &V, path: &str) -> Result<i128, ProbeError> {
558    value.as_integer(path)
559}
560
561/// An integer that fits the primitive it is destined for.
562///
563/// The range check belongs with the dialect and not with the
564/// serialiser: an `octet` of `300` is a value file that says something
565/// the type cannot hold, and a backend that emitted `300` into a C
566/// `uint8_t` would put `44` on the wire without a word.
567///
568/// # Errors
569/// [`ProbeError::Mismatch`].
570pub fn integer_for<V: Source>(
571    kind: PrimitiveKind,
572    value: &V,
573    path: &str,
574) -> Result<i128, ProbeError> {
575    let raw = integer(value, path)?;
576    let (low, high, name): (i128, i128, &str) = match kind {
577        PrimitiveKind::Octet | PrimitiveKind::UInt8 => (0, 0xff, "an octet"),
578        PrimitiveKind::Int8 => (-0x80, 0x7f, "an int8"),
579        PrimitiveKind::Int16 => (-0x8000, 0x7fff, "a short"),
580        PrimitiveKind::UInt16 => (0, 0xffff, "an unsigned short"),
581        PrimitiveKind::Int32 => (-0x8000_0000, 0x7fff_ffff, "a long"),
582        PrimitiveKind::UInt32 => (0, 0xffff_ffff, "an unsigned long"),
583        PrimitiveKind::Int64 => (-0x8000_0000_0000_0000, 0x7fff_ffff_ffff_ffff, "a long long"),
584        PrimitiveKind::UInt64 => (0, 0xffff_ffff_ffff_ffff, "an unsigned long long"),
585        // Not integers. A caller that routed one here is asking the
586        // wrong question, and answering it would be the silent part.
587        PrimitiveKind::Boolean
588        | PrimitiveKind::Char
589        | PrimitiveKind::WChar
590        | PrimitiveKind::Float32
591        | PrimitiveKind::Float64
592        | PrimitiveKind::Float128 => {
593            return Err(ProbeError::Mismatch {
594                path: path.to_string(),
595                expected: "an integral type".to_string(),
596                found: format!("{kind:?}"),
597            });
598        }
599    };
600    if raw < low || raw > high {
601        return Err(ProbeError::Mismatch {
602            path: path.to_string(),
603            expected: format!("{name} in range {low}..={high}"),
604            found: format!("{raw}"),
605        });
606    }
607    Ok(raw)
608}
609
610// -- named things ----------------------------------------------------
611
612/// The enumerator a value names, and its effective value.
613///
614/// The name is checked against the declared set by all three backends.
615/// Two of them used to paste the name into an identifier without
616/// looking: a typo compiled into a different enumerator, or did not
617/// compile at all in a place the matrix reads as a vendor difference.
618///
619/// # Errors
620/// [`ProbeError::Mismatch`] for a value that is not a string,
621/// [`ProbeError::Unknown`] for a name the enumeration does not declare.
622pub fn enumerator<V: Source>(
623    ast: &Ast,
624    type_: Id<TypeNode>,
625    value: &V,
626    path: &str,
627) -> Result<(String, i128), ProbeError> {
628    let name = text(value, path).map_err(|_| ProbeError::Mismatch {
629        path: path.to_string(),
630        expected: "an enumerator name, as a string".to_string(),
631        found: kind_of(value).to_string(),
632    })?;
633    let mut declared = Vec::new();
634    let mut found = None;
635    for id in ast.enumerators_of(type_) {
636        let Ok(node) = ast.enumerator(*id) else {
637            continue;
638        };
639        declared.push(node.name.clone());
640        if node.name == name {
641            found = ast.effective_enumerator_value(*id);
642        }
643    }
644    let value = found.ok_or_else(|| ProbeError::Unknown {
645        path: path.to_string(),
646        declared,
647    })?;
648    Ok((name.to_string(), value))
649}
650
651/// The bit names a bitmask value sets, each with its position.
652///
653/// # Errors
654/// [`ProbeError::Mismatch`] for a bound Table 33 does not tabulate, for
655/// an entry that is not a string, or for a bit at or past the bound;
656/// [`ProbeError::Unknown`] for a name the bitmask does not declare.
657pub fn bit_names<V: Source>(
658    ast: &Ast,
659    type_: Id<TypeNode>,
660    value: &V,
661    path: &str,
662) -> Result<Vec<(String, u16)>, ProbeError> {
663    let bound = ast.effective_bit_bound(type_);
664    if bound == 0 || bound > 64 {
665        return Err(ProbeError::Mismatch {
666            path: path.to_string(),
667            expected: "an @bit_bound between 1 and 64, as Table 33 tabulates".to_string(),
668            found: format!("{bound}"),
669        });
670    }
671    let entries = array(value, path)?;
672
673    let mut declared = Vec::new();
674    let mut positions = Vec::new();
675    for id in ast.bits_of(type_) {
676        let Ok(node) = ast.bit_value(*id) else {
677            continue;
678        };
679        declared.push(node.name.clone());
680        positions.push((node.name.clone(), ast.effective_bit_position(*id)));
681    }
682
683    let mut out = Vec::with_capacity(entries.len());
684    for (index, entry) in entries.iter().enumerate() {
685        let element = format!("{path}[{index}]");
686        let name = text(entry, &element).map_err(|_| ProbeError::Mismatch {
687            path: element.clone(),
688            expected: "a bit name, as a string".to_string(),
689            found: kind_of(entry).to_string(),
690        })?;
691        let position = positions
692            .iter()
693            .find(|(declared_name, _)| declared_name == name)
694            .and_then(|(_, position)| *position)
695            .ok_or_else(|| ProbeError::Unknown {
696                path: element.clone(),
697                declared: declared.clone(),
698            })?;
699        // §7.4.1.1.3.2: the bits above the bound "are not considered to
700        // be part of the bitmask", so setting one puts a value on the
701        // wire no peer is required to read back.
702        if position >= bound {
703            return Err(ProbeError::Mismatch {
704                path: element,
705                expected: format!("a bit below the @bit_bound({bound})"),
706                found: format!("`{name}` at position {position}"),
707            });
708        }
709        out.push((name.to_string(), position));
710    }
711    Ok(out)
712}
713
714/// The elements of a collection, with its declared bound enforced.
715///
716/// The bound is part of the type, so a sample that overruns it is a
717/// sample no peer's generated code can hold. One backend checked it and
718/// two did not, which made an over-long sequence a ZeroDDS-only refusal.
719///
720/// # Errors
721/// [`ProbeError::Mismatch`].
722pub fn bounded_items<'a, V: Source>(
723    ast: &Ast,
724    value: &'a V,
725    bound: Bound,
726    noun: &str,
727    path: &str,
728) -> Result<&'a [V], ProbeError> {
729    let items = array(value, path)?;
730    if let Some(limit) = bound.expr().and_then(|expr| ast.eval_integral(expr).ok()) {
731        if i128::try_from(items.len()).unwrap_or(i128::MAX) > limit {
732            return Err(ProbeError::Mismatch {
733                path: path.to_string(),
734                expected: format!("at most {limit} {noun}"),
735                found: format!("{}", items.len()),
736            });
737        }
738    }
739    Ok(items)
740}
741
742/// The key and the value of one map entry.
743///
744/// A map entry is a two-element array, `[key, value]`, and not a JSON
745/// object: a JSON object keys on a string and `map<long, float>` does
746/// not. §7.4.1.1.4 restricts a key to a scalar, which is what makes the
747/// pair form sufficient.
748///
749/// # Errors
750/// [`ProbeError::Mismatch`] for anything that is not a pair.
751pub fn map_entry<'a, V: Source>(entry: &'a V, path: &str) -> Result<(&'a V, &'a V), ProbeError> {
752    let pair = array(entry, path)?;
753    match pair {
754        [key, value] => Ok((key, value)),
755        other => Err(ProbeError::Mismatch {
756            path: path.to_string(),
757            expected: "a two-element array, `[key, value]`".to_string(),
758            found: format!("{} elements", other.len()),
759        }),
760    }
761}
762
763/// The element counts of an array's dimensions.
764///
765/// # Errors
766/// [`ProbeError::Unsupported`] where a dimension does not evaluate. A
767/// dimension that substituted zero wrote no elements at all and said it
768/// had worked — defect 7 of the sweep, in its probe-side form.
769pub fn array_dimensions(
770    ast: &Ast,
771    dims: &[Id<zerodds_ast::ConstExprNode>],
772    path: &str,
773) -> Result<Vec<usize>, ProbeError> {
774    dims.iter()
775        .map(|dim| {
776            ast.eval_integral(*dim)
777                .ok()
778                .and_then(|value| usize::try_from(value).ok())
779                .ok_or(ProbeError::Unsupported {
780                    path: path.to_string(),
781                    kind: "an array dimension that does not evaluate to a count",
782                })
783        })
784        .collect()
785}
786
787/// How a union's `switch` and its `case` labels have to be spelled in C
788/// and C++.
789///
790/// `switch ((long long) …)` with `case <v>LL:` is right for every
791/// signed discriminator and wrong for one case that is in the corpus:
792/// `union Choice switch (unsigned long long) { case 16045690984833335998: … }`
793/// does not fit a `long long`, so the label is a constant the compiler
794/// refuses — `integer constant is so large that it is unsigned`, and
795/// then an error. The generated probe does not build at all.
796///
797/// It is a real gap and not a hypothetical: `union-discriminator-ulonglong`
798/// is a corpus case, and every emitter that writes a `switch` over a
799/// discriminator had this. Found through the CORBA column's decode
800/// probe, which is the first one to compile that case.
801///
802/// Returns `("unsigned long long", "ULL")` for an unsigned
803/// discriminator and `("long long", "LL")` for everything else. An
804/// enumeration is signed — E1 — and so is `char`.
805#[must_use]
806pub fn discriminator_c_width(
807    ast: &Ast,
808    discriminator: Id<TypeNode>,
809) -> (&'static str, &'static str) {
810    let resolved = resolve_alias(ast, discriminator);
811    let unsigned = matches!(
812        ast.type_(resolved).map(|node| node.kind.clone()),
813        Ok(TypeKind::Primitive(
814            PrimitiveKind::UInt8
815                | PrimitiveKind::UInt16
816                | PrimitiveKind::UInt32
817                | PrimitiveKind::UInt64
818                | PrimitiveKind::Octet
819        ))
820    );
821    if unsigned {
822        ("unsigned long long", "ULL")
823    } else {
824        ("long long", "LL")
825    }
826}
827
828/// How many typedefs one chain may hop through before it is taken for a
829/// cycle.
830///
831/// The same number [`crate::MAX_VALUE_DEPTH`] and
832/// `zerodds_ast_layout::MAX_DEPTH` carry, and for the same reason: every
833/// hop is one frame of a recursive descent, and 64 sits far enough below
834/// the thread's stack that the refusal arrives before the stack does.
835pub const MAX_ALIAS_HOPS: usize = 64;
836
837/// Refuse a model whose typedefs close a cycle, before any walk follows
838/// one.
839///
840/// IDL 4.2 §7.4.1.4.4.4 has no declaration that closes a typedef cycle —
841/// a `typedef` names a type that must already be declared, and the
842/// forward declaration IDL does have is for structures, unions and
843/// interfaces, not for aliases. So `typedef T T;` is not IDL. The
844/// frontend admits it anyway, measured 2026-08-31 on
845/// `typedef Tee Tee; @final struct Ess { Tee ex; };`, which
846/// `model_from_idl` accepts with no diagnostic.
847///
848/// What that model then does is the reason this exists. Every walk in
849/// this crate and in `zerodds-wire-probe` sees a typedef the same way —
850/// resolve the target, recurse on it, with nothing else changing — so a
851/// chain that returns to itself is an unbounded recursion in each of
852/// them. Thirteen of them ran into the stack rather than answering:
853/// `canonicalise`, the five fill emitters, the five dump emitters,
854/// `ros_idl::ros_package` and `recursion::recursive_aggregates`. A
855/// probe that aborts has measured nothing, and an empty cell reads as a
856/// vendor difference.
857///
858/// Called once at the entry of each walk rather than at each alias arm:
859/// the answer is a property of the model, not of the position in it, and
860/// one scan up front leaves every arm below free to follow a chain that
861/// is now known to be finite.
862///
863/// Every alias in the model and not only the ones below `root`: an
864/// unreachable cyclic typedef is a declaration the frontend should have
865/// refused, and a probe run over a file holding one is measuring a model
866/// nobody can write down.
867///
868/// # Errors
869/// [`ProbeError::NoLayout`], naming the typedef the chain returns to.
870pub fn reject_alias_cycle(ast: &Ast) -> Result<(), ProbeError> {
871    let aliases: Vec<Id<TypeNode>> = ast
872        .types()
873        .filter(|(_, node)| matches!(node.kind, TypeKind::Alias { .. }))
874        .map(|(id, _)| id)
875        .collect();
876    for start in aliases {
877        let mut chain: Vec<Id<TypeNode>> = vec![start];
878        let mut current = start;
879        loop {
880            let Ok(TypeKind::Alias { aliased }) = ast.type_(current).map(|node| node.kind.clone())
881            else {
882                break;
883            };
884            let Some(next) = aliased.resolved() else {
885                break;
886            };
887            if chain.contains(&next) || chain.len() > MAX_ALIAS_HOPS {
888                return Err(ProbeError::NoLayout {
889                    detail: format!(
890                        "{}: a typedef chain that returns to itself, which IDL 4.2 §7.4.1.4.4.4 \
891                         has no declaration for and no walk over the type graph can end",
892                        alias_name(ast, start)
893                    ),
894                });
895            }
896            chain.push(next);
897            current = next;
898        }
899    }
900    Ok(())
901}
902
903/// The scoped name of a typedef, for the refusal above.
904fn alias_name(ast: &Ast, type_: Id<TypeNode>) -> String {
905    ast.any_of_type(type_)
906        .ok()
907        .and_then(|any| ast.scoped_name_of(any))
908        .map_or_else(
909            || String::from("a typedef"),
910            |scoped| scoped.segments().join("::"),
911        )
912}
913
914/// Whether `type_` is one of the two aggregates a JSON object stands
915/// for, once typedefs are seen through.
916#[must_use]
917pub fn resolve_alias(ast: &Ast, type_: Id<TypeNode>) -> Id<TypeNode> {
918    let mut current = type_;
919    // A typedef chain is finite in a committed model; the bound is
920    // belt and braces against a cycle a hand-built model could hold.
921    // [`reject_alias_cycle`] is the loud half of the same guard, and
922    // runs at the entry of every walk that reaches here.
923    for _ in 0..MAX_ALIAS_HOPS {
924        match ast.type_(current).map(|node| node.kind.clone()) {
925            Ok(TypeKind::Alias { aliased }) => match aliased.resolved() {
926                Some(next) => current = next,
927                None => return current,
928            },
929            _ => return current,
930        }
931    }
932    current
933}
934
935/// The kind a value has, for a refusal that says what was there.
936///
937/// The representation's own word for it — [`Source::kind`] — because
938/// what shapes there are to be is the representation's question and not
939/// the type system's.
940#[must_use]
941pub fn kind_of<V: Source>(value: &V) -> &'static str {
942    value.kind()
943}