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}