Skip to main content

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}