Skip to main content

omnist/formats/
json.rs

1//! JSON codec. Ported from `~/dev/omnist/omnist/formats.py`'s
2//! `read_json`/`write_json`/`check_json`.
3//!
4//! ## Depth guard reuse
5//!
6//! [`read_json`] parses JSON text into a [`crate::document::Value`], then
7//! builds a [`Doc`] via [`Doc::of`] -- which calls
8//! `crate::document::check_write_depth` internally (see `document.rs`).
9//! [`write_json`]/[`check_json`] walk an already-built `Doc` (via
10//! `Doc::to_grouped`/`Doc::root`), whose every node was depth-checked at
11//! construction time -- there is nothing left to re-guard on the way out,
12//! exactly the reasoning `Doc::to_grouped`'s own doc comment gives. So this
13//! module reuses the *one* shared depth guard transitively rather than
14//! adding a second (or third) copy.
15//!
16//! ## No native temporal type
17//!
18//! Python's JSON writer stringifies `datetime.date`/`datetime.time` values
19//! (JSON has no native temporal type) and records a `temporal.stringified`
20//! warning. This port's [`crate::document::Scalar`] has no temporal variant
21//! at all (see `document.rs`'s module doc) -- a `date`/`time`/`datetime`
22//! value is already a `Scalar::Str` holding its ISO spelling by the time it
23//! reaches this codec, so there is nothing left to adjust or report here.
24//! The only lossy JSON write left is `NaN`/`Infinity`/`-Infinity`, which
25//! JSON's grammar has no token for.
26//!
27//! ## Integer digit cap (omnist-ts#54 / oml.rs precedent)
28//!
29//! Live-checked against Python (`omnist.formats.read_json`): a JSON integer
30//! literal over 4300 digits raises `ParseError` (CPython's
31//! `sys.set_int_max_str_digits` guard fires inside `json.loads` itself,
32//! before `build_node` ever sees a value); under the cap, arbitrary
33//! precision is accepted (`'9' * 4300` reads as a plain Python `int`). This
34//! scanner applies the identical 4300-digit cap *before* attempting to
35//! parse the literal, mirroring `oml.rs`'s `MAX_INT_DIGITS` guard exactly
36//! (same constant, same "reject the digit run before conversion" shape).
37//! Because this port's `Scalar::Int` is `i64` (max ~19 digits), any literal
38//! over 19 digits fails as "out of range for a 64-bit integer" well before
39//! the 4300-digit cap would ever fire on its own -- the same representational
40//! gap already documented in `document.rs`'s module doc for OML. The cap is
41//! kept anyway (dead in practice for `i64`, exactly like Python's own limit
42//! for who never encounters it) purely to give a stable, specific error for
43//! egregiously long digit runs rather than a generic overflow message, and
44//! to keep this scanner structurally parallel to `oml.rs`'s.
45
46use crate::WriteError;
47use crate::document::{Doc, Value};
48use crate::error::{OmnistError, ParseError};
49use crate::formats::float_fmt;
50use crate::formats::int_cap::{MAX_INT_DIGITS, over_cap_message};
51use crate::formats::string_escape::{JSON_ESCAPES, write_quoted};
52use crate::formats::textpos::line_col_bytes;
53use crate::report::{Severity, WriteReport};
54use indexmap::IndexMap;
55
56// Same guard, same constant as `oml.rs`'s -- see this module's doc
57// comment. Constant and message constructors now live in
58// [`crate::formats::int_cap`] (issue #49).
59
60/// Parse JSON text into a [`Doc`].
61///
62/// A bare top-level array, an array nested directly inside another array,
63/// or nesting past [`crate::document::MAX_DEPTH`] all surface as
64/// [`crate::error::DocumentError`] (via [`Doc::of`]) rather than
65/// [`ParseError`], matching Python's `read_json`, which lets `build_node`'s
66/// `DocumentError` propagate uncaught alongside its own caught
67/// `json.JSONDecodeError`/`ValueError` -> `ParseError` translation.
68pub fn read_json(text: &str) -> Result<Doc, OmnistError> {
69    let mut p = Parser::new(text);
70    p.skip_ws();
71    let value = p.parse_value()?;
72    p.skip_ws();
73    if p.pos < p.n {
74        return Err(p
75            .error_at(
76                p.pos,
77                "unexpected trailing data after JSON value".to_string(),
78            )
79            .into());
80    }
81    Ok(Doc::of(&value)?)
82}
83
84/// Project a [`Doc`] to JSON text.
85///
86/// `indent: None` writes compact JSON (`, `/`: ` separators, single line);
87/// `indent: Some(n)` pretty-prints with `n` spaces per level, matching
88/// Python's `indent=` parameter. A `NaN`/`Infinity`/`-Infinity` leaf now
89/// fails the write unconditionally (`write.unsupported-value`, spec
90/// Sec8.3.8/Sec8.3.9 updated 2026-08-24) -- regardless of `strict` -- rather
91/// than substituting `null` and reporting an adjustment: writing a genuine
92/// `null` and writing `NaN` used to produce the identical JSON `null`
93/// token, so a substituted `NaN` was indistinguishable from an original
94/// `null` on read-back (confirmed live). See this module's doc comment on
95/// why no temporal adjustment is needed.
96pub fn write_json(
97    doc: &Doc,
98    indent: Option<usize>,
99    strict: bool,
100    report: Option<&mut WriteReport>,
101) -> Result<String, WriteError> {
102    let grouped = doc.to_grouped();
103    if let Some((path, x)) = find_special_float(&grouped) {
104        return Err(crate::report::unsupported_value_error(
105            &path,
106            format!("{x} has no JSON token (JSON's grammar has no NaN/Infinity literal)"),
107        ));
108    }
109    let mut rep = check_json_grouped(&grouped);
110    add_interleaving_diagnostic(doc, &mut rep);
111    let mut out = String::new();
112    write_value(&grouped, indent, 0, &mut out);
113    crate::report::finish_write(out, rep, strict, report)
114}
115
116/// Find the first NaN/Infinity leaf in a grouped `Value`, depth-first, for
117/// [`write_json`]'s unconditional pre-write failure check. `None` when
118/// every float in the tree is finite.
119fn find_special_float(grouped: &Value) -> Option<(String, f64)> {
120    let mut path = String::from("$");
121    let mut found: Option<(String, f64)> = None;
122    crate::formats::visit_grouped(grouped, &mut path, &mut |visited, path| {
123        if found.is_some() {
124            return;
125        }
126        let crate::formats::Visited::Node { value } = visited else {
127            return;
128        };
129        if let Value::Float(x) = value
130            && (x.is_nan() || x.is_infinite())
131        {
132            found = Some((path.to_string(), *x));
133        }
134    });
135    found
136}
137
138/// `format.interleaving-lost` (spec Sec8.3.8, D-3) is a whole-document
139/// diagnostic that depends on the original `Doc`'s edge order, not on the
140/// already-grouped `Value` `check_json_grouped` scans -- so it is detected
141/// separately via `Doc::has_interleaving_loss` and added here rather than
142/// folded into that grouped-`Value` walk.
143fn add_interleaving_diagnostic(doc: &Doc, rep: &mut WriteReport) {
144    if doc.has_interleaving_loss() {
145        rep.add(
146            "$",
147            "format.interleaving-lost",
148            "cross-label interleaving could not be written; same-label edges were grouped",
149            Severity::Warning,
150        );
151    }
152}
153
154/// Report what writing JSON would adjust, without producing output.
155///
156/// Walks every leaf via the shared `crate::formats::visit_grouped` walker
157/// (issue #51) rather than a bespoke `collect_leaves` pass; the path
158/// `String` that ends up in a [`crate::report::Adjustment`] is only built
159/// for the (rare) NaN/Infinity leaf -- not for every leaf up front (issue
160/// #44), since `visit_grouped` reuses one path buffer for the whole walk.
161pub fn check_json(doc: &Doc) -> WriteReport {
162    let grouped = doc.to_grouped();
163    let mut rep = check_json_grouped(&grouped);
164    add_interleaving_diagnostic(doc, &mut rep);
165    rep
166}
167
168fn check_json_grouped(grouped: &Value) -> WriteReport {
169    let mut rep = WriteReport::new();
170    let mut path = String::from("$");
171    crate::formats::visit_grouped(grouped, &mut path, &mut |visited, path| {
172        let crate::formats::Visited::Node { value } = visited else {
173            return;
174        };
175        match value {
176            // Preview-only: `check_json`/`check_json_grouped` never write,
177            // so this stays a reported adjustment even though `write_json`
178            // itself now fails unconditionally on the same condition (see
179            // that function's doc comment) rather than substituting and
180            // reporting `float.special` -- that code is retired.
181            Value::Float(x) if x.is_nan() || x.is_infinite() => {
182                rep.add(
183                    path,
184                    "write.unsupported-value",
185                    format!("{x} has no JSON token (JSON's grammar has no NaN/Infinity literal)"),
186                    Severity::Error,
187                );
188            }
189            // JSON has no temporal type (issue #105 -- previously
190            // structurally unreachable per issue #16/#89, since `Scalar`
191            // had no temporal variant for this leaf to ever hold; now
192            // reachable for real). Matches Python's writer, which
193            // stringifies and reports the same adjustment.
194            Value::Date(_) | Value::Time(_) | Value::Datetime(_) => {
195                rep.add(
196                    path,
197                    "format.temporal-stringified",
198                    "date/time/datetime has no native JSON literal; wrote as an ISO-8601 string"
199                        .to_string(),
200                    Severity::Warning,
201                );
202            }
203            _ => {}
204        }
205    });
206    rep
207}
208
209/// Marker type implementing [`crate::formats::Codec`] for JSON -- adapts
210/// [`read_json`]/[`write_json`]/[`check_json`] to the registry's uniform
211/// shape with the documented defaults (`indent: None`, `strict: false`, no
212/// report). See the trait's doc comment for why `write_json`'s `strict`-
213/// dependent content (NaN/Infinity substitution) ruled out a richer
214/// `scan`/`emit`-splitting trait.
215pub(crate) struct Json;
216
217impl crate::formats::Codec for Json {
218    const NAME: &'static str = "json";
219
220    fn read(text: &str) -> Result<Doc, OmnistError> {
221        read_json(text)
222    }
223
224    fn write(doc: &Doc) -> Result<String, OmnistError> {
225        write_json(doc, None, false, None).map_err(Into::into)
226    }
227
228    fn check(doc: &Doc) -> WriteReport {
229        check_json(doc)
230    }
231}
232
233fn write_value(v: &Value, indent: Option<usize>, level: usize, out: &mut String) {
234    match v {
235        Value::Null => out.push_str("null"),
236        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
237        Value::Int(i) => out.push_str(&i.to_string()),
238        Value::Float(x) => write_float(*x, out),
239        // JSON has no native temporal literal -- a genuinely
240        // temporal-kinded value (issue #105) is stringified the same way
241        // Python's writer does, with `check_json` reporting the
242        // now-reachable `temporal.stringified` adjustment (see below).
243        Value::Str(s) | Value::Date(s) | Value::Time(s) | Value::Datetime(s) => {
244            write_json_string(s, out)
245        }
246        Value::Array(items) => write_seq(items.iter(), '[', ']', indent, level, out, write_value),
247        Value::Object(map) => write_seq(
248            map.iter(),
249            '{',
250            '}',
251            indent,
252            level,
253            out,
254            |(k, val), indent, level, out| {
255                write_json_string(k, out);
256                out.push_str(": ");
257                write_value(val, indent, level, out);
258            },
259        ),
260    }
261}
262
263fn write_seq<I, T>(
264    items: I,
265    open: char,
266    close: char,
267    indent: Option<usize>,
268    level: usize,
269    out: &mut String,
270    mut write_item: impl FnMut(T, Option<usize>, usize, &mut String),
271) where
272    I: ExactSizeIterator<Item = T>,
273{
274    if items.len() == 0 {
275        out.push(open);
276        out.push(close);
277        return;
278    }
279    out.push(open);
280    let child_level = level + 1;
281    let mut first = true;
282    for item in items {
283        if !first {
284            out.push(',');
285            if indent.is_none() {
286                out.push(' ');
287            }
288        }
289        first = false;
290        if let Some(n) = indent {
291            out.push('\n');
292            out.push_str(&" ".repeat(n * child_level));
293        }
294        write_item(item, indent, child_level, out);
295    }
296    if let Some(n) = indent {
297        out.push('\n');
298        out.push_str(&" ".repeat(n * level));
299    }
300    out.push(close);
301}
302
303/// Matches Python's `json.dumps(..., default=_iso)` encoder for the special
304/// floats it's still asked to serialize in strict mode (discarded when
305/// `finish_write` raises, but still produced -- see `write_json`'s call
306/// site): bare `NaN`/`Infinity`/`-Infinity` tokens, not valid JSON on their
307/// own but exactly what Python's own encoder emits by default.
308///
309/// The non-special-value rendering (issue #46's render-then-inspect fix)
310/// and the shared core live in `formats::float_fmt` (issue #47) -- this is
311/// now just JSON's spelling table for the three special values.
312fn write_float(x: f64, out: &mut String) {
313    float_fmt::write_float(x, "NaN", "Infinity", "-Infinity", out);
314}
315
316fn write_json_string(s: &str, out: &mut String) {
317    write_quoted(s, &JSON_ESCAPES, out);
318}
319
320// ---------------------------------------------------------------- Reader
321
322struct Parser<'a> {
323    text: &'a str,
324    n: usize,
325    pos: usize,
326    depth: usize,
327}
328
329impl<'a> Parser<'a> {
330    fn new(text: &'a str) -> Self {
331        // `pos`/`n` are now byte offsets into `text`, not char indices --
332        // this scanner reads UTF-8 lazily (via `peek`/`char_at`, which
333        // decode at most one char at a time from the current byte offset)
334        // instead of materializing the whole input into a `Vec<char>`
335        // upfront (issue #43). `pos` is always kept on a UTF-8 char
336        // boundary, so every `text[pos..]`/`text.get(pos..)` slice below is
337        // safe.
338        let n = text.len();
339        Parser {
340            text,
341            n,
342            pos: 0,
343            depth: 0,
344        }
345    }
346
347    fn error_at(&self, pos: usize, msg: String) -> ParseError {
348        let (line, col) = line_col_bytes(self.text, pos);
349        ParseError::new(line, col, format!("invalid JSON: {msg}"))
350    }
351
352    /// Decode the char starting at byte offset `at`, if any. `at` must be a
353    /// char boundary (always true for `self.pos`, and for the lookahead
354    /// offsets used below, since they only ever land on boundaries produced
355    /// by this same scanner).
356    fn char_at(&self, at: usize) -> Option<char> {
357        self.text.get(at..)?.chars().next()
358    }
359
360    fn peek(&self) -> Option<char> {
361        self.char_at(self.pos)
362    }
363
364    fn skip_ws(&mut self) {
365        while matches!(
366            self.peek(),
367            Some(' ') | Some('\t') | Some('\n') | Some('\r')
368        ) {
369            self.pos += 1;
370        }
371    }
372
373    fn expect(&mut self, c: char) -> Result<(), ParseError> {
374        if self.peek() == Some(c) {
375            self.pos += c.len_utf8();
376            Ok(())
377        } else {
378            Err(self.error_at(self.pos, format!("expected {c:?}")))
379        }
380    }
381
382    /// `word` is always an ASCII literal keyword (`true`/`false`/`null`/
383    /// `NaN`/`Infinity`/`-Infinity`), so comparing byte-for-byte at
384    /// `self.pos + i` is exactly equivalent to comparing char-for-char, and
385    /// avoids decoding a char per position.
386    fn matches_word(&self, word: &str) -> bool {
387        debug_assert!(
388            word.is_ascii(),
389            "matches_word is only used with ASCII keywords"
390        );
391        let bytes = self.text.as_bytes();
392        word.bytes()
393            .enumerate()
394            .all(|(i, b)| bytes.get(self.pos + i) == Some(&b))
395    }
396
397    fn parse_value(&mut self) -> Result<Value, ParseError> {
398        self.skip_ws();
399        match self.peek() {
400            None => Err(self.error_at(self.pos, "unexpected end of input".to_string())),
401            Some('{') => self.parse_object(),
402            Some('[') => self.parse_array(),
403            Some('"') => Ok(Value::Str(self.parse_string()?)),
404            Some('t') if self.matches_word("true") => {
405                self.pos += 4;
406                Ok(Value::Bool(true))
407            }
408            Some('f') if self.matches_word("false") => {
409                self.pos += 5;
410                Ok(Value::Bool(false))
411            }
412            Some('n') if self.matches_word("null") => {
413                self.pos += 4;
414                Ok(Value::Null)
415            }
416            Some('N') if self.matches_word("NaN") => {
417                self.pos += 3;
418                Ok(Value::Float(f64::NAN))
419            }
420            Some('I') if self.matches_word("Infinity") => {
421                self.pos += 8;
422                Ok(Value::Float(f64::INFINITY))
423            }
424            Some('-') if self.matches_word("-Infinity") => {
425                self.pos += 9;
426                Ok(Value::Float(f64::NEG_INFINITY))
427            }
428            Some(c) if c == '-' || c.is_ascii_digit() => self.parse_number(),
429            Some(c) => Err(self.error_at(self.pos, format!("unexpected character {c:?}"))),
430        }
431    }
432
433    fn parse_object(&mut self) -> Result<Value, ParseError> {
434        self.depth += 1;
435        if self.depth > crate::document::MAX_DEPTH {
436            return Err(self.error_at(
437                self.pos,
438                format!(
439                    "nesting exceeds the maximum depth ({})",
440                    crate::document::MAX_DEPTH
441                ),
442            ));
443        }
444        self.expect('{')?;
445        let mut map: IndexMap<String, Value> = IndexMap::new();
446        self.skip_ws();
447        if self.peek() == Some('}') {
448            self.pos += 1;
449            self.depth -= 1;
450            return Ok(Value::Object(map));
451        }
452        loop {
453            self.skip_ws();
454            if self.peek() != Some('"') {
455                return Err(self.error_at(self.pos, "expected string key".to_string()));
456            }
457            let key = self.parse_string()?;
458            self.skip_ws();
459            self.expect(':')?;
460            let value = self.parse_value()?;
461            // Last-duplicate-key-wins, first-seen position kept -- matches
462            // Python `dict` semantics (`json.loads('{"a":1,"a":2}')` ==
463            // `{"a": 2}`, key position from first occurrence) and
464            // `IndexMap::insert`'s own behavior on re-insertion.
465            map.insert(key, value);
466            self.skip_ws();
467            match self.peek() {
468                Some(',') => {
469                    self.pos += 1;
470                }
471                Some('}') => {
472                    self.pos += 1;
473                    break;
474                }
475                _ => return Err(self.error_at(self.pos, "expected ',' or '}'".to_string())),
476            }
477        }
478        self.depth -= 1;
479        Ok(Value::Object(map))
480    }
481
482    fn parse_array(&mut self) -> Result<Value, ParseError> {
483        self.depth += 1;
484        if self.depth > crate::document::MAX_DEPTH {
485            return Err(self.error_at(
486                self.pos,
487                format!(
488                    "nesting exceeds the maximum depth ({})",
489                    crate::document::MAX_DEPTH
490                ),
491            ));
492        }
493        self.expect('[')?;
494        let mut items = Vec::new();
495        self.skip_ws();
496        if self.peek() == Some(']') {
497            self.pos += 1;
498            self.depth -= 1;
499            return Ok(Value::Array(items));
500        }
501        loop {
502            let v = self.parse_value()?;
503            items.push(v);
504            self.skip_ws();
505            match self.peek() {
506                Some(',') => {
507                    self.pos += 1;
508                }
509                Some(']') => {
510                    self.pos += 1;
511                    break;
512                }
513                _ => return Err(self.error_at(self.pos, "expected ',' or ']'".to_string())),
514            }
515        }
516        self.depth -= 1;
517        Ok(Value::Array(items))
518    }
519
520    fn parse_string(&mut self) -> Result<String, ParseError> {
521        self.expect('"')?;
522        let mut s = String::new();
523        loop {
524            match self.peek() {
525                None => return Err(self.error_at(self.pos, "unterminated string".to_string())),
526                Some('"') => {
527                    self.pos += 1;
528                    break;
529                }
530                Some('\\') => {
531                    self.pos += 1;
532                    match self.peek() {
533                        Some('"') => {
534                            s.push('"');
535                            self.pos += 1;
536                        }
537                        Some('\\') => {
538                            s.push('\\');
539                            self.pos += 1;
540                        }
541                        Some('/') => {
542                            s.push('/');
543                            self.pos += 1;
544                        }
545                        Some('b') => {
546                            s.push('\u{08}');
547                            self.pos += 1;
548                        }
549                        Some('f') => {
550                            s.push('\u{0c}');
551                            self.pos += 1;
552                        }
553                        Some('n') => {
554                            s.push('\n');
555                            self.pos += 1;
556                        }
557                        Some('r') => {
558                            s.push('\r');
559                            self.pos += 1;
560                        }
561                        Some('t') => {
562                            s.push('\t');
563                            self.pos += 1;
564                        }
565                        Some('u') => {
566                            self.pos += 1;
567                            let hi = self.parse_hex4()?;
568                            if (0xD800..=0xDBFF).contains(&hi) {
569                                if self.peek() == Some('\\')
570                                    && self.char_at(self.pos + 1) == Some('u')
571                                {
572                                    self.pos += 2;
573                                    let lo = self.parse_hex4()?;
574                                    if (0xDC00..=0xDFFF).contains(&lo) {
575                                        let c = 0x10000 + (hi - 0xD800) * 0x400 + (lo - 0xDC00);
576                                        // `hi` in 0xD800..=0xDBFF and `lo` in
577                                        // 0xDC00..=0xDFFF (just confirmed by
578                                        // the two range checks above) always
579                                        // combine to a value in
580                                        // 0x10000..=0x10FFFF -- the entire
581                                        // supplementary-plane range, all of
582                                        // which is a valid `char` -- so this
583                                        // can never fail; `.expect()`
584                                        // documents the invariant instead of
585                                        // an unreachable error branch (see
586                                        // `oml.rs`'s identical `f64::from_str`
587                                        // precedent).
588                                        s.push(char::from_u32(c).expect(
589                                            "a well-formed UTF-16 surrogate pair always \
590                                             combines to a valid supplementary-plane char",
591                                        ));
592                                    } else {
593                                        return Err(self.error_at(
594                                            self.pos,
595                                            "invalid low surrogate".to_string(),
596                                        ));
597                                    }
598                                } else {
599                                    return Err(self.error_at(
600                                        self.pos,
601                                        "unpaired high surrogate".to_string(),
602                                    ));
603                                }
604                            } else if (0xDC00..=0xDFFF).contains(&hi) {
605                                return Err(
606                                    self.error_at(self.pos, "unpaired low surrogate".to_string())
607                                );
608                            } else {
609                                // `hi` has already been confirmed outside
610                                // both surrogate ranges (0xD800..=0xDFFF) by
611                                // the two branches above, and `parse_hex4`
612                                // only ever returns a 4-hex-digit value, so
613                                // `hi` is in 0..=0xFFFF minus the surrogate
614                                // range -- every such value is a valid BMP
615                                // `char`, so this can never fail; `.expect()`
616                                // documents the invariant (see the surrogate-
617                                // pair branch above for the identical
618                                // reasoning).
619                                s.push(char::from_u32(hi).expect(
620                                    "a 4-hex-digit \\u escape outside the surrogate range is \
621                                     always a valid BMP char",
622                                ));
623                            }
624                        }
625                        _ => return Err(self.error_at(self.pos, "invalid escape".to_string())),
626                    }
627                }
628                Some(c) if (c as u32) < 0x20 => {
629                    return Err(self.error_at(self.pos, "control character in string".to_string()));
630                }
631                Some(c) => {
632                    s.push(c);
633                    self.pos += c.len_utf8();
634                }
635            }
636        }
637        Ok(s)
638    }
639
640    fn parse_hex4(&mut self) -> Result<u32, ParseError> {
641        let mut v: u32 = 0;
642        for _ in 0..4 {
643            let c = self.peek().ok_or_else(|| {
644                self.error_at(self.pos, "unterminated unicode escape".to_string())
645            })?;
646            let d = c.to_digit(16).ok_or_else(|| {
647                self.error_at(self.pos, "invalid hex digit in unicode escape".to_string())
648            })?;
649            v = v * 16 + d;
650            // Hex digits are always ASCII (single byte); `to_digit(16)`
651            // already rejected any non-hex-digit (including any multi-byte
652            // char), so `+= 1` is exactly `+= c.len_utf8()` here.
653            self.pos += 1;
654        }
655        Ok(v)
656    }
657
658    /// Number grammar: `-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?`. An integer
659    /// literal (no fraction/exponent) is parsed as `i64`, applying the
660    /// digit cap and out-of-range check documented in this module's doc
661    /// comment; a literal with a fraction or exponent is a float.
662    fn parse_number(&mut self) -> Result<Value, ParseError> {
663        let start = self.pos;
664        if self.peek() == Some('-') {
665            self.pos += 1;
666        }
667        let int_start = self.pos;
668        if self.peek() == Some('0') {
669            self.pos += 1;
670        } else if self.peek().is_some_and(|c| c.is_ascii_digit()) {
671            while self.peek().is_some_and(|c| c.is_ascii_digit()) {
672                self.pos += 1;
673            }
674        } else {
675            return Err(self.error_at(self.pos, "invalid number literal".to_string()));
676        }
677        debug_assert!(
678            self.pos > int_start,
679            "the '0' and digit-run branches above both advance pos by at least 1"
680        );
681        // The rest of the number grammar (`.`/`e`/`E`/`+`/`-`/digits) is
682        // entirely ASCII, so lookahead here compares raw bytes rather than
683        // decoding a char at each position.
684        let bytes = self.text.as_bytes();
685        let byte_at = |p: usize| bytes.get(p).copied();
686        let mut is_float = false;
687        if self.peek() == Some('.') {
688            let frac_start = self.pos + 1;
689            let mut p = frac_start;
690            while byte_at(p).is_some_and(|b| b.is_ascii_digit()) {
691                p += 1;
692            }
693            if p > frac_start {
694                is_float = true;
695                self.pos = p;
696            }
697        }
698        if matches!(self.peek(), Some('e') | Some('E')) {
699            let mut p = self.pos + 1;
700            if matches!(byte_at(p), Some(b'+') | Some(b'-')) {
701                p += 1;
702            }
703            let exp_start = p;
704            while byte_at(p).is_some_and(|b| b.is_ascii_digit()) {
705                p += 1;
706            }
707            if p > exp_start {
708                is_float = true;
709                self.pos = p;
710            }
711        }
712        let text: &str = &self.text[start..self.pos];
713        if is_float {
714            let v: f64 = text
715                .parse()
716                .expect("scanner only emits number-shaped text, which f64::from_str always parses");
717            Ok(Value::Float(v))
718        } else {
719            let digits = &text[if text.starts_with('-') { 1 } else { 0 }..];
720            if digits.len() > MAX_INT_DIGITS {
721                return Err(self.error_at(start, over_cap_message("", digits.len())));
722            }
723            // Arbitrary-precision (issue #104): the scanner only emits
724            // number-shaped ASCII-digit text (with an optional leading
725            // `-`), and BigInt::parse_bytes always succeeds on that shape.
726            let v = num_bigint::BigInt::parse_bytes(text.as_bytes(), 10).expect(
727                "scanner only emits digit-shaped text, which BigInt::parse_bytes always parses",
728            );
729            Ok(Value::Int(v))
730        }
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::document::{Doc, Scalar, Value};
738    use crate::report::Severity;
739
740    fn obj(pairs: Vec<(&str, Value)>) -> Value {
741        Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
742    }
743
744    // ---------------------------------------------------------- reader
745
746    #[test]
747    fn reads_object_with_scalars() {
748        let doc = read_json(r#"{"a": 1, "b": "s", "c": true, "d": null, "e": 1.5}"#).unwrap();
749        let root = doc.root();
750        assert_eq!(
751            *root.get_one("a").unwrap().value().unwrap(),
752            Scalar::Int((1).into())
753        );
754        assert_eq!(
755            *root.get_one("b").unwrap().value().unwrap(),
756            Scalar::Str("s".to_string())
757        );
758        assert_eq!(
759            *root.get_one("c").unwrap().value().unwrap(),
760            Scalar::Bool(true)
761        );
762        assert_eq!(*root.get_one("d").unwrap().value().unwrap(), Scalar::Null);
763        assert_eq!(
764            *root.get_one("e").unwrap().value().unwrap(),
765            Scalar::Float(1.5)
766        );
767    }
768
769    #[test]
770    fn reads_false_literal() {
771        let doc = read_json(r#"{"c": false}"#).unwrap();
772        assert_eq!(
773            *doc.root().get_one("c").unwrap().value().unwrap(),
774            Scalar::Bool(false)
775        );
776    }
777
778    #[test]
779    fn reads_array_as_repeated_edges() {
780        let doc = read_json(r#"{"m": [1, 2, 3]}"#).unwrap();
781        let root = doc.root();
782        let ms = root.get("m");
783        assert_eq!(ms.len(), 3);
784        assert_eq!(*ms[0].value().unwrap(), Scalar::Int((1).into()));
785        assert_eq!(*ms[2].value().unwrap(), Scalar::Int((3).into()));
786    }
787
788    #[test]
789    fn reads_empty_array_literal_as_no_edges() {
790        let doc = read_json(r#"{"m": [], "n": 1}"#).unwrap();
791        assert!(doc.root().get("m").is_empty());
792    }
793
794    #[test]
795    fn reads_nested_object() {
796        let doc = read_json(r#"{"a": {"b": {"c": 1}}}"#).unwrap();
797        let root = doc.root();
798        let a = root.get_one("a").unwrap();
799        let b = a.get_one("b").unwrap();
800        assert_eq!(
801            *b.get_one("c").unwrap().value().unwrap(),
802            Scalar::Int((1).into())
803        );
804    }
805
806    #[test]
807    fn bare_top_level_array_is_a_document_error_not_a_parse_error() {
808        let err = read_json("[1, 2, 3]").unwrap_err();
809        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
810    }
811
812    #[test]
813    fn array_of_arrays_is_a_document_error() {
814        let err = read_json(r#"{"m": [[1, 2]]}"#).unwrap_err();
815        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
816    }
817
818    #[test]
819    fn invalid_json_syntax_is_a_parse_error() {
820        let err = read_json("{not json}").unwrap_err();
821        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
822    }
823
824    #[test]
825    fn trailing_data_after_a_value_is_a_parse_error() {
826        let err = read_json("1 2").unwrap_err();
827        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
828    }
829
830    #[test]
831    fn nesting_past_max_depth_is_a_parse_error() {
832        let mut text = String::new();
833        for _ in 0..=crate::document::MAX_DEPTH {
834            text.push_str(r#"{"a":"#);
835        }
836        text.push('1');
837        for _ in 0..=crate::document::MAX_DEPTH {
838            text.push('}');
839        }
840        let err = read_json(&text).unwrap_err();
841        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
842        assert!(err.to_string().contains("maximum depth"));
843    }
844
845    #[test]
846    fn duplicate_object_keys_last_value_wins_first_position_kept() {
847        let doc = read_json(r#"{"a": 1, "b": 2, "a": 3}"#).unwrap();
848        let root = doc.root();
849        assert_eq!(root.labels(), vec!["a".to_string(), "b".to_string()]);
850        assert_eq!(
851            *root.get_one("a").unwrap().value().unwrap(),
852            Scalar::Int((3).into())
853        );
854    }
855
856    #[test]
857    fn reads_string_escapes_and_unicode_escape() {
858        let doc = read_json(r#"{"s": "a\n\r\t\"\\é"}"#).unwrap();
859        let v = doc.root().get_one("s").unwrap();
860        assert_eq!(
861            *v.value().unwrap(),
862            Scalar::Str("a\n\r\t\"\\\u{e9}".to_string())
863        );
864    }
865
866    #[test]
867    fn reads_plain_unicode_escape() {
868        const BSL: char = '\u{5c}';
869        let input = format!("{{\"s\": \"{BSL}u0041\"}}");
870        let doc = read_json(&input).unwrap();
871        let v = doc.root().get_one("s").unwrap();
872        assert_eq!(*v.value().unwrap(), Scalar::Str("A".to_string()));
873    }
874
875    #[test]
876    fn reads_surrogate_pair_escape() {
877        // U+1F600 (grinning face) written directly as UTF-8 in the source
878        // text (not a `\u` escape) -- exercises the scanner's general
879        // multi-byte-character handling.
880        let doc = read_json(r#"{"s": "😀"}"#).unwrap();
881        let v = doc.root().get_one("s").unwrap();
882        assert_eq!(*v.value().unwrap(), Scalar::Str("\u{1F600}".to_string()));
883    }
884
885    #[test]
886    fn reads_surrogate_pair_written_as_two_u_escapes() {
887        // The same U+1F600 grinning face, this time spelled as its UTF-16
888        // surrogate pair `😀` -- exercises the combining-formula
889        // branch the plain-emoji test above never reaches.
890        const BSL: char = '\u{5c}';
891        let input = format!("{{\"s\": \"{BSL}ud83d{BSL}ude00\"}}");
892        let doc = read_json(&input).unwrap();
893        let v = doc.root().get_one("s").unwrap();
894        assert_eq!(*v.value().unwrap(), Scalar::Str("\u{1F600}".to_string()));
895    }
896
897    #[test]
898    fn reads_bare_nan_and_infinity_tokens() {
899        // Confirmed live against Python's json.loads (allow_nan=True by
900        // default): NaN/Infinity/-Infinity are accepted on read.
901        let doc = read_json(r#"{"a": NaN, "b": Infinity, "c": -Infinity}"#).unwrap();
902        let root = doc.root();
903        assert!(
904            matches!(root.get_one("a").unwrap().value().unwrap(), Scalar::Float(x) if x.is_nan())
905        );
906        assert_eq!(
907            *root.get_one("b").unwrap().value().unwrap(),
908            Scalar::Float(f64::INFINITY)
909        );
910        assert_eq!(
911            *root.get_one("c").unwrap().value().unwrap(),
912            Scalar::Float(f64::NEG_INFINITY)
913        );
914    }
915
916    #[test]
917    fn integer_literal_under_digit_cap_but_over_i64_range_parses() {
918        // 20 nines: over i64::MAX's 19 digits, comfortably under the
919        // 4300-digit cap -- issue #104: `Scalar::Int` is arbitrary-precision
920        // (`BigInt`), so this is no longer an out-of-range error, it's a
921        // real, correctly-parsed value.
922        let text = format!(r#"{{"a": {}}}"#, "9".repeat(20));
923        let doc = read_json(&text).unwrap();
924        let value = doc.root().child("a").unwrap().value().unwrap();
925        assert_eq!(
926            value,
927            &Scalar::Int(num_bigint::BigInt::parse_bytes(b"99999999999999999999", 10).unwrap())
928        );
929    }
930
931    #[test]
932    fn integer_literal_over_digit_cap_is_rejected_before_range_check() {
933        let text = format!(r#"{{"a": {}}}"#, "9".repeat(MAX_INT_DIGITS + 1));
934        let err = read_json(&text).unwrap_err();
935        assert!(
936            matches!(&err, OmnistError::Parse(e) if e.message.contains("4300-digit")),
937            "got {err:?}"
938        )
939    }
940
941    #[test]
942    fn integer_literal_exactly_at_digit_cap_parses_not_digit_cap_error() {
943        // At exactly 4300 digits: under arbitrary-precision (issue #104)
944        // this is a real, successfully-parsed value -- confirms the cap
945        // boundary is `> MAX_INT_DIGITS`, not `>=`.
946        let text = format!(r#"{{"a": {}}}"#, "9".repeat(MAX_INT_DIGITS));
947        let doc = read_json(&text).unwrap();
948        let value = doc.root().child("a").unwrap().value().unwrap();
949        assert!(
950            matches!(value, Scalar::Int(i) if i.to_string().len() == MAX_INT_DIGITS),
951            "got {value:?}"
952        );
953    }
954
955    #[test]
956    fn whitespace_and_negative_zero_and_exponent_numbers_read() {
957        let doc = read_json("  {\n\"a\" : 1e3,\n\"b\": -0.5, \"c\": 2E-2\t}  ").unwrap();
958        let root = doc.root();
959        assert_eq!(
960            *root.get_one("a").unwrap().value().unwrap(),
961            Scalar::Float(1000.0)
962        );
963        assert_eq!(
964            *root.get_one("b").unwrap().value().unwrap(),
965            Scalar::Float(-0.5)
966        );
967        assert_eq!(
968            *root.get_one("c").unwrap().value().unwrap(),
969            Scalar::Float(0.02)
970        );
971    }
972
973    #[test]
974    fn error_position_after_multibyte_content_reports_correct_line() {
975        // Regression for issue #43's byte-offset scanner rewrite:
976        // `line_col` now counts `\n` *bytes* rather than char-vec indices --
977        // confirm the reported line for an error on a later line is still
978        // correct when an earlier line contains multi-byte UTF-8 content
979        // (accented letters, emoji), i.e. byte-offset arithmetic doesn't
980        // regress on non-ASCII input.
981        let err = read_json("{\"s\": \"café \u{1F600}\"}\n@").unwrap_err();
982        assert!(
983            matches!(&err, OmnistError::Parse(e) if e.line == 2),
984            "got {err:?}"
985        );
986    }
987
988    #[test]
989    fn empty_object_and_array_read() {
990        let doc = read_json(r#"{"a": {}}"#).unwrap();
991        assert!(doc.root().get_one("a").unwrap().edges().unwrap().is_empty());
992    }
993
994    #[test]
995    fn empty_input_is_unexpected_end_of_input_error() {
996        let err = read_json("").unwrap_err();
997        assert!(
998            matches!(&err, OmnistError::Parse(e) if e.message.contains("unexpected end of input")),
999            "got {err:?}"
1000        )
1001    }
1002
1003    #[test]
1004    fn unrecognized_character_is_a_parse_error() {
1005        let err = read_json("@").unwrap_err();
1006        assert!(
1007            matches!(&err, OmnistError::Parse(e) if e.message.contains("unexpected character")),
1008            "got {err:?}"
1009        )
1010    }
1011
1012    #[test]
1013    fn a_bareword_that_only_partially_matches_a_keyword_is_unexpected_character() {
1014        // 't' starts "true" but "tx" isn't it -- falls through every
1015        // keyword-literal guard to the catch-all "unexpected character".
1016        let err = read_json("tx").unwrap_err();
1017        assert!(
1018            matches!(&err, OmnistError::Parse(e) if e.message.contains("unexpected character")),
1019            "got {err:?}"
1020        )
1021    }
1022
1023    #[test]
1024    fn error_position_reports_the_line_after_a_newline() {
1025        let err = read_json("{\n  \"a\": @\n}").unwrap_err();
1026        assert!(
1027            matches!(&err, OmnistError::Parse(e) if e.line == 2),
1028            "got {err:?}"
1029        );
1030    }
1031
1032    #[test]
1033    fn object_missing_colon_is_a_parse_error() {
1034        let err = read_json(r#"{"a" 1}"#).unwrap_err();
1035        assert!(
1036            matches!(&err, OmnistError::Parse(e) if e.message.contains("expected ':'")),
1037            "got {err:?}"
1038        )
1039    }
1040
1041    #[test]
1042    fn object_missing_key_is_a_parse_error() {
1043        let err = read_json(r#"{1: 2}"#).unwrap_err();
1044        assert!(
1045            matches!(&err, OmnistError::Parse(e) if e.message.contains("expected string key")),
1046            "got {err:?}"
1047        )
1048    }
1049
1050    #[test]
1051    fn object_missing_comma_or_brace_is_a_parse_error() {
1052        let err = read_json(r#"{"a": 1 "b": 2}"#).unwrap_err();
1053        assert!(
1054            matches!(&err, OmnistError::Parse(e) if e.message.contains("expected ',' or '}'")),
1055            "got {err:?}"
1056        )
1057    }
1058
1059    #[test]
1060    fn array_missing_comma_or_bracket_is_a_parse_error() {
1061        let err = read_json(r#"{"a": [1 2]}"#).unwrap_err();
1062        assert!(
1063            matches!(&err, OmnistError::Parse(e) if e.message.contains("expected ',' or ']'")),
1064            "got {err:?}"
1065        )
1066    }
1067
1068    #[test]
1069    fn unterminated_string_is_a_parse_error() {
1070        let err = read_json(r#"{"a": "hi}"#).unwrap_err();
1071        assert!(
1072            matches!(&err, OmnistError::Parse(e) if e.message.contains("unterminated string")),
1073            "got {err:?}"
1074        )
1075    }
1076
1077    #[test]
1078    fn control_character_in_string_is_a_parse_error() {
1079        let err = read_json("{\"a\": \"x\u{0007}y\"}").unwrap_err();
1080        assert!(
1081            matches!(&err, OmnistError::Parse(e) if e.message.contains("control character")),
1082            "got {err:?}"
1083        )
1084    }
1085
1086    #[test]
1087    fn every_short_escape_reads_its_control_character() {
1088        let doc = read_json(r#"{"a": "\/\b\f"}"#).unwrap();
1089        let v = doc.root().get_one("a").unwrap();
1090        assert_eq!(
1091            *v.value().unwrap(),
1092            Scalar::Str("/\u{08}\u{0c}".to_string())
1093        );
1094    }
1095
1096    #[test]
1097    fn invalid_escape_character_is_a_parse_error() {
1098        let err = read_json(r#"{"a": "\q"}"#).unwrap_err();
1099        assert!(
1100            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid escape")),
1101            "got {err:?}"
1102        )
1103    }
1104
1105    #[test]
1106    fn unpaired_high_surrogate_is_a_parse_error() {
1107        let err = read_json(r#"{"a": "\ud800x"}"#).unwrap_err();
1108        assert!(
1109            matches!(&err, OmnistError::Parse(e) if e.message.contains("unpaired high surrogate")),
1110            "got {err:?}"
1111        )
1112    }
1113
1114    #[test]
1115    fn high_surrogate_followed_by_non_low_surrogate_escape_is_a_parse_error() {
1116        // `A` ('A') is itself a well-formed escape but not a low
1117        // surrogate, so this exercises "high surrogate followed by
1118        // another `\u` escape that isn't a low surrogate" specifically
1119        // (distinct from the sibling test's "high surrogate with no
1120        // following `\u` escape at all"). Built via `format!` (rather than
1121        // a literal `A` inside a raw string) to sidestep this file's
1122        // own multi-layer string-escaping when the second `\u` needs to
1123        // appear literally in the JSON source text.
1124        const BSL: char = '\u{5c}';
1125        let input = format!("{{\"a\": \"{BSL}ud800{BSL}u0041\"}}");
1126        let err = read_json(&input).unwrap_err();
1127        assert!(
1128            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid low surrogate")),
1129            "got {err:?}"
1130        )
1131    }
1132
1133    #[test]
1134    fn unpaired_low_surrogate_is_a_parse_error() {
1135        let err = read_json(r#"{"a": "\udc00"}"#).unwrap_err();
1136        assert!(
1137            matches!(&err, OmnistError::Parse(e) if e.message.contains("unpaired low surrogate")),
1138            "got {err:?}"
1139        )
1140    }
1141
1142    #[test]
1143    fn unterminated_unicode_escape_is_a_parse_error() {
1144        let err = read_json(r#"{"a": "\u12"#).unwrap_err();
1145        assert!(
1146            matches!(&err, OmnistError::Parse(e) if e.message.contains("unterminated unicode escape")),
1147            "got {err:?}"
1148        )
1149    }
1150
1151    #[test]
1152    fn invalid_hex_digit_in_unicode_escape_is_a_parse_error() {
1153        let err = read_json(r#"{"a": "\u12zz"}"#).unwrap_err();
1154        assert!(
1155            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid hex digit")),
1156            "got {err:?}"
1157        )
1158    }
1159
1160    #[test]
1161    fn invalid_number_literal_is_a_parse_error() {
1162        let err = read_json(r#"{"a": -x}"#).unwrap_err();
1163        assert!(
1164            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid number literal")),
1165            "got {err:?}"
1166        )
1167    }
1168
1169    // ---------------------------------------------------------- writer
1170
1171    fn doc_of(v: Value) -> Doc {
1172        Doc::of(&v).unwrap()
1173    }
1174
1175    #[test]
1176    fn round_trips_every_scalar_kind() {
1177        let v = obj(vec![
1178            ("null", Value::Null),
1179            ("bool", Value::Bool(true)),
1180            ("int", Value::Int((42).into())),
1181            ("float", Value::Float(1.5)),
1182            ("str", Value::Str("hi".to_string())),
1183        ]);
1184        let doc = doc_of(v);
1185        let text = write_json(&doc, None, false, None).unwrap();
1186        let back = read_json(&text).unwrap();
1187        assert!(doc.eq_doc(&back));
1188    }
1189
1190    #[test]
1191    fn round_trips_integral_float_at_and_above_1e17_boundary_issue_46() {
1192        // Regression test for issue #46: an integral-valued float >= 1e17
1193        // used to render as a bare digit run (Rust's `f64::to_string()`
1194        // drops the decimal point up there), which `read_json` then
1195        // re-read as `Scalar::Int` -- silently changing the scalar's type
1196        // across a round trip.
1197        for x in [1.0e17, 1.0e18, -1.23e17, 9.9e16_f64] {
1198            let doc = doc_of(obj(vec![("a", Value::Float(x))]));
1199            let text = write_json(&doc, None, false, None).unwrap();
1200            let back = read_json(&text).unwrap();
1201            assert_eq!(
1202                *back.root().get_one("a").unwrap().value().unwrap(),
1203                Scalar::Float(x),
1204                "x={x} text={text}"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn round_trips_temporal_like_strings_since_scalar_has_no_temporal_type() {
1211        // date/time/datetime values are already Scalar::Str in this port
1212        // (see module doc) -- confirm they round-trip as plain strings,
1213        // with no adjustment recorded.
1214        let v = obj(vec![("d", Value::Str("2024-01-15".to_string()))]);
1215        let doc = doc_of(v);
1216        let mut rep = WriteReport::new();
1217        let text = write_json(&doc, None, false, Some(&mut rep)).unwrap();
1218        assert!(rep.is_empty());
1219        let back = read_json(&text).unwrap();
1220        assert!(doc.eq_doc(&back));
1221    }
1222
1223    #[test]
1224    fn writes_repeated_labels_as_a_json_array() {
1225        let doc = doc_of(obj(vec![(
1226            "m",
1227            Value::Array(vec![Value::Int((1).into()), Value::Int((2).into())]),
1228        )]));
1229        let text = write_json(&doc, None, false, None).unwrap();
1230        assert_eq!(text, r#"{"m": [1, 2]}"#);
1231    }
1232
1233    #[test]
1234    fn writes_compact_with_comma_space_separators() {
1235        let doc = doc_of(obj(vec![
1236            ("a", Value::Int((1).into())),
1237            ("b", Value::Int((2).into())),
1238        ]));
1239        let text = write_json(&doc, None, false, None).unwrap();
1240        assert_eq!(text, r#"{"a": 1, "b": 2}"#);
1241    }
1242
1243    #[test]
1244    fn writes_indented_multiline() {
1245        let doc = doc_of(obj(vec![("a", Value::Int((1).into()))]));
1246        let text = write_json(&doc, Some(2), false, None).unwrap();
1247        assert_eq!(text, "{\n  \"a\": 1\n}");
1248    }
1249
1250    #[test]
1251    fn writes_empty_object_compactly() {
1252        let doc = doc_of(obj(vec![("o", Value::Object(IndexMap::new()))]));
1253        let text = write_json(&doc, Some(2), false, None).unwrap();
1254        assert!(text.contains("\"o\": {}"));
1255    }
1256
1257    #[test]
1258    fn an_empty_array_value_produces_no_edge_at_all() {
1259        // `Value::Array([])` under a key expands into zero repeated edges
1260        // (see `document.rs`'s `child_specs`) -- the label simply doesn't
1261        // appear in the built `Doc`, so it can't round-trip as `[]`. This
1262        // is a Document-model property, not something this codec controls.
1263        let doc = doc_of(obj(vec![
1264            ("a", Value::Array(vec![])),
1265            ("b", Value::Int((1).into())),
1266        ]));
1267        let text = write_json(&doc, None, false, None).unwrap();
1268        assert_eq!(text, r#"{"b": 1}"#);
1269    }
1270
1271    // Was `lenient_write_substitutes_nan_and_infinity_with_null_and_reports_error_severity`
1272    // before spec Sec8.3.8/Sec8.3.9 (updated 2026-08-24): writing NaN/Infinity to
1273    // JSON now fails unconditionally (`write.unsupported-value`) instead of
1274    // substituting `null` and succeeding with a report -- a substituted NaN
1275    // was indistinguishable from a genuine null on read-back (confirmed
1276    // live). `strict` no longer changes the outcome, so lenient and strict
1277    // are now the same single behavior; both are asserted below.
1278    #[test]
1279    fn write_fails_unconditionally_on_nan_and_infinity_lenient() {
1280        let doc = doc_of(obj(vec![
1281            ("a", Value::Float(f64::NAN)),
1282            ("b", Value::Float(f64::INFINITY)),
1283        ]));
1284        let mut rep = WriteReport::new();
1285        let err = write_json(&doc, None, false, Some(&mut rep)).unwrap_err();
1286        assert!(err.to_string().contains("write.unsupported-value"));
1287        assert!(err.to_string().contains("$.a"));
1288        // No adjustment was ever recorded -- the write failed before
1289        // `check_json_grouped` had a chance to run, and the unconditional
1290        // failure path attaches no report of its own.
1291        assert!(rep.is_empty());
1292        assert!(err.report().is_none());
1293    }
1294
1295    // Was `strict_write_raises_on_nan_and_carries_the_report`: `strict`
1296    // used to be what made this a hard failure; now it fails identically
1297    // whether or not `strict` is set (renamed to say so explicitly).
1298    #[test]
1299    fn write_fails_unconditionally_on_nan_strict() {
1300        let doc = doc_of(obj(vec![("a", Value::Float(f64::NAN))]));
1301        let err = write_json(&doc, None, true, None).unwrap_err();
1302        assert!(err.to_string().contains("write.unsupported-value"));
1303        assert!(err.report().is_none());
1304    }
1305
1306    #[test]
1307    fn strict_write_with_no_adjustments_succeeds() {
1308        let doc = doc_of(obj(vec![("a", Value::Int((1).into()))]));
1309        let text = write_json(&doc, None, true, None).unwrap();
1310        assert_eq!(text, r#"{"a": 1}"#);
1311    }
1312
1313    #[test]
1314    fn check_json_reports_without_producing_output() {
1315        let doc = doc_of(obj(vec![("a", Value::Float(f64::INFINITY))]));
1316        let rep = check_json(&doc);
1317        assert_eq!(rep.len(), 1);
1318        assert_eq!(rep.adjustments()[0].path, "$.a");
1319        // check_json only previews; write.unsupported-value is reported
1320        // here even though write_json itself now fails unconditionally on
1321        // the same condition rather than substituting-and-reporting.
1322        assert_eq!(rep.adjustments()[0].code, "write.unsupported-value");
1323    }
1324
1325    #[test]
1326    fn writes_string_escapes() {
1327        let doc = doc_of(obj(vec![("s", Value::Str("a\n\"\\\tb".to_string()))]));
1328        let text = write_json(&doc, None, false, None).unwrap();
1329        assert_eq!(text, r#"{"s": "a\n\"\\\tb"}"#);
1330    }
1331
1332    #[test]
1333    fn writes_unicode_without_escaping_non_ascii() {
1334        // Matches Python's `ensure_ascii=False`.
1335        let doc = doc_of(obj(vec![("s", Value::Str("caf\u{e9}".to_string()))]));
1336        let text = write_json(&doc, None, false, None).unwrap();
1337        assert_eq!(text, "{\"s\": \"caf\u{e9}\"}");
1338    }
1339
1340    #[test]
1341    fn writes_float_with_trailing_dot_zero_for_integral_values() {
1342        let doc = doc_of(obj(vec![("f", Value::Float(2.0))]));
1343        let text = write_json(&doc, None, false, None).unwrap();
1344        assert_eq!(text, r#"{"f": 2.0}"#);
1345    }
1346
1347    // Was `strict_write_of_negative_infinity_renders_the_bare_token`: now
1348    // -Infinity fails unconditionally like every other special float,
1349    // regardless of `strict`, and carries no report (the unconditional
1350    // failure path returns before any report is built).
1351    #[test]
1352    fn write_fails_unconditionally_on_negative_infinity() {
1353        let doc = doc_of(obj(vec![("f", Value::Float(f64::NEG_INFINITY))]));
1354        let err = write_json(&doc, None, true, None).unwrap_err();
1355        assert!(err.to_string().contains("write.unsupported-value"));
1356        assert!(err.report().is_none());
1357    }
1358
1359    #[test]
1360    fn write_float_directly_covers_every_branch() {
1361        let mut out = String::new();
1362        write_float(f64::NAN, &mut out);
1363        assert_eq!(out, "NaN");
1364        out.clear();
1365        write_float(f64::INFINITY, &mut out);
1366        assert_eq!(out, "Infinity");
1367        out.clear();
1368        write_float(f64::NEG_INFINITY, &mut out);
1369        assert_eq!(out, "-Infinity");
1370        out.clear();
1371        write_float(1.5, &mut out);
1372        assert_eq!(out, "1.5");
1373    }
1374
1375    #[test]
1376    fn writes_carriage_return_and_control_character_escapes() {
1377        let doc = doc_of(obj(vec![(
1378            "s",
1379            Value::Str("a\rb\u{08}c\u{0c}d\u{01}".to_string()),
1380        )]));
1381        let text = write_json(&doc, None, false, None).unwrap();
1382        const BS: char = '\u{5c}';
1383        let expected = format!("{{\"s\": \"a{BS}rb{BS}bc{BS}fd{BS}u0001\"}}");
1384        assert_eq!(text, expected);
1385    }
1386
1387    #[test]
1388    fn deeply_nested_document_write_reuses_doc_construction_depth_guard() {
1389        // Doc::of already rejects nesting past MAX_DEPTH at construction
1390        // time (see this module's doc comment) -- confirms write_json never
1391        // even sees an over-deep Doc to begin with.
1392        let mut v = Value::Int((0).into());
1393        for _ in 0..=crate::document::MAX_DEPTH {
1394            v = obj(vec![("a", v)]);
1395        }
1396        assert!(Doc::of(&v).is_err());
1397    }
1398
1399    #[test]
1400    fn round_trip_via_live_python_equivalent_scalars() {
1401        // Cross-checked live against `omnist.formats.write_json`/`read_json`
1402        // in the accompanying Python venv for the exact same literal text
1403        // (see the PR description) -- this test pins the Rust side of that
1404        // comparison.
1405        let doc = doc_of(obj(vec![
1406            ("a", Value::Int((1).into())),
1407            ("b", Value::Str("x".to_string())),
1408            (
1409                "c",
1410                Value::Array(vec![
1411                    Value::Int((1).into()),
1412                    Value::Int((2).into()),
1413                    Value::Int((3).into()),
1414                ]),
1415            ),
1416        ]));
1417        let text = write_json(&doc, None, false, None).unwrap();
1418        assert_eq!(text, r#"{"a": 1, "b": "x", "c": [1, 2, 3]}"#);
1419    }
1420
1421    #[test]
1422    fn test_deeply_nested_json_depth_limit() {
1423        let nested = "[".repeat(50_000) + &"]".repeat(50_000);
1424        let err = read_json(&nested).unwrap_err();
1425        assert!(err.to_string().contains("maximum depth"));
1426    }
1427
1428    // ---------------------------------------------- D-3: format.interleaving-lost
1429    // (issue #156, spec Sec8.3.8. See
1430    // formats-json/basic/cross-label-interleaving-lost-and-reported in the
1431    // conformance suite for the normative vector this mirrors; the same MUST
1432    // applies to YAML/TOML for the identical grouping reason -- see the
1433    // matching tests in yaml.rs/toml.rs.)
1434
1435    fn interleaved_doc() -> Doc {
1436        // [(m,A),(x,X),(m,B)]: m's two edges are not contiguous.
1437        Doc::from_raw(crate::document::RawNode::Edges(vec![
1438            (
1439                "m".to_string(),
1440                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
1441            ),
1442            (
1443                "x".to_string(),
1444                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
1445            ),
1446            (
1447                "m".to_string(),
1448                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
1449            ),
1450        ]))
1451        .unwrap()
1452    }
1453
1454    fn contiguous_repeat_doc() -> Doc {
1455        // [(m,A),(m,B),(x,X)]: m's two edges are contiguous -- not interleaved.
1456        Doc::from_raw(crate::document::RawNode::Edges(vec![
1457            (
1458                "m".to_string(),
1459                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
1460            ),
1461            (
1462                "m".to_string(),
1463                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
1464            ),
1465            (
1466                "x".to_string(),
1467                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
1468            ),
1469        ]))
1470        .unwrap()
1471    }
1472
1473    #[test]
1474    fn reports_interleaving_lost_on_write() {
1475        let doc = interleaved_doc();
1476        let mut report = crate::report::WriteReport::new();
1477        let text = write_json(&doc, None, false, Some(&mut report)).unwrap();
1478        assert_eq!(text, r#"{"m": ["A", "B"], "x": "X"}"#);
1479        let adjustments = report.adjustments();
1480        assert_eq!(adjustments.len(), 1);
1481        assert_eq!(adjustments[0].path, "$");
1482        assert_eq!(adjustments[0].code, "format.interleaving-lost");
1483        assert_eq!(adjustments[0].severity, Severity::Warning);
1484    }
1485
1486    #[test]
1487    fn check_json_reports_interleaving_lost() {
1488        let rep = check_json(&interleaved_doc());
1489        assert_eq!(rep.adjustments().len(), 1);
1490        assert_eq!(rep.adjustments()[0].code, "format.interleaving-lost");
1491    }
1492
1493    #[test]
1494    fn contiguous_repeated_label_does_not_report_interleaving_lost() {
1495        let doc = contiguous_repeat_doc();
1496        let mut report = crate::report::WriteReport::new();
1497        let text = write_json(&doc, None, false, Some(&mut report)).unwrap();
1498        assert_eq!(text, r#"{"m": ["A", "B"], "x": "X"}"#);
1499        assert!(report.is_empty());
1500        assert!(check_json(&doc).is_empty());
1501    }
1502}