Skip to main content

zerodds_wire_codec/
json.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! What a value file is written in.
4//!
5//! The probe's value representation is JSON, because a probe has to be
6//! callable from a shell script and diffable by a human. This is the
7//! whole of what that costs: two trait implementations, and the four
8//! rules that are JSON's rather than IDL's.
9//!
10//! ## The four rules
11//!
12//! - **A 64-bit integer is a string.** A JSON number is a double, so
13//!   `9007199254740993` read back through one is `9007199254740992`. A
14//!   value file that spells a 64-bit integer as a number therefore says
15//!   a different number than its author typed and every reader of the
16//!   file agrees on the wrong one. Refused on the way in
17//!   ([`EXACT_JSON_INTEGER`]) and never produced on the way out — for
18//!   every value of those two widths and not only the large ones,
19//!   because a form that changed shape at 2^53 would compare unequal
20//!   across the boundary.
21//! - **A `long double` is a string.** Sixty of a binary128 significand's
22//!   113 bits do not fit a double. A number is still accepted on the way
23//!   in, because `1.5` in a value file means 1.5 and every double widens
24//!   into binary128 exactly; the way out is always the exact decimal.
25//! - **A `fixed<P, S>` is a string**, for the same reason: a
26//!   `fixed<31, 2>` is exactly the value a double cannot carry.
27//! - **NaN and infinity have no spelling.** JSON has neither, so a
28//!   sample carrying one has no canonical form and is refused out loud
29//!   rather than written as `null`, which would compare equal to an
30//!   absent member.
31//!
32//! Everything else about what a value *means* is in
33//! [`crate::contract`] and is the same in every representation.
34//!
35//! ## Why this is here and not in `zerodds-wire-probe`
36//!
37//! It belongs with the probe, which is the crate that reads value files;
38//! Rust's orphan rule puts it here instead. Both traits and
39//! `serde_json::Value` are foreign to the probe, so the probe cannot
40//! implement one for the other. Behind the default `serde-json` feature,
41//! so a caller that brings its own representation does not take the
42//! dependency.
43
44use crate::encode::ProbeError;
45use crate::sink::{Aggregate, Sink};
46use crate::source::Source;
47use serde_json::{Map, Number, Value};
48use std::string::String;
49use std::vec::Vec;
50use zerodds_ast::PrimitiveKind;
51use zerodds_float128::Float128;
52
53/// The largest integer a JSON number carries without loss.
54///
55/// A JSON number is a double, so `9007199254740993` does not survive the
56/// round trip through one. `tests/interop-corpus/README.md` states the
57/// rule the corpus is written to — "a 64-bit integer is a **string**"
58/// — and this is where it is enforced rather than only documented.
59pub const EXACT_JSON_INTEGER: i128 = 1 << 53;
60
61impl Source for Value {
62    fn kind(&self) -> &'static str {
63        match self {
64            Self::Null => "null",
65            Self::Bool(_) => "a boolean",
66            Self::Number(_) => "a number",
67            Self::String(_) => "a string",
68            Self::Array(_) => "an array",
69            Self::Object(_) => "an object",
70        }
71    }
72
73    // Spelled with the inherent method named outright rather than as
74    // `self.is_null()`: the trait method has the same name, inherent
75    // resolution is what keeps the two apart, and a rename upstream
76    // would turn silent precedence into silent recursion.
77    fn is_null(&self) -> bool {
78        Self::is_null(self)
79    }
80
81    fn is_object(&self) -> bool {
82        Self::is_object(self)
83    }
84
85    fn as_bool(&self) -> Option<bool> {
86        Self::as_bool(self)
87    }
88
89    fn as_text(&self) -> Option<&str> {
90        self.as_str()
91    }
92
93    fn as_float(&self) -> Option<f64> {
94        self.as_f64()
95    }
96
97    fn as_items(&self) -> Option<&[Self]> {
98        self.as_array().map(Vec::as_slice)
99    }
100
101    fn field(&self, name: &str) -> Option<&Self> {
102        self.as_object().and_then(|map| map.get(name))
103    }
104
105    fn entries(&self) -> Vec<(&str, &Self)> {
106        self.as_object().map_or_else(Vec::new, |map| {
107            map.iter()
108                .map(|(name, value)| (name.as_str(), value))
109                .collect()
110        })
111    }
112
113    fn as_integer(&self, path: &str) -> Result<i128, ProbeError> {
114        if let Some(text) = self.as_str() {
115            return text.parse::<i128>().map_err(|_| ProbeError::Mismatch {
116                path: path.to_string(),
117                expected: "an integer in a string".to_string(),
118                found: format!("`{text}`"),
119            });
120        }
121        let number = self
122            .as_i64()
123            .map(i128::from)
124            .or_else(|| self.as_u64().map(i128::from))
125            .ok_or_else(|| ProbeError::Mismatch {
126                path: path.to_string(),
127                expected: "an integer".to_string(),
128                found: Source::kind(self).to_string(),
129            })?;
130        if number.abs() > EXACT_JSON_INTEGER {
131            return Err(ProbeError::Mismatch {
132                path: path.to_string(),
133                expected: "an integer past 2^53 written as a string, which a JSON number \
134                           cannot carry exactly"
135                    .to_string(),
136                found: format!("the number {number}"),
137            });
138        }
139        Ok(number)
140    }
141
142    fn as_float128(&self, path: &str) -> Result<Float128, ProbeError> {
143        match self {
144            Self::String(text) => match float128_from_hex_bits(text) {
145                Some(Ok(value)) => Ok(value),
146                Some(Err(expected)) => Err(ProbeError::Mismatch {
147                    path: path.to_string(),
148                    expected: expected.to_string(),
149                    found: format!("`{text}`"),
150                }),
151                None => text
152                    .parse::<Float128>()
153                    .map_err(|error| ProbeError::Mismatch {
154                        path: path.to_string(),
155                        expected: "a decimal a long double can hold, or 0x and 32 hex digits"
156                            .to_string(),
157                        found: format!("`{text}` — {error}"),
158                    }),
159            },
160            // Total for a JSON number — every one of the three widths
161            // `serde_json` holds converts — and refused rather than
162            // defaulted anyway: substituting a zero for a number that
163            // would not convert is a value nobody wrote.
164            Self::Number(_) => {
165                self.as_f64()
166                    .map(Float128::from_f64)
167                    .ok_or_else(|| ProbeError::Mismatch {
168                        path: path.to_string(),
169                        expected: "a number".to_string(),
170                        found: Source::kind(self).to_string(),
171                    })
172            }
173            other => Err(ProbeError::Mismatch {
174                path: path.to_string(),
175                expected: "a long double, as a decimal string or a number".to_string(),
176                found: Source::kind(other).to_string(),
177            }),
178        }
179    }
180
181    fn render(&self) -> String {
182        let mut out = String::new();
183        write_text(self, &mut out);
184        out
185    }
186}
187
188/// The canonical text of one JSON value.
189///
190/// Keys sorted, no whitespace, numbers as `serde_json` writes them —
191/// which is the shortest decimal that reads back as the same `f64`. This
192/// is what a fixture records and what a comparison hashes; a form that
193/// depended on the map implementation would change under a Cargo feature
194/// somewhere else in the workspace, which is why the keys are sorted
195/// here rather than taken in the order the map holds them.
196///
197/// zerodds-lint: recursion-depth 128 — the value's own nesting. This walks
198/// a `serde_json::Value` and nothing else: every arm descends into a
199/// strictly smaller part of a tree that cannot hold a cycle, so it ends
200/// because the value is finite. `serde_json`'s parser caps nesting at its
201/// `RECURSION_LIMIT` of 128, and a value out of `crate::decode` at
202/// `crate::MAX_VALUE_DEPTH`.
203fn write_text(value: &Value, out: &mut String) {
204    use std::fmt::Write as _;
205    match value {
206        Value::Object(map) => {
207            let mut keys: Vec<&String> = map.keys().collect();
208            keys.sort();
209            out.push('{');
210            for (index, key) in keys.into_iter().enumerate() {
211                if index > 0 {
212                    out.push(',');
213                }
214                write_text(&Value::String(key.clone()), out);
215                out.push(':');
216                match map.get(key) {
217                    Some(entry) => write_text(entry, out),
218                    // Unreachable through `keys()`, but a probe that
219                    // printed nothing here would emit invalid JSON and
220                    // the reader downstream would say only "parse
221                    // error". Naming it keeps the cause visible.
222                    None => out.push_str("\"<missing>\""),
223                }
224            }
225            out.push('}');
226        }
227        Value::Array(items) => {
228            out.push('[');
229            for (index, item) in items.iter().enumerate() {
230                if index > 0 {
231                    out.push(',');
232                }
233                write_text(item, out);
234            }
235            out.push(']');
236        }
237        Value::String(text) => {
238            out.push('"');
239            for ch in text.chars() {
240                match ch {
241                    '"' => out.push_str("\\\""),
242                    '\\' => out.push_str("\\\\"),
243                    '\n' => out.push_str("\\n"),
244                    '\r' => out.push_str("\\r"),
245                    '\t' => out.push_str("\\t"),
246                    control if (control as u32) < 0x20 => {
247                        let _ = write!(out, "\\u{:04x}", control as u32);
248                    }
249                    other => out.push(other),
250                }
251            }
252            out.push('"');
253        }
254        Value::Number(number) => out.push_str(&number.to_string()),
255        Value::Bool(true) => out.push_str("true"),
256        Value::Bool(false) => out.push_str("false"),
257        Value::Null => out.push_str("null"),
258    }
259}
260
261impl Sink for Value {
262    fn boolean(value: bool) -> Self {
263        Self::Bool(value)
264    }
265
266    fn integer(kind: PrimitiveKind, value: i128, path: &str) -> Result<Self, ProbeError> {
267        // The two 64-bit widths are decimal strings and every narrower
268        // one is a number. Decided on the **width** and not on the
269        // magnitude: a form that changed shape at 2^53 would compare
270        // unequal across the boundary, so a `long long` holding 3 is
271        // `"3"` and a `long` holding 3 is `3`.
272        if matches!(kind, PrimitiveKind::Int64 | PrimitiveKind::UInt64) {
273            return Ok(Self::String(format!("{value}")));
274        }
275        match i64::try_from(value) {
276            Ok(fits) => Ok(Self::Number(fits.into())),
277            // Only reachable for a width this arm is not called for, and
278            // an error rather than a truncation: a canonical form that
279            // silently narrowed would compare equal to a wrong decode.
280            Err(_) => Err(ProbeError::Mismatch {
281                path: path.to_string(),
282                expected: "an integer a JSON number can hold".to_string(),
283                found: format!("{value}"),
284            }),
285        }
286    }
287
288    fn float(kind: PrimitiveKind, value: f64, path: &str) -> Result<Self, ProbeError> {
289        let _ = kind;
290        Self::number(value, path)
291    }
292
293    fn float128(value: Float128) -> Self {
294        // The *exact* expansion and not the shortest one. Every binary
295        // value has finitely many decimal digits and every column can
296        // print them with nothing but long multiplication; the shortest
297        // form needs a parser and a search, and four columns that each
298        // implement that search will disagree about which of two equally
299        // short spellings to emit long before they disagree about the
300        // value. `crates/float128` still has `to_shortest_decimal` for
301        // callers that want it — this is the canonical form, not the
302        // readable one.
303        Self::String(value.to_exact_decimal())
304    }
305
306    fn character(kind: PrimitiveKind, value: char) -> Self {
307        // One-character string for both widths. The width has already
308        // decided what may be in it — §7.2.2.2.1.2.2 gives Char8 eight
309        // bits and §7.2.2.2.1.2.5 holds Char16 to the Basic Multilingual
310        // Plane — and JSON writes both the same way.
311        let _ = kind;
312        Self::String(value.to_string())
313    }
314
315    fn text(value: String) -> Self {
316        Self::String(value)
317    }
318
319    fn fixed(decimal: String) -> Self {
320        Self::String(decimal)
321    }
322
323    fn enumerator(name: String) -> Self {
324        Self::String(name)
325    }
326
327    fn bit_names(names: Vec<String>) -> Self {
328        Self::Array(names.into_iter().map(Self::String).collect())
329    }
330
331    fn bitfields(fields: Vec<(String, i128)>, path: &str) -> Result<Self, ProbeError> {
332        let mut out = Map::new();
333        for (name, raw) in fields {
334            match i64::try_from(raw) {
335                Ok(number) => {
336                    out.insert(name, Self::Number(number.into()));
337                }
338                Err(_) => {
339                    return Err(ProbeError::Mismatch {
340                        path: format!("{path}.{name}"),
341                        expected: "a bitfield a JSON number can hold".to_string(),
342                        found: format!("{raw}"),
343                    });
344                }
345            }
346        }
347        Ok(Self::Object(out))
348    }
349
350    fn collection(items: Vec<Self>) -> Self {
351        Self::Array(items)
352    }
353
354    fn map_entry(key: Self, value: Self) -> Self {
355        Self::Array(vec![key, value])
356    }
357
358    fn aggregate(members: Aggregate<Self>) -> Self {
359        Self::Object(members.into_entries().into_iter().collect())
360    }
361
362    fn number(value: f64, path: &str) -> Result<Self, ProbeError> {
363        // JSON has no NaN and no infinity, so a sample carrying one has
364        // no canonical form and cannot be compared. Said out loud rather
365        // than written as `null`, which would compare equal to an absent
366        // member.
367        match Number::from_f64(value) {
368            Some(number) => Ok(Self::Number(number)),
369            None => Err(ProbeError::Mismatch {
370                path: path.to_string(),
371                expected: "a finite number, which is all JSON can write".to_string(),
372                found: format!("{value}"),
373            }),
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
381    use super::*;
382    use crate::contract::{Member, character, integer, integer_for, member_value, object};
383    use serde_json::json;
384    use zerodds_ast_core::Id;
385
386    #[test]
387    fn an_integer_past_two_to_the_fifty_third_has_to_be_a_string() {
388        assert_eq!(
389            integer(&json!("9007199254740993"), "p"),
390            Ok(9_007_199_254_740_993)
391        );
392        let refused = integer(&json!(9_007_199_254_740_993i64), "p").expect_err("a refusal");
393        assert!(matches!(refused, ProbeError::Mismatch { ref path, .. } if path == "p"));
394        // The boundary itself still round-trips through a double.
395        assert_eq!(
396            integer(&json!(9_007_199_254_740_992i64), "p"),
397            Ok(EXACT_JSON_INTEGER)
398        );
399    }
400
401    #[test]
402    fn a_value_outside_its_primitive_is_refused_and_not_wrapped() {
403        assert_eq!(integer_for(PrimitiveKind::Octet, &json!(255), "p"), Ok(255));
404        assert!(integer_for(PrimitiveKind::Octet, &json!(300), "p").is_err());
405        assert!(integer_for(PrimitiveKind::Int8, &json!(-129), "p").is_err());
406        assert!(integer_for(PrimitiveKind::UInt32, &json!(-1), "p").is_err());
407    }
408
409    #[test]
410    fn a_char_is_one_character_and_never_the_first_of_several() {
411        assert_eq!(character(&json!("a"), "p"), Ok('a'));
412        assert!(character(&json!("ab"), "p").is_err());
413        assert!(character(&json!(""), "p").is_err());
414    }
415
416    #[test]
417    fn null_is_neither_a_value_nor_an_absence() {
418        let value = json!({ "a": null });
419        let fields = object(&value, "S").expect("it is an object");
420        let required = Member {
421            node: zerodds_ast_core::NodeId::from_parts(zerodds_ast_core::IdSpace::MODEL, 1),
422            name: "a".to_string(),
423            type_: Id::first_at(0),
424            optional: false,
425        };
426        let optional = Member {
427            optional: true,
428            ..required.clone()
429        };
430        for member in [&required, &optional] {
431            assert!(matches!(
432                member_value(fields, member, "S"),
433                Err(ProbeError::Mismatch { ref path, .. }) if path == "S.a"
434            ));
435        }
436    }
437
438    #[test]
439    fn a_sixty_four_bit_width_is_a_string_at_every_magnitude() {
440        // The width decides and not the value: a form that changed shape
441        // at 2^53 would compare unequal across the boundary.
442        assert_eq!(
443            <Value as Sink>::integer(PrimitiveKind::Int64, 3, "p"),
444            Ok(Value::String("3".to_string()))
445        );
446        assert_eq!(
447            <Value as Sink>::integer(PrimitiveKind::Int32, 3, "p"),
448            Ok(Value::Number(3.into()))
449        );
450    }
451}
452
453/// A `long double` written as its raw binary128 bits: `0x` and exactly
454/// 32 hexadecimal digits, least significant octet first.
455///
456/// The dds-xtypes test suite spells every `float128` this way — its
457/// `struct_float128_x1.json` carries `0x0000000000000000000000000000fe3f`,
458/// which is 0.5, and `struct_primitives.json` carries
459/// `0x9A999999999999999999999999890240`, which is 12.3. A decimal parse
460/// cannot read either: it stops at the `x` in byte 1. Both are the
461/// octets a little-endian host would write, so they are read back the
462/// same way.
463///
464/// Returns `None` when the text is not this form at all, so the caller
465/// can fall through to the decimal spelling. A text that *is* this form
466/// but malformed — wrong digit count, a non-hex digit — is an error at
467/// the caller, never a silently substituted value.
468fn float128_from_hex_bits(text: &str) -> Option<Result<Float128, &'static str>> {
469    let digits = text
470        .strip_prefix("0x")
471        .or_else(|| text.strip_prefix("0X"))?;
472    if digits.len() != 32 {
473        return Some(Err(
474            "exactly 32 hexadecimal digits, one per binary128 nibble",
475        ));
476    }
477    // Decoded rather than validated-then-parsed: a second pass would need a
478    // fallback for the digit the first pass already accepted, and a fallback
479    // on an unreachable branch is exactly the shape that hides a real one
480    // later. `nibble` answers `None`, so there is no value to substitute.
481    let nibble = |b: u8| match b {
482        b'0'..=b'9' => Some(b - b'0'),
483        b'a'..=b'f' => Some(b - b'a' + 10),
484        b'A'..=b'F' => Some(b - b'A' + 10),
485        _ => None,
486    };
487    let mut octets = [0u8; 16];
488    for (slot, pair) in octets.iter_mut().zip(digits.as_bytes().chunks_exact(2)) {
489        match (nibble(pair[0]), nibble(pair[1])) {
490            (Some(high), Some(low)) => *slot = (high << 4) | low,
491            _ => return Some(Err("a hexadecimal digit")),
492        }
493    }
494    Some(Ok(Float128::from_le_bytes(octets)))
495}
496
497#[cfg(test)]
498mod float128_hex_tests {
499    #![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)]
500    use super::*;
501
502    // The two values the dds-xtypes suite actually ships, with the
503    // decimals they stand for. Before this path existed, both reached
504    // `parse::<Float128>()` and failed at the `x` in byte 1 — which is
505    // why 22 of the suite's 26 remaining failures were filed as "waiting
506    // for a stable Rust f128" rather than as a missing spelling.
507    #[test]
508    fn the_suites_own_float128_literals_are_read_as_bits() {
509        let half = float128_from_hex_bits("0x0000000000000000000000000000fe3f")
510            .expect("recognised as hex")
511            .expect("well formed");
512        assert_eq!(half.to_le_bytes()[15], 0x3f, "0.5 keeps its exponent octet");
513        assert_eq!(half, Float128::from_f64(0.5), "struct_float128_x1 is 0.5");
514
515        let twelve_three = float128_from_hex_bits("0x9A999999999999999999999999890240")
516            .expect("recognised as hex")
517            .expect("well formed");
518        let want: [u8; 16] = [
519            0x9A, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x99, 0x89,
520            0x02, 0x40,
521        ];
522        assert_eq!(
523            twelve_three.to_le_bytes(),
524            want,
525            "struct_primitives x12 round-trips its octets"
526        );
527    }
528
529    #[test]
530    fn a_decimal_still_reaches_the_decimal_parser() {
531        assert!(
532            float128_from_hex_bits("12.3").is_none(),
533            "a decimal is not this spelling and must fall through"
534        );
535    }
536
537    #[test]
538    fn a_malformed_hex_literal_is_refused_rather_than_substituted() {
539        assert!(
540            matches!(float128_from_hex_bits("0xfe3f"), Some(Err(_))),
541            "too few digits is an error, not a zero"
542        );
543        assert!(
544            matches!(float128_from_hex_bits("0xZZ"), Some(Err(_))),
545            "a non-hex digit is an error, not a zero"
546        );
547    }
548}