zerodds_wire_codec/canonical.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The one form two implementations' values can be compared in.
4//!
5//! A pairwise run compares what came out of B against what went into A.
6//! Both sides are written in the same representation, and a
7//! representation usually has more than one way to write the same value:
8//! in JSON, `2.0` and `2.0e0`, `{"a":1,"b":2}` and `{"b":2,"a":1}`,
9//! `12345678901234567890` as a number and as a string. Comparing the two
10//! texts would report a difference in formatting as a difference in
11//! wire behaviour.
12//!
13//! ## Why the normalisation is type-directed
14//!
15//! A normaliser that only knew JSON could sort keys and reprint numbers,
16//! and would still be wrong twice:
17//!
18//! - A `float` member carries a value the JSON was written with in full
19//! `double` precision — `0.1` in the file, `0.10000000149011612`
20//! coming back out of thirty-two bits. Neither side is wrong and the
21//! texts differ. Only the type says which of the two is the value.
22//! - A `long long` is a string, because a JSON number is a double and
23//! loses the low bits past 2^53. A type-blind normaliser cannot tell
24//! a 64-bit integer that must stay a string from a `long` that must
25//! not become one.
26//!
27//! So [`canonicalise`] walks the type beside the value, the same walk
28//! `encode` and `decode` make, and rebuilds the value in the one form
29//! the table in `crate` documents. Running it over the case's own value
30//! file and over a probe's output puts both in that form before
31//! anything is compared.
32//!
33//! ## What it also does
34//!
35//! It validates. A key that names no member, a missing member that is
36//! not `@optional`, an explicit `null` — all are errors here, with the
37//! path, for the same reason they are errors in `encode`.
38
39use crate::contract;
40use crate::encode::ProbeError;
41use crate::sink::{Aggregate, Sink};
42use crate::source::Source;
43use std::string::String;
44use std::vec::Vec;
45use zerodds_ast::{Ast, CharWidth, PrimitiveKind, TypeKind, TypeNode};
46use zerodds_ast_core::Id;
47
48/// Put one value into canonical form against its type.
49///
50/// # Errors
51/// [`ProbeError`], naming the path in the value.
52pub fn canonicalise<V: Source + Sink>(
53 ast: &Ast,
54 type_: Id<TypeNode>,
55 value: &V,
56 path: &str,
57) -> Result<V, ProbeError> {
58 // Before the first hop: the walk below follows a typedef by
59 // recursing on its target with the same value, so a chain that
60 // returns to itself has no end. See
61 // [`contract::reject_alias_cycle`].
62 contract::reject_alias_cycle(ast)?;
63 canonicalise_at(ast, type_, value, path)
64}
65
66/// [`canonicalise`], below the gate the entry point has already passed.
67///
68/// zerodds-lint: recursion-depth 128 — the value's own nesting. Every
69/// arm but the typedef one descends into a strictly smaller part of
70/// `value`, so the walk ends because the value is finite; the typedef
71/// arm keeps the value and is finite because
72/// [`contract::reject_alias_cycle`] has refused a chain that returns to
73/// itself. A value file is `serde_json`'s to parse and its
74/// `RECURSION_LIMIT` is 128; a value that came out of
75/// [`crate::decode`] is capped at [`crate::MAX_VALUE_DEPTH`] instead.
76fn canonicalise_at<V: Source + Sink>(
77 ast: &Ast,
78 type_: Id<TypeNode>,
79 value: &V,
80 path: &str,
81) -> Result<V, ProbeError> {
82 let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
83 detail: format!("{error}"),
84 })?;
85 match &node.kind {
86 TypeKind::Primitive(kind) => primitive(*kind, value, path),
87 TypeKind::String { width, .. } => {
88 let text = contract::text(value, path)?;
89 match width {
90 // A `wstring` on the wire is UTF-16 code units, and a
91 // code point outside the Basic Multilingual Plane comes
92 // back as the surrogate pair it was written as. Rust
93 // strings are UTF-8, so the pair has already been joined
94 // by the time it is here and nothing more is needed.
95 CharWidth::Char | CharWidth::WChar => Ok(V::text(text.to_string())),
96 }
97 }
98 TypeKind::Fixed { digits, scale } => {
99 let text = contract::text(value, path)?;
100 Ok(V::fixed(decimal(text, *digits, *scale, path)?))
101 }
102 TypeKind::Alias { aliased } => {
103 let target = aliased.resolved().ok_or_else(|| ProbeError::NoLayout {
104 detail: format!("{path}: the typedef target is unresolved"),
105 })?;
106 canonicalise_at(ast, target, value, path)
107 }
108 TypeKind::Enumeration { .. } => {
109 let (name, _) = contract::enumerator(ast, type_, value, path)?;
110 Ok(V::enumerator(name))
111 }
112 TypeKind::Bitmask { .. } => bitmask(ast, type_, value, path),
113 TypeKind::Bitset { .. } => {
114 let object = contract::object(value, path)?;
115 // `all_bitfields_of` and not the node's own `fields`: a
116 // `bitset` that extends another carries the base's
117 // bitfields, and `encode::write_bitset` and
118 // `decode::read_bitset` both write and read them. Taking
119 // only the derived type's own list here refused
120 // `bitset-inheritance`'s sample — `Sample.value.low: not a
121 // member; the type declares ["high"]` — for a member the
122 // encoder had just written. Nothing caught it because the
123 // canonical form is only reached by the read direction and
124 // by `--values-out`, and neither was run over the corpus.
125 let declared = contract::bitfield_names(ast, &ast.all_bitfields_of(type_));
126 contract::reject_unknown_keys(object, &declared, path)?;
127 let mut out = Vec::with_capacity(declared.len());
128 for name in &declared {
129 let field_path = contract::join(path, name);
130 let Some(field) = object.get(name) else {
131 return Err(ProbeError::Missing { path: field_path });
132 };
133 out.push((name.clone(), contract::integer(field, &field_path)?));
134 }
135 V::bitfields(out, path)
136 }
137 TypeKind::Sequence { element, .. } | TypeKind::Array { element, .. } => {
138 let element = element.resolved().ok_or_else(|| ProbeError::NoLayout {
139 detail: format!("{path}: the element type is unresolved"),
140 })?;
141 let dims = match &node.kind {
142 TypeKind::Array { dims, .. } => dims.len(),
143 _ => 1,
144 };
145 collection(ast, element, value, path, dims)
146 }
147 TypeKind::Map {
148 key, value: entry, ..
149 } => {
150 let key_type = key.resolved().ok_or_else(|| ProbeError::NoLayout {
151 detail: format!("{path}: the map key type is unresolved"),
152 })?;
153 let value_type = entry.resolved().ok_or_else(|| ProbeError::NoLayout {
154 detail: format!("{path}: the map value type is unresolved"),
155 })?;
156 let entries = contract::array(value, path)?;
157 let mut out = Vec::with_capacity(entries.len());
158 for (index, pair) in entries.iter().enumerate() {
159 let entry_path = format!("{path}[{index}]");
160 let (entry_key, entry_value) = contract::map_entry(pair, &entry_path)?;
161 out.push(V::map_entry(
162 canonicalise_at(ast, key_type, entry_key, &format!("{entry_path}.key"))?,
163 canonicalise_at(ast, value_type, entry_value, &format!("{entry_path}.value"))?,
164 ));
165 }
166 // Sorted by key, because a map is a mapping and not a list.
167 // XTypes 1.3 §7.4.1.1.4 defines the serialisation as a
168 // sequence of entries and says nothing about their order, and
169 // the bindings differ: `fastddsgen` maps a `map` to
170 // `std::map`, which is ordered by key, so Fast DDS writes and
171 // reads entries in a different order than a sample that was
172 // written in another one. Measured on 2026-08-09 —
173 // `map-key-long` came back with 1 where the sample had
174 // 2147483647 in the same slot, on Fast DDS reading its own
175 // bytes. Comparing the order would report a container choice
176 // as a wire disagreement; a key that arrived with the wrong
177 // value still fails, which is the part that is one.
178 out.sort_by_key(|entry| {
179 entry
180 .as_items()
181 .and_then(<[V]>::first)
182 .map_or_else(String::new, Source::render)
183 });
184 Ok(V::collection(out))
185 }
186 TypeKind::Structure { .. } | TypeKind::Valuetype { .. } => {
187 aggregate(ast, type_, value, path)
188 }
189 TypeKind::Union { .. } => union(ast, type_, value, path),
190 other => Err(ProbeError::Unfilled {
191 path: path.to_string(),
192 kind: other.as_str(),
193 }),
194 }
195}
196
197/// A structure or a valuetype, member by member in declaration order.
198///
199/// `all_members_of` and not `members_of`: a derived type's base
200/// contributes members, and a normaliser that walked only the type's own
201/// would report every inherited member as an undeclared key.
202///
203/// zerodds-lint: recursion-depth 128 — one frame per aggregate of the
204/// value, below `canonicalise_at`, which carries the argument. Each member
205/// is a strictly smaller part of the object this was handed.
206fn aggregate<V: Source + Sink>(
207 ast: &Ast,
208 type_: Id<TypeNode>,
209 value: &V,
210 path: &str,
211) -> Result<V, ProbeError> {
212 let object = contract::object(value, path)?;
213 let members = contract::members(ast, type_);
214 let declared: Vec<String> = members.iter().map(|member| member.name.clone()).collect();
215 contract::reject_unknown_keys(object, &declared, path)?;
216
217 let mut out = Aggregate::new();
218 for member in members {
219 // `contract::member_value` is the one place the two rules live:
220 // a key left out is an absent `@optional`, and `null` is neither
221 // a value nor an absence. All three probes ask it, so the input
222 // sample and every probe's output mean the same thing.
223 let Some(present) = contract::member_value(object, &member, path)? else {
224 continue;
225 };
226 let member_path = contract::join(path, &member.name);
227 out.insert(
228 member.name.clone(),
229 canonicalise_at(ast, member.type_, present, &member_path)?,
230 );
231 }
232 Ok(V::aggregate(out))
233}
234
235/// A union: `_d`, and the member the value names.
236///
237/// The label is not evaluated here. A sample whose `_d` selects no case
238/// carries no member at all — `union-default-unselected` in the corpus
239/// is exactly that — and a sample that names a member the discriminator
240/// does not select is a case somebody wrote on purpose. Deciding either
241/// way here would change what the comparison is testing.
242///
243/// zerodds-lint: recursion-depth 128 — one frame per aggregate of the
244/// value, below `canonicalise_at`, which carries the argument. The
245/// discriminant and each named member are strictly smaller parts of the
246/// object this was handed.
247fn union<V: Source + Sink>(
248 ast: &Ast,
249 type_: Id<TypeNode>,
250 value: &V,
251 path: &str,
252) -> Result<V, ProbeError> {
253 let object = contract::object(value, path)?;
254 let node = ast.type_(type_).map_err(|error| ProbeError::NoLayout {
255 detail: format!("{error}"),
256 })?;
257 let TypeKind::Union { discriminator, .. } = &node.kind else {
258 return Err(ProbeError::Unsupported {
259 path: path.to_string(),
260 kind: "a union whose kind changed under the probe",
261 });
262 };
263 let discriminator = discriminator
264 .resolved()
265 .ok_or_else(|| ProbeError::NoLayout {
266 detail: format!("{path}: the discriminator is unresolved"),
267 })?;
268
269 let cases = contract::union_members(ast, type_);
270 let mut out = Aggregate::new();
271 let discriminant = contract::discriminator(object, path)?;
272 out.insert(
273 "_d".to_string(),
274 canonicalise_at(
275 ast,
276 discriminator,
277 discriminant,
278 &contract::join(path, "_d"),
279 )?,
280 );
281
282 for (name, member_value) in object {
283 if name == "_d" {
284 continue;
285 }
286 let member_type = cases
287 .iter()
288 .find(|(case_name, _)| case_name == name)
289 .map(|(_, member_type)| *member_type)
290 .ok_or_else(|| ProbeError::Unknown {
291 path: format!("{path}.{name}"),
292 declared: cases.iter().map(|(name, _)| name.clone()).collect(),
293 })?;
294 out.insert(
295 name.to_string(),
296 canonicalise_at(ast, member_type, member_value, &format!("{path}.{name}"))?,
297 );
298 }
299 Ok(V::aggregate(out))
300}
301
302/// An array or a sequence, one dimension at a time.
303///
304/// A multi-dimensional array is nested JSON arrays, so the recursion
305/// counts dimensions down rather than flattening: `long grid[2][3]` is
306/// two arrays of three, and a normaliser that produced six would compare
307/// equal against a probe that got the dimensions the wrong way round.
308///
309/// zerodds-lint: recursion-depth 8 — one frame per array dimension. `dims`
310/// counts down by one at each frame and the last dimension's elements go
311/// to `canonicalise_at`, which is bounded separately.
312fn collection<V: Source + Sink>(
313 ast: &Ast,
314 element: Id<TypeNode>,
315 value: &V,
316 path: &str,
317 dims: usize,
318) -> Result<V, ProbeError> {
319 let items = contract::array(value, path)?;
320 let mut out = Vec::with_capacity(items.len());
321 for (index, item) in items.iter().enumerate() {
322 let item_path = format!("{path}[{index}]");
323 out.push(if dims > 1 {
324 collection(ast, element, item, &item_path, dims - 1)?
325 } else {
326 canonicalise_at(ast, element, item, &item_path)?
327 });
328 }
329 Ok(V::collection(out))
330}
331
332/// A bitmask, as the set bits in declaration order.
333///
334/// Order and duplicates are both normalised away because neither
335/// survives the wire: the value is an integer with bits in it, so
336/// `["B", "A", "A"]` and `["A", "B"]` are the same sample and only one
337/// of the two can come back out.
338fn bitmask<V: Source + Sink>(
339 ast: &Ast,
340 type_: Id<TypeNode>,
341 value: &V,
342 path: &str,
343) -> Result<V, ProbeError> {
344 let mut set = Vec::new();
345 for (name, _) in contract::bit_names(ast, type_, value, path)? {
346 set.push(name);
347 }
348 // Declaration order and not the order the names were written in:
349 // the value is an integer with bits in it, so `["B", "A", "A"]` and
350 // `["A", "B"]` are the same sample and only one can come back out.
351 let mut out = Vec::new();
352 for id in ast.bits_of(type_) {
353 let Ok(node) = ast.bit_value(*id) else {
354 continue;
355 };
356 if set.contains(&node.name) {
357 out.push(node.name.clone());
358 }
359 }
360 Ok(V::bit_names(out))
361}
362
363/// One primitive, in the shape its width calls for.
364///
365/// Which shape that is belongs to the representation and is
366/// [`Sink`]'s answer; what is decided here is the *type* half of it,
367/// which is the same in every representation: an `octet` is checked
368/// against an octet's range, a `float` is narrowed through thirty-two
369/// bits before anything is stored, and a `wchar` is checked against the
370/// Basic Multilingual Plane.
371fn primitive<V: Source + Sink>(
372 kind: PrimitiveKind,
373 value: &V,
374 path: &str,
375) -> Result<V, ProbeError> {
376 match kind {
377 PrimitiveKind::Boolean => Ok(V::boolean(contract::boolean(value, path)?)),
378 PrimitiveKind::Char | PrimitiveKind::WChar => Ok(V::character(
379 kind,
380 contract::character_for(kind, value, path)?,
381 )),
382 // Thirty-two bits of float hold fewer digits than the JSON that
383 // was written for them. Rounding through `f32` here is what puts
384 // the file's `0.1` and the wire's `0.10000000149011612` into the
385 // same value.
386 PrimitiveKind::Float32 => {
387 #[allow(clippy::cast_possible_truncation)]
388 let narrowed = f64::from(contract::float(value, path)? as f32);
389 V::float(kind, narrowed, path)
390 }
391 PrimitiveKind::Float64 => V::float(kind, contract::float(value, path)?, path),
392 // A `long double` canonicalises to its exact value, whatever
393 // form the value file used, and never back through a double —
394 // which would undo the point of accepting the string on the way
395 // in. See `decode.rs`.
396 PrimitiveKind::Float128 => Ok(V::float128(contract::float128(value, path)?)),
397 PrimitiveKind::Octet
398 | PrimitiveKind::UInt8
399 | PrimitiveKind::Int8
400 | PrimitiveKind::Int16
401 | PrimitiveKind::UInt16
402 | PrimitiveKind::Int32
403 | PrimitiveKind::UInt32
404 | PrimitiveKind::Int64
405 | PrimitiveKind::UInt64 => {
406 V::integer(kind, contract::integer_for(kind, value, path)?, path)
407 }
408 }
409}
410
411/// The canonical decimal for a `fixed<P, S>`.
412///
413/// The wire form is `P` digits and a sign, so `1.5` and `0001.500` are
414/// the same sample under `fixed<7, 3>` and only one string can come back
415/// out of it. That one is the digits the type has: no leading zeros
416/// beyond a single one before the point, exactly `S` after it, and a
417/// leading `-` only where a non-zero value is negative — [CDR] §9.3.2.8
418/// has one zero and gives it the positive sign nibble.
419pub(crate) fn decimal(text: &str, digits: u8, scale: u8, path: &str) -> Result<String, ProbeError> {
420 // Every rule about what is a legal value for `fixed<P, S>` is in
421 // `zerodds_wire::parse_fixed`, and it was written out here as well until
422 // the two disagreed about nothing only because nobody had changed either:
423 // a canonical form that accepted a value the writer refuses would hand a
424 // sample back that cannot be written again.
425 let parts =
426 zerodds_wire::parse_fixed(text, digits, scale, 0).map_err(|_| ProbeError::Mismatch {
427 path: path.to_string(),
428 expected: format!("a decimal that fits fixed<{digits}, {scale}>"),
429 found: format!("`{text}`"),
430 })?;
431
432 let mut out = String::new();
433 let all_zero = parts.whole.is_empty() && parts.fraction.bytes().all(|byte| byte == b'0');
434 // [CDR] §9.3.2.8 has one zero and gives it the positive sign nibble, so
435 // the canonical text of a negative zero carries no `-` either.
436 if parts.negative && !all_zero {
437 out.push('-');
438 }
439 out.push_str(if parts.whole.is_empty() {
440 "0"
441 } else {
442 parts.whole
443 });
444 if scale > 0 {
445 out.push('.');
446 out.push_str(parts.fraction);
447 for _ in parts.fraction.len()..usize::from(scale) {
448 out.push('0');
449 }
450 }
451 Ok(out)
452}
453
454/// The canonical text of a canonical value.
455///
456/// Keys ordered, no whitespace, one spelling per number. What that
457/// spelling is belongs to the representation — [`Source::render`] — for
458/// the reason it belongs there and not here: a form that depended on the
459/// map implementation would change under a Cargo feature somewhere else
460/// in the workspace, and only the representation knows what its own maps
461/// and numbers are. This is what a fixture records and what a comparison
462/// hashes.
463#[must_use]
464pub fn text<V: Source>(value: &V) -> String {
465 value.render()
466}
467
468/// Where two canonical values first differ, as a path and the two forms.
469///
470/// The first difference and not all of them: a decoder that lost its
471/// place reports every member after the one it lost, and a report of
472/// forty differences says less than a report of the first.
473///
474/// Two aggregates are walked member by member and two collections
475/// element by element; anything else — including an aggregate against a
476/// collection — is compared as text, which is what says the two are
477/// different shapes rather than different values.
478///
479/// zerodds-lint: recursion-depth 128 — the shallower of the two values'
480/// nesting. Both arms descend into a strictly smaller part of *both*
481/// values, and neither is a type walk at all, so the walk ends because the
482/// values are finite. A value file is `serde_json`'s to parse and its
483/// `RECURSION_LIMIT` is 128.
484#[must_use]
485pub fn difference<V: Source>(expected: &V, found: &V, path: &str) -> Option<Difference> {
486 if expected.is_object() && found.is_object() {
487 let mut keys: Vec<&str> = expected
488 .entries()
489 .into_iter()
490 .map(|(name, _)| name)
491 .chain(found.entries().into_iter().map(|(name, _)| name))
492 .collect();
493 keys.sort_unstable();
494 keys.dedup();
495 for key in keys {
496 let member = format!("{path}.{key}");
497 match (expected.field(key), found.field(key)) {
498 (Some(a), Some(b)) => {
499 if let Some(inner) = difference(a, b, &member) {
500 return Some(inner);
501 }
502 }
503 (a, b) => {
504 return Some(Difference {
505 path: member,
506 expected: a.map(text),
507 found: b.map(text),
508 });
509 }
510 }
511 }
512 return None;
513 }
514 if let (Some(left), Some(right)) = (expected.as_items(), found.as_items()) {
515 for index in 0..left.len().max(right.len()) {
516 let element = format!("{path}[{index}]");
517 match (left.get(index), right.get(index)) {
518 (Some(a), Some(b)) => {
519 if let Some(inner) = difference(a, b, &element) {
520 return Some(inner);
521 }
522 }
523 (a, b) => {
524 return Some(Difference {
525 path: element,
526 expected: a.map(text),
527 found: b.map(text),
528 });
529 }
530 }
531 }
532 return None;
533 }
534 if text(expected) == text(found) {
535 return None;
536 }
537 Some(Difference {
538 path: path.to_string(),
539 expected: Some(text(expected)),
540 found: Some(text(found)),
541 })
542}
543
544/// One place two values disagree.
545#[derive(Debug, Clone, PartialEq, Eq)]
546pub struct Difference {
547 /// Where, as a dotted path from the sample's root.
548 pub path: String,
549 /// What went in. `None` where the key was not there at all.
550 pub expected: Option<String>,
551 /// What came back.
552 pub found: Option<String>,
553}
554
555impl core::fmt::Display for Difference {
556 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
557 let expected = self.expected.as_deref().unwrap_or("<absent>");
558 let found = self.found.as_deref().unwrap_or("<absent>");
559 write!(f, "{}: wrote {expected}, read {found}", self.path)
560 }
561}