Skip to main content

omnist/formats/
yaml.rs

1//! YAML codec. Ported from `~/dev/omnist/omnist/formats.py`'s
2//! `read_yaml`/`write_yaml`/`check_yaml`.
3//!
4//! ## Crate choice
5//!
6//! YAML's grammar (block/flow collections, indentation-sensitivity, scalar
7//! styles, anchors/aliases, tags) is significantly more complex than JSON's.
8//! This module uses [`yaml_rust2`]'s low-level [`Parser`]/[`MarkedEventReceiver`]
9//! event stream for raw tokenization/structure (indentation handling, flow vs.
10//! block collections, quoting, anchors/aliases) -- the "reasonable,
11//! spec-compliant" choice issue #18 calls out, since re-deriving YAML's
12//! indentation/quoting grammar by hand would duplicate a large, well-tested
13//! surface for little benefit. Everything omnist-specific is still hand-written
14//! Rust code in this module, not delegated to the crate:
15//!
16//! * **Scalar-tag resolution** (`resolve_plain_scalar`) -- `yaml_rust2`'s own
17//!   built-in resolver (`Yaml::from_str`) only recognizes `true`/`false`
18//!   (YAML 1.2 core schema) for booleans. Live-checked against Python's
19//!   `yaml.safe_load` (PyYAML, which this project's Python reference wraps):
20//!   PyYAML additionally treats `yes`/`no`/`on`/`off` (and case variants) as
21//!   booleans (YAML 1.1 `Resolver.yaml_implicit_resolvers`, confirmed via the
22//!   `tag:yaml.org,2002:bool` regex) -- `y`/`n` alone are **not** included and
23//!   stay strings. Because `yaml_rust2`'s own resolution would silently
24//!   produce the wrong Rust value for `yes`/`no`/`on`/`off`, this module
25//!   ignores the crate's built-in scalar typing entirely and re-implements
26//!   PyYAML's exact implicit-resolver regexes for null/bool/int/float/
27//!   timestamp against the raw scalar text + style (quoted scalars are never
28//!   auto-typed, matching PyYAML, which only applies implicit resolution to
29//!   plain-style scalars).
30//! * **Merge-key (`<<`) handling** (`expand_merge_keys`) -- `yaml_rust2` has
31//!   no built-in merge-key support at all (confirmed by reading its source);
32//!   this module implements the YAML merge-key spec directly: an unquoted
33//!   `<<` key's value must be a mapping or a sequence of mappings, merged in
34//!   order with explicit keys in the mapping taking precedence, else a clean
35//!   [`ParseError`] (never a panic) -- the omnist-ts#46 regression this
36//!   module's test suite pins.
37//! * **Depth guard, temporal shape-check, integer digit cap** -- reused from
38//!   [`crate::document`]/[`crate::schema`]/this crate's established
39//!   4300-digit-cap pattern (see `MAX_INT_DIGITS`), not reimplemented.
40//!
41//! ## Depth guard reuse
42//!
43//! Same reasoning as `json.rs`: [`read_yaml`] builds a [`Doc`] via [`Doc::of`],
44//! which calls `crate::document::check_write_depth` internally. The merge-
45//! key/alias-resolution pass that runs *before* `Doc::of` also depth-guards
46//! itself (an alias can reference an already-deep subtree, and merging can
47//! grow a mapping before the Document-model depth check ever sees it), reusing
48//! the same `crate::document::check_write_depth` guard rather than adding a
49//! second copy. [`write_yaml`]/[`check_yaml`] walk an already-built `Doc` (via
50//! `to_grouped`), whose nodes are already depth-checked, so there is nothing
51//! left to re-guard on the way out.
52//!
53//! ## No native temporal type; but YAML dates/datetimes still need normalizing
54//!
55//! Like `json.rs`, this port's [`crate::document::Scalar`] has no temporal
56//! variant -- a YAML timestamp becomes a `Scalar::Str` holding its ISO
57//! spelling. Unlike JSON, YAML's timestamp grammar is looser than the ISO
58//! shapes `schema.rs`'s temporal shape-check accepts (space-separated date/
59//! time, single-digit month/day, a bare `Z` suffix, no zero-padding) --
60//! Python's PyYAML normalizes any such spelling to a `datetime.date`/
61//! `datetime.datetime` object and then (elsewhere in the pipeline) back to a
62//! canonical ISO string. `normalize_timestamp` reproduces that
63//! normalization for the timestamp grammar PyYAML's own
64//! `tag:yaml.org,2002:timestamp` resolver accepts, so a YAML value like
65//! `2001-12-14 21:59:43.10 -5` round-trips to the same canonical
66//! `2001-12-14T21:59:43.100000-05:00` shape Python's `datetime.isoformat()`
67//! would produce, not the original loose spelling. A timestamp-shaped string
68//! naming a calendar/clock value that doesn't exist (`2024-13-01`,
69//! `2024-02-30`) is a [`ParseError`], not a silent string fallback --
70//! live-confirmed: PyYAML's construction step raises `ValueError` there,
71//! which fails the whole document. Calendar/clock validity itself reuses
72//! `crate::schema::valid_ymd`/`crate::schema::valid_hms` rather than a
73//! second copy of that logic, even though the *shape* regex is necessarily
74//! separate (looser than `schema.rs`'s).
75//!
76//! ## Native NaN/Infinity support (no lossy adjustment, unlike JSON)
77//!
78//! YAML's float grammar has native tokens for `.nan`/`.inf`/`-.inf`
79//! (`tag:yaml.org,2002:float`), so -- unlike `json.rs`'s `write_json`, which
80//! must substitute `null` for a special float -- [`write_yaml`] never needs to
81//! adjust a `NaN`/`Infinity` leaf. The only adjustment [`check_yaml`] ever
82//! records is forcing double-quoted style for a string containing U+0085
83//! (NEL), which PyYAML's default (unquoted/single-quoted) scalar styles
84//! normalize away as a line break -- mirrors Python's `_scan_yaml_labels`/
85//! `_yaml_str_representer` exactly.
86//!
87//! ## Integer digit cap (omnist-ts#54 / oml.rs / json.rs precedent)
88//!
89//! Same 4300-digit cap, applied to a plain decimal integer scalar's digit run
90//! before attempting to parse it, mirroring `json.rs`'s identical guard.
91
92use std::collections::HashMap;
93
94use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser, Tag};
95use yaml_rust2::scanner::{Marker, ScanError, TScalarStyle};
96
97use crate::WriteError;
98use crate::document::{Doc, Value};
99use crate::error::{DocumentError, OmnistError, ParseError};
100use crate::formats::float_fmt;
101use crate::formats::int_cap::{MAX_INT_DIGITS, over_cap_message};
102use crate::formats::string_escape::{YAML_ESCAPES, write_quoted};
103use crate::report::{Severity, WriteReport};
104use indexmap::IndexMap;
105use num_bigint::BigInt;
106
107// Same guard, same constant as `json.rs`'s/`oml.rs`'s -- see this
108// module's doc comment. Constant and message constructors now live in
109// [`crate::formats::int_cap`] (issue #49).
110
111/// Bound on the total number of [`Raw`] tree nodes materialized while
112/// rebuilding the event stream (issue #42, "YAML alias/anchor expansion
113/// amplification"): every ordinary node counts once, but a `*alias`
114/// reference counts the *entire size* of the subtree it clones, so a chain
115/// of anchors each referencing the previous generation's alias multiple
116/// times ("billion laughs") is rejected by total materialized size long
117/// before `resolve_merges`'s depth guard (`crate::document::MAX_DEPTH`,
118/// which bounds nesting *depth*, not fan-out) would ever see it -- this
119/// attack reaches enormous size at *shallow*, constant-per-generation
120/// depth, which is exactly what the depth guard cannot catch.
121///
122/// Python's reference implementation (`~/dev/omnist/omnist/formats.py`'s
123/// `read_yaml`) calls PyYAML's `yaml.safe_load` directly with no such
124/// guard -- live-confirmed vulnerable to the identical pattern (see the
125/// upstream issue filed against `omnist-dev/omnist` for the Python side),
126/// so there is no existing Python limit to port here. 100,000 nodes is
127/// generous for any legitimate document (even a large real-world config
128/// easily fits in a few thousand nodes) while still keeping the *cost of
129/// detecting an attack* small: this guard's charge-before-clone still has
130/// to walk (and, once approved, clone) whatever it charges, so the ceiling
131/// itself bounds the worst-case rejection cost, not just the worst-case
132/// accepted-document size -- a 1,000,000 ceiling let a debug build's
133/// unoptimized recursive clone of the final, still-materialized generation
134/// take upwards of ten seconds; 100,000 is a round, documented ceiling
135/// with the same generous headroom over real documents while keeping that
136/// worst-case rejection well under a second even in a debug build.
137const MAX_MATERIALIZED_NODES: usize = 100_000;
138
139/// Counts every [`Raw`] node in `node`'s subtree, including `node` itself --
140/// used to charge an alias reference for the full size of the subtree it
141/// clones, not just "one node", so repeated aliasing of a large anchor is
142/// charged its real, amplified cost.
143fn count_nodes(node: &Raw) -> usize {
144    match node {
145        Raw::Scalar(..) => 1,
146        Raw::Sequence(items) => 1 + items.iter().map(count_nodes).sum::<usize>(),
147        Raw::Mapping(entries) => {
148            1 + entries
149                .iter()
150                .map(|(k, v)| count_nodes(k) + count_nodes(v))
151                .sum::<usize>()
152        }
153    }
154}
155
156// ============================================================== Reader
157
158/// The raw shape `yaml_rust2`'s event stream is rebuilt into: a scalar with
159/// its original text and style (style is what `resolve_plain_scalar` and
160/// merge-key detection both need, and what `yaml_rust2`'s own `Yaml` enum
161/// throws away by pre-resolving plain scalars with its own, PyYAML-incompatible
162/// rules -- see this module's doc comment), or an ordered sequence/mapping.
163#[derive(Debug, Clone)]
164enum Raw {
165    Scalar(String, TScalarStyle, Option<Tag>),
166    Sequence(Vec<Raw>),
167    Mapping(Vec<(Raw, Raw)>),
168}
169
170/// Rebuilds a [`Raw`] tree from `yaml_rust2`'s parser event stream, resolving
171/// aliases against anchors seen so far (anchors always precede their aliases
172/// in a valid YAML document). Structurally mirrors `yaml_rust2::yaml::YamlLoader`
173/// (the crate's own reference event-to-tree builder), adapted to keep scalar
174/// style/tag information `YamlLoader`'s `Yaml` throws away.
175struct Builder {
176    doc_stack: Vec<(Raw, usize)>,
177    key_stack: Vec<Option<Raw>>,
178    anchor_map: HashMap<usize, Raw>,
179    docs: Vec<Raw>,
180    /// Running total of [`Raw`] nodes materialized so far -- see
181    /// [`MAX_MATERIALIZED_NODES`].
182    node_count: usize,
183    /// Set once [`MAX_MATERIALIZED_NODES`] is exceeded; further events are
184    /// ignored (no more work is done building an already-rejected tree) and
185    /// this is surfaced as a [`ParseError`] once parsing finishes.
186    error: Option<ParseError>,
187}
188
189impl Builder {
190    fn new() -> Self {
191        Builder {
192            doc_stack: Vec::new(),
193            key_stack: Vec::new(),
194            anchor_map: HashMap::new(),
195            docs: Vec::new(),
196            node_count: 0,
197            error: None,
198        }
199    }
200
201    /// Charges `n` newly-materialized nodes against the running total,
202    /// setting `self.error` (if not already set) the first time the total
203    /// exceeds [`MAX_MATERIALIZED_NODES`]. Returns `true` if the caller
204    /// should proceed (still under the limit), `false` if it tripped (or
205    /// had already tripped) and should skip the work it was about to do.
206    fn charge(&mut self, n: usize, mark: Marker) -> bool {
207        if self.error.is_some() {
208            return false;
209        }
210        self.node_count = self.node_count.saturating_add(n);
211        if self.node_count > MAX_MATERIALIZED_NODES {
212            self.error = Some(ParseError::new(
213                mark.line(),
214                mark.col() + 1,
215                format!(
216                    "invalid YAML: document materializes more than \
217                     {MAX_MATERIALIZED_NODES} nodes (security: unbounded anchor/alias \
218                     expansion can amplify a small document into an enormous tree, \
219                     independent of nesting depth)"
220                ),
221            ));
222            return false;
223        }
224        true
225    }
226
227    fn insert(&mut self, node: Raw, aid: usize, _mark: Marker) {
228        if aid > 0 {
229            self.anchor_map.insert(aid, node.clone());
230        }
231        match self.doc_stack.last_mut() {
232            None => self.doc_stack.push((node, aid)),
233            Some((Raw::Sequence(items), _)) => items.push(node),
234            Some((Raw::Mapping(_), _)) => {
235                let cur_key = self
236                    .key_stack
237                    .last_mut()
238                    .expect("a Mapping is only ever pushed alongside a matching key_stack entry");
239                match cur_key.take() {
240                    None => *cur_key = Some(node),
241                    Some(k) => {
242                        if let Some((Raw::Mapping(entries), _)) = self.doc_stack.last_mut() {
243                            entries.push((k, node));
244                        }
245                    }
246                }
247            }
248            Some((Raw::Scalar(..), _)) => {
249                // A scalar is never pushed onto doc_stack as a container
250                // (only Sequence/Mapping are, in on_event's SequenceStart/
251                // MappingStart arms) -- this arm is structurally unreachable.
252                unreachable!("a Scalar is never a container on doc_stack")
253            }
254        }
255    }
256
257    /// Handles one parser event. `Event::Alias` never fails here: live-
258    /// confirmed against `yaml_rust2::YamlLoader::load_from_str` (see this
259    /// module's doc comment) -- the crate's own scanner already rejects an
260    /// alias whose anchor was never defined (`ScanError: found unknown
261    /// anchor`, surfaced through [`scan_error_to_parse_error`] before this
262    /// receiver ever runs) for *every* input that reaches an event receiver
263    /// at all, so an `anchor_map` miss inside `on_event` is unreachable in
264    /// practice -- `.expect()` documents that invariant instead of leaving a
265    /// structurally-dead error branch, matching `json.rs`'s identical
266    /// surrogate-pair `.expect()` precedent.
267    fn on_event_impl(&mut self, ev: Event, mark: Marker) {
268        // Once tripped, stop doing any further tree-building work -- the
269        // document is already rejected, and continuing to clone/insert
270        // subsequent alias references would just keep paying the same
271        // amplified cost this guard exists to avoid.
272        if self.error.is_some() {
273            return;
274        }
275        match ev {
276            Event::Nothing | Event::StreamStart | Event::StreamEnd | Event::DocumentStart => {}
277            Event::DocumentEnd => match self.doc_stack.len() {
278                0 => self
279                    .docs
280                    .push(Raw::Scalar(String::new(), TScalarStyle::Plain, None)),
281                1 => self.docs.push(self.doc_stack.pop().unwrap().0),
282                _ => unreachable!("a single document's stack never nests more than one root"),
283            },
284            Event::SequenceStart(aid, _) => {
285                if !self.charge(1, mark) {
286                    return;
287                }
288                self.doc_stack.push((Raw::Sequence(Vec::new()), aid));
289            }
290            Event::SequenceEnd => {
291                let (node, aid) = self.doc_stack.pop().expect("matched by SequenceStart");
292                self.insert(node, aid, mark);
293            }
294            Event::MappingStart(aid, _) => {
295                if !self.charge(1, mark) {
296                    return;
297                }
298                self.doc_stack.push((Raw::Mapping(Vec::new()), aid));
299                self.key_stack.push(None);
300            }
301            Event::MappingEnd => {
302                let (node, aid) = self.doc_stack.pop().expect("matched by MappingStart");
303                self.key_stack.pop();
304                self.insert(node, aid, mark);
305            }
306            Event::Scalar(v, style, aid, tag) => {
307                if !self.charge(1, mark) {
308                    return;
309                }
310                self.insert(Raw::Scalar(v, style, tag), aid, mark);
311            }
312            Event::Alias(id) => {
313                // Count the *whole subtree's* size before cloning it -- an
314                // alias amplifies by the size of what it references, not
315                // by one node, so charging anything less would let the
316                // exponential "billion laughs" pattern through uncounted.
317                // The borrow of `anchor_map` ends with this block, so
318                // `self.charge` below can take `&mut self` freely.
319                let n = {
320                    let referenced = self.anchor_map.get(&id).expect(
321                        "yaml_rust2's scanner rejects an alias to an undefined anchor before \
322                         this receiver ever runs -- see on_event_impl's doc comment",
323                    );
324                    count_nodes(referenced)
325                };
326                if !self.charge(n, mark) {
327                    return;
328                }
329                let node = self
330                    .anchor_map
331                    .get(&id)
332                    .cloned()
333                    .expect("checked above: the anchor_map entry exists for this id");
334                self.insert(node, 0, mark);
335            }
336        }
337    }
338}
339
340impl MarkedEventReceiver for Builder {
341    fn on_event(&mut self, ev: Event, mark: Marker) {
342        self.on_event_impl(ev, mark);
343    }
344}
345
346fn scan_error_to_parse_error(e: &ScanError) -> ParseError {
347    let mark = e.marker();
348    ParseError::new(mark.line(), mark.col() + 1, format!("invalid YAML: {e}"))
349}
350
351/// Parse YAML text into a [`Doc`].
352///
353/// Exactly one YAML document is accepted (matching Python's `yaml.safe_load`,
354/// which raises on a stream containing more than one `---`-separated
355/// document); an empty/blank input parses as a `Null` document, also matching
356/// `yaml.safe_load("")` returning `None`. A bare top-level sequence, a
357/// sequence nested directly inside another sequence, or nesting past
358/// [`crate::document::MAX_DEPTH`] all surface as
359/// [`crate::error::DocumentError`] (via [`Doc::of`]), matching `json.rs`'s
360/// identical `read_json` behavior.
361pub fn read_yaml(text: &str) -> Result<Doc, OmnistError> {
362    let mut parser = Parser::new(text.chars());
363    let mut builder = Builder::new();
364    parser
365        .load(&mut builder, true)
366        .map_err(|e| scan_error_to_parse_error(&e))?;
367    if let Some(e) = builder.error {
368        return Err(e.into());
369    }
370    if builder.docs.len() > 1 {
371        return Err(ParseError::new(
372            1,
373            1,
374            "invalid YAML: expected a single document in the stream, found more than one",
375        )
376        .into());
377    }
378    let raw = builder.docs.into_iter().next().unwrap_or(Raw::Scalar(
379        String::new(),
380        TScalarStyle::Plain,
381        None,
382    ));
383    let resolved = resolve_merges(&raw, 0)?;
384    let value = raw_to_value(&resolved)?;
385    Ok(Doc::of(&value)?)
386}
387
388/// Recursively expands every `<<` merge key, depth-guarded the same way
389/// `document.rs`'s own construction path is (an alias can smuggle in an
390/// already-deep subtree before `Doc::of` ever sees it).
391fn resolve_merges(node: &Raw, depth: usize) -> Result<Raw, OmnistError> {
392    crate::document::check_write_depth(depth, "$")?;
393    match node {
394        Raw::Scalar(..) => Ok(node.clone()),
395        Raw::Sequence(items) => {
396            let mut out = Vec::with_capacity(items.len());
397            for item in items {
398                out.push(resolve_merges(item, depth + 1)?);
399            }
400            Ok(Raw::Sequence(out))
401        }
402        Raw::Mapping(entries) => {
403            let mut merged_from: Vec<(Raw, Raw)> = Vec::new();
404            let mut own: Vec<(Raw, Raw)> = Vec::new();
405            for (k, v) in entries {
406                if is_merge_key(k) {
407                    for (mk, mv) in merge_source_entries(v, depth)? {
408                        merged_from.push((mk, mv));
409                    }
410                } else {
411                    own.push((resolve_merges(k, depth + 1)?, resolve_merges(v, depth + 1)?));
412                }
413            }
414            // Explicit keys take precedence over merged-in ones (an explicit
415            // duplicate key among `own` is left untouched here -- last-wins
416            // for those is `raw_to_value`'s `IndexMap::insert`'s job, exactly
417            // like `json.rs`'s reader). Among the merge sources themselves,
418            // first-listed source wins a collision (YAML merge spec).
419            let own_labels: std::collections::HashSet<&str> =
420                own.iter().filter_map(|(k, _)| scalar_key_text(k)).collect();
421            let mut merged_seen: std::collections::HashSet<&str> =
422                std::collections::HashSet::with_capacity(merged_from.len());
423            let mut result = own.clone();
424            for (k, v) in &merged_from {
425                let label = scalar_key_text(k);
426                if let Some(label) = label
427                    && (own_labels.contains(label) || !merged_seen.insert(label))
428                {
429                    continue;
430                }
431                result.push((k.clone(), v.clone()));
432            }
433            Ok(Raw::Mapping(result))
434        }
435    }
436}
437
438/// A merge key's own key text, for de-duplication purposes -- non-scalar
439/// (or non-string-scalar) keys never collide with anything by this scheme,
440/// matching the fact that only string-labeled fields exist in this model.
441fn scalar_key_text(k: &Raw) -> Option<&str> {
442    match k {
443        Raw::Scalar(s, _, _) => Some(s.as_str()),
444        _ => None,
445    }
446}
447
448/// Is `k` an (unquoted) `<<` merge-key marker? A *quoted* `"<<"` is a literal
449/// string key, not a merge marker -- matches PyYAML's resolver, which only
450/// assigns the `tag:yaml.org,2002:merge` tag to a plain-style scalar spelled
451/// exactly `<<`.
452fn is_merge_key(k: &Raw) -> bool {
453    matches!(k, Raw::Scalar(s, TScalarStyle::Plain, None) if s == "<<")
454}
455
456/// The `(key, value)` pairs a merge key's value contributes: a mapping
457/// contributes its own entries directly; a sequence contributes every
458/// element's entries in order; anything else -- the omnist-ts#46 regression
459/// this reader guards against -- is a clean [`ParseError`], never a panic.
460fn merge_source_entries(v: &Raw, depth: usize) -> Result<Vec<(Raw, Raw)>, OmnistError> {
461    match v {
462        Raw::Mapping(entries) => {
463            let mut out = Vec::with_capacity(entries.len());
464            for (k, val) in entries {
465                out.push((
466                    resolve_merges(k, depth + 1)?,
467                    resolve_merges(val, depth + 1)?,
468                ));
469            }
470            Ok(out)
471        }
472        Raw::Sequence(items) => {
473            let mut out = Vec::new();
474            for item in items {
475                out.extend(merge_source_entries(item, depth + 1)?);
476            }
477            Ok(out)
478        }
479        Raw::Scalar(..) => Err(ParseError::new(
480            1,
481            1,
482            "invalid YAML: merge key '<<' requires a mapping or a sequence of mappings, \
483             found a scalar",
484        )
485        .into()),
486    }
487}
488
489/// Turn a (merge-resolved) [`Raw`] tree into a [`Value`], applying scalar-tag
490/// resolution to every leaf along the way.
491fn raw_to_value(node: &Raw) -> Result<Value, OmnistError> {
492    match node {
493        Raw::Scalar(text, style, tag) => Ok(scalar_to_value(text, *style, tag.as_ref())?),
494        Raw::Sequence(items) => {
495            let mut out = Vec::with_capacity(items.len());
496            for item in items {
497                out.push(raw_to_value(item)?);
498            }
499            Ok(Value::Array(out))
500        }
501        Raw::Mapping(entries) => {
502            let mut map: IndexMap<String, Value> = IndexMap::new();
503            for (k, v) in entries {
504                let key = match k {
505                    // Route the key through the same implicit-type
506                    // resolver mapping *values* already go through
507                    // (`scalar_to_value`) -- issue #88, the "Norway
508                    // problem": YAML 1.1's core schema resolves a bare
509                    // `on`/`off`/`yes`/`no`/`true`/`false`/`~`/etc. key the
510                    // same way it would as a value. A label MUST be a
511                    // string, so a key that resolves to anything else
512                    // (bool, null, int, float) is rejected here, matching
513                    // Python's `DocumentError: object key <value> is not a
514                    // string` (live-confirmed).
515                    Raw::Scalar(s, style, tag) => match scalar_to_value(s, *style, tag.as_ref())? {
516                        Value::Str(s) => s,
517                        other => {
518                            return Err(DocumentError::new(
519                                "$",
520                                format!(
521                                    "object key {} is not a string",
522                                    describe_non_string_key(&other)
523                                ),
524                            )
525                            .into());
526                        }
527                    },
528                    _ => {
529                        return Err(ParseError::new(
530                            1,
531                            1,
532                            "invalid YAML: a mapping key must be a scalar",
533                        )
534                        .into());
535                    }
536                };
537                // Last-duplicate-key-wins, matching json.rs's IndexMap::insert
538                // semantics and PyYAML's own dict-construction behavior
539                // (confirmed live: `yaml.safe_load("a: 1\\na: 2\\n") == {'a': 2}`).
540                map.insert(key, raw_to_value(v)?);
541            }
542            Ok(Value::Object(map))
543        }
544    }
545}
546
547/// Renders a non-string [`Value`] the way Python's reference implementation
548/// spells it in its `DocumentError` message (`True`/`False`/`None`/a plain
549/// number), for parity with the diagnostic text PyYAML/`omnist`'s own
550/// `object key <value> is not a string` error uses -- live-confirmed:
551/// `omnist.read_yaml("on:\n  push: true\n")` raises `object key True is not
552/// a string`. Only `Bool`/`Null`/`Int`/`Float` are structurally reachable
553/// here -- `scalar_to_value` (this function's only caller's source) never
554/// produces `Str` (excluded by the caller's own match arm before this runs)
555/// or `Array`/`Object` (it operates on a single `Raw::Scalar` leaf) -- the
556/// wildcard arm is an unreachable-but-harmless fallback, not a real case.
557fn describe_non_string_key(v: &Value) -> String {
558    match v {
559        Value::Bool(true) => "True".to_string(),
560        Value::Bool(false) => "False".to_string(),
561        Value::Null => "None".to_string(),
562        Value::Int(i) => i.to_string(),
563        Value::Float(f) => {
564            // Rust's `f64::to_string()` renders whole numbers like `1.0`
565            // as `"1"`, but Python's `repr(float)` always keeps the `.0`
566            // -- confirmed live: `repr(1.0)` is `'1.0'`, not `'1'`.
567            let s = f.to_string();
568            if f.is_finite() && !s.contains('.') && !s.contains('e') && !s.contains('E') {
569                format!("{s}.0")
570            } else {
571                s
572            }
573        }
574        other => format!("{other:?}"),
575    }
576}
577
578fn scalar_to_value(
579    text: &str,
580    style: TScalarStyle,
581    tag: Option<&Tag>,
582) -> Result<Value, ParseError> {
583    if let Some(t) = tag
584        && t.handle == "tag:yaml.org,2002:"
585    {
586        return explicit_tag_to_value(text, &t.suffix);
587    }
588    if style != TScalarStyle::Plain {
589        return Ok(Value::Str(text.to_string()));
590    }
591    resolve_plain_scalar(text)
592}
593
594/// Constructs a [`Value`] from an explicit standard YAML tag
595/// (`!!str`/`!!int`/`!!float`/`!!bool`/`!!null`), matching what PyYAML's
596/// `SafeConstructor` supports for these five. Any other explicit tag (a
597/// custom `!!` type, `!!seq`/`!!map` forced onto a scalar, an unknown handle)
598/// is rejected with a [`ParseError`] -- PyYAML's `SafeConstructor` itself
599/// raises `ConstructorError: could not determine a constructor for the tag`
600/// for anything it doesn't recognize, so this is matching behavior, not an
601/// arbitrarily narrower one.
602fn explicit_tag_to_value(text: &str, suffix: &str) -> Result<Value, ParseError> {
603    match suffix {
604        "str" => Ok(Value::Str(text.to_string())),
605        "null" => Ok(Value::Null),
606        // PyYAML's `SafeConstructor.construct_yaml_bool` looks up
607        // `node.value.lower()` in `bool_values = {"yes": True, "no": False,
608        // "true": True, "false": False, "on": True, "off": False}`
609        // regardless of how the `!!bool` tag got attached (explicit or
610        // implicit) -- live-confirmed (see module doc comment): `!!bool
611        // "YES"`/`"On"`/`"OFF"` all construct successfully, not just
612        // true/false spellings; bare `y`/`n`/`1`/`0` do not (`KeyError`).
613        "bool" => match text.to_ascii_lowercase().as_str() {
614            "true" | "yes" | "on" => Ok(Value::Bool(true)),
615            "false" | "no" | "off" => Ok(Value::Bool(false)),
616            _ => Err(ParseError::new(
617                1,
618                1,
619                format!("invalid YAML: {text:?} is not a valid !!bool value"),
620            )),
621        },
622        "int" => parse_int_literal(text),
623        "float" => parse_float_literal(text),
624        other => Err(ParseError::new(
625            1,
626            1,
627            format!("invalid YAML: unsupported explicit tag '!!{other}'"),
628        )),
629    }
630}
631
632/// PyYAML's `tag:yaml.org,2002:bool` implicit-resolver spelling set --
633/// live-confirmed (see module doc comment): `yes`/`no`/`on`/`off` (and case
634/// variants) count as booleans; bare `y`/`n` do not.
635fn resolve_plain_scalar(text: &str) -> Result<Value, ParseError> {
636    match text {
637        "" | "~" | "null" | "Null" | "NULL" => return Ok(Value::Null),
638        "true" | "True" | "TRUE" | "yes" | "Yes" | "YES" | "on" | "On" | "ON" => {
639            return Ok(Value::Bool(true));
640        }
641        "false" | "False" | "FALSE" | "no" | "No" | "NO" | "off" | "Off" | "OFF" => {
642            return Ok(Value::Bool(false));
643        }
644        _ => {}
645    }
646    if is_int_literal_shape(text) {
647        return parse_int_literal(text);
648    }
649    if is_sexagesimal_int_shape(text) {
650        return parse_sexagesimal_int(text);
651    }
652    if is_float_literal_shape(text) {
653        return parse_float_literal(text);
654    }
655    if let Some(iso) = normalize_timestamp(text)? {
656        // YAML's own implicit-resolver timestamp grammar (unlike OML's or
657        // TOML's) has no standalone bare-time form -- `normalize_timestamp`
658        // only ever produces a date-only or a full datetime spelling, never
659        // a time-only one (issue #105: real provenance instead of staying
660        // `Str`, the same fix issue #99 already applied to OML). A
661        // datetime spelling always contains the date/time `T` separator;
662        // a date-only one never does.
663        return Ok(if iso.contains('T') {
664            Value::Datetime(iso)
665        } else {
666            Value::Date(iso)
667        });
668    }
669    Ok(Value::Str(text.to_string()))
670}
671
672/// Live-confirmed against `yaml.safe_load` (see module doc comment): PyYAML's
673/// `tag:yaml.org,2002:int` implicit resolver recognizes `0x`/`0b` prefixes and
674/// a bare leading zero as octal (`"017" -> 15`), but **not** a YAML-1.2-style
675/// `0o` prefix (`"0o17"` stays a plain string). The legacy sexagesimal
676/// `1:20` form is handled separately by [`SEXAGESIMAL_INT_RE`]/
677/// [`is_sexagesimal_int_shape`] below (issue #87 -- this comment previously
678/// called it "out of scope", but the spec (`docs/formats/yaml.md`) and both
679/// sibling ports require it; that framing was simply wrong).
680static INT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
681    regex::Regex::new(
682        r"^(?:[-+]?0b[0-1_]+|[-+]?0[0-7_]+|[-+]?(?:0|[1-9][0-9_]*)|[-+]?0x[0-9a-fA-F_]+)$",
683    )
684    .unwrap()
685});
686
687fn is_int_literal_shape(text: &str) -> bool {
688    INT_RE.is_match(text)
689}
690
691/// PyYAML's legacy sexagesimal integer form: a colon-separated run of
692/// base-60 digit groups, e.g. `12:00:00` -> `12*3600 + 0*60 + 0 = 43200`.
693/// Live-confirmed against `yaml.safe_load`/`omnist.read_yaml` (issue #87):
694/// the first group must NOT have a leading zero (`"0:0:1"`/`"01:20"` stay
695/// plain strings) and every subsequent group is constrained to `0-59`
696/// (`"1:60"`/`"1:600"` stay plain strings) -- exactly mirroring PyYAML's own
697/// `tag:yaml.org,2002:int` resolver regex
698/// (`^[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+$`, see
699/// <https://yaml.org/type/int.html>).
700static SEXAGESIMAL_INT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
701    regex::Regex::new(r"^[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+$").unwrap()
702});
703
704fn is_sexagesimal_int_shape(text: &str) -> bool {
705    SEXAGESIMAL_INT_RE.is_match(text)
706}
707
708/// Parses a sexagesimal-shaped string (already confirmed via
709/// [`is_sexagesimal_int_shape`]) into its base-60 integer value: fold each
710/// `:`-separated group left to right as `acc = acc*60 + group`, matching
711/// PyYAML's `construct_yaml_int`'s own sexagesimal branch.
712///
713/// Arbitrary-precision (issue #104): the fold itself can no longer
714/// overflow -- `BigInt` has no width limit -- so `MAX_INT_DIGITS` (the
715/// same cap `parse_int_literal` enforces) is checked explicitly on the
716/// fold's *result* instead. This replaces a real guard, not just
717/// tidies one: the old `i64` accumulator's `checked_mul`/`checked_add`
718/// overflow was incidentally bounding a many-group literal like
719/// `1:0:0:...:0` (thousands of groups); a naive swap to unchecked
720/// `BigInt` arithmetic here would silently remove that bound entirely,
721/// letting such a literal build an arbitrarily large integer.
722fn parse_sexagesimal_int(text: &str) -> Result<Value, ParseError> {
723    let neg = text.starts_with('-');
724    let t = text.strip_prefix(['+', '-']).unwrap_or(text);
725    let mut acc = BigInt::from(0);
726    let sixty = BigInt::from(60);
727    for group in t.split(':') {
728        let cleaned: String = group.chars().filter(|&c| c != '_').collect();
729        // Every group is guaranteed decimal digits by `SEXAGESIMAL_INT_RE`
730        // (the caller's shape check) -- this can't fail.
731        let digit = BigInt::parse_bytes(cleaned.as_bytes(), 10)
732            .expect("SEXAGESIMAL_INT_RE guarantees decimal digit groups");
733        acc = acc * &sixty + digit;
734    }
735    let value = if neg { -acc } else { acc };
736    let digit_count = value.to_string().trim_start_matches('-').len();
737    if digit_count > MAX_INT_DIGITS {
738        return Err(ParseError::new(
739            1,
740            1,
741            over_cap_message("invalid YAML: ", digit_count),
742        ));
743    }
744    Ok(Value::Int(value))
745}
746
747/// Arbitrary-precision (issue #104): a magnitude that fits the shape
748/// `is_int_literal_shape` already confirmed always parses -- no more
749/// `i64`-overflow special-casing (the old version's own comment about
750/// `i64::MIN`'s asymmetric magnitude is now moot, since `BigInt` negation
751/// never overflows).
752fn parse_int_literal(text: &str) -> Result<Value, ParseError> {
753    let neg = text.starts_with('-');
754    let t = text.strip_prefix(['+', '-']).unwrap_or(text);
755    let cleaned: String = t.chars().filter(|&c| c != '_').collect();
756    let (radix, digits) = if let Some(rest) = cleaned.strip_prefix("0x") {
757        (16, rest)
758    } else if let Some(rest) = cleaned.strip_prefix("0b") {
759        (2, rest)
760    } else if cleaned.starts_with('0') && cleaned.len() > 1 {
761        (8, &cleaned[1..])
762    } else {
763        (10, cleaned.as_str())
764    };
765    if radix == 10 && digits.len() > MAX_INT_DIGITS {
766        return Err(ParseError::new(
767            1,
768            1,
769            over_cap_message("invalid YAML: ", digits.len()),
770        ));
771    }
772    let magnitude = BigInt::parse_bytes(digits.as_bytes(), radix)
773        .expect("is_int_literal_shape guarantees valid digits for the detected radix");
774    let value = if neg { -magnitude } else { magnitude };
775    Ok(Value::Int(value))
776}
777
778/// Live-confirmed against `yaml.safe_load`: PyYAML's `tag:yaml.org,2002:float`
779/// implicit resolver requires a literal `.` -- `"1e3"` (no decimal point)
780/// stays a plain string, and the exponent sign is **mandatory** when present
781/// (`"1.0e3"` stays a string; `"1.0e+3"`/`"1.0e-3"` are floats). The legacy
782/// sexagesimal float form is out of scope, same rationale as
783/// `is_int_literal_shape`.
784static FLOAT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
785    regex::Regex::new(
786        r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
787    )
788    .unwrap()
789});
790
791fn is_float_literal_shape(text: &str) -> bool {
792    FLOAT_RE.is_match(text)
793}
794
795fn parse_float_literal(text: &str) -> Result<Value, ParseError> {
796    match text {
797        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => {
798            return Ok(Value::Float(f64::INFINITY));
799        }
800        "-.inf" | "-.Inf" | "-.INF" => return Ok(Value::Float(f64::NEG_INFINITY)),
801        ".nan" | ".NaN" | ".NAN" => return Ok(Value::Float(f64::NAN)),
802        _ => {}
803    }
804    let cleaned: String = text.chars().filter(|&c| c != '_').collect();
805    cleaned.parse::<f64>().map(Value::Float).map_err(|_| {
806        ParseError::new(
807            1,
808            1,
809            format!("invalid YAML: invalid float literal {text:?}"),
810        )
811    })
812}
813
814/// Reproduces PyYAML's `tag:yaml.org,2002:timestamp` resolver + construction
815/// (`construct_yaml_timestamp`): accepts a bare ISO date, or a date/time
816/// joined by `T`/`t`/one-or-more spaces, with an optional fractional-second
817/// part and an optional `Z`/`±HH[:MM]` offset -- and returns the value
818/// re-spelled the way `datetime.date.isoformat()`/`datetime.datetime.isoformat()`
819/// would: zero-padded, `T`-joined, offset as `+HH:MM`/`-HH:MM` (a bare `Z`
820/// becomes `+00:00`, matching `datetime.timezone.utc`'s own `isoformat()`).
821/// Returns `Ok(None)` for anything not shaped like a timestamp at all (the
822/// overwhelmingly common case -- most plain scalars are plain strings).
823///
824/// A string that *is* timestamp-shaped but names a calendar/clock value that
825/// doesn't exist (`2024-13-01`, `2024-02-30`, `2024-01-01T25:00:00`, an
826/// out-of-range timezone offset) is a [`ParseError`], **not** a silent
827/// fallback to a plain string -- live-confirmed against PyYAML (see this
828/// module's doc comment): `yaml.safe_load` calls `datetime.date`/
829/// `datetime.datetime`'s constructor on the captured fields, which raises a
830/// `ValueError` PyYAML doesn't catch, so the *whole document* fails to parse
831/// rather than quietly typing the value as a string. Calendar-date validity
832/// (leap years, per-month day counts) reuses `crate::schema::valid_ymd`/
833/// `crate::schema::valid_hms` rather than a second copy of that logic.
834fn normalize_timestamp(text: &str) -> Result<Option<String>, ParseError> {
835    static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
836        regex::Regex::new(
837            r"^(?P<year>[0-9]{4})-(?P<month>[0-9][0-9]?)-(?P<day>[0-9][0-9]?)(?:(?:[Tt]|[ \t]+)(?P<hour>[0-9][0-9]?):(?P<minute>[0-9][0-9]):(?P<second>[0-9][0-9])(?:\.(?P<fraction>[0-9]*))?(?:[ \t]*(?:Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)(?::(?P<tz_minute>[0-9][0-9]))?))?)?$",
838        )
839        .unwrap()
840    });
841    let Some(caps) = RE.captures(text) else {
842        return Ok(None);
843    };
844    let bad = |what: &str| {
845        Err(ParseError::new(
846            1,
847            1,
848            format!("invalid YAML: {text:?} is timestamp-shaped but names an invalid {what}"),
849        ))
850    };
851    let year: u32 = caps["year"].parse().unwrap_or(u32::MAX);
852    let month: u32 = caps["month"].parse().unwrap_or(u32::MAX);
853    let day: u32 = caps["day"].parse().unwrap_or(u32::MAX);
854    if !crate::schema::valid_ymd(year, month, day) {
855        return bad("calendar date");
856    }
857    let Some(hour_m) = caps.name("hour") else {
858        return Ok(Some(format!("{year:04}-{month:02}-{day:02}")));
859    };
860    let hour: u32 = hour_m.as_str().parse().unwrap_or(u32::MAX);
861    let minute: u32 = caps["minute"].parse().unwrap_or(u32::MAX);
862    let second: u32 = caps["second"].parse().unwrap_or(u32::MAX);
863    if !crate::schema::valid_hms(hour, minute, second) {
864        return bad("time of day");
865    }
866    let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
867    if let Some(frac) = caps.name("fraction") {
868        let mut digits = frac.as_str().to_string();
869        while digits.len() < 6 {
870            digits.push('0');
871        }
872        digits.truncate(6);
873        out.push('.');
874        out.push_str(&digits);
875    }
876    match caps.name("tz_sign") {
877        Some(sign) => {
878            let tz_hour: u32 = caps["tz_hour"].parse().unwrap_or(u32::MAX);
879            let tz_minute: u32 = caps
880                .name("tz_minute")
881                .map(|m| m.as_str().parse().unwrap_or(u32::MAX))
882                .unwrap_or(0);
883            if tz_hour > 23 || tz_minute > 59 {
884                return bad("timezone offset");
885            }
886            out.push_str(sign.as_str());
887            out.push_str(&format!("{tz_hour:02}:{tz_minute:02}"));
888        }
889        None if text.trim_end().ends_with('Z') => out.push_str("+00:00"),
890        None => {}
891    }
892    Ok(Some(out))
893}
894
895// ============================================================== Writer
896
897/// Project a [`Doc`] to YAML text (block style, 2-space indent, insertion
898/// order preserved -- matching Python's `yaml.dump(..., sort_keys=False,
899/// default_flow_style=False)`).
900pub fn write_yaml(
901    doc: &Doc,
902    strict: bool,
903    report: Option<&mut WriteReport>,
904) -> Result<String, WriteError> {
905    let grouped = doc.to_grouped();
906    let mut rep = check_yaml_grouped(&grouped);
907    add_interleaving_diagnostic(doc, &mut rep);
908    let mut out = String::new();
909    write_node(&grouped, 0, &mut out, true);
910    if out.ends_with('\n') {
911        out.pop();
912    }
913    crate::report::finish_write(out, rep, strict, report)
914}
915
916/// `format.interleaving-lost` (spec Sec8.3.8, D-3) is a whole-document
917/// diagnostic that depends on the original `Doc`'s edge order, lost by the
918/// time `to_grouped` runs -- so it is detected separately via
919/// `Doc::has_interleaving_loss` rather than folded into `check_yaml_grouped`'s
920/// grouped-`Value` walk.
921fn add_interleaving_diagnostic(doc: &Doc, rep: &mut WriteReport) {
922    if doc.has_interleaving_loss() {
923        rep.add(
924            "$",
925            "format.interleaving-lost",
926            "cross-label interleaving could not be written; same-label edges were grouped",
927            Severity::Warning,
928        );
929    }
930}
931
932/// Report what writing YAML would adjust, without producing output. The only
933/// adjustment YAML ever needs is forcing double-quoted style for a U+0085
934/// (NEL) string/label -- see this module's doc comment.
935pub fn check_yaml(doc: &Doc) -> WriteReport {
936    let grouped = doc.to_grouped();
937    let mut rep = check_yaml_grouped(&grouped);
938    add_interleaving_diagnostic(doc, &mut rep);
939    rep
940}
941
942fn check_yaml_grouped(grouped: &Value) -> WriteReport {
943    let mut rep = WriteReport::new();
944    let mut path = String::from("$");
945    crate::formats::visit_grouped(grouped, &mut path, &mut |visited, path| match visited {
946        crate::formats::Visited::Edge { label } if label.contains('\u{0085}') => {
947            rep.add(
948                path,
949                "string.line-break-char",
950                "label contains U+0085 (NEL); written double-quoted to round-trip correctly",
951                Severity::Warning,
952            );
953        }
954        crate::formats::Visited::Node {
955            value: Value::Str(s),
956        } if s.contains('\u{0085}') => {
957            rep.add(
958                path,
959                "string.line-break-char",
960                "value contains U+0085 (NEL); written double-quoted to round-trip correctly",
961                Severity::Warning,
962            );
963        }
964        _ => {}
965    });
966    rep
967}
968
969/// Marker type implementing [`crate::formats::Codec`] for YAML -- adapts
970/// [`read_yaml`]/[`write_yaml`]/[`check_yaml`] to the registry's uniform
971/// shape with the documented defaults (`strict: false`, no report).
972pub(crate) struct Yaml;
973
974impl crate::formats::Codec for Yaml {
975    const NAME: &'static str = "yaml";
976
977    fn read(text: &str) -> Result<Doc, OmnistError> {
978        read_yaml(text)
979    }
980
981    fn write(doc: &Doc) -> Result<String, OmnistError> {
982        write_yaml(doc, false, None).map_err(Into::into)
983    }
984
985    fn check(doc: &Doc) -> WriteReport {
986        check_yaml(doc)
987    }
988}
989
990fn indent(out: &mut String, level: usize) {
991    for _ in 0..level {
992        out.push_str("  ");
993    }
994}
995
996/// Writes `node` at `level`, matching PyYAML's block style: a scalar directly
997/// after `key:`/`- ` on the same line; a nested mapping/sequence starts on
998/// the next line, indented. `top` is `true` only for the document root, which
999/// (for an empty root object) still needs `{}` -- an empty nested object
1000/// is written the same way PyYAML does, inline `{}`/`[]`.
1001fn write_node(node: &Value, level: usize, out: &mut String, top: bool) {
1002    match node {
1003        Value::Object(map) if map.is_empty() => {
1004            out.push_str("{}\n");
1005        }
1006        Value::Array(items) if items.is_empty() => {
1007            out.push_str("[]\n");
1008        }
1009        Value::Object(map) => {
1010            for (label, child) in map {
1011                indent(out, level);
1012                write_scalar(label, out);
1013                out.push(':');
1014                write_child(child, level, out);
1015            }
1016            let _ = top;
1017        }
1018        Value::Array(items) => {
1019            for item in items {
1020                indent(out, level);
1021                out.push('-');
1022                write_seq_child(item, level, out);
1023            }
1024        }
1025        other => {
1026            write_scalar_value(other, out);
1027            out.push('\n');
1028        }
1029    }
1030}
1031
1032fn write_child(child: &Value, level: usize, out: &mut String) {
1033    match child {
1034        Value::Object(m) if !m.is_empty() => {
1035            out.push('\n');
1036            write_node(child, level + 1, out, false);
1037        }
1038        Value::Array(a) if !a.is_empty() => {
1039            out.push('\n');
1040            write_node(child, level, out, false);
1041        }
1042        _ => {
1043            out.push(' ');
1044            write_node(child, level + 1, out, false);
1045        }
1046    }
1047}
1048
1049fn write_seq_child(item: &Value, level: usize, out: &mut String) {
1050    match item {
1051        Value::Object(m) if !m.is_empty() => {
1052            out.push(' ');
1053            // The first field of a mapping under a `- ` sequence marker is
1054            // written on the same line as the dash; PyYAML indents the
1055            // remaining fields to align under it (one level deeper than the
1056            // dash itself, i.e. `level + 1`).
1057            let mut first = true;
1058            for (label, child) in m {
1059                if !first {
1060                    indent(out, level + 1);
1061                }
1062                first = false;
1063                write_scalar(label, out);
1064                out.push(':');
1065                write_child(child, level + 1, out);
1066            }
1067        }
1068        Value::Array(a) if !a.is_empty() => {
1069            out.push('\n');
1070            write_node(item, level + 1, out, false);
1071        }
1072        _ => {
1073            out.push(' ');
1074            write_node(item, level + 1, out, false);
1075        }
1076    }
1077}
1078
1079fn write_scalar(s: &str, out: &mut String) {
1080    write_scalar_value(&Value::Str(s.to_string()), out);
1081}
1082
1083fn write_scalar_value(v: &Value, out: &mut String) {
1084    match v {
1085        Value::Null => out.push_str("null"),
1086        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1087        Value::Int(i) => out.push_str(&i.to_string()),
1088        Value::Float(x) => write_float(*x, out),
1089        // Always quoted -- no shape-guessing (issue #105, the same fix
1090        // issue #99 already applied to OML). A genuinely temporal-kinded
1091        // value is a `Date`/`Time`/`Datetime` variant, not a
1092        // shape-matched `Str`.
1093        Value::Str(s) => write_yaml_string(s, out),
1094        // Written bare unconditionally -- by construction (see
1095        // `Scalar::Date`'s own doc comment) always already a validated,
1096        // canonical spelling that YAML's own implicit resolver reads back
1097        // as the identical kind (`normalize_timestamp` produces exactly
1098        // this date-or-datetime shape, see `resolve_plain_scalar`).
1099        Value::Date(s) | Value::Datetime(s) => out.push_str(s),
1100        // Unlike `Date`/`Datetime`, YAML's implicit resolver has no bare
1101        // standalone-time form at all (`normalize_timestamp`'s grammar
1102        // always requires a date) -- a genuine `Time` value (e.g. from a
1103        // schema-directed upgrade of a JSON-sourced sample, or read from
1104        // OML's own bare time grammar) has no native YAML spelling, so it
1105        // must stay a quoted string, the same fallback TOML's writer uses
1106        // for a `Time` value that carries a UTC offset.
1107        Value::Time(s) => write_yaml_string(s, out),
1108        Value::Object(_) | Value::Array(_) => {
1109            unreachable!("write_scalar_value is only ever called on a leaf")
1110        }
1111    }
1112}
1113
1114/// See `formats::float_fmt` (issue #47) for the shared render-then-inspect
1115/// core (issue #46's fix); this is just YAML's spelling table.
1116fn write_float(x: f64, out: &mut String) {
1117    float_fmt::write_float(x, ".nan", ".inf", "-.inf", out);
1118}
1119
1120/// Writes `s` as a YAML scalar: double-quoted (with the U+0085 escape
1121/// `check_yaml` warns about) if it contains a NEL, or if writing it bare
1122/// would round-trip back as a *different* value (it would be re-resolved as
1123/// null/bool/int/float/timestamp, it's empty, or it has YAML-significant
1124/// leading/embedded punctuation) -- otherwise written plain.
1125fn write_yaml_string(s: &str, out: &mut String) {
1126    if needs_quoting(s) {
1127        write_quoted(s, &YAML_ESCAPES, out);
1128    } else {
1129        out.push_str(s);
1130    }
1131}
1132
1133fn needs_quoting(s: &str) -> bool {
1134    if s.is_empty() || s.contains('\u{0085}') || s.contains('\n') {
1135        return true;
1136    }
1137    if matches!(resolve_plain_scalar(s), Ok(Value::Str(ref t)) if t == s) {
1138        // Round-trips as the identical plain string -- but a leading char /
1139        // embedded token that's YAML-significant still needs quoting even
1140        // though resolve_plain_scalar wouldn't itself retype it.
1141    } else {
1142        return true; // would be re-read as null/bool/int/float/timestamp
1143    }
1144    // `s` is guaranteed non-empty here: the early return at line 1021-1023
1145    // already handles `s.is_empty()`, so `.chars().next()` always yields a
1146    // char.
1147    let first = s.chars().next().unwrap();
1148    if matches!(
1149        first,
1150        '-' | '?'
1151            | ':'
1152            | ','
1153            | '['
1154            | ']'
1155            | '{'
1156            | '}'
1157            | '#'
1158            | '&'
1159            | '*'
1160            | '!'
1161            | '|'
1162            | '>'
1163            | '\''
1164            | '"'
1165            | '%'
1166            | '@'
1167            | '`'
1168            | ' '
1169    ) {
1170        return true;
1171    }
1172    if s.ends_with(' ') || s.contains(": ") || s.ends_with(':') || s.contains(" #") {
1173        return true;
1174    }
1175    false
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181    use crate::document::{Doc, Scalar, Value};
1182
1183    fn obj(pairs: Vec<(&str, Value)>) -> Value {
1184        Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
1185    }
1186
1187    fn doc_of(v: Value) -> Doc {
1188        Doc::of(&v).unwrap()
1189    }
1190
1191    // ---------------------------------------------------------- reader: scalars
1192
1193    #[test]
1194    fn reads_every_scalar_kind() {
1195        let doc = read_yaml("a: 1\nb: \"s\"\nc: true\nd: null\ne: 1.5\n").unwrap();
1196        let root = doc.root();
1197        assert_eq!(
1198            *root.get_one("a").unwrap().value().unwrap(),
1199            Scalar::Int((1).into())
1200        );
1201        assert_eq!(
1202            *root.get_one("b").unwrap().value().unwrap(),
1203            Scalar::Str("s".to_string())
1204        );
1205        assert_eq!(
1206            *root.get_one("c").unwrap().value().unwrap(),
1207            Scalar::Bool(true)
1208        );
1209        assert_eq!(*root.get_one("d").unwrap().value().unwrap(), Scalar::Null);
1210        assert_eq!(
1211            *root.get_one("e").unwrap().value().unwrap(),
1212            Scalar::Float(1.5)
1213        );
1214    }
1215
1216    #[test]
1217    fn reads_yaml_1_1_bool_spellings_but_not_bare_y_or_n() {
1218        let doc = read_yaml("a: yes\nb: no\nc: on\nd: off\ne: Yes\nf: y\ng: n\n").unwrap();
1219        let root = doc.root();
1220        assert_eq!(
1221            *root.get_one("a").unwrap().value().unwrap(),
1222            Scalar::Bool(true)
1223        );
1224        assert_eq!(
1225            *root.get_one("b").unwrap().value().unwrap(),
1226            Scalar::Bool(false)
1227        );
1228        assert_eq!(
1229            *root.get_one("c").unwrap().value().unwrap(),
1230            Scalar::Bool(true)
1231        );
1232        assert_eq!(
1233            *root.get_one("d").unwrap().value().unwrap(),
1234            Scalar::Bool(false)
1235        );
1236        assert_eq!(
1237            *root.get_one("e").unwrap().value().unwrap(),
1238            Scalar::Bool(true)
1239        );
1240        // 'y'/'n' alone stay strings -- live-confirmed against PyYAML (see
1241        // module doc comment).
1242        assert_eq!(
1243            *root.get_one("f").unwrap().value().unwrap(),
1244            Scalar::Str("y".to_string())
1245        );
1246        assert_eq!(
1247            *root.get_one("g").unwrap().value().unwrap(),
1248            Scalar::Str("n".to_string())
1249        );
1250    }
1251
1252    #[test]
1253    fn quoted_yes_stays_a_string_not_a_bool() {
1254        let doc = read_yaml("a: \"yes\"\n").unwrap();
1255        assert_eq!(
1256            *doc.root().get_one("a").unwrap().value().unwrap(),
1257            Scalar::Str("yes".to_string())
1258        );
1259    }
1260
1261    #[test]
1262    fn reads_null_spellings() {
1263        let doc = read_yaml("a: ~\nb: null\nc: Null\nd: NULL\ne:\n").unwrap();
1264        let root = doc.root();
1265        for label in ["a", "b", "c", "d", "e"] {
1266            assert_eq!(*root.get_one(label).unwrap().value().unwrap(), Scalar::Null);
1267        }
1268    }
1269
1270    #[test]
1271    fn reads_negative_and_hex_and_octal_and_binary_ints() {
1272        // Note: PyYAML's legacy (YAML-1.1-derived) int resolver recognizes a
1273        // *bare leading zero* as octal ("017" -> 15), not a `0o` prefix --
1274        // live-confirmed (see module doc comment on `INT_RE`); a separate
1275        // test below pins `"0o17"` staying a string for that exact reason.
1276        let doc = read_yaml("a: -5\nb: 0x1A\nc: 017\nd: 0b101\ne: 1_000\n").unwrap();
1277        let root = doc.root();
1278        assert_eq!(
1279            *root.get_one("a").unwrap().value().unwrap(),
1280            Scalar::Int((-5).into())
1281        );
1282        assert_eq!(
1283            *root.get_one("b").unwrap().value().unwrap(),
1284            Scalar::Int((26).into())
1285        );
1286        assert_eq!(
1287            *root.get_one("c").unwrap().value().unwrap(),
1288            Scalar::Int((15).into())
1289        );
1290        assert_eq!(
1291            *root.get_one("d").unwrap().value().unwrap(),
1292            Scalar::Int((5).into())
1293        );
1294        assert_eq!(
1295            *root.get_one("e").unwrap().value().unwrap(),
1296            Scalar::Int((1000).into())
1297        );
1298    }
1299
1300    #[test]
1301    fn a_yaml_1_2_style_0o_octal_prefix_is_not_recognized_and_stays_a_string() {
1302        let doc = read_yaml("a: 0o17\n").unwrap();
1303        assert_eq!(
1304            *doc.root().get_one("a").unwrap().value().unwrap(),
1305            Scalar::Str("0o17".to_string())
1306        );
1307    }
1308
1309    #[test]
1310    fn reads_legacy_sexagesimal_int_forms() {
1311        // issue #87: live-confirmed against PyYAML/omnist -- `12:00:00`
1312        // resolves via the legacy YAML-1.1 sexagesimal integer form
1313        // (base-60 digit groups) to the plain integer 43200, not a string.
1314        let doc =
1315            read_yaml("a: 12:00:00\nb: 1:20\nc: 1:2:3\nd: -1:20\ne: +1:20\nf: 123:45\ng: 1_2:30\n")
1316                .unwrap();
1317        let root = doc.root();
1318        assert_eq!(
1319            *root.get_one("a").unwrap().value().unwrap(),
1320            Scalar::Int((43200).into())
1321        );
1322        assert_eq!(
1323            *root.get_one("b").unwrap().value().unwrap(),
1324            Scalar::Int((80).into())
1325        );
1326        assert_eq!(
1327            *root.get_one("c").unwrap().value().unwrap(),
1328            Scalar::Int((3723).into())
1329        );
1330        assert_eq!(
1331            *root.get_one("d").unwrap().value().unwrap(),
1332            Scalar::Int((-80).into())
1333        );
1334        assert_eq!(
1335            *root.get_one("e").unwrap().value().unwrap(),
1336            Scalar::Int((80).into())
1337        );
1338        assert_eq!(
1339            *root.get_one("f").unwrap().value().unwrap(),
1340            Scalar::Int((7425).into())
1341        );
1342        assert_eq!(
1343            *root.get_one("g").unwrap().value().unwrap(),
1344            Scalar::Int((750).into())
1345        );
1346    }
1347
1348    #[test]
1349    fn sexagesimal_first_group_over_i64_range_parses() {
1350        // Issue #104: `Scalar::Int` is arbitrary-precision (`BigInt`), so
1351        // a first digit group beyond `i64`'s ~19-digit range is no longer
1352        // a parse error -- `parse_sexagesimal_int`'s fold just produces a
1353        // real, correctly-computed `BigInt` value.
1354        let text = format!("a: {}:0\n", "9".repeat(20));
1355        let doc = read_yaml(&text).unwrap();
1356        let value = doc.root().child("a").unwrap().value().unwrap();
1357        assert_eq!(
1358            value,
1359            &Scalar::Int(num_bigint::BigInt::parse_bytes(b"5999999999999999999940", 10).unwrap())
1360        );
1361    }
1362
1363    #[test]
1364    fn sexagesimal_fold_overflow_across_many_in_range_groups_parses() {
1365        // Every individual group here is a legal 0-59 sexagesimal digit;
1366        // this exercises the *fold* itself accumulating well past `i64`
1367        // range across many groups -- issue #104 means the fold no longer
1368        // overflows, it just keeps growing the `BigInt`.
1369        let text = format!("a: 1{}\n", ":59".repeat(15));
1370        let doc = read_yaml(&text).unwrap();
1371        let value = doc.root().child("a").unwrap().value().unwrap();
1372        assert_eq!(
1373            value,
1374            &Scalar::Int(
1375                num_bigint::BigInt::parse_bytes(b"940369969151999999999999999", 10).unwrap()
1376            )
1377        );
1378    }
1379
1380    #[test]
1381    fn sexagesimal_fold_still_rejects_past_the_digit_cap() {
1382        // The digit-cap check this migration *added* to the fold (issue
1383        // #104 -- replacing the `i64` overflow that used to bound this
1384        // incidentally) -- enough groups to push the folded result's
1385        // decimal digit count past MAX_INT_DIGITS must still be rejected,
1386        // not silently accepted now that raw fold overflow no longer does
1387        // that job.
1388        let text = format!("a: 1{}\n", ":59".repeat(2500));
1389        let err = read_yaml(&text).unwrap_err();
1390        assert!(
1391            matches!(&err, OmnistError::Parse(e) if e.message.contains("4300-digit")),
1392            "got {err:?}"
1393        );
1394    }
1395
1396    #[test]
1397    fn sexagesimal_shape_with_leading_zero_or_out_of_range_group_stays_a_string() {
1398        // Live-confirmed against PyYAML: the first digit group must NOT
1399        // have a leading zero, and every subsequent `:NN` group must be
1400        // 0-59 -- otherwise the whole thing stays a plain string, it does
1401        // not partially resolve.
1402        let doc = read_yaml("a: 0:0:1\nb: 1:60\nc: 1:600\nd: 01:20\n").unwrap();
1403        let root = doc.root();
1404        assert_eq!(
1405            *root.get_one("a").unwrap().value().unwrap(),
1406            Scalar::Str("0:0:1".to_string())
1407        );
1408        assert_eq!(
1409            *root.get_one("b").unwrap().value().unwrap(),
1410            Scalar::Str("1:60".to_string())
1411        );
1412        assert_eq!(
1413            *root.get_one("c").unwrap().value().unwrap(),
1414            Scalar::Str("1:600".to_string())
1415        );
1416        assert_eq!(
1417            *root.get_one("d").unwrap().value().unwrap(),
1418            Scalar::Str("01:20".to_string())
1419        );
1420    }
1421
1422    #[test]
1423    fn reads_float_and_inf_and_nan_tokens() {
1424        let doc = read_yaml("a: 1.5\nb: .inf\nc: -.inf\nd: .nan\ne: 1.0e+3\n").unwrap();
1425        let root = doc.root();
1426        assert_eq!(
1427            *root.get_one("a").unwrap().value().unwrap(),
1428            Scalar::Float(1.5)
1429        );
1430        assert_eq!(
1431            *root.get_one("b").unwrap().value().unwrap(),
1432            Scalar::Float(f64::INFINITY)
1433        );
1434        assert_eq!(
1435            *root.get_one("c").unwrap().value().unwrap(),
1436            Scalar::Float(f64::NEG_INFINITY)
1437        );
1438        assert!(
1439            matches!(root.get_one("d").unwrap().value().unwrap(), Scalar::Float(x) if x.is_nan())
1440        );
1441        assert_eq!(
1442            *root.get_one("e").unwrap().value().unwrap(),
1443            Scalar::Float(1000.0)
1444        );
1445    }
1446
1447    #[test]
1448    fn a_bare_exponent_without_a_decimal_point_is_not_float_shaped_and_stays_a_string() {
1449        // Live-confirmed against PyYAML: its float resolver requires a
1450        // literal `.`, and a mandatory sign on the exponent when present --
1451        // "1e3" and "1.0e3" both stay plain strings.
1452        let doc = read_yaml("a: 1e3\nb: 1.0e3\n").unwrap();
1453        let root = doc.root();
1454        assert_eq!(
1455            *root.get_one("a").unwrap().value().unwrap(),
1456            Scalar::Str("1e3".to_string())
1457        );
1458        assert_eq!(
1459            *root.get_one("b").unwrap().value().unwrap(),
1460            Scalar::Str("1.0e3".to_string())
1461        );
1462    }
1463
1464    #[test]
1465    fn reads_bare_date_as_iso_string() {
1466        let doc = read_yaml("a: 2024-01-15\n").unwrap();
1467        assert_eq!(
1468            *doc.root().get_one("a").unwrap().value().unwrap(),
1469            Scalar::Date("2024-01-15".to_string())
1470        );
1471    }
1472
1473    #[test]
1474    fn genuine_date_and_datetime_values_write_bare_and_a_time_value_writes_quoted() {
1475        // `Date`/`Datetime` (issue #105) write as YAML's own bare
1476        // timestamp literal, since `write_scalar_value` trusts them as
1477        // already-canonical (see that function's doc comment); `Time` has
1478        // no native YAML spelling at all, so it always stays quoted.
1479        let v = obj(vec![
1480            ("d", Value::Date("2024-01-15".to_string())),
1481            ("dt", Value::Datetime("2024-01-15T12:00:00".to_string())),
1482            ("t", Value::Time("12:00:00".to_string())),
1483        ]);
1484        let doc = doc_of(v);
1485        let text = write_yaml(&doc, false, None).unwrap();
1486        assert!(text.contains("d: 2024-01-15\n"));
1487        assert!(text.contains("dt: 2024-01-15T12:00:00\n"));
1488        assert!(text.contains("t: \"12:00:00\""));
1489    }
1490
1491    #[test]
1492    fn reads_loose_timestamp_and_normalizes_to_canonical_iso() {
1493        // Space-separated, single-digit month/day/hour, short fraction,
1494        // bare `Z` -- all normalized the way `datetime.isoformat()` would.
1495        // (Minute/second must still be exactly two digits -- that's PyYAML's
1496        // own `tag:yaml.org,2002:timestamp` regex, not a relaxation this port
1497        // invented; live-confirmed via the module doc comment's approach.)
1498        let doc = read_yaml("a: 2001-2-3 4:05:06.7 Z\n").unwrap();
1499        assert_eq!(
1500            *doc.root().get_one("a").unwrap().value().unwrap(),
1501            Scalar::Datetime("2001-02-03T04:05:06.700000+00:00".to_string())
1502        );
1503    }
1504
1505    #[test]
1506    fn a_single_digit_minute_or_second_is_not_timestamp_shaped_and_stays_a_string() {
1507        // PyYAML's own timestamp resolver requires an exactly-2-digit
1508        // minute/second even though hour/month/day may be 1 or 2 digits --
1509        // confirmed by this port's `normalize_timestamp` regex (mirroring
1510        // PyYAML's), not a looser rule this port invented.
1511        let doc = read_yaml("a: 2001-2-3 4:5:6\n").unwrap();
1512        assert_eq!(
1513            *doc.root().get_one("a").unwrap().value().unwrap(),
1514            Scalar::Str("2001-2-3 4:5:6".to_string())
1515        );
1516    }
1517
1518    #[test]
1519    fn reads_datetime_with_no_timezone_at_all() {
1520        // A full date+time with no `Z` and no explicit offset -- exercises
1521        // `normalize_timestamp`'s "no timezone information at all" arm,
1522        // distinct from both the explicit-offset and bare-`Z` cases.
1523        let doc = read_yaml("a: 2024-01-15T12:30:00\n").unwrap();
1524        assert_eq!(
1525            *doc.root().get_one("a").unwrap().value().unwrap(),
1526            Scalar::Datetime("2024-01-15T12:30:00".to_string())
1527        );
1528    }
1529
1530    #[test]
1531    fn reads_timestamp_with_explicit_offset() {
1532        let doc = read_yaml("a: 2001-12-14T21:59:43.10-05:00\n").unwrap();
1533        assert_eq!(
1534            *doc.root().get_one("a").unwrap().value().unwrap(),
1535            Scalar::Datetime("2001-12-14T21:59:43.100000-05:00".to_string())
1536        );
1537    }
1538
1539    #[test]
1540    fn a_string_that_merely_looks_like_a_short_date_but_isnt_shaped_right_stays_a_string() {
1541        let doc = read_yaml("a: 2024-1\n").unwrap();
1542        assert_eq!(
1543            *doc.root().get_one("a").unwrap().value().unwrap(),
1544            Scalar::Str("2024-1".to_string())
1545        );
1546    }
1547
1548    // ---------------------------------------------------------- reader: structure
1549
1550    #[test]
1551    fn reads_nested_mapping_and_sequence() {
1552        let doc = read_yaml("a:\n  b:\n    c: 1\nm:\n  - 1\n  - 2\n  - 3\n").unwrap();
1553        let root = doc.root();
1554        let a = root.get_one("a").unwrap();
1555        let b = a.get_one("b").unwrap();
1556        assert_eq!(
1557            *b.get_one("c").unwrap().value().unwrap(),
1558            Scalar::Int((1).into())
1559        );
1560        let ms = root.get("m");
1561        assert_eq!(ms.len(), 3);
1562        assert_eq!(*ms[2].value().unwrap(), Scalar::Int((3).into()));
1563    }
1564
1565    #[test]
1566    fn reads_flow_style_mapping_and_sequence() {
1567        let doc = read_yaml("a: {b: 1, c: 2}\nm: [1, 2, 3]\n").unwrap();
1568        let root = doc.root();
1569        let a = root.get_one("a").unwrap();
1570        assert_eq!(
1571            *a.get_one("b").unwrap().value().unwrap(),
1572            Scalar::Int((1).into())
1573        );
1574        assert_eq!(root.get("m").len(), 3);
1575    }
1576
1577    #[test]
1578    fn bare_top_level_sequence_is_a_document_error_not_a_parse_error() {
1579        let err = read_yaml("- 1\n- 2\n").unwrap_err();
1580        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
1581    }
1582
1583    #[test]
1584    fn sequence_of_sequences_is_a_document_error() {
1585        let err = read_yaml("m:\n  - [1, 2]\n").unwrap_err();
1586        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
1587    }
1588
1589    #[test]
1590    fn empty_input_reads_as_a_null_document() {
1591        let doc = read_yaml("").unwrap();
1592        assert_eq!(*doc.root().value().unwrap(), Scalar::Null);
1593    }
1594
1595    #[test]
1596    fn explicit_empty_document_marker_reads_as_a_null_document() {
1597        // An explicit `---` document-start marker with no content produces a
1598        // `DocumentEnd` event with nothing ever pushed onto the doc stack --
1599        // a different code path than a wholly empty input (which never
1600        // produces a `DocumentStart`/`DocumentEnd` pair at all).
1601        let doc = read_yaml("---\n").unwrap();
1602        assert_eq!(*doc.root().value().unwrap(), Scalar::Null);
1603    }
1604
1605    #[test]
1606    fn duplicate_mapping_keys_last_value_wins() {
1607        let doc = read_yaml("a: 1\nb: 2\na: 3\n").unwrap();
1608        let root = doc.root();
1609        assert_eq!(root.labels(), vec!["a".to_string(), "b".to_string()]);
1610        assert_eq!(
1611            *root.get_one("a").unwrap().value().unwrap(),
1612            Scalar::Int((3).into())
1613        );
1614    }
1615
1616    #[test]
1617    fn invalid_yaml_syntax_is_a_parse_error() {
1618        let err = read_yaml("a: [1, 2\n").unwrap_err();
1619        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
1620    }
1621
1622    #[test]
1623    fn multiple_documents_is_a_parse_error() {
1624        let err = read_yaml("a: 1\n---\nb: 2\n").unwrap_err();
1625        assert!(
1626            matches!(&err, OmnistError::Parse(e) if e.message.contains("single document")),
1627            "got {err:?}"
1628        );
1629    }
1630
1631    #[test]
1632    fn nesting_past_max_depth_is_a_document_error() {
1633        // Genuine indentation-nested mappings, not a flat run of overwritten
1634        // "a:" keys -- each level indented two spaces deeper than the last.
1635        let mut text = String::new();
1636        for i in 0..=crate::document::MAX_DEPTH {
1637            text.push_str(&"  ".repeat(i));
1638            text.push_str("a:\n");
1639        }
1640        let err = read_yaml(&text).unwrap_err();
1641        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
1642    }
1643
1644    #[test]
1645    fn integer_literal_over_digit_cap_is_rejected() {
1646        let text = format!("a: {}\n", "9".repeat(MAX_INT_DIGITS + 1));
1647        let err = read_yaml(&text).unwrap_err();
1648        assert!(
1649            matches!(&err, OmnistError::Parse(e) if e.message.contains("4300-digit")),
1650            "got {err:?}"
1651        );
1652    }
1653
1654    #[test]
1655    fn i64_min_round_trips_through_yaml() {
1656        // Regression test for issue #26's fuzz harness finding: the
1657        // previous `parse_int_literal` stripped the sign, then parsed the
1658        // *positive* magnitude as `i64`, which overflows for `i64::MIN`
1659        // (whose magnitude, 9223372036854775808, is one past `i64::MAX`)
1660        // even though the signed value itself is representable.
1661        let doc = read_yaml("a: -9223372036854775808\n").unwrap();
1662        assert_eq!(
1663            *doc.root().get_one("a").unwrap().value().unwrap(),
1664            Scalar::Int((i64::MIN).into())
1665        );
1666    }
1667
1668    #[test]
1669    fn positive_integer_one_past_i64_max_parses() {
1670        // Issue #104: no more `i64` ceiling -- one past `i64::MAX` is now
1671        // just a real, correctly-parsed value.
1672        let doc = read_yaml("a: 9223372036854775808\n").unwrap();
1673        let value = doc.root().child("a").unwrap().value().unwrap();
1674        assert_eq!(value, &Scalar::Int(num_bigint::BigInt::from(i64::MAX) + 1));
1675    }
1676
1677    #[test]
1678    fn negative_integer_one_past_i64_min_parses() {
1679        // Issue #104: same, on the negative side -- was the `checked_neg`
1680        // overflow arm, now just a real value one past `i64::MIN`.
1681        let doc = read_yaml("a: -9223372036854775809\n").unwrap();
1682        let value = doc.root().child("a").unwrap().value().unwrap();
1683        assert_eq!(value, &Scalar::Int(num_bigint::BigInt::from(i64::MIN) - 1));
1684    }
1685
1686    #[test]
1687    fn integer_literal_over_i64_range_parses() {
1688        let text = format!("a: {}\n", "9".repeat(20));
1689        let doc = read_yaml(&text).unwrap();
1690        let value = doc.root().child("a").unwrap().value().unwrap();
1691        assert_eq!(
1692            value,
1693            &Scalar::Int(num_bigint::BigInt::parse_bytes(b"99999999999999999999", 10).unwrap())
1694        );
1695    }
1696
1697    // ---------------------------------------------------------- reader: anchors/aliases
1698
1699    #[test]
1700    fn reads_anchor_and_alias_as_a_deep_copy() {
1701        let doc = read_yaml("a: &x [1, 2, 3]\nb: *x\n").unwrap();
1702        let root = doc.root();
1703        assert_eq!(root.get("a").len(), 3);
1704        assert_eq!(root.get("b").len(), 3);
1705        assert_eq!(*root.get("b")[1].value().unwrap(), Scalar::Int((2).into()));
1706    }
1707
1708    #[test]
1709    fn unknown_alias_is_a_parse_error() {
1710        let err = read_yaml("a: *nope\n").unwrap_err();
1711        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
1712    }
1713
1714    /// Issue #42: builds the classic "billion laughs" pattern -- each
1715    /// generation's anchor references the previous generation's alias
1716    /// twice, so after `n` generations there are `2^n` leaf scalars
1717    /// materialized from a source document only `n` lines long and only
1718    /// `n` levels deep (well under `crate::document::MAX_DEPTH == 200`).
1719    /// 24 generations reaches `2^24` (16,777,216) nodes -- comfortably over
1720    /// `MAX_MATERIALIZED_NODES` (100,000) so the guard trips partway
1721    /// through, and small enough that even the *unguarded* clone-everything
1722    /// behavior finishes (rather than hanging or exhausting memory) within
1723    /// this test's patience, which is what let this be captured red before
1724    /// the fix: before the fix this took over 4 seconds and allocated a
1725    /// many-million-node tree; after the fix it is rejected as a clean
1726    /// `ParseError` in well under a second (a debug build still has to
1727    /// walk/clone the last, still-under-the-ceiling generation, so this
1728    /// is not sub-millisecond, but it is bounded by the ceiling rather
1729    /// than by the attack's exponent), well before the full tree is
1730    /// ever materialized.
1731    fn billion_laughs_yaml(generations: usize) -> String {
1732        let mut out = String::new();
1733        out.push_str("a0: &a0 [x, x]\n");
1734        for i in 1..generations {
1735            out.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
1736        }
1737        out
1738    }
1739
1740    #[test]
1741    fn billion_laughs_alias_amplification_is_rejected_fast_issue_42() {
1742        let text = billion_laughs_yaml(24);
1743        // A source document of only 24 short lines.
1744        assert!(text.len() < 1000, "source text should be tiny: {text:?}");
1745
1746        let start = std::time::Instant::now();
1747        let err = read_yaml(&text).unwrap_err();
1748        let elapsed = start.elapsed();
1749
1750        assert!(
1751            matches!(&err, OmnistError::Parse(e) if e.message.contains("materializes more than")
1752                && e.message.contains("100000")),
1753            "expected a materialized-node-limit ParseError, got {err:?}"
1754        );
1755        assert!(
1756            elapsed < std::time::Duration::from_secs(5),
1757            "fix should reject the bomb almost immediately, took {elapsed:?}"
1758        );
1759    }
1760
1761    #[test]
1762    fn moderate_legitimate_nested_alias_reuse_still_works() {
1763        // A handful of anchors reused a few times each -- ordinary,
1764        // legitimate YAML anchor/alias usage (e.g. shared defaults), well
1765        // within any reasonable node-count limit. Must not be affected by
1766        // the issue #42 fix.
1767        let doc = read_yaml(
1768            "base: &base\n  x: 1\n  y: 2\na: *base\nb: *base\nc: *base\nlist: &list [1, 2, 3]\nd: *list\ne: *list\n",
1769        )
1770        .unwrap();
1771        let root = doc.root();
1772        for label in ["a", "b", "c"] {
1773            let node = root.get_one(label).unwrap();
1774            assert_eq!(
1775                *node.get_one("x").unwrap().value().unwrap(),
1776                Scalar::Int((1).into())
1777            );
1778            assert_eq!(
1779                *node.get_one("y").unwrap().value().unwrap(),
1780                Scalar::Int((2).into())
1781            );
1782        }
1783        for label in ["d", "e"] {
1784            assert_eq!(root.get(label).len(), 3);
1785        }
1786    }
1787
1788    // ---------------------------------------------------------- reader: merge keys
1789
1790    #[test]
1791    fn merge_key_from_a_mapping_merges_with_local_keys_winning() {
1792        let doc =
1793            read_yaml("base: &b\n  x: 1\n  y: 2\nchild:\n  <<: *b\n  y: 20\n  z: 3\n").unwrap();
1794        let child = doc.root().get_one("child").unwrap();
1795        assert_eq!(
1796            *child.get_one("x").unwrap().value().unwrap(),
1797            Scalar::Int((1).into())
1798        );
1799        assert_eq!(
1800            *child.get_one("y").unwrap().value().unwrap(),
1801            Scalar::Int((20).into()),
1802            "an explicit local key beats the merged-in value"
1803        );
1804        assert_eq!(
1805            *child.get_one("z").unwrap().value().unwrap(),
1806            Scalar::Int((3).into())
1807        );
1808    }
1809
1810    #[test]
1811    fn merge_key_from_a_sequence_of_mappings_merges_each_in_order() {
1812        // First-listed source wins a collision between the sources
1813        // themselves (YAML merge spec), matching PyYAML's own behavior.
1814        let doc =
1815            read_yaml("a: &a\n  x: 1\nb: &b\n  x: 2\n  y: 3\nchild:\n  <<: [*a, *b]\n").unwrap();
1816        let child = doc.root().get_one("child").unwrap();
1817        assert_eq!(
1818            *child.get_one("x").unwrap().value().unwrap(),
1819            Scalar::Int((1).into())
1820        );
1821        assert_eq!(
1822            *child.get_one("y").unwrap().value().unwrap(),
1823            Scalar::Int((3).into())
1824        );
1825    }
1826
1827    #[test]
1828    fn quoted_double_angle_bracket_key_is_a_literal_string_not_a_merge() {
1829        let doc = read_yaml("a:\n  \"<<\": 1\n").unwrap();
1830        let a = doc.root().get_one("a").unwrap();
1831        assert_eq!(
1832            *a.get_one("<<").unwrap().value().unwrap(),
1833            Scalar::Int((1).into())
1834        );
1835    }
1836
1837    // omnist-ts#46: a fuzz suite intermittently found a ParseError with a
1838    // non-map merge source -- pinned here as an explicit regression: merging
1839    // from a scalar must fail predictably (a clean ParseError), never panic
1840    // or behave inconsistently.
1841    #[test]
1842    fn merge_key_from_a_non_map_scalar_source_is_a_clean_parse_error_omnist_ts_46() {
1843        let err = read_yaml("child:\n  <<: 5\n  y: 2\n").unwrap_err();
1844        assert!(
1845            matches!(&err, OmnistError::Parse(e) if e.message.contains("merge key")),
1846            "got {err:?}"
1847        );
1848    }
1849
1850    #[test]
1851    fn merge_key_from_a_sequence_containing_a_scalar_is_a_clean_parse_error() {
1852        let err = read_yaml("child:\n  <<: [1, 2]\n").unwrap_err();
1853        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
1854    }
1855
1856    #[test]
1857    fn merge_source_with_a_non_scalar_key_is_a_clean_parse_error() {
1858        // Exercises the (structurally rare) branch where a merge source's
1859        // own key isn't a plain scalar (YAML's complex-mapping-key syntax) --
1860        // `scalar_key_text` returns `None` for it during merge de-duplication,
1861        // and the final scalar-key check in `raw_to_value` still catches it.
1862        let err = read_yaml("base: &b\n  ? [1, 2]\n  : 3\nchild:\n  <<: *b\n").unwrap_err();
1863        assert!(
1864            matches!(&err, OmnistError::Parse(e) if e.message.contains("mapping key must be a scalar")),
1865            "got {err:?}"
1866        );
1867    }
1868
1869    #[test]
1870    fn merge_key_from_an_alias_to_a_scalar_is_a_clean_parse_error() {
1871        let err = read_yaml("base: &b 5\nchild:\n  <<: *b\n").unwrap_err();
1872        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
1873    }
1874
1875    // ---------------------------------------------------------- reader: explicit tags
1876
1877    #[test]
1878    fn explicit_tags_construct_the_named_type_regardless_of_spelling() {
1879        let doc = read_yaml("a: !!str yes\nb: !!int \"5\"\nc: !!bool \"true\"\n").unwrap();
1880        let root = doc.root();
1881        assert_eq!(
1882            *root.get_one("a").unwrap().value().unwrap(),
1883            Scalar::Str("yes".to_string())
1884        );
1885        assert_eq!(
1886            *root.get_one("b").unwrap().value().unwrap(),
1887            Scalar::Int((5).into())
1888        );
1889        assert_eq!(
1890            *root.get_one("c").unwrap().value().unwrap(),
1891            Scalar::Bool(true)
1892        );
1893    }
1894
1895    #[test]
1896    fn unsupported_explicit_tag_is_a_parse_error() {
1897        let err = read_yaml("a: !!binary \"x\"\n").unwrap_err();
1898        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
1899    }
1900
1901    #[test]
1902    fn explicit_bool_tag_accepts_the_full_yaml_1_1_spelling_set_not_just_true_false() {
1903        // Live-confirmed against PyYAML (see module doc comment on
1904        // `explicit_tag_to_value`'s "bool" arm): `!!bool` uses the exact same
1905        // `bool_values` lookup regardless of implicit vs. explicit tagging,
1906        // so "yes"/"On"/"OFF" all construct via the explicit tag too.
1907        let doc = read_yaml("a: !!bool \"yes\"\nb: !!bool \"On\"\nc: !!bool \"OFF\"\n").unwrap();
1908        let root = doc.root();
1909        assert_eq!(
1910            *root.get_one("a").unwrap().value().unwrap(),
1911            Scalar::Bool(true)
1912        );
1913        assert_eq!(
1914            *root.get_one("b").unwrap().value().unwrap(),
1915            Scalar::Bool(true)
1916        );
1917        assert_eq!(
1918            *root.get_one("c").unwrap().value().unwrap(),
1919            Scalar::Bool(false)
1920        );
1921    }
1922
1923    #[test]
1924    fn invalid_explicit_bool_spelling_is_a_parse_error() {
1925        let err = read_yaml("a: !!bool \"nonsense\"\n").unwrap_err();
1926        assert!(
1927            matches!(&err, OmnistError::Parse(e) if e.message.contains("!!bool")),
1928            "got {err:?}"
1929        );
1930    }
1931
1932    #[test]
1933    fn bare_y_or_n_is_not_a_valid_explicit_bool_spelling() {
1934        // Live-confirmed: PyYAML's `bool_values` dict has no "y"/"n" keys
1935        // even though the plain-scalar implicit resolver never even tags
1936        // these as bool in the first place -- a bare "y"/"n" reaches this
1937        // arm only via an explicit `!!bool` tag, and PyYAML raises
1938        // (`KeyError` -> `ConstructorError`) rather than accepting it.
1939        let err = read_yaml("a: !!bool \"y\"\n").unwrap_err();
1940        assert!(
1941            matches!(&err, OmnistError::Parse(e) if e.message.contains("!!bool")),
1942            "got {err:?}"
1943        );
1944    }
1945
1946    #[test]
1947    fn bare_on_key_resolves_to_boolean_and_is_rejected_norway_problem() {
1948        // issue #88: YAML 1.1's core schema resolves a bare `on:` key to
1949        // the boolean `true`, same as it would as a value -- since a label
1950        // MUST be a string, this must be rejected as a DocumentError, not
1951        // silently kept as the literal string "on".
1952        let err = read_yaml("on:\n  push: true\n").unwrap_err();
1953        assert!(
1954            matches!(&err, OmnistError::Document(e) if e.path == "$"),
1955            "got {err:?}"
1956        );
1957    }
1958
1959    #[test]
1960    fn other_implicit_bool_and_null_key_spellings_are_also_rejected() {
1961        for key in ["off", "yes", "no", "Off", "YES", "~", "null", "true"] {
1962            let text = format!("{key}:\n  push: true\n");
1963            let err = read_yaml(&text).unwrap_err();
1964            assert!(
1965                matches!(&err, OmnistError::Document(_)),
1966                "key {key:?} got {err:?}"
1967            );
1968        }
1969    }
1970
1971    #[test]
1972    fn bare_y_or_n_key_is_not_a_bool_and_stays_a_string_label() {
1973        // Live-confirmed against PyYAML/omnist: unlike "yes"/"no", bare
1974        // "y"/"n" are NOT booleans under the implicit resolver, so these
1975        // stay ordinary string keys and parse successfully.
1976        let doc = read_yaml("y:\n  push: true\n").unwrap();
1977        assert!(doc.root().get_one("y").is_ok());
1978        let doc = read_yaml("n:\n  push: true\n").unwrap();
1979        assert!(doc.root().get_one("n").is_ok());
1980    }
1981
1982    #[test]
1983    fn sexagesimal_looking_key_also_resolves_and_is_rejected() {
1984        // Interaction check between #87 and #88: a key shaped like a
1985        // sexagesimal int must go through the same resolver and be
1986        // rejected as a non-string label, consistent with the bool case.
1987        let err = read_yaml("12:00:00:\n  push: true\n").unwrap_err();
1988        assert!(
1989            matches!(&err, OmnistError::Document(e) if e.path == "$"),
1990            "got {err:?}"
1991        );
1992    }
1993
1994    #[test]
1995    fn describe_non_string_key_renders_pythonic_spellings() {
1996        assert_eq!(describe_non_string_key(&Value::Bool(true)), "True");
1997        assert_eq!(describe_non_string_key(&Value::Bool(false)), "False");
1998        assert_eq!(describe_non_string_key(&Value::Null), "None");
1999        assert_eq!(
2000            describe_non_string_key(&Value::Int((43200).into())),
2001            "43200"
2002        );
2003        assert_eq!(describe_non_string_key(&Value::Float(1.5)), "1.5");
2004        // Whole-number floats must keep the `.0` (Python's `repr(1.0)` is
2005        // `'1.0'`, not `'1'` -- `f64::to_string()` alone drops it).
2006        assert_eq!(describe_non_string_key(&Value::Float(1.0)), "1.0");
2007        assert_eq!(describe_non_string_key(&Value::Float(-2.0)), "-2.0");
2008        // Structurally unreachable via the real call site (see the
2009        // function's doc comment), exercised directly here so the wildcard
2010        // fallback arm is real, tested code, not a dead branch.
2011        assert_eq!(
2012            describe_non_string_key(&Value::Str("x".to_string())),
2013            "Str(\"x\")"
2014        );
2015    }
2016
2017    #[test]
2018    fn int_and_float_shaped_mapping_keys_are_rejected_with_python_parity_messages() {
2019        // Direct coverage for the reviewer-flagged gap: int/float-shaped
2020        // (non-bool/null) keys are also generically rejected, and the
2021        // whole-number float case must render as Python's `1.0`, not `1`.
2022        let err = read_yaml("123:\n  a: 1\n").unwrap_err();
2023        assert!(
2024            matches!(&err, OmnistError::Document(e) if e.path == "$" && e.message.contains("123")),
2025            "got {err:?}"
2026        );
2027        let err = read_yaml("1.0:\n  a: 1\n").unwrap_err();
2028        assert!(
2029            matches!(&err, OmnistError::Document(e) if e.path == "$" && e.message.contains("1.0")),
2030            "got {err:?}"
2031        );
2032    }
2033
2034    #[test]
2035    fn a_non_scalar_mapping_key_is_a_clean_parse_error() {
2036        // YAML's complex-mapping-key syntax (`? ... : ...`) allows a
2037        // sequence/mapping as a key -- this model's edges are always
2038        // string-labeled, so this is rejected with a clean ParseError.
2039        let err = read_yaml("? [1, 2]\n: 3\n").unwrap_err();
2040        assert!(
2041            matches!(&err, OmnistError::Parse(e) if e.message.contains("mapping key must be a scalar")),
2042            "got {err:?}"
2043        );
2044    }
2045
2046    // ---------------------------------------------------------- writer
2047
2048    #[test]
2049    fn round_trips_every_scalar_kind() {
2050        let v = obj(vec![
2051            ("null", Value::Null),
2052            ("bool", Value::Bool(true)),
2053            ("int", Value::Int((42).into())),
2054            ("float", Value::Float(1.5)),
2055            ("str", Value::Str("hi".to_string())),
2056        ]);
2057        let doc = doc_of(v);
2058        let text = write_yaml(&doc, false, None).unwrap();
2059        let back = read_yaml(&text).unwrap();
2060        assert!(doc.eq_doc(&back));
2061    }
2062
2063    #[test]
2064    fn round_trips_integral_float_at_and_above_1e17_boundary_issue_46() {
2065        // Regression test for issue #46 (see json.rs's twin test for the
2066        // full explanation): an integral-valued float >= 1e17 used to
2067        // render as a bare digit run and re-read as `Scalar::Int`.
2068        for x in [1.0e17, 1.0e18, -1.23e17, 9.9e16_f64] {
2069            let doc = doc_of(obj(vec![("a", Value::Float(x))]));
2070            let text = write_yaml(&doc, false, None).unwrap();
2071            let back = read_yaml(&text).unwrap();
2072            assert_eq!(
2073                *back.root().get_one("a").unwrap().value().unwrap(),
2074                Scalar::Float(x),
2075                "x={x} text={text}"
2076            );
2077        }
2078    }
2079
2080    #[test]
2081    fn round_trips_nan_and_infinity_natively_no_adjustment_needed() {
2082        let v = obj(vec![
2083            ("a", Value::Float(f64::NAN)),
2084            ("b", Value::Float(f64::INFINITY)),
2085            ("c", Value::Float(f64::NEG_INFINITY)),
2086        ]);
2087        let doc = doc_of(v);
2088        let mut rep = WriteReport::new();
2089        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
2090        assert!(rep.is_empty());
2091        let back = read_yaml(&text).unwrap();
2092        assert!(
2093            matches!(back.root().get_one("a").unwrap().value().unwrap(), Scalar::Float(x) if x.is_nan())
2094        );
2095        assert_eq!(
2096            *back.root().get_one("b").unwrap().value().unwrap(),
2097            Scalar::Float(f64::INFINITY)
2098        );
2099        assert_eq!(
2100            *back.root().get_one("c").unwrap().value().unwrap(),
2101            Scalar::Float(f64::NEG_INFINITY)
2102        );
2103    }
2104
2105    #[test]
2106    fn round_trips_strings_that_look_like_other_scalar_kinds() {
2107        let v = obj(vec![
2108            ("a", Value::Str("yes".to_string())),
2109            ("b", Value::Str("null".to_string())),
2110            ("c", Value::Str("123".to_string())),
2111            ("d", Value::Str("1.5".to_string())),
2112            ("e", Value::Str("".to_string())),
2113            ("f", Value::Str("2024-01-15".to_string())),
2114        ]);
2115        let doc = doc_of(v);
2116        let text = write_yaml(&doc, false, None).unwrap();
2117        let back = read_yaml(&text).unwrap();
2118        assert!(doc.eq_doc(&back), "text was:\n{text}");
2119    }
2120
2121    #[test]
2122    fn round_trips_repeated_labels_as_a_yaml_sequence() {
2123        let doc = doc_of(obj(vec![(
2124            "m",
2125            Value::Array(vec![
2126                Value::Int((1).into()),
2127                Value::Int((2).into()),
2128                Value::Int((3).into()),
2129            ]),
2130        )]));
2131        let text = write_yaml(&doc, false, None).unwrap();
2132        let back = read_yaml(&text).unwrap();
2133        assert!(doc.eq_doc(&back));
2134    }
2135
2136    #[test]
2137    fn round_trips_nested_mappings_and_sequences_of_mappings() {
2138        let v = obj(vec![
2139            (
2140                "a",
2141                obj(vec![
2142                    ("b", Value::Int((1).into())),
2143                    ("c", Value::Int((2).into())),
2144                ]),
2145            ),
2146            (
2147                "items",
2148                Value::Array(vec![
2149                    obj(vec![
2150                        ("x", Value::Int((1).into())),
2151                        ("y", Value::Int((2).into())),
2152                    ]),
2153                    obj(vec![
2154                        ("x", Value::Int((3).into())),
2155                        ("y", Value::Int((4).into())),
2156                    ]),
2157                ]),
2158            ),
2159        ]);
2160        let doc = doc_of(v);
2161        let text = write_yaml(&doc, false, None).unwrap();
2162        let back = read_yaml(&text).unwrap();
2163        assert!(doc.eq_doc(&back), "text was:\n{text}");
2164    }
2165
2166    #[test]
2167    fn writes_empty_object_and_array_compactly() {
2168        let doc = doc_of(obj(vec![("o", Value::Object(IndexMap::new()))]));
2169        let text = write_yaml(&doc, false, None).unwrap();
2170        assert!(text.contains("o: {}"));
2171    }
2172
2173    #[test]
2174    fn nel_string_triggers_a_warning_and_still_round_trips() {
2175        let s = format!("a{}b", '\u{0085}');
2176        let doc = doc_of(obj(vec![("s", Value::Str(s.clone()))]));
2177        let mut rep = WriteReport::new();
2178        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
2179        assert_eq!(rep.len(), 1);
2180        assert_eq!(rep.adjustments()[0].code, "string.line-break-char");
2181        let back = read_yaml(&text).unwrap();
2182        assert_eq!(
2183            *back.root().get_one("s").unwrap().value().unwrap(),
2184            Scalar::Str(s)
2185        );
2186    }
2187
2188    #[test]
2189    fn strict_write_with_nel_raises_and_carries_the_report() {
2190        let s = format!("x{}y", '\u{0085}');
2191        let doc = doc_of(obj(vec![("s", Value::Str(s))]));
2192        let err = write_yaml(&doc, true, None).unwrap_err();
2193        let rep = err.report().expect("strict WriteError carries a report");
2194        assert_eq!(rep.len(), 1);
2195    }
2196
2197    #[test]
2198    fn strict_write_with_no_adjustments_succeeds() {
2199        let doc = doc_of(obj(vec![("a", Value::Int((1).into()))]));
2200        let text = write_yaml(&doc, true, None).unwrap();
2201        assert!(text.contains("a: 1"));
2202    }
2203
2204    #[test]
2205    fn check_yaml_reports_without_producing_output() {
2206        let s = format!("a{}b", '\u{0085}');
2207        let doc = doc_of(obj(vec![("s", Value::Str(s))]));
2208        let rep = check_yaml(&doc);
2209        assert_eq!(rep.len(), 1);
2210        assert_eq!(rep.adjustments()[0].path, "$.s");
2211    }
2212
2213    #[test]
2214    fn deeply_nested_document_write_reuses_doc_construction_depth_guard() {
2215        let mut v = Value::Int((0).into());
2216        for _ in 0..=crate::document::MAX_DEPTH {
2217            v = obj(vec![("a", v)]);
2218        }
2219        assert!(Doc::of(&v).is_err());
2220    }
2221
2222    // ---------------------------------------------------------- omnist-ts#43: wide document
2223
2224    // omnist-ts#43: YAML read was a ~3x-25x performance outlier vs OML/JSON on
2225    // wide documents. Not a hard timing assertion here (Rust is a different
2226    // performance regime) -- this is the *correctness*-under-scale angle:
2227    // a wide document reads and round-trips correctly, so a similarly bad
2228    // implementation (e.g. quadratic re-scanning per field) doesn't silently
2229    // produce wrong output even if it's slow.
2230    #[test]
2231    fn wide_document_smoke_test_reads_and_round_trips_every_field() {
2232        let n = 5_000;
2233        let mut text = String::new();
2234        for i in 0..n {
2235            text.push_str(&format!("field{i}: {i}\n"));
2236        }
2237        let doc = read_yaml(&text).unwrap();
2238        let root = doc.root();
2239        assert_eq!(root.labels().len(), n);
2240        for i in [0, n / 2, n - 1] {
2241            assert_eq!(
2242                *root.get_one(&format!("field{i}")).unwrap().value().unwrap(),
2243                Scalar::Int((i as i64).into())
2244            );
2245        }
2246        let out = write_yaml(&doc, false, None).unwrap();
2247        let back = read_yaml(&out).unwrap();
2248        assert!(doc.eq_doc(&back));
2249    }
2250
2251    #[test]
2252    fn wide_flat_sequence_smoke_test() {
2253        let n = 5_000;
2254        let mut text = String::from("m:\n");
2255        for i in 0..n {
2256            text.push_str(&format!("  - {i}\n"));
2257        }
2258        let doc = read_yaml(&text).unwrap();
2259        assert_eq!(doc.root().get("m").len(), n);
2260    }
2261
2262    // ---------------------------------------------------------- coverage: explicit tags/errors
2263
2264    #[test]
2265    fn invalid_explicit_float_literal_is_a_parse_error() {
2266        let err = read_yaml("a: !!float \"not-a-float\"\n").unwrap_err();
2267        assert!(
2268            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid float literal")),
2269            "got {err:?}"
2270        );
2271    }
2272
2273    #[test]
2274    fn explicit_float_tag_accepts_inf_and_nan_and_negative() {
2275        let doc = read_yaml("a: !!float \"-1.5\"\n").unwrap();
2276        assert_eq!(
2277            *doc.root().get_one("a").unwrap().value().unwrap(),
2278            Scalar::Float(-1.5)
2279        );
2280    }
2281
2282    // ---------------------------------------------------------- coverage: timestamp edge cases
2283
2284    // Live-confirmed against PyYAML (see `normalize_timestamp`'s doc
2285    // comment): a timestamp-*shaped* string naming a calendar/clock value
2286    // that doesn't exist is a clean ParseError, not a silent string
2287    // fallback -- `yaml.safe_load` calls `datetime.date`/`datetime.datetime`
2288    // construction on the captured fields, and that raises `ValueError`
2289    // for an out-of-range month/day/hour/minute/timezone, failing the
2290    // *entire document*, not just retyping this one scalar as a string.
2291
2292    #[test]
2293    fn timestamp_with_invalid_month_is_a_parse_error() {
2294        let err = read_yaml("a: 2024-13-01\n").unwrap_err();
2295        assert!(
2296            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
2297            "got {err:?}"
2298        );
2299    }
2300
2301    #[test]
2302    fn timestamp_with_year_zero_is_a_parse_error() {
2303        let err = read_yaml("a: 0000-01-01\n").unwrap_err();
2304        assert!(
2305            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
2306            "got {err:?}"
2307        );
2308    }
2309
2310    #[test]
2311    fn timestamp_with_a_day_that_doesnt_exist_in_the_month_is_a_parse_error() {
2312        // February 30th never exists, regardless of leap year -- exercises
2313        // the day-count upper bound (`schema::valid_ymd`'s `days_in_month`),
2314        // not just the "day < 1" lower bound.
2315        let err = read_yaml("a: 2024-02-30\n").unwrap_err();
2316        assert!(
2317            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
2318            "got {err:?}"
2319        );
2320    }
2321
2322    #[test]
2323    fn timestamp_february_29_on_a_non_leap_year_is_a_parse_error() {
2324        let err = read_yaml("a: 2023-02-29\n").unwrap_err();
2325        assert!(
2326            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
2327            "got {err:?}"
2328        );
2329    }
2330
2331    #[test]
2332    fn timestamp_february_29_on_a_leap_year_normalizes_fine() {
2333        let doc = read_yaml("a: 2024-02-29\n").unwrap();
2334        assert_eq!(
2335            *doc.root().get_one("a").unwrap().value().unwrap(),
2336            Scalar::Date("2024-02-29".to_string())
2337        );
2338    }
2339
2340    #[test]
2341    fn timestamp_with_out_of_range_hour_is_a_parse_error() {
2342        let err = read_yaml("a: 2024-01-01T25:00:00\n").unwrap_err();
2343        assert!(
2344            matches!(&err, OmnistError::Parse(e) if e.message.contains("time of day")),
2345            "got {err:?}"
2346        );
2347    }
2348
2349    #[test]
2350    fn timestamp_with_out_of_range_minute_is_a_parse_error() {
2351        let err = read_yaml("a: 2024-01-01T00:61:00\n").unwrap_err();
2352        assert!(
2353            matches!(&err, OmnistError::Parse(e) if e.message.contains("time of day")),
2354            "got {err:?}"
2355        );
2356    }
2357
2358    #[test]
2359    fn timestamp_with_out_of_range_timezone_offset_is_a_parse_error() {
2360        let err = read_yaml("a: 2024-01-01T00:00:00+25:00\n").unwrap_err();
2361        assert!(
2362            matches!(&err, OmnistError::Parse(e) if e.message.contains("timezone offset")),
2363            "got {err:?}"
2364        );
2365    }
2366
2367    #[test]
2368    fn timestamp_with_hour_only_timezone_offset_normalizes_with_zero_minutes() {
2369        let doc = read_yaml("a: 2024-01-01T00:00:00+05\n").unwrap();
2370        assert_eq!(
2371            *doc.root().get_one("a").unwrap().value().unwrap(),
2372            Scalar::Datetime("2024-01-01T00:00:00+05:00".to_string())
2373        );
2374    }
2375
2376    // ---------------------------------------------------------- coverage: writer
2377
2378    #[test]
2379    fn nel_in_a_label_triggers_a_warning_and_still_round_trips() {
2380        let label = format!("a{}b", '\u{0085}');
2381        let doc = doc_of(obj(vec![(label.as_str(), Value::Int((1).into()))]));
2382        let mut rep = WriteReport::new();
2383        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
2384        assert_eq!(rep.len(), 1);
2385        assert_eq!(rep.adjustments()[0].code, "string.line-break-char");
2386        let back = read_yaml(&text).unwrap();
2387        assert_eq!(
2388            *back.root().get_one(&label).unwrap().value().unwrap(),
2389            Scalar::Int((1).into())
2390        );
2391    }
2392
2393    #[test]
2394    fn write_node_on_a_bare_empty_array_writes_the_flow_empty_token() {
2395        // `Doc`'s own model never produces a *standalone* empty-array node
2396        // (an empty `Value::Array` under a key expands into zero edges at
2397        // construction time -- see `document.rs`'s own note on this) --
2398        // white-box exercising `write_node`'s empty-array arm directly here,
2399        // the same "test the arm directly since the public API can't reach
2400        // it" pattern `document.rs`'s `internal_edges_mut_rejects_a_leaf_directly`
2401        // and `json.rs`'s handling of the analogous case establish.
2402        let mut out = String::new();
2403        write_node(&Value::Array(vec![]), 0, &mut out, true);
2404        assert_eq!(out, "[]\n");
2405    }
2406
2407    #[test]
2408    fn round_trips_strings_needing_every_quoting_trigger() {
2409        let cases = [
2410            "-leading-dash",
2411            "?leading-question",
2412            ":leading-colon",
2413            ",leading-comma",
2414            "[leading-bracket",
2415            "]leading-bracket",
2416            "{leading-brace",
2417            "}leading-brace",
2418            "#leading-hash",
2419            "&leading-amp",
2420            "*leading-star",
2421            "!leading-bang",
2422            "|leading-pipe",
2423            ">leading-gt",
2424            "'leading-quote",
2425            "\"leading-dquote",
2426            "%leading-percent",
2427            "@leading-at",
2428            "`leading-backtick",
2429            " leading-space",
2430            "trailing-space ",
2431            "embedded: colon-space",
2432            "trailing-colon:",
2433            "embedded #hash-space",
2434            "line\nbreak",
2435            "tab\ttab",
2436            "quote\"quote",
2437            "back\\slash",
2438            "control\u{01}char",
2439        ];
2440        for s in cases {
2441            let doc = doc_of(obj(vec![("s", Value::Str(s.to_string()))]));
2442            let text = write_yaml(&doc, false, None).unwrap();
2443            let back = read_yaml(&text).unwrap();
2444            assert!(
2445                doc.eq_doc(&back),
2446                "round trip failed for {s:?}, text was:\n{text}"
2447            );
2448        }
2449    }
2450
2451    #[test]
2452    fn write_scalar_value_panics_on_a_non_leaf_value() {
2453        // `write_scalar_value` is only ever called on a leaf via the public
2454        // `write_yaml` path (every call site passes a scalar) -- white-box
2455        // confirming the documented invariant directly, same rationale as
2456        // the empty-array test above.
2457        let result = std::panic::catch_unwind(|| {
2458            let mut out = String::new();
2459            write_scalar_value(&Value::Object(IndexMap::new()), &mut out);
2460        });
2461        assert!(result.is_err());
2462    }
2463
2464    // ---------------------------------------------------------- coverage: Builder white-box
2465
2466    // The following four tests drive `Builder`'s private event-handling
2467    // methods directly rather than through `read_yaml`/`Parser`. Each covers
2468    // a branch that is structurally unreachable via any real YAML text --
2469    // confirmed empirically (see `on_event_impl`'s doc comment and this
2470    // module's doc comment on the crate-choice rationale): `yaml_rust2`'s own
2471    // scanner already rejects an alias to an undefined anchor, and every
2472    // document's event stream always nests its root exactly once before a
2473    // `DocumentEnd`/pushes only `Sequence`/`Mapping` as containers. Testing
2474    // the arm directly (rather than leaving it an untested dead branch)
2475    // matches `document.rs`'s `internal_edges_mut_rejects_a_leaf_directly`
2476    // precedent.
2477    use yaml_rust2::parser::Event;
2478    use yaml_rust2::scanner::Marker;
2479
2480    /// `Marker` has no public constructor, so a real one is captured from an
2481    /// actual (trivial) parse -- the tests below only care about the event
2482    /// stream reaching `Builder`'s methods, not about which `Marker` value
2483    /// they carry.
2484    fn test_marker() -> Marker {
2485        struct Capture(Option<Marker>);
2486        impl MarkedEventReceiver for Capture {
2487            fn on_event(&mut self, _ev: Event, mark: Marker) {
2488                self.0.get_or_insert(mark);
2489            }
2490        }
2491        let mut cap = Capture(None);
2492        Parser::new("x".chars()).load(&mut cap, false).unwrap();
2493        cap.0
2494            .expect("a trivial scalar document always emits at least one event")
2495    }
2496
2497    #[test]
2498    fn builder_document_end_with_an_empty_stack_pushes_a_null_scalar() {
2499        let mut b = Builder::new();
2500        b.on_event_impl(Event::DocumentEnd, test_marker());
2501        assert_eq!(b.docs.len(), 1);
2502        assert!(matches!(&b.docs[0], Raw::Scalar(s, TScalarStyle::Plain, None) if s.is_empty()));
2503    }
2504
2505    #[test]
2506    #[should_panic(expected = "a single document's stack never nests more than one root")]
2507    fn builder_document_end_with_more_than_one_stack_entry_panics() {
2508        let mut b = Builder::new();
2509        b.doc_stack
2510            .push((Raw::Scalar(String::new(), TScalarStyle::Plain, None), 0));
2511        b.doc_stack
2512            .push((Raw::Scalar(String::new(), TScalarStyle::Plain, None), 0));
2513        b.on_event_impl(Event::DocumentEnd, test_marker());
2514    }
2515
2516    #[test]
2517    #[should_panic(expected = "a Scalar is never a container on doc_stack")]
2518    fn builder_insert_onto_a_scalar_container_panics() {
2519        let mut b = Builder::new();
2520        b.doc_stack
2521            .push((Raw::Scalar("x".to_string(), TScalarStyle::Plain, None), 0));
2522        b.insert(
2523            Raw::Scalar("y".to_string(), TScalarStyle::Plain, None),
2524            0,
2525            test_marker(),
2526        );
2527    }
2528
2529    #[test]
2530    #[should_panic(expected = "yaml_rust2's scanner rejects an alias to an undefined anchor")]
2531    fn builder_alias_to_an_unknown_anchor_panics() {
2532        // Calling `on_event_impl` directly bypasses `yaml_rust2`'s own
2533        // scanner validation (which -- live-confirmed via
2534        // `yaml_rust2::YamlLoader::load_from_str("a: *nope\n")` -- always
2535        // catches this first for real input), exercising the `.expect()`'s
2536        // documented invariant on purpose.
2537        let mut b = Builder::new();
2538        b.on_event_impl(Event::Alias(999), test_marker());
2539    }
2540
2541    // -------------------------------------------------- coverage: issue #42 node-count guard
2542
2543    /// Calling `charge` directly once `self.error` is already `Some(..)`
2544    /// exercises the short-circuit at the very top of `charge` (returns
2545    /// `false` without touching `node_count` or overwriting `error`) --
2546    /// unreachable through `on_event_impl` alone, since its own top-of-
2547    /// function `self.error.is_some()` check means `charge` is never
2548    /// invoked a second time once tripped in the normal event-driven path.
2549    #[test]
2550    fn charge_after_already_tripped_is_a_pure_no_op() {
2551        let mut b = Builder::new();
2552        assert!(!b.charge(MAX_MATERIALIZED_NODES + 1, test_marker()));
2553        let first_error = format!("{:?}", b.error);
2554        let count_after_first_trip = b.node_count;
2555        assert!(!b.charge(1, test_marker()));
2556        assert_eq!(
2557            format!("{:?}", b.error),
2558            first_error,
2559            "error must not change"
2560        );
2561        assert_eq!(
2562            b.node_count, count_after_first_trip,
2563            "node_count must not change once already tripped"
2564        );
2565    }
2566
2567    /// Drives the guard to trip from `Event::SequenceStart`'s `charge` call
2568    /// specifically (as opposed to the size-charging `Event::Alias` path
2569    /// the top-level `billion_laughs_...` integration test exercises),
2570    /// confirming every one of the three plain-node `charge` call sites is
2571    /// independently reachable.
2572    #[test]
2573    fn sequence_start_can_itself_trip_the_node_count_guard() {
2574        let mut b = Builder::new();
2575        b.node_count = MAX_MATERIALIZED_NODES;
2576        b.on_event_impl(Event::SequenceStart(0, None), test_marker());
2577        assert!(
2578            matches!(&b.error, Some(e) if e.message.contains("materializes more than")),
2579            "got {:?}",
2580            b.error
2581        );
2582        // The doc_stack push it guards must not have happened.
2583        assert!(b.doc_stack.is_empty());
2584    }
2585
2586    /// Same as above, for `Event::MappingStart`.
2587    #[test]
2588    fn mapping_start_can_itself_trip_the_node_count_guard() {
2589        let mut b = Builder::new();
2590        b.node_count = MAX_MATERIALIZED_NODES;
2591        b.on_event_impl(Event::MappingStart(0, None), test_marker());
2592        assert!(
2593            matches!(&b.error, Some(e) if e.message.contains("materializes more than")),
2594            "got {:?}",
2595            b.error
2596        );
2597        assert!(b.doc_stack.is_empty());
2598        assert!(b.key_stack.is_empty());
2599    }
2600
2601    /// Same as above, for `Event::Scalar`.
2602    #[test]
2603    fn scalar_can_itself_trip_the_node_count_guard() {
2604        let mut b = Builder::new();
2605        b.node_count = MAX_MATERIALIZED_NODES;
2606        b.on_event_impl(
2607            Event::Scalar("x".to_string(), TScalarStyle::Plain, 0, None),
2608            test_marker(),
2609        );
2610        assert!(
2611            matches!(&b.error, Some(e) if e.message.contains("materializes more than")),
2612            "got {:?}",
2613            b.error
2614        );
2615        // The insert it guards must not have happened.
2616        assert!(b.doc_stack.is_empty());
2617    }
2618
2619    // ---------------------------------------------------------- coverage: writer edge cases
2620
2621    #[test]
2622    fn round_trips_an_integral_float_with_trailing_dot_zero() {
2623        let doc = doc_of(obj(vec![("f", Value::Float(2.0))]));
2624        let text = write_yaml(&doc, false, None).unwrap();
2625        assert!(text.contains("f: 2.0"), "text was:\n{text}");
2626        let back = read_yaml(&text).unwrap();
2627        assert!(doc.eq_doc(&back));
2628    }
2629
2630    #[test]
2631    fn quoted_string_escapes_every_special_character_in_one_pass() {
2632        // A leading `-` forces quoting; once quoted, this exercises every
2633        // remaining escape arm in `write_yaml_string`'s per-char loop in a
2634        // single string: backslash, tab, double-quote, and a low control
2635        // character (NEL and newline are already covered by dedicated tests).
2636        let s = "-a\tb\\c\"d\u{01}e";
2637        let doc = doc_of(obj(vec![("s", Value::Str(s.to_string()))]));
2638        let text = write_yaml(&doc, false, None).unwrap();
2639        let back = read_yaml(&text).unwrap();
2640        assert!(doc.eq_doc(&back), "text was:\n{text}");
2641    }
2642
2643    #[test]
2644    fn write_seq_child_on_a_bare_non_empty_array_item_writes_it_on_the_next_line() {
2645        // The Document model never produces a sequence item that is itself a
2646        // *standalone* non-empty array (sequence-of-sequences has no
2647        // labeled-edge form -- see `sequence_of_sequences_is_a_document_error`
2648        // above), so this arm of `write_seq_child` is unreachable via any
2649        // real `Doc` -- white-box exercising it directly, same rationale as
2650        // `write_node_on_a_bare_empty_array_writes_the_flow_empty_token`.
2651        let mut out = String::new();
2652        write_seq_child(
2653            &Value::Array(vec![Value::Int((1).into()), Value::Int((2).into())]),
2654            0,
2655            &mut out,
2656        );
2657        assert_eq!(out, "\n  - 1\n  - 2\n");
2658    }
2659
2660    #[test]
2661    fn test_yaml_merge_key_deduplication_order() {
2662        let src = r#"
2663base1: &b1
2664  a: 1
2665  b: 2
2666  dup: "from_b1"
2667base2: &b2
2668  b: 20
2669  c: 3
2670  dup: "from_b2"
2671child:
2672  <<: [*b1, *b2]
2673  own: 0
2674  a: 100
2675"#;
2676        let doc = read_yaml(src).unwrap();
2677        let root = doc.root();
2678        let child = root.get_one("child").unwrap();
2679        let labels = child.labels();
2680        // own keys first ("own", "a"), then merged keys in order ("b", "dup", "c")
2681        assert_eq!(labels, vec!["own", "a", "b", "dup", "c"]);
2682        assert_eq!(
2683            *child.get_one("a").unwrap().value().unwrap(),
2684            Scalar::Int((100).into())
2685        );
2686        assert_eq!(
2687            *child.get_one("b").unwrap().value().unwrap(),
2688            Scalar::Int((2).into())
2689        );
2690        assert_eq!(
2691            *child.get_one("dup").unwrap().value().unwrap(),
2692            Scalar::Str("from_b1".into())
2693        );
2694        assert_eq!(
2695            *child.get_one("c").unwrap().value().unwrap(),
2696            Scalar::Int((3).into())
2697        );
2698    }
2699
2700    // ---------------------------------------------- D-3: format.interleaving-lost
2701    // (issue #156, spec Sec8.3.8. Same MUST as formats-json/basic/
2702    // cross-label-interleaving-lost-and-reported -- YAML shares JSON's
2703    // `to_grouped` grouping, so the loss and the report are identical
2704    // in shape; see json.rs's mirrored tests.)
2705
2706    fn interleaved_doc() -> Doc {
2707        Doc::from_raw(crate::document::RawNode::Edges(vec![
2708            (
2709                "m".to_string(),
2710                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
2711            ),
2712            (
2713                "x".to_string(),
2714                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
2715            ),
2716            (
2717                "m".to_string(),
2718                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
2719            ),
2720        ]))
2721        .unwrap()
2722    }
2723
2724    fn contiguous_repeat_doc() -> Doc {
2725        Doc::from_raw(crate::document::RawNode::Edges(vec![
2726            (
2727                "m".to_string(),
2728                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
2729            ),
2730            (
2731                "m".to_string(),
2732                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
2733            ),
2734            (
2735                "x".to_string(),
2736                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
2737            ),
2738        ]))
2739        .unwrap()
2740    }
2741
2742    #[test]
2743    fn reports_interleaving_lost_on_write() {
2744        let doc = interleaved_doc();
2745        let mut report = crate::report::WriteReport::new();
2746        write_yaml(&doc, false, Some(&mut report)).unwrap();
2747        let adjustments = report.adjustments();
2748        assert_eq!(adjustments.len(), 1);
2749        assert_eq!(adjustments[0].path, "$");
2750        assert_eq!(adjustments[0].code, "format.interleaving-lost");
2751        assert_eq!(adjustments[0].severity, crate::report::Severity::Warning);
2752    }
2753
2754    #[test]
2755    fn check_yaml_reports_interleaving_lost() {
2756        let rep = check_yaml(&interleaved_doc());
2757        assert_eq!(rep.adjustments().len(), 1);
2758        assert_eq!(rep.adjustments()[0].code, "format.interleaving-lost");
2759    }
2760
2761    #[test]
2762    fn contiguous_repeated_label_does_not_report_interleaving_lost() {
2763        let doc = contiguous_repeat_doc();
2764        let mut report = crate::report::WriteReport::new();
2765        write_yaml(&doc, false, Some(&mut report)).unwrap();
2766        assert!(report.is_empty());
2767        assert!(check_yaml(&doc).is_empty());
2768    }
2769}