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