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
92mod alias;
93#[cfg(test)]
94mod alias_tests;
95
96use std::collections::HashMap;
97
98use yaml_rust2::parser::{Event, MarkedEventReceiver, Parser, Tag};
99use yaml_rust2::scanner::{Marker, ScanError, TScalarStyle};
100
101use crate::WriteError;
102use crate::document::{Doc, Value};
103use crate::error::{DocumentError, OmnistError, ParseError};
104use crate::formats::float_fmt;
105use crate::formats::string_escape::{YAML_ESCAPES, write_quoted};
106use crate::limits::{IntGuard, Limits, Resolved, int_exceeds};
107use crate::report::{Severity, WriteReport};
108use indexmap::IndexMap;
109use num_bigint::BigInt;
110
111// Same guard, same constant as `json.rs`'s/`oml.rs`'s -- see this
112// module's doc comment. Constant and message constructors now live in
113// [`crate::formats::int_cap`] (issue #49).
114
115/// The default bound on the total number of nodes materialized while
116/// rebuilding the event stream (issue #42, "YAML alias/anchor expansion
117/// amplification"); the bound in force is [`Limits::max_nodes`].
118/// A node is what the spec's D-9 counts: a **container**, a mapping or a
119/// sequence (an edge list). Keys and scalar values are not nodes, so a flat
120/// mapping of any number of scalar entries is one node (omnist-rs#189).
121/// The number is the spec's reference default, 1 000 000
122/// (docs/02-document-model.md §2.4); it is [`Limits::max_nodes`].
123///
124/// An `*alias` reference counts every container of the subtree it clones,
125/// so a chain of anchors each referencing the previous generation's alias
126/// multiple times ("billion laughs") is still charged its real, amplified
127/// size. The value-slot amplification of aliases and merges is bounded
128/// separately and first, by D-18 and D-22, before any tree is built.
129#[cfg(test)]
130const MAX_MATERIALIZED_NODES: usize = crate::limits::DEFAULT_MAX_NODES as usize;
131
132/// Counts the containers (mappings and sequences) in `node`'s subtree,
133/// including `node` itself when it is one. Used to charge an alias reference
134/// for the containers it clones, not just "one node". Scalars are not nodes.
135fn count_nodes(node: &Raw) -> usize {
136    match node {
137        Raw::Scalar(..) => 0,
138        Raw::Sequence(items) => 1 + items.iter().map(count_nodes).sum::<usize>(),
139        Raw::Mapping(entries) => {
140            1 + entries
141                .iter()
142                .map(|(k, v)| count_nodes(k) + count_nodes(v))
143                .sum::<usize>()
144        }
145    }
146}
147
148// ============================================================== Reader
149
150/// The raw shape `yaml_rust2`'s event stream is rebuilt into: a scalar with
151/// its original text and style (style is what `resolve_plain_scalar` and
152/// merge-key detection both need, and what `yaml_rust2`'s own `Yaml` enum
153/// throws away by pre-resolving plain scalars with its own, PyYAML-incompatible
154/// rules -- see this module's doc comment), or an ordered sequence/mapping.
155#[derive(Debug, Clone)]
156enum Raw {
157    Scalar(String, TScalarStyle, Option<Tag>),
158    Sequence(Vec<Raw>),
159    Mapping(Vec<(Raw, Raw)>),
160}
161
162/// Rebuilds a [`Raw`] tree from `yaml_rust2`'s parser event stream, resolving
163/// aliases against anchors seen so far (anchors always precede their aliases
164/// in a valid YAML document). Structurally mirrors `yaml_rust2::yaml::YamlLoader`
165/// (the crate's own reference event-to-tree builder), adapted to keep scalar
166/// style/tag information `YamlLoader`'s `Yaml` throws away.
167struct Builder {
168    doc_stack: Vec<(Raw, usize)>,
169    key_stack: Vec<Option<Raw>>,
170    anchor_map: HashMap<usize, Raw>,
171    docs: Vec<Raw>,
172    /// Running total of [`Raw`] nodes materialized so far -- see
173    /// [`MAX_MATERIALIZED_NODES`].
174    node_count: usize,
175    /// Set once the node cap ([`Limits::max_nodes`]) is exceeded; further
176    /// events are ignored (no more work is done building an already-rejected
177    /// tree) and this is surfaced as `document.limit.nodes` at `$` once
178    /// parsing finishes.
179    error: Option<DocumentError>,
180    /// The limits in force.
181    limits: Resolved,
182    /// Set when an alias refers to an anchor that is not complete yet (a
183    /// self-referential definition, D-20): `document.limit.alias-expansion`
184    /// at `$`; or when block/flow nesting runs far past
185    /// [`crate::document::MAX_DEPTH`]: `document.limit.depth` at `$`. Like
186    /// `error`, once set the receiver stops building.
187    self_reference: Option<DocumentError>,
188    /// Set when a node is tagged `!!pairs`, `!!omap` or `!!set` (C-13):
189    /// `format.tag-forbidden` at `$`. Like `error`, once set the receiver
190    /// stops building.
191    tag_forbidden: Option<DocumentError>,
192}
193
194impl Builder {
195    fn new(limits: &Resolved) -> Self {
196        Builder {
197            limits: *limits,
198            doc_stack: Vec::new(),
199            key_stack: Vec::new(),
200            anchor_map: HashMap::new(),
201            docs: Vec::new(),
202            node_count: 0,
203            error: None,
204            self_reference: None,
205            tag_forbidden: None,
206        }
207    }
208
209    /// Whether opening one more collection would nest far beyond
210    /// [`crate::document::MAX_DEPTH`]. The margin keeps every boundary case
211    /// the later `check_write_depth` stages decide exactly as before; only
212    /// nesting they would reject anyway is caught here.
213    fn too_deep(&self) -> bool {
214        self.doc_stack.len() > self.limits.max_depth + 8
215    }
216
217    /// Charges `n` newly-materialized nodes against the running total,
218    /// setting `self.error` (if not already set) the first time the total
219    /// exceeds [`MAX_MATERIALIZED_NODES`]. Returns `true` if the caller
220    /// should proceed (still under the limit), `false` if it tripped (or
221    /// had already tripped) and should skip the work it was about to do.
222    fn charge(&mut self, n: usize, _mark: Marker) -> bool {
223        if self.error.is_some() {
224            return false;
225        }
226        self.node_count = self.node_count.saturating_add(n);
227        if self.node_count > self.limits.max_nodes {
228            self.error = Some(DocumentError::with_code(
229                "$",
230                "document.limit.nodes",
231                format!(
232                    "invalid YAML: document materializes more than \
233                     {} nodes (security: unbounded anchor/alias \
234                     expansion can amplify a small document into an enormous tree, \
235                     independent of nesting depth)",
236                    self.limits.max_nodes
237                ),
238            ));
239            return false;
240        }
241        true
242    }
243
244    fn insert(&mut self, node: Raw, aid: usize, _mark: Marker) {
245        if aid > 0 {
246            self.anchor_map.insert(aid, node.clone());
247        }
248        match self.doc_stack.last_mut() {
249            None => self.doc_stack.push((node, aid)),
250            Some((Raw::Sequence(items), _)) => items.push(node),
251            Some((Raw::Mapping(_), _)) => {
252                let cur_key = self
253                    .key_stack
254                    .last_mut()
255                    .expect("a Mapping is only ever pushed alongside a matching key_stack entry");
256                match cur_key.take() {
257                    None => *cur_key = Some(node),
258                    Some(k) => {
259                        if let Some((Raw::Mapping(entries), _)) = self.doc_stack.last_mut() {
260                            entries.push((k, node));
261                        }
262                    }
263                }
264            }
265            Some((Raw::Scalar(..), _)) => {
266                // A scalar is never pushed onto doc_stack as a container
267                // (only Sequence/Mapping are, in on_event's SequenceStart/
268                // MappingStart arms) -- this arm is structurally unreachable.
269                unreachable!("a Scalar is never a container on doc_stack")
270            }
271        }
272    }
273
274    /// Handles one parser event. The crate's own scanner rejects an alias
275    /// whose anchor was never defined (`ScanError: found unknown anchor`,
276    /// surfaced through [`scan_error_to_parse_error`]), but it DOES emit the
277    /// `Event::Alias` for a reference made inside its own still-open anchor
278    /// (`a: &A\n  b: *A`): the anchor map has no entry yet. That is a
279    /// self-referential definition, recorded in `self_reference` (D-20) and
280    /// never materialized.
281    fn on_event_impl(&mut self, ev: Event, mark: Marker) {
282        // Once tripped, stop doing any further tree-building work -- the
283        // document is already rejected, and continuing to clone/insert
284        // subsequent alias references would just keep paying the same
285        // amplified cost this guard exists to avoid.
286        if self.error.is_some() || self.self_reference.is_some() || self.tag_forbidden.is_some() {
287            return;
288        }
289        match ev {
290            Event::Nothing | Event::StreamStart | Event::StreamEnd | Event::DocumentStart => {}
291            Event::DocumentEnd => match self.doc_stack.len() {
292                0 => self
293                    .docs
294                    .push(Raw::Scalar(String::new(), TScalarStyle::Plain, None)),
295                1 => self.docs.push(self.doc_stack.pop().unwrap().0),
296                _ => unreachable!("a single document's stack never nests more than one root"),
297            },
298            Event::SequenceStart(_, Some(ref tag))
299            | Event::MappingStart(_, Some(ref tag))
300            | Event::Scalar(_, _, _, Some(ref tag))
301                if is_forbidden_tag(Some(tag)) =>
302            {
303                self.tag_forbidden = Some(tag_forbidden_error(tag));
304            }
305            Event::SequenceStart(..) | Event::MappingStart(..) if self.too_deep() => {
306                // Nesting this far past the depth cap is rejected here,
307                // while the tree is still shallow enough to drop: yaml-rust2
308                // has no recursion limit for BLOCK collections (`- - - ...`
309                // or one more indent per line), so a small input could
310                // otherwise build a tree deep enough to overflow the stack
311                // when it is dropped or walked.
312                self.self_reference = Some(self.limits.depth_error());
313            }
314            Event::SequenceStart(aid, _) => {
315                if !self.charge(1, mark) {
316                    return;
317                }
318                self.doc_stack.push((Raw::Sequence(Vec::new()), aid));
319            }
320            Event::SequenceEnd => {
321                let (node, aid) = self.doc_stack.pop().expect("matched by SequenceStart");
322                self.insert(node, aid, mark);
323            }
324            Event::MappingStart(aid, _) => {
325                if !self.charge(1, mark) {
326                    return;
327                }
328                self.doc_stack.push((Raw::Mapping(Vec::new()), aid));
329                self.key_stack.push(None);
330            }
331            Event::MappingEnd => {
332                let (node, aid) = self.doc_stack.pop().expect("matched by MappingStart");
333                self.key_stack.pop();
334                self.insert(node, aid, mark);
335            }
336            Event::Scalar(v, style, aid, tag) => {
337                // A scalar is not a node (D-9): it is not charged.
338                self.insert(Raw::Scalar(v, style, tag), aid, mark);
339            }
340            Event::Alias(id) => {
341                // Count the *whole subtree's* size before cloning it -- an
342                // alias amplifies by the size of what it references, not
343                // by one node, so charging anything less would let the
344                // exponential "billion laughs" pattern through uncounted.
345                // The borrow of `anchor_map` ends with this block, so
346                // `self.charge` below can take `&mut self` freely.
347                // An alias to an anchor with no entry yet: yaml_rust2 rejects an
348                // alias to a NEVER-defined anchor itself, but it emits the
349                // Alias event for a reference made while its own anchor is
350                // still being built (`a: &A\n  b: *A`, `a: &A\n  <<: *A`).
351                // That is a self-referential definition, which D-20 says MUST
352                // be rejected with `document.limit.alias-expansion` (path `$`,
353                // E-4a) and never materialized.
354                let Some(referenced) = self.anchor_map.get(&id) else {
355                    self.self_reference = Some(DocumentError::with_code(
356                        "$",
357                        "document.limit.alias-expansion",
358                        "an alias refers to an anchor that is not yet complete: a \
359                         self-referential definition has unbounded expansion (D-20)",
360                    ));
361                    return;
362                };
363                let n = count_nodes(referenced);
364                if !self.charge(n, mark) {
365                    return;
366                }
367                let node = self
368                    .anchor_map
369                    .get(&id)
370                    .cloned()
371                    .expect("checked above: the anchor_map entry exists for this id");
372                self.insert(node, 0, mark);
373            }
374        }
375    }
376}
377
378impl MarkedEventReceiver for Builder {
379    fn on_event(&mut self, ev: Event, mark: Marker) {
380        self.on_event_impl(ev, mark);
381    }
382}
383
384/// The reference default for the maximum alias expansion factor (spec
385/// §2.4, D-18): the reader rejects any mapping or sequence whose
386/// materialized-to-written value-slot ratio exceeds it. Documented per D-11.
387pub const DEFAULT_MAX_ALIAS_EXPANSION: u32 = 50;
388
389/// The largest maximum alias expansion factor [`read_yaml_with`] accepts.
390/// A larger value is refused rather than clamped (D-10: the limit must be
391/// finite and meaningful; it is a denial-of-service bound, not a tuning knob
392/// without an upper end).
393pub const MAX_ALIAS_EXPANSION_CEILING: u32 = 10_000;
394
395/// The reference default for the maximum expanded size (spec §2.4.1, D-22):
396/// the reader rejects an input that contains an alias or a merge key and
397/// whose document root materializes more value slots (`W(root)`) than this,
398/// with `document.limit.expanded-size`. Documented per D-11.
399pub const DEFAULT_MAX_EXPANDED_SLOTS: u32 = 1_000_000;
400
401/// The largest maximum expanded size [`read_yaml_with`] accepts: the spec's
402/// recommended ceiling (D-22, "SHOULD NOT configure one above 10 000 000
403/// without measuring"). A larger value is refused rather than clamped, as for
404/// [`MAX_ALIAS_EXPANSION_CEILING`].
405pub const MAX_EXPANDED_SLOTS_CEILING: u32 = 10_000_000;
406
407/// Options for [`read_yaml_with`].
408///
409/// ```
410/// use omnist::formats::yaml::{read_yaml_with, YamlReadOptions};
411///
412/// let opts = YamlReadOptions::default().with_max_alias_expansion(200);
413/// let doc = read_yaml_with("a: 1\n", &opts).unwrap();
414/// assert_eq!(doc.to_raw(), omnist::formats::yaml::read_yaml("a: 1\n").unwrap().to_raw());
415/// ```
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417#[non_exhaustive]
418pub struct YamlReadOptions {
419    /// The maximum alias expansion factor `E = W / S` any one mapping or
420    /// sequence (anchored or not, the document root included) may reach
421    /// before the reader rejects the input with
422    /// `document.limit.alias-expansion` (spec §2.4.1, D-18).
423    ///
424    /// `0` selects [`DEFAULT_MAX_ALIAS_EXPANSION`] (50): a zero or unset value
425    /// never widens the limit. Values above [`MAX_ALIAS_EXPANSION_CEILING`]
426    /// (10 000) are rejected by [`read_yaml_with`]. Note the
427    /// `(keys + 2) / 3` behaviour of merges: a mapping that merges an
428    /// `n`-key anchor and writes one key of its own has `E ~ (n + 2) / 3`, so
429    /// the default of 50 rejects merging a 150-key anchor into such a
430    /// mapping; raise this option for configurations that large.
431    pub max_alias_expansion: u32,
432    /// The maximum expanded size `W(root)`, in value slots, an input that
433    /// contains at least one alias or merge key may reach before the reader
434    /// rejects it with `document.limit.expanded-size` (spec §2.4.1, D-22).
435    /// `W(root)` equal to the maximum is accepted; one past is rejected.
436    ///
437    /// This is independent of [`YamlReadOptions::max_alias_expansion`]: the
438    /// ratio limit bounds amplification, this one bounds absolute size, and
439    /// an input can pass one and fail the other (when both fail the ratio
440    /// code is reported). An input with neither an alias nor a merge key is
441    /// exempt however large: a plain file of two million slots passes, and
442    /// adding one alias subjects it to this cap. `0` selects
443    /// [`DEFAULT_MAX_EXPANDED_SLOTS`] (1 000 000); values above
444    /// [`MAX_EXPANDED_SLOTS_CEILING`] (10 000 000) are rejected by
445    /// [`read_yaml_with`].
446    pub max_expanded_slots: u32,
447    /// The safety limits every format shares (nesting depth, node count,
448    /// integer digits; spec section 2.4): see [`Limits`].
449    pub limits: Limits,
450}
451
452impl Default for YamlReadOptions {
453    fn default() -> Self {
454        YamlReadOptions {
455            max_alias_expansion: DEFAULT_MAX_ALIAS_EXPANSION,
456            max_expanded_slots: DEFAULT_MAX_EXPANDED_SLOTS,
457            limits: Limits::default(),
458        }
459    }
460}
461
462impl YamlReadOptions {
463    /// These options with [`YamlReadOptions::max_alias_expansion`] set to `n`
464    /// (the struct is `#[non_exhaustive]`, so a downstream crate builds it
465    /// from [`Default`] with this).
466    #[must_use]
467    pub fn with_max_alias_expansion(mut self, n: u32) -> Self {
468        self.max_alias_expansion = n;
469        self
470    }
471
472    /// These options with [`YamlReadOptions::max_expanded_slots`] set to `n`.
473    #[must_use]
474    pub fn with_max_expanded_slots(mut self, n: u32) -> Self {
475        self.max_expanded_slots = n;
476        self
477    }
478
479    /// These options with [`YamlReadOptions::limits`] set to `limits`.
480    #[must_use]
481    pub fn with_limits(mut self, limits: Limits) -> Self {
482        self.limits = limits;
483        self
484    }
485
486    /// The maximum expanded size these options select: the configured value,
487    /// or [`DEFAULT_MAX_EXPANDED_SLOTS`] when it is `0`.
488    pub fn effective_max_expanded_slots(&self) -> u32 {
489        if self.max_expanded_slots == 0 {
490            DEFAULT_MAX_EXPANDED_SLOTS
491        } else {
492            self.max_expanded_slots
493        }
494    }
495
496    /// The maximum expansion factor these options select: the configured
497    /// value, or [`DEFAULT_MAX_ALIAS_EXPANSION`] when it is `0`.
498    pub fn effective_max_alias_expansion(&self) -> u32 {
499        if self.max_alias_expansion == 0 {
500            DEFAULT_MAX_ALIAS_EXPANSION
501        } else {
502            self.max_alias_expansion
503        }
504    }
505
506    /// Checks the options: [`YamlReadOptions::max_alias_expansion`] must not
507    /// exceed [`MAX_ALIAS_EXPANSION_CEILING`], nor
508    /// [`YamlReadOptions::max_expanded_slots`] [`MAX_EXPANDED_SLOTS_CEILING`],
509    /// and [`YamlReadOptions::limits`] must pass [`Limits::validate`].
510    /// [`read_yaml_with`] calls this
511    /// first and returns its error unchanged.
512    pub fn validate(&self) -> Result<(), DocumentError> {
513        if self.max_alias_expansion > MAX_ALIAS_EXPANSION_CEILING {
514            return Err(DocumentError::new(
515                "$",
516                format!(
517                    "max_alias_expansion {} exceeds the ceiling {MAX_ALIAS_EXPANSION_CEILING} \
518                     (0 selects the default {DEFAULT_MAX_ALIAS_EXPANSION})",
519                    self.max_alias_expansion
520                ),
521            ));
522        }
523        if self.max_expanded_slots > MAX_EXPANDED_SLOTS_CEILING {
524            return Err(DocumentError::new(
525                "$",
526                format!(
527                    "max_expanded_slots {} exceeds the ceiling {MAX_EXPANDED_SLOTS_CEILING} \
528                     (0 selects the default {DEFAULT_MAX_EXPANDED_SLOTS})",
529                    self.max_expanded_slots
530                ),
531            ));
532        }
533        self.limits.validate()?;
534        Ok(())
535    }
536}
537
538/// Drives `parser` to the end of the stream, running the alias expansion
539/// and merge-shape check (D-18/D-18a/D-19/D-20/D-22) on every event and
540/// buffering the events, which are replayed into [`Builder`] only once the
541/// whole stream has passed -- so no alias is ever expanded before the check
542/// has accepted the input.
543///
544/// This is what `Parser::load(builder, true)` does, minus its recursion.
545/// `load` walks nested collections with mutually recursive `load_node` /
546/// `load_sequence` / `load_mapping` calls and no depth limit for BLOCK
547/// collections (its scanner only limits flow nesting): about 15 KB of `- - -
548/// ...` or one more indent per line overflowed the stack and aborted the
549/// process, before any of this crate's depth guards could run. The event
550/// stream itself ([`Parser::next_token`]) is a flat state machine, so pulling
551/// events directly is safe at any depth; the checker keeps its frames on the
552/// heap, and once nesting runs far past the depth cap buffering stops (the
553/// replay reports the depth error from the events already kept), so a hostile
554/// nesting costs a scan, not a stack.
555///
556/// The checker keeps reading after a limit violation, because a malformed
557/// merge later in the stream is a syntax error that wins over every
558/// `document.limit.*` code (D-18a); it stops buffering, as nothing will be
559/// replayed. A scan error wins over both, as every earlier self-reference
560/// error did: the scan always runs to the end.
561///
562/// One difference from `load`, which cleared the parser's anchor table
563/// between documents: this does not (that table is private), so a later
564/// document's alias to an earlier document's anchor now resolves instead of
565/// being an unknown-anchor scan error. A multi-document stream is rejected
566/// afterwards either way, as `parse.codec-syntax`.
567fn feed_events(
568    parser: &mut Parser<std::str::Chars<'_>>,
569    check: &mut alias::AliasCheck,
570    max_depth: usize,
571) -> Result<Vec<(Event, Marker)>, ScanError> {
572    let mut events = Vec::new();
573    let mut halted = false;
574    loop {
575        let (ev, mark) = parser.next_token()?;
576        let done = ev == Event::StreamEnd;
577        check.event(&ev, (mark.line(), mark.col() + 1));
578        if !check.failed() && !halted {
579            halted = check.depth() > max_depth + 10;
580            events.push((ev, mark));
581        }
582        if done {
583            return Ok(events);
584        }
585    }
586}
587
588fn scan_error_to_parse_error(e: &ScanError) -> ParseError {
589    let mark = e.marker();
590    ParseError::codec_syntax(mark.line(), mark.col() + 1, format!("invalid YAML: {e}"))
591}
592
593/// Parse YAML text into a [`Doc`], with the default [`YamlReadOptions`].
594///
595/// Exactly one YAML document is accepted (matching Python's `yaml.safe_load`,
596/// which raises on a stream containing more than one `---`-separated
597/// document); an empty/blank input parses as a `Null` document, also matching
598/// `yaml.safe_load("")` returning `None`. A bare top-level sequence, a
599/// sequence nested directly inside another sequence, or nesting past
600/// [`crate::document::MAX_DEPTH`] all surface as
601/// [`crate::error::DocumentError`] (via [`Doc::of`]), matching `json.rs`'s
602/// identical `read_json` behavior.
603///
604/// Input whose alias expansion factor exceeds
605/// [`DEFAULT_MAX_ALIAS_EXPANSION`] (spec D-18), or that holds a
606/// self-referential anchor (D-20), is rejected with
607/// `document.limit.alias-expansion` at `$` before anything is materialized;
608/// see [`read_yaml_with`] to change the maximum.
609pub fn read_yaml(text: &str) -> Result<Doc, OmnistError> {
610    read_yaml_with(text, &YamlReadOptions::default())
611}
612
613/// Parse YAML text into a [`Doc`] with explicit [`YamlReadOptions`]; see
614/// [`read_yaml`] for everything else.
615///
616/// Returns the [`YamlReadOptions::validate`] error unchanged when the options
617/// are out of range.
618pub fn read_yaml_with(text: &str, options: &YamlReadOptions) -> Result<Doc, OmnistError> {
619    options.validate()?;
620    // D-23: the size is checked first, on the input as received (BOM
621    // counted), for every YAML input, an alias-free one included.
622    options
623        .limits
624        .resolve_validated()
625        .check_input_size(text.len())?;
626    // D-15/D-21: one leading BOM is stripped, a second is rejected at 1:1.
627    // Without this pre-check yaml-rust2 would swallow (or keep) the second
628    // mark silently; YAML 1.2 itself admits a leading BOM, so the library
629    // cannot be relied on to fail (D-21).
630    let text = crate::bom::strip_leading_bom(text)
631        .map_err(|_| ParseError::codec_syntax(1, 1, crate::bom::DOUBLED_BOM_MESSAGE))?;
632    let mut parser = Parser::new(text.chars());
633    let mut check = alias::AliasCheck::new(
634        u64::from(options.effective_max_alias_expansion()),
635        u64::from(options.effective_max_expanded_slots()),
636    );
637    let limits = options.limits.resolve_validated();
638    let events = feed_events(&mut parser, &mut check, limits.max_depth)
639        .map_err(|e| scan_error_to_parse_error(&e))?;
640    if let Some(e) = check.into_error() {
641        return Err(e);
642    }
643    let mut builder = Builder::new(&limits);
644    for (ev, mark) in events {
645        builder.on_event(ev, mark);
646    }
647    if let Some(e) = builder.tag_forbidden {
648        return Err(e.into());
649    }
650    if let Some(e) = builder.self_reference {
651        return Err(e.into());
652    }
653    if let Some(e) = builder.error {
654        return Err(e.into());
655    }
656    if builder.docs.len() > 1 {
657        return Err(ParseError::codec_syntax(
658            1,
659            1,
660            "invalid YAML: expected a single document in the stream, found more than one",
661        )
662        .into());
663    }
664    let raw = builder.docs.into_iter().next().unwrap_or(Raw::Scalar(
665        String::new(),
666        TScalarStyle::Plain,
667        None,
668    ));
669    let mut dropped: Vec<Raw> = Vec::new();
670    let resolved = resolve_merges(&raw, 0, limits.max_depth, &mut dropped)?;
671    let mut guard = IntGuard::new(limits.max_int_digits);
672    let value = raw_to_value(&resolved, &Trail::Root, &mut guard)?;
673    // A merged value a mapping's own key overrode is never part of the
674    // Document, but an integer in it past the digit limit is still refused
675    // (D-13): convert what was dropped so the guard sees it. The work is
676    // bounded by the size of the (already expansion-limited) tree.
677    for node in &dropped {
678        note_dropped_integers(node, &mut guard);
679    }
680    let doc = Doc::of_resolved(&value, limits).map_err(|e| limits.whole_document(e))?;
681    // A placeholder for an over-cap literal that never reached the Document
682    // is still a limit error (D-13).
683    guard.finish(&limits)?;
684    Ok(doc)
685}
686
687/// Recursively expands every `<<` merge key, depth-guarded the same way
688/// `document.rs`'s own construction path is (an alias can smuggle in an
689/// already-deep subtree before `Doc::of` ever sees it).
690fn resolve_merges(
691    node: &Raw,
692    depth: usize,
693    max_depth: usize,
694    dropped: &mut Vec<Raw>,
695) -> Result<Raw, OmnistError> {
696    crate::document::check_write_depth(depth, "$", max_depth)?;
697    match node {
698        Raw::Scalar(..) => Ok(node.clone()),
699        Raw::Sequence(items) => {
700            let mut out = Vec::with_capacity(items.len());
701            for item in items {
702                out.push(resolve_merges(item, depth + 1, max_depth, dropped)?);
703            }
704            Ok(Raw::Sequence(out))
705        }
706        Raw::Mapping(entries) => Ok(Raw::Mapping(resolve_mapping(
707            entries, depth, max_depth, dropped,
708        )?)),
709    }
710}
711
712/// Resolves one mapping's entries, flattening every `<<` merge key in.
713///
714/// The order is normative (`docs/formats/yaml.md`, "Merged entries come
715/// first, in source order"): the merged entries come first, in the order the
716/// aliases are written (`<<: [*a, *b]` is `a`'s entries, then `b`'s -- never
717/// reversed, whatever a YAML library's own constructor does), then the
718/// referring mapping's own entries. Key collisions resolve to ONE edge at the
719/// position of the key's first occurrence in that order: it carries the
720/// referring mapping's own value when the mapping writes one (whether the
721/// local key is written before or after the `<<`), otherwise the value from
722/// the EARLIEST alias that supplies it.
723fn resolve_mapping(
724    entries: &[(Raw, Raw)],
725    depth: usize,
726    max_depth: usize,
727    dropped: &mut Vec<Raw>,
728) -> Result<Vec<(Raw, Raw)>, OmnistError> {
729    let mut merged_from: Vec<(Raw, Raw)> = Vec::new();
730    let mut own: Vec<(Raw, Raw)> = Vec::new();
731    let mut written: std::collections::HashSet<&str> = std::collections::HashSet::new();
732    for (k, v) in entries {
733        if is_merge_key(k) {
734            merged_from.extend(merge_source_entries(v, depth, max_depth, dropped)?);
735        } else {
736            // C-12: a key written twice in one mapping is malformed YAML
737            // (`a` and `"a"` are one key). A key a merge supplies is not a
738            // repeat of one written here: the written one overrides it.
739            if let Some(label) = scalar_key_text(k)
740                && !written.insert(label)
741            {
742                return Err(ParseError::codec_syntax(
743                    1,
744                    1,
745                    format!("invalid YAML: duplicate mapping key {label:?}"),
746                )
747                .into());
748            }
749            own.push((
750                resolve_merges(k, depth + 1, max_depth, dropped)?,
751                resolve_merges(v, depth + 1, max_depth, dropped)?,
752            ));
753        }
754    }
755    // Merged entries, first supplier of a key wins (this also makes a
756    // repeated alias in one sequence contribute once).
757    let mut result: Vec<(Raw, Raw)> = Vec::with_capacity(merged_from.len() + own.len());
758    let mut position: std::collections::HashMap<String, usize> =
759        std::collections::HashMap::with_capacity(merged_from.len());
760    for (k, v) in merged_from {
761        match scalar_key_text(&k) {
762            Some(label) if position.contains_key(label) => dropped.push(v),
763            Some(label) => {
764                position.insert(label.to_string(), result.len());
765                result.push((k, v));
766            }
767            None => result.push((k, v)),
768        }
769    }
770    // The mapping's own entries: a key a merge already supplied keeps the
771    // merged key's position but takes the local value; any other key is
772    // appended. A key written twice among the own keys was refused above.
773    for (k, v) in own {
774        match scalar_key_text(&k).and_then(|label| position.get(label).copied()) {
775            Some(at) => dropped.push(std::mem::replace(&mut result[at].1, v)),
776            None => result.push((k, v)),
777        }
778    }
779    Ok(result)
780}
781
782/// Resolves every scalar of a dropped subtree so an integer past the digit
783/// limit leaves its mark on `guard` (which [`IntGuard::finish`] then refuses).
784/// Any other outcome of the resolution is irrelevant for a value the Document
785/// does not hold.
786fn note_dropped_integers(node: &Raw, guard: &mut IntGuard) {
787    match node {
788        Raw::Scalar(text, style, tag) => {
789            let _ = scalar_to_value(text, *style, tag.as_ref(), guard);
790        }
791        Raw::Sequence(items) => {
792            for item in items {
793                note_dropped_integers(item, guard);
794            }
795        }
796        Raw::Mapping(entries) => {
797            for (_, v) in entries {
798                note_dropped_integers(v, guard);
799            }
800        }
801    }
802}
803
804/// A merge key's own key text, for de-duplication purposes -- non-scalar
805/// (or non-string-scalar) keys never collide with anything by this scheme,
806/// matching the fact that only string-labeled fields exist in this model.
807fn scalar_key_text(k: &Raw) -> Option<&str> {
808    match k {
809        Raw::Scalar(s, _, _) => Some(s.as_str()),
810        _ => None,
811    }
812}
813
814/// Whether a scalar is a merge key (D-27): a plain `<<`, or a `<<` tagged
815/// `!!merge` (`tag:yaml.org,2002:merge`) in any style. A quoted `<<` and a
816/// `!!str <<` are the string `<<`, an ordinary key: an explicit tag decides
817/// what a `<<` is, as YAML 1.1 resolves the merge key by its tag.
818fn is_merge_scalar(text: &str, style: TScalarStyle, tag: Option<&Tag>) -> bool {
819    text == "<<"
820        && match tag {
821            None => style == TScalarStyle::Plain,
822            Some(t) => is_yaml_tag(t, "merge"),
823        }
824}
825
826/// Whether `tag` is the standard YAML tag `!!suffix` (`tag:yaml.org,2002:suffix`).
827fn is_yaml_tag(tag: &Tag, suffix: &str) -> bool {
828    tag.handle == "tag:yaml.org,2002:" && tag.suffix == suffix
829}
830
831/// Whether `tag` is one of the three collection tags C-13 refuses: `!!pairs`,
832/// `!!omap` and `!!set`, on a node of any kind.
833fn is_forbidden_tag(tag: Option<&Tag>) -> bool {
834    tag.is_some_and(|t| {
835        t.handle == "tag:yaml.org,2002:" && matches!(t.suffix.as_str(), "pairs" | "omap" | "set")
836    })
837}
838
839/// The `format.tag-forbidden` refusal (C-13): well-formed YAML outside the
840/// supported profile, so not a syntax error. Its path is `$`.
841fn tag_forbidden_error(tag: &Tag) -> DocumentError {
842    DocumentError::with_code(
843        "$",
844        "format.tag-forbidden",
845        format!(
846            "the YAML tag '!!{}' is outside the supported profile (use a plain sequence or \
847             mapping instead)",
848            tag.suffix
849        ),
850    )
851}
852
853/// Is `k` a `<<` merge-key marker? See [`is_merge_scalar`].
854fn is_merge_key(k: &Raw) -> bool {
855    matches!(k, Raw::Scalar(s, style, tag) if is_merge_scalar(s, *style, tag.as_ref()))
856}
857
858/// The `(key, value)` pairs a merge key's value contributes: a mapping
859/// contributes its own entries directly; a sequence contributes every
860/// member's entries in order. The alias check has already refused every other
861/// shape (a scalar, a scalar or sequence member, `parse.codec-syntax`, spec
862/// D-18a) before this runs, so only mappings reach here.
863fn merge_source_entries(
864    v: &Raw,
865    depth: usize,
866    max_depth: usize,
867    dropped: &mut Vec<Raw>,
868) -> Result<Vec<(Raw, Raw)>, OmnistError> {
869    match v {
870        // A merged mapping is itself resolved first, so a nested `<<` inside
871        // it flattens recursively and the grandparent's entries arrive first.
872        Raw::Mapping(entries) => resolve_mapping(entries, depth + 1, max_depth, dropped),
873        Raw::Sequence(items) => {
874            let mut out = Vec::new();
875            for item in items {
876                let Raw::Mapping(entries) = item else {
877                    unreachable!("the alias check refused a non-mapping merge member")
878                };
879                out.extend(resolve_mapping(entries, depth + 2, max_depth, dropped)?);
880            }
881            Ok(out)
882        }
883        Raw::Scalar(..) => unreachable!("the alias check refused a scalar merge value"),
884    }
885}
886
887/// Where [`raw_to_value`] is in the tree, as a Document path, kept as a
888/// linked chain and rendered only when a diagnostic needs it (a path string
889/// per node would cost an allocation per scalar of a large document that
890/// never errs).
891enum Trail<'a> {
892    /// The document root, `$`.
893    Root,
894    /// A mapping entry's label under the parent position.
895    Key(&'a Trail<'a>, &'a str),
896    /// The `i`-th of a label's repeated edges under the parent position
897    /// (E-10: only when the label occurs more than once, i.e. the sequence
898    /// has more than one item).
899    Index(&'a Trail<'a>, usize),
900}
901
902impl Trail<'_> {
903    /// The Document path of this position (`$`, `$.a`, `$.a[1].b`).
904    fn render(&self) -> String {
905        match self {
906            Trail::Root => "$".to_string(),
907            Trail::Key(parent, key) => crate::document::join(&parent.render(), key),
908            Trail::Index(parent, i) => format!("{}[{i}]", parent.render()),
909        }
910    }
911}
912
913/// Turn a (merge-resolved) [`Raw`] tree into a [`Value`], applying scalar-tag
914/// resolution to every leaf along the way. `trail` is where `node` sits, so a
915/// diagnostic about it (a non-string mapping key) carries its Document path.
916fn raw_to_value(node: &Raw, trail: &Trail<'_>, guard: &mut IntGuard) -> Result<Value, OmnistError> {
917    match node {
918        Raw::Scalar(text, style, tag) => Ok(scalar_to_value(text, *style, tag.as_ref(), guard)?),
919        Raw::Sequence(items) => {
920            let mut out = Vec::with_capacity(items.len());
921            for (i, item) in items.iter().enumerate() {
922                // E-10: no index when the label occurs once.
923                let value = if items.len() == 1 {
924                    raw_to_value(item, trail, guard)?
925                } else {
926                    raw_to_value(item, &Trail::Index(trail, i), guard)?
927                };
928                out.push(value);
929            }
930            Ok(Value::Array(out))
931        }
932        Raw::Mapping(entries) => {
933            let mut map: IndexMap<String, Value> = IndexMap::new();
934            for (k, v) in entries {
935                let key = match k {
936                    // Route the key through the same implicit-type
937                    // resolver mapping *values* already go through
938                    // (`scalar_to_value`) -- issue #88, the "Norway
939                    // problem": YAML 1.1's core schema resolves a bare
940                    // `on`/`off`/`yes`/`no`/`true`/`false`/`~`/etc. key the
941                    // same way it would as a value. A label MUST be a
942                    // string, so a key that resolves to anything else
943                    // (bool, null, int, float) is rejected here, matching
944                    // Python's `DocumentError: object key <value> is not a
945                    // string` (live-confirmed).
946                    Raw::Scalar(s, style, tag) => {
947                        match scalar_to_value(s, *style, tag.as_ref(), guard)? {
948                            Value::Str(s) => s,
949                            // An over-long integer used as a key: the digit
950                            // limit is what refuses it, not the missing label.
951                            Value::Int(i) if int_exceeds(&i, guard.max()) => {
952                                return Err(DocumentError::with_code(
953                                    trail.render(),
954                                    "document.limit.int-digits",
955                                    crate::formats::int_cap::over_cap_message(
956                                        "invalid YAML: ",
957                                        guard.max(),
958                                    ),
959                                )
960                                .into());
961                            }
962                            other => {
963                                // `document.unlabeled-element`: the key has no
964                                // string label to become an edge. Its path is
965                                // that of the mapping the key is in (`$` for
966                                // the top-level mapping).
967                                return Err(DocumentError::with_code(
968                                    trail.render(),
969                                    "document.unlabeled-element",
970                                    format!(
971                                        "object key {} is not a string",
972                                        describe_non_string_key(&other)
973                                    ),
974                                )
975                                .into());
976                            }
977                        }
978                    }
979                    _ => {
980                        return Err(ParseError::codec_syntax(
981                            1,
982                            1,
983                            "invalid YAML: a mapping key must be a scalar",
984                        )
985                        .into());
986                    }
987                };
988                // No key repeats here: `resolve_mapping` refused a repeated
989                // key (C-12) before this tree was built.
990                let value = raw_to_value(v, &Trail::Key(trail, &key), guard)?;
991                map.insert(key, value);
992            }
993            Ok(Value::Object(map))
994        }
995    }
996}
997
998/// Renders a non-string [`Value`] the way Python's reference implementation
999/// spells it in its `DocumentError` message (`True`/`False`/`None`/a plain
1000/// number), for parity with the diagnostic text PyYAML/`omnist`'s own
1001/// `object key <value> is not a string` error uses -- live-confirmed:
1002/// `omnist.read_yaml("on:\n  push: true\n")` raises `object key True is not
1003/// a string`. Only `Bool`/`Null`/`Int`/`Float` are structurally reachable
1004/// here -- `scalar_to_value` (this function's only caller's source) never
1005/// produces `Str` (excluded by the caller's own match arm before this runs)
1006/// or `Array`/`Object` (it operates on a single `Raw::Scalar` leaf) -- the
1007/// wildcard arm is an unreachable-but-harmless fallback, not a real case.
1008fn describe_non_string_key(v: &Value) -> String {
1009    match v {
1010        Value::Bool(true) => "True".to_string(),
1011        Value::Bool(false) => "False".to_string(),
1012        Value::Null => "None".to_string(),
1013        Value::Int(i) => i.to_string(),
1014        Value::Float(f) => {
1015            // Rust's `f64::to_string()` renders whole numbers like `1.0`
1016            // as `"1"`, but Python's `repr(float)` always keeps the `.0`
1017            // -- confirmed live: `repr(1.0)` is `'1.0'`, not `'1'`.
1018            let s = f.to_string();
1019            if f.is_finite() && !s.contains('.') && !s.contains('e') && !s.contains('E') {
1020                format!("{s}.0")
1021            } else {
1022                s
1023            }
1024        }
1025        other => format!("{other:?}"),
1026    }
1027}
1028
1029fn scalar_to_value(
1030    text: &str,
1031    style: TScalarStyle,
1032    tag: Option<&Tag>,
1033    guard: &mut IntGuard,
1034) -> Result<Value, ParseError> {
1035    if let Some(t) = tag
1036        && t.handle == "tag:yaml.org,2002:"
1037    {
1038        return explicit_tag_to_value(text, &t.suffix, guard);
1039    }
1040    if style != TScalarStyle::Plain {
1041        return Ok(Value::Str(text.to_string()));
1042    }
1043    resolve_plain_scalar(text, guard)
1044}
1045
1046/// Constructs a [`Value`] from an explicit standard YAML tag
1047/// (`!!str`/`!!int`/`!!float`/`!!bool`/`!!null`), matching what PyYAML's
1048/// `SafeConstructor` supports for these five. Any other explicit tag (a
1049/// custom `!!` type, `!!seq`/`!!map` forced onto a scalar, an unknown handle)
1050/// is rejected with a [`ParseError`] -- PyYAML's `SafeConstructor` itself
1051/// raises `ConstructorError: could not determine a constructor for the tag`
1052/// for anything it doesn't recognize, so this is matching behavior, not an
1053/// arbitrarily narrower one.
1054fn explicit_tag_to_value(
1055    text: &str,
1056    suffix: &str,
1057    guard: &mut IntGuard,
1058) -> Result<Value, ParseError> {
1059    match suffix {
1060        "str" => Ok(Value::Str(text.to_string())),
1061        "null" => Ok(Value::Null),
1062        // PyYAML's `SafeConstructor.construct_yaml_bool` looks up
1063        // `node.value.lower()` in `bool_values = {"yes": True, "no": False,
1064        // "true": True, "false": False, "on": True, "off": False}`
1065        // regardless of how the `!!bool` tag got attached (explicit or
1066        // implicit) -- live-confirmed (see module doc comment): `!!bool
1067        // "YES"`/`"On"`/`"OFF"` all construct successfully, not just
1068        // true/false spellings; bare `y`/`n`/`1`/`0` do not (`KeyError`).
1069        "bool" => match text.to_ascii_lowercase().as_str() {
1070            "true" | "yes" | "on" => Ok(Value::Bool(true)),
1071            "false" | "no" | "off" => Ok(Value::Bool(false)),
1072            _ => Err(ParseError::codec_syntax(
1073                1,
1074                1,
1075                format!("invalid YAML: {text:?} is not a valid !!bool value"),
1076            )),
1077        },
1078        "int" => parse_int_literal(text, guard),
1079        "float" => parse_float_literal(text),
1080        other => Err(ParseError::codec_syntax(
1081            1,
1082            1,
1083            format!("invalid YAML: unsupported explicit tag '!!{other}'"),
1084        )),
1085    }
1086}
1087
1088/// PyYAML's `tag:yaml.org,2002:bool` implicit-resolver spelling set --
1089/// live-confirmed (see module doc comment): `yes`/`no`/`on`/`off` (and case
1090/// variants) count as booleans; bare `y`/`n` do not.
1091fn resolve_plain_scalar(text: &str, guard: &mut IntGuard) -> Result<Value, ParseError> {
1092    match text {
1093        "" | "~" | "null" | "Null" | "NULL" => return Ok(Value::Null),
1094        "true" | "True" | "TRUE" | "yes" | "Yes" | "YES" | "on" | "On" | "ON" => {
1095            return Ok(Value::Bool(true));
1096        }
1097        "false" | "False" | "FALSE" | "no" | "No" | "NO" | "off" | "Off" | "OFF" => {
1098            return Ok(Value::Bool(false));
1099        }
1100        _ => {}
1101    }
1102    if is_int_literal_shape(text) {
1103        return parse_int_literal(text, guard);
1104    }
1105    if is_sexagesimal_int_shape(text) {
1106        return parse_sexagesimal_int(text, guard);
1107    }
1108    if is_float_literal_shape(text) {
1109        return parse_float_literal(text);
1110    }
1111    if SEXAGESIMAL_FLOAT_RE.is_match(text) {
1112        return Ok(parse_sexagesimal_float(text));
1113    }
1114    if let Some(iso) = normalize_timestamp(text)? {
1115        // YAML's own implicit-resolver timestamp grammar (unlike OML's or
1116        // TOML's) has no standalone bare-time form -- `normalize_timestamp`
1117        // only ever produces a date-only or a full datetime spelling, never
1118        // a time-only one (issue #105: real provenance instead of staying
1119        // `Str`, the same fix issue #99 already applied to OML). A
1120        // datetime spelling always contains the date/time `T` separator;
1121        // a date-only one never does.
1122        return Ok(if iso.contains('T') {
1123            Value::Datetime(iso)
1124        } else {
1125            Value::Date(iso)
1126        });
1127    }
1128    Ok(Value::Str(text.to_string()))
1129}
1130
1131/// Live-confirmed against `yaml.safe_load` (see module doc comment): PyYAML's
1132/// `tag:yaml.org,2002:int` implicit resolver recognizes `0x`/`0b` prefixes and
1133/// a bare leading zero as octal (`"017" -> 15`), but **not** a YAML-1.2-style
1134/// `0o` prefix (`"0o17"` stays a plain string). The legacy sexagesimal
1135/// `1:20` form is handled separately by [`SEXAGESIMAL_INT_RE`]/
1136/// [`is_sexagesimal_int_shape`] below (issue #87 -- this comment previously
1137/// called it "out of scope", but the spec (`docs/formats/yaml.md`) and both
1138/// sibling ports require it; that framing was simply wrong).
1139static INT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1140    regex::Regex::new(
1141        r"^(?:[-+]?0b[0-1_]+|[-+]?0[0-7_]+|[-+]?(?:0|[1-9][0-9_]*)|[-+]?0x[0-9a-fA-F_]+)$",
1142    )
1143    .unwrap()
1144});
1145
1146fn is_int_literal_shape(text: &str) -> bool {
1147    INT_RE.is_match(text)
1148}
1149
1150/// PyYAML's legacy sexagesimal integer form: a colon-separated run of
1151/// base-60 digit groups, e.g. `12:00:00` -> `12*3600 + 0*60 + 0 = 43200`.
1152/// Live-confirmed against `yaml.safe_load`/`omnist.read_yaml` (issue #87):
1153/// the first group must NOT have a leading zero (`"0:0:1"`/`"01:20"` stay
1154/// plain strings) and every subsequent group is constrained to `0-59`
1155/// (`"1:60"`/`"1:600"` stay plain strings) -- exactly mirroring PyYAML's own
1156/// `tag:yaml.org,2002:int` resolver regex
1157/// (`^[-+]?[1-9][0-9_]*(:[0-5]?[0-9])+$`, see
1158/// <https://yaml.org/type/int.html>).
1159static SEXAGESIMAL_INT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1160    regex::Regex::new(r"^[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+$").unwrap()
1161});
1162
1163fn is_sexagesimal_int_shape(text: &str) -> bool {
1164    SEXAGESIMAL_INT_RE.is_match(text)
1165}
1166
1167/// Parses a sexagesimal-shaped string (already confirmed via
1168/// [`is_sexagesimal_int_shape`]) into its base-60 integer value: fold each
1169/// `:`-separated group left to right as `acc = acc*60 + group`, matching
1170/// PyYAML's `construct_yaml_int`'s own sexagesimal branch.
1171///
1172/// Arbitrary-precision (issue #104): the fold itself can no longer
1173/// overflow -- `BigInt` has no width limit -- so `MAX_INT_DIGITS` (the
1174/// same cap `parse_int_literal` enforces) is checked explicitly on the
1175/// fold's *result* instead. This replaces a real guard, not just
1176/// tidies one: the old `i64` accumulator's `checked_mul`/`checked_add`
1177/// overflow was incidentally bounding a many-group literal like
1178/// `1:0:0:...:0` (thousands of groups); a naive swap to unchecked
1179/// `BigInt` arithmetic here would silently remove that bound entirely,
1180/// letting such a literal build an arbitrarily large integer.
1181fn parse_sexagesimal_int(text: &str, guard: &mut IntGuard) -> Result<Value, ParseError> {
1182    let neg = text.starts_with('-');
1183    let t = text.strip_prefix(['+', '-']).unwrap_or(text);
1184    let mut acc = BigInt::from(0);
1185    let sixty = BigInt::from(60);
1186    for group in t.split(':') {
1187        let cleaned: String = group.chars().filter(|&c| c != '_').collect();
1188        // Every group is guaranteed decimal digits by `SEXAGESIMAL_INT_RE`
1189        // (the caller's shape check) -- this can't fail.
1190        let digit = BigInt::parse_bytes(cleaned.as_bytes(), 10)
1191            .expect("SEXAGESIMAL_INT_RE guarantees decimal digit groups");
1192        acc = acc * &sixty + digit;
1193    }
1194    let value = if neg { -acc } else { acc };
1195    if int_exceeds(&value, guard.max()) {
1196        return Ok(Value::Int(guard.placeholder()));
1197    }
1198    Ok(Value::Int(value))
1199}
1200
1201/// Arbitrary-precision (issue #104): a magnitude that fits the shape
1202/// `is_int_literal_shape` already confirmed always parses -- no more
1203/// `i64`-overflow special-casing (the old version's own comment about
1204/// `i64::MIN`'s asymmetric magnitude is now moot, since `BigInt` negation
1205/// never overflows).
1206fn parse_int_literal(text: &str, guard: &mut IntGuard) -> Result<Value, ParseError> {
1207    let neg = text.starts_with('-');
1208    let t = text.strip_prefix(['+', '-']).unwrap_or(text);
1209    let cleaned: String = t.chars().filter(|&c| c != '_').collect();
1210    let (radix, digits) = if let Some(rest) = cleaned.strip_prefix("0x") {
1211        (16, rest)
1212    } else if let Some(rest) = cleaned.strip_prefix("0b") {
1213        (2, rest)
1214    } else if cleaned.starts_with('0') && cleaned.len() > 1 {
1215        (8, &cleaned[1..])
1216    } else {
1217        (10, cleaned.as_str())
1218    };
1219    if radix == 10 && digits.len() > guard.max() {
1220        return Ok(Value::Int(guard.placeholder()));
1221    }
1222    // Implicit resolution only calls this after `is_int_literal_shape` has
1223    // confirmed the shape, but an EXPLICIT `!!int` tag reaches it with any
1224    // text at all (`a: !!int x`), so the digits are not guaranteed valid.
1225    let Some(magnitude) = BigInt::parse_bytes(digits.as_bytes(), radix) else {
1226        return Err(ParseError::codec_syntax(
1227            1,
1228            1,
1229            format!("invalid YAML: {text:?} is not a valid !!int value"),
1230        ));
1231    };
1232    let value = if neg { -magnitude } else { magnitude };
1233    Ok(Value::Int(value))
1234}
1235
1236/// Live-confirmed against `yaml.safe_load`: PyYAML's `tag:yaml.org,2002:float`
1237/// implicit resolver requires a literal `.` -- `"1e3"` (no decimal point)
1238/// stays a plain string, and the exponent sign is **mandatory** when present
1239/// (`"1.0e3"` stays a string; `"1.0e+3"`/`"1.0e-3"` are floats). The legacy
1240/// sexagesimal float form is out of scope, same rationale as
1241/// `is_int_literal_shape`.
1242static FLOAT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1243    regex::Regex::new(
1244        r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$",
1245    )
1246    .unwrap()
1247});
1248
1249/// PyYAML's legacy sexagesimal float form (C-17): colon-separated base-60
1250/// groups ending in a fractional group, `1:30.5` -> `90.5`. Mirrors PyYAML's
1251/// `tag:yaml.org,2002:float` resolver regex
1252/// (`^[-+]?[0-9][0-9_]*(:[0-5]?[0-9])+\.[0-9_]*$`).
1253static SEXAGESIMAL_FLOAT_RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1254    regex::Regex::new(r"^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$").unwrap()
1255});
1256
1257/// Folds a sexagesimal-float-shaped string (confirmed by
1258/// [`SEXAGESIMAL_FLOAT_RE`]) left to right as `acc = acc*60 + group`, the last
1259/// group carrying the fraction, as PyYAML's `construct_yaml_float` does.
1260fn parse_sexagesimal_float(text: &str) -> Value {
1261    let neg = text.starts_with('-');
1262    let t = text.strip_prefix(['+', '-']).unwrap_or(text);
1263    let cleaned: String = t.chars().filter(|&c| c != '_').collect();
1264    let mut acc = 0.0_f64;
1265    for group in cleaned.split(':') {
1266        let group: f64 = group
1267            .parse()
1268            .expect("SEXAGESIMAL_FLOAT_RE guarantees numeric groups");
1269        acc = acc * 60.0 + group;
1270    }
1271    Value::Float(if neg { -acc } else { acc })
1272}
1273
1274fn is_float_literal_shape(text: &str) -> bool {
1275    FLOAT_RE.is_match(text)
1276}
1277
1278fn parse_float_literal(text: &str) -> Result<Value, ParseError> {
1279    match text {
1280        ".inf" | ".Inf" | ".INF" | "+.inf" | "+.Inf" | "+.INF" => {
1281            return Ok(Value::Float(f64::INFINITY));
1282        }
1283        "-.inf" | "-.Inf" | "-.INF" => return Ok(Value::Float(f64::NEG_INFINITY)),
1284        ".nan" | ".NaN" | ".NAN" => return Ok(Value::Float(f64::NAN)),
1285        _ => {}
1286    }
1287    let cleaned: String = text.chars().filter(|&c| c != '_').collect();
1288    cleaned.parse::<f64>().map(Value::Float).map_err(|_| {
1289        ParseError::codec_syntax(
1290            1,
1291            1,
1292            format!("invalid YAML: invalid float literal {text:?}"),
1293        )
1294    })
1295}
1296
1297/// Reproduces PyYAML's `tag:yaml.org,2002:timestamp` resolver + construction
1298/// (`construct_yaml_timestamp`): accepts a bare ISO date, or a date/time
1299/// joined by `T`/`t`/one-or-more spaces, with an optional fractional-second
1300/// part and an optional `Z`/`±HH[:MM]` offset -- and returns the value
1301/// re-spelled the way `datetime.date.isoformat()`/`datetime.datetime.isoformat()`
1302/// would: zero-padded, `T`-joined, offset as `+HH:MM`/`-HH:MM` (a bare `Z`
1303/// becomes `+00:00`, matching `datetime.timezone.utc`'s own `isoformat()`).
1304/// Returns `Ok(None)` for anything not shaped like a timestamp at all (the
1305/// overwhelmingly common case -- most plain scalars are plain strings).
1306///
1307/// A string that *is* timestamp-shaped but names a calendar/clock value that
1308/// doesn't exist (`2024-13-01`, `2024-02-30`, `2024-01-01T25:00:00`, an
1309/// out-of-range timezone offset) is a [`ParseError`], **not** a silent
1310/// fallback to a plain string -- live-confirmed against PyYAML (see this
1311/// module's doc comment): `yaml.safe_load` calls `datetime.date`/
1312/// `datetime.datetime`'s constructor on the captured fields, which raises a
1313/// `ValueError` PyYAML doesn't catch, so the *whole document* fails to parse
1314/// rather than quietly typing the value as a string. Calendar-date validity
1315/// (leap years, per-month day counts) reuses `crate::schema::valid_ymd`/
1316/// `crate::schema::valid_hms` rather than a second copy of that logic.
1317fn normalize_timestamp(text: &str) -> Result<Option<String>, ParseError> {
1318    static RE: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(|| {
1319        regex::Regex::new(
1320            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]))?))?)?$",
1321        )
1322        .unwrap()
1323    });
1324    let Some(caps) = RE.captures(text) else {
1325        return Ok(None);
1326    };
1327    let bad = |what: &str| {
1328        Err(ParseError::codec_syntax(
1329            1,
1330            1,
1331            format!("invalid YAML: {text:?} is timestamp-shaped but names an invalid {what}"),
1332        ))
1333    };
1334    let year: u32 = caps["year"].parse().unwrap_or(u32::MAX);
1335    let month: u32 = caps["month"].parse().unwrap_or(u32::MAX);
1336    let day: u32 = caps["day"].parse().unwrap_or(u32::MAX);
1337    if !crate::schema::valid_ymd(year, month, day) {
1338        return bad("calendar date");
1339    }
1340    let Some(hour_m) = caps.name("hour") else {
1341        return Ok(Some(format!("{year:04}-{month:02}-{day:02}")));
1342    };
1343    let hour: u32 = hour_m.as_str().parse().unwrap_or(u32::MAX);
1344    let minute: u32 = caps["minute"].parse().unwrap_or(u32::MAX);
1345    let second: u32 = caps["second"].parse().unwrap_or(u32::MAX);
1346    if !crate::schema::valid_hms(hour, minute, second) {
1347        return bad("time of day");
1348    }
1349    let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
1350    if let Some(frac) = caps.name("fraction") {
1351        let mut digits = frac.as_str().to_string();
1352        // The Document model holds at most six fractional digits; a
1353        // non-zero digit beyond them cannot be kept, so it fails rather
1354        // than being truncated (fail, don't invent). Trailing zeros are
1355        // insignificant.
1356        if digits.len() > 6 && digits[6..].bytes().any(|b| b != b'0') {
1357            return bad("fractional second (more than six digits)");
1358        }
1359        while digits.len() < 6 {
1360            digits.push('0');
1361        }
1362        digits.truncate(6);
1363        out.push('.');
1364        out.push_str(&digits);
1365    }
1366    match caps.name("tz_sign") {
1367        Some(sign) => {
1368            let tz_hour: u32 = caps["tz_hour"].parse().unwrap_or(u32::MAX);
1369            let tz_minute: u32 = caps
1370                .name("tz_minute")
1371                .map(|m| m.as_str().parse().unwrap_or(u32::MAX))
1372                .unwrap_or(0);
1373            if tz_hour > 23 || tz_minute > 59 {
1374                return bad("timezone offset");
1375            }
1376            out.push_str(sign.as_str());
1377            out.push_str(&format!("{tz_hour:02}:{tz_minute:02}"));
1378        }
1379        None if text.trim_end().ends_with('Z') => out.push_str("+00:00"),
1380        None => {}
1381    }
1382    Ok(Some(out))
1383}
1384
1385// ============================================================== Writer
1386
1387/// Project a [`Doc`] to YAML text (block style, 2-space indent, insertion
1388/// order preserved -- matching Python's `yaml.dump(..., sort_keys=False,
1389/// default_flow_style=False)`).
1390pub fn write_yaml(
1391    doc: &Doc,
1392    strict: bool,
1393    report: Option<&mut WriteReport>,
1394) -> Result<String, WriteError> {
1395    let grouped = doc.to_grouped();
1396    let mut rep = check_yaml_grouped(&grouped);
1397    add_interleaving_diagnostic(doc, &mut rep);
1398    let mut out = String::new();
1399    write_node(&grouped, 0, &mut out, true);
1400    if out.ends_with('\n') {
1401        out.pop();
1402    }
1403    crate::report::finish_write(out, rep, strict, report)
1404}
1405
1406/// `format.interleaving-lost` (spec Sec8.3.8, D-3) is a whole-document
1407/// diagnostic that depends on the original `Doc`'s edge order, lost by the
1408/// time `to_grouped` runs -- so it is detected separately via
1409/// `Doc::has_interleaving_loss` rather than folded into `check_yaml_grouped`'s
1410/// grouped-`Value` walk.
1411fn add_interleaving_diagnostic(doc: &Doc, rep: &mut WriteReport) {
1412    if doc.has_interleaving_loss() {
1413        rep.add(
1414            "$",
1415            "format.interleaving-lost",
1416            "cross-label interleaving could not be written; same-label edges were grouped",
1417            Severity::Warning,
1418        );
1419    }
1420}
1421
1422/// Report what writing YAML would adjust, without producing output. The only
1423/// adjustment YAML ever needs is forcing double-quoted style for a U+0085
1424/// (NEL) string/label -- see this module's doc comment.
1425pub fn check_yaml(doc: &Doc) -> WriteReport {
1426    let grouped = doc.to_grouped();
1427    let mut rep = check_yaml_grouped(&grouped);
1428    add_interleaving_diagnostic(doc, &mut rep);
1429    rep
1430}
1431
1432fn check_yaml_grouped(grouped: &Value) -> WriteReport {
1433    let mut rep = WriteReport::new();
1434    let mut path = String::from("$");
1435    crate::formats::visit_grouped(grouped, &mut path, &mut |visited, path| match visited {
1436        crate::formats::Visited::Edge { label } if label.contains('\u{0085}') => {
1437            rep.add(
1438                path,
1439                "format.string-line-break-char",
1440                "label contains U+0085 (NEL); written double-quoted to round-trip correctly",
1441                Severity::Warning,
1442            );
1443        }
1444        crate::formats::Visited::Node {
1445            value: Value::Str(s),
1446        } if s.contains('\u{0085}') => {
1447            rep.add(
1448                path,
1449                "format.string-line-break-char",
1450                "value contains U+0085 (NEL); written double-quoted to round-trip correctly",
1451                Severity::Warning,
1452            );
1453        }
1454        // E-37 rule 4: YAML 1.1 has no time-of-day type, so every `time` is
1455        // written as a double-quoted string.
1456        crate::formats::Visited::Node {
1457            value: Value::Datetime(s),
1458        } if crate::formats::fraction_inexact(s) => {
1459            rep.add(
1460                path,
1461                "format.temporal-stringified",
1462                "fractional second has more than six digits; wrote as a quoted ISO-8601 string",
1463                Severity::Warning,
1464            );
1465        }
1466        crate::formats::Visited::Node {
1467            value: Value::Time(_),
1468        } => {
1469            rep.add(
1470                path,
1471                "format.temporal-stringified",
1472                "time has no native YAML literal; wrote as a quoted ISO-8601 string",
1473                Severity::Warning,
1474            );
1475        }
1476        _ => {}
1477    });
1478    rep
1479}
1480
1481/// Marker type implementing [`crate::formats::Codec`] for YAML -- adapts
1482/// [`read_yaml`]/[`write_yaml`]/[`check_yaml`] to the registry's uniform
1483/// shape with the documented defaults (`strict: false`, no report).
1484pub(crate) struct Yaml;
1485
1486impl crate::formats::Codec for Yaml {
1487    const NAME: &'static str = "yaml";
1488
1489    fn read(text: &str) -> Result<Doc, OmnistError> {
1490        read_yaml(text)
1491    }
1492
1493    fn write(doc: &Doc) -> Result<String, OmnistError> {
1494        write_yaml(doc, false, None).map_err(Into::into)
1495    }
1496
1497    fn check(doc: &Doc) -> WriteReport {
1498        check_yaml(doc)
1499    }
1500}
1501
1502fn indent(out: &mut String, level: usize) {
1503    for _ in 0..level {
1504        out.push_str("  ");
1505    }
1506}
1507
1508/// Writes `node` at `level`, matching PyYAML's block style: a scalar directly
1509/// after `key:`/`- ` on the same line; a nested mapping/sequence starts on
1510/// the next line, indented. `top` is `true` only for the document root, which
1511/// (for an empty root object) still needs `{}` -- an empty nested object
1512/// is written the same way PyYAML does, inline `{}`/`[]`.
1513fn write_node(node: &Value, level: usize, out: &mut String, top: bool) {
1514    match node {
1515        Value::Object(map) if map.is_empty() => {
1516            out.push_str("{}\n");
1517        }
1518        Value::Array(items) if items.is_empty() => {
1519            out.push_str("[]\n");
1520        }
1521        Value::Object(map) => {
1522            for (label, child) in map {
1523                indent(out, level);
1524                write_scalar(label, out);
1525                out.push(':');
1526                write_child(child, level, out);
1527            }
1528            let _ = top;
1529        }
1530        Value::Array(items) => {
1531            for item in items {
1532                indent(out, level);
1533                out.push('-');
1534                write_seq_child(item, level, out);
1535            }
1536        }
1537        other => {
1538            write_scalar_value(other, out);
1539            out.push('\n');
1540        }
1541    }
1542}
1543
1544fn write_child(child: &Value, level: usize, out: &mut String) {
1545    match child {
1546        Value::Object(m) if !m.is_empty() => {
1547            out.push('\n');
1548            write_node(child, level + 1, out, false);
1549        }
1550        Value::Array(a) if !a.is_empty() => {
1551            out.push('\n');
1552            write_node(child, level, out, false);
1553        }
1554        _ => {
1555            out.push(' ');
1556            write_node(child, level + 1, out, false);
1557        }
1558    }
1559}
1560
1561fn write_seq_child(item: &Value, level: usize, out: &mut String) {
1562    match item {
1563        Value::Object(m) if !m.is_empty() => {
1564            out.push(' ');
1565            // The first field of a mapping under a `- ` sequence marker is
1566            // written on the same line as the dash; PyYAML indents the
1567            // remaining fields to align under it (one level deeper than the
1568            // dash itself, i.e. `level + 1`).
1569            let mut first = true;
1570            for (label, child) in m {
1571                if !first {
1572                    indent(out, level + 1);
1573                }
1574                first = false;
1575                write_scalar(label, out);
1576                out.push(':');
1577                write_child(child, level + 1, out);
1578            }
1579        }
1580        Value::Array(a) if !a.is_empty() => {
1581            out.push('\n');
1582            write_node(item, level + 1, out, false);
1583        }
1584        _ => {
1585            out.push(' ');
1586            write_node(item, level + 1, out, false);
1587        }
1588    }
1589}
1590
1591fn write_scalar(s: &str, out: &mut String) {
1592    write_scalar_value(&Value::Str(s.to_string()), out);
1593}
1594
1595fn write_scalar_value(v: &Value, out: &mut String) {
1596    match v {
1597        Value::Null => out.push_str("null"),
1598        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
1599        Value::Int(i) => out.push_str(&i.to_string()),
1600        Value::Float(x) => write_float(*x, out),
1601        // Always quoted -- no shape-guessing (issue #105, the same fix
1602        // issue #99 already applied to OML). A genuinely temporal-kinded
1603        // value is a `Date`/`Time`/`Datetime` variant, not a
1604        // shape-matched `Str`.
1605        Value::Str(s) => write_yaml_string(s, out),
1606        // Written bare unconditionally -- by construction (see
1607        // `Scalar::Date`'s own doc comment) always already a validated,
1608        // canonical spelling that YAML's own implicit resolver reads back
1609        // as the identical kind (`normalize_timestamp` produces exactly
1610        // this date-or-datetime shape, see `resolve_plain_scalar`).
1611        Value::Date(s) => out.push_str(s),
1612        Value::Datetime(s) => {
1613            let text = super::temporal_text(s, true);
1614            if super::fraction_inexact(&text) {
1615                write_quoted(&text, &YAML_ESCAPES, out);
1616            } else {
1617                out.push_str(&text);
1618            }
1619        }
1620        // Unlike `Date`/`Datetime`, YAML's implicit resolver has no bare
1621        // standalone-time form at all (`normalize_timestamp`'s grammar
1622        // always requires a date) -- a genuine `Time` value (e.g. from a
1623        // schema-directed upgrade of a JSON-sourced sample, or read from
1624        // OML's own bare time grammar) has no native YAML spelling, so it
1625        // must stay a quoted string, the same fallback TOML's writer uses
1626        // for a `Time` value that carries a UTC offset.
1627        Value::Time(s) => write_quoted(&super::temporal_text(s, false), &YAML_ESCAPES, out),
1628        Value::Object(_) | Value::Array(_) => {
1629            unreachable!("write_scalar_value is only ever called on a leaf")
1630        }
1631    }
1632}
1633
1634/// See `formats::float_fmt` (issue #47) for the shared render-then-inspect
1635/// core (issue #46's fix); this is just YAML's spelling table.
1636fn write_float(x: f64, out: &mut String) {
1637    float_fmt::write_float(x, ".nan", ".inf", "-.inf", out);
1638}
1639
1640/// Writes `s` as a YAML scalar: double-quoted (with the U+0085 escape
1641/// `check_yaml` warns about) if it contains a NEL, or if writing it bare
1642/// would round-trip back as a *different* value (it would be re-resolved as
1643/// null/bool/int/float/timestamp, it's empty, or it has YAML-significant
1644/// leading/embedded punctuation) -- otherwise written plain.
1645fn write_yaml_string(s: &str, out: &mut String) {
1646    if needs_quoting(s) {
1647        write_quoted(s, &YAML_ESCAPES, out);
1648    } else {
1649        out.push_str(s);
1650    }
1651}
1652
1653fn needs_quoting(s: &str) -> bool {
1654    if s.is_empty() || s.contains('\u{0085}') || s.contains('\n') {
1655        return true;
1656    }
1657    // C-14: a character YAML 1.1 does not print is escaped, so the scalar is
1658    // double-quoted (tab and CR keep their existing plain handling).
1659    if s.chars()
1660        .any(|c| (c < ' ' && c != '\t' && c != '\r') || YAML_ESCAPES.also_escape.contains(&c))
1661    {
1662        return true;
1663    }
1664    // A string that starts with a byte-order mark is always quoted (D-15,
1665    // D-21): written bare as the very first key or the whole document it
1666    // would put a leading BOM at offset zero of the output, which a writer
1667    // MUST NOT emit and which a reader strips (changing the label) or, doubled,
1668    // rejects.
1669    if s.starts_with(crate::bom::BOM) {
1670        return true;
1671    }
1672    if matches!(resolve_plain_scalar(s, &mut IntGuard::new(crate::formats::int_cap::MAX_INT_DIGITS)), Ok(Value::Str(ref t)) if t == s)
1673    {
1674        // Round-trips as the identical plain string -- but a leading char /
1675        // embedded token that's YAML-significant still needs quoting even
1676        // though resolve_plain_scalar wouldn't itself retype it.
1677    } else {
1678        return true; // would be re-read as null/bool/int/float/timestamp
1679    }
1680    // `s` is guaranteed non-empty here: the early return at line 1021-1023
1681    // already handles `s.is_empty()`, so `.chars().next()` always yields a
1682    // char.
1683    let first = s.chars().next().unwrap();
1684    if matches!(
1685        first,
1686        '-' | '?'
1687            | ':'
1688            | ','
1689            | '['
1690            | ']'
1691            | '{'
1692            | '}'
1693            | '#'
1694            | '&'
1695            | '*'
1696            | '!'
1697            | '|'
1698            | '>'
1699            | '\''
1700            | '"'
1701            | '%'
1702            | '@'
1703            | '`'
1704            | ' '
1705    ) {
1706        return true;
1707    }
1708    if s.ends_with(' ') || s.contains(": ") || s.ends_with(':') || s.contains(" #") {
1709        return true;
1710    }
1711    false
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716    use super::*;
1717    use crate::document::{Doc, Scalar, Value};
1718    use crate::formats::int_cap::MAX_INT_DIGITS;
1719
1720    fn obj(pairs: Vec<(&str, Value)>) -> Value {
1721        Value::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
1722    }
1723
1724    fn doc_of(v: Value) -> Doc {
1725        Doc::of(&v).unwrap()
1726    }
1727
1728    // ---------------------------------------------------------- reader: scalars
1729
1730    #[test]
1731    fn reads_every_scalar_kind() {
1732        let doc = read_yaml("a: 1\nb: \"s\"\nc: true\nd: null\ne: 1.5\n").unwrap();
1733        let root = doc.root();
1734        assert_eq!(
1735            *root.get_one("a").unwrap().value().unwrap(),
1736            Scalar::Int((1).into())
1737        );
1738        assert_eq!(
1739            *root.get_one("b").unwrap().value().unwrap(),
1740            Scalar::Str("s".to_string())
1741        );
1742        assert_eq!(
1743            *root.get_one("c").unwrap().value().unwrap(),
1744            Scalar::Bool(true)
1745        );
1746        assert_eq!(*root.get_one("d").unwrap().value().unwrap(), Scalar::Null);
1747        assert_eq!(
1748            *root.get_one("e").unwrap().value().unwrap(),
1749            Scalar::Float(1.5)
1750        );
1751    }
1752
1753    #[test]
1754    fn reads_yaml_1_1_bool_spellings_but_not_bare_y_or_n() {
1755        let doc = read_yaml("a: yes\nb: no\nc: on\nd: off\ne: Yes\nf: y\ng: n\n").unwrap();
1756        let root = doc.root();
1757        assert_eq!(
1758            *root.get_one("a").unwrap().value().unwrap(),
1759            Scalar::Bool(true)
1760        );
1761        assert_eq!(
1762            *root.get_one("b").unwrap().value().unwrap(),
1763            Scalar::Bool(false)
1764        );
1765        assert_eq!(
1766            *root.get_one("c").unwrap().value().unwrap(),
1767            Scalar::Bool(true)
1768        );
1769        assert_eq!(
1770            *root.get_one("d").unwrap().value().unwrap(),
1771            Scalar::Bool(false)
1772        );
1773        assert_eq!(
1774            *root.get_one("e").unwrap().value().unwrap(),
1775            Scalar::Bool(true)
1776        );
1777        // 'y'/'n' alone stay strings -- live-confirmed against PyYAML (see
1778        // module doc comment).
1779        assert_eq!(
1780            *root.get_one("f").unwrap().value().unwrap(),
1781            Scalar::Str("y".to_string())
1782        );
1783        assert_eq!(
1784            *root.get_one("g").unwrap().value().unwrap(),
1785            Scalar::Str("n".to_string())
1786        );
1787    }
1788
1789    #[test]
1790    fn a_string_starting_with_a_bom_is_quoted_so_the_output_never_leads_with_one() {
1791        let doc = doc_of(obj(vec![(
1792            "\u{FEFF}k",
1793            Value::Str("\u{FEFF}v".to_string()),
1794        )]));
1795        let text = write_yaml(&doc, false, None).unwrap();
1796        assert!(text.starts_with('"'), "{text:?}");
1797        assert!(!text.starts_with('\u{FEFF}'));
1798        // It reads back to the same Document (the mark survives inside quotes).
1799        assert_eq!(read_yaml(&text).unwrap().to_raw(), doc.to_raw());
1800        // A mark that is not first is not quoted for its sake.
1801        let doc = doc_of(obj(vec![("a\u{FEFF}b", Value::Str("x".to_string()))]));
1802        assert_eq!(write_yaml(&doc, false, None).unwrap(), "a\u{FEFF}b: x");
1803    }
1804
1805    #[test]
1806    fn quoted_yes_stays_a_string_not_a_bool() {
1807        let doc = read_yaml("a: \"yes\"\n").unwrap();
1808        assert_eq!(
1809            *doc.root().get_one("a").unwrap().value().unwrap(),
1810            Scalar::Str("yes".to_string())
1811        );
1812    }
1813
1814    #[test]
1815    fn reads_null_spellings() {
1816        let doc = read_yaml("a: ~\nb: null\nc: Null\nd: NULL\ne:\n").unwrap();
1817        let root = doc.root();
1818        for label in ["a", "b", "c", "d", "e"] {
1819            assert_eq!(*root.get_one(label).unwrap().value().unwrap(), Scalar::Null);
1820        }
1821    }
1822
1823    #[test]
1824    fn reads_negative_and_hex_and_octal_and_binary_ints() {
1825        // Note: PyYAML's legacy (YAML-1.1-derived) int resolver recognizes a
1826        // *bare leading zero* as octal ("017" -> 15), not a `0o` prefix --
1827        // live-confirmed (see module doc comment on `INT_RE`); a separate
1828        // test below pins `"0o17"` staying a string for that exact reason.
1829        let doc = read_yaml("a: -5\nb: 0x1A\nc: 017\nd: 0b101\ne: 1_000\n").unwrap();
1830        let root = doc.root();
1831        assert_eq!(
1832            *root.get_one("a").unwrap().value().unwrap(),
1833            Scalar::Int((-5).into())
1834        );
1835        assert_eq!(
1836            *root.get_one("b").unwrap().value().unwrap(),
1837            Scalar::Int((26).into())
1838        );
1839        assert_eq!(
1840            *root.get_one("c").unwrap().value().unwrap(),
1841            Scalar::Int((15).into())
1842        );
1843        assert_eq!(
1844            *root.get_one("d").unwrap().value().unwrap(),
1845            Scalar::Int((5).into())
1846        );
1847        assert_eq!(
1848            *root.get_one("e").unwrap().value().unwrap(),
1849            Scalar::Int((1000).into())
1850        );
1851    }
1852
1853    #[test]
1854    fn a_yaml_1_2_style_0o_octal_prefix_is_not_recognized_and_stays_a_string() {
1855        let doc = read_yaml("a: 0o17\n").unwrap();
1856        assert_eq!(
1857            *doc.root().get_one("a").unwrap().value().unwrap(),
1858            Scalar::Str("0o17".to_string())
1859        );
1860    }
1861
1862    #[test]
1863    fn reads_legacy_sexagesimal_int_forms() {
1864        // issue #87: live-confirmed against PyYAML/omnist -- `12:00:00`
1865        // resolves via the legacy YAML-1.1 sexagesimal integer form
1866        // (base-60 digit groups) to the plain integer 43200, not a string.
1867        let doc =
1868            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")
1869                .unwrap();
1870        let root = doc.root();
1871        assert_eq!(
1872            *root.get_one("a").unwrap().value().unwrap(),
1873            Scalar::Int((43200).into())
1874        );
1875        assert_eq!(
1876            *root.get_one("b").unwrap().value().unwrap(),
1877            Scalar::Int((80).into())
1878        );
1879        assert_eq!(
1880            *root.get_one("c").unwrap().value().unwrap(),
1881            Scalar::Int((3723).into())
1882        );
1883        assert_eq!(
1884            *root.get_one("d").unwrap().value().unwrap(),
1885            Scalar::Int((-80).into())
1886        );
1887        assert_eq!(
1888            *root.get_one("e").unwrap().value().unwrap(),
1889            Scalar::Int((80).into())
1890        );
1891        assert_eq!(
1892            *root.get_one("f").unwrap().value().unwrap(),
1893            Scalar::Int((7425).into())
1894        );
1895        assert_eq!(
1896            *root.get_one("g").unwrap().value().unwrap(),
1897            Scalar::Int((750).into())
1898        );
1899    }
1900
1901    #[test]
1902    fn sexagesimal_first_group_over_i64_range_parses() {
1903        // Issue #104: `Scalar::Int` is arbitrary-precision (`BigInt`), so
1904        // a first digit group beyond `i64`'s ~19-digit range is no longer
1905        // a parse error -- `parse_sexagesimal_int`'s fold just produces a
1906        // real, correctly-computed `BigInt` value.
1907        let text = format!("a: {}:0\n", "9".repeat(20));
1908        let doc = read_yaml(&text).unwrap();
1909        let value = doc.root().child("a").unwrap().value().unwrap();
1910        assert_eq!(
1911            value,
1912            &Scalar::Int(num_bigint::BigInt::parse_bytes(b"5999999999999999999940", 10).unwrap())
1913        );
1914    }
1915
1916    #[test]
1917    fn sexagesimal_fold_overflow_across_many_in_range_groups_parses() {
1918        // Every individual group here is a legal 0-59 sexagesimal digit;
1919        // this exercises the *fold* itself accumulating well past `i64`
1920        // range across many groups -- issue #104 means the fold no longer
1921        // overflows, it just keeps growing the `BigInt`.
1922        let text = format!("a: 1{}\n", ":59".repeat(15));
1923        let doc = read_yaml(&text).unwrap();
1924        let value = doc.root().child("a").unwrap().value().unwrap();
1925        assert_eq!(
1926            value,
1927            &Scalar::Int(
1928                num_bigint::BigInt::parse_bytes(b"940369969151999999999999999", 10).unwrap()
1929            )
1930        );
1931    }
1932
1933    #[test]
1934    fn sexagesimal_fold_still_rejects_past_the_digit_cap() {
1935        // The digit-cap check this migration *added* to the fold (issue
1936        // #104 -- replacing the `i64` overflow that used to bound this
1937        // incidentally) -- enough groups to push the folded result's
1938        // decimal digit count past MAX_INT_DIGITS must still be rejected,
1939        // not silently accepted now that raw fold overflow no longer does
1940        // that job.
1941        let text = format!("a: 1{}\n", ":59".repeat(2500));
1942        let err = read_yaml(&text).unwrap_err();
1943        assert!(
1944            matches!(&err, OmnistError::Document(e)
1945                if e.code.as_deref() == Some("document.limit.int-digits")
1946                    && e.path == "$.a"
1947                    && e.message.contains("4300-digit")),
1948            "got {err:?}"
1949        );
1950    }
1951
1952    #[test]
1953    fn sexagesimal_shape_with_leading_zero_or_out_of_range_group_stays_a_string() {
1954        // Live-confirmed against PyYAML: the first digit group must NOT
1955        // have a leading zero, and every subsequent `:NN` group must be
1956        // 0-59 -- otherwise the whole thing stays a plain string, it does
1957        // not partially resolve.
1958        let doc = read_yaml("a: 0:0:1\nb: 1:60\nc: 1:600\nd: 01:20\n").unwrap();
1959        let root = doc.root();
1960        assert_eq!(
1961            *root.get_one("a").unwrap().value().unwrap(),
1962            Scalar::Str("0:0:1".to_string())
1963        );
1964        assert_eq!(
1965            *root.get_one("b").unwrap().value().unwrap(),
1966            Scalar::Str("1:60".to_string())
1967        );
1968        assert_eq!(
1969            *root.get_one("c").unwrap().value().unwrap(),
1970            Scalar::Str("1:600".to_string())
1971        );
1972        assert_eq!(
1973            *root.get_one("d").unwrap().value().unwrap(),
1974            Scalar::Str("01:20".to_string())
1975        );
1976    }
1977
1978    #[test]
1979    fn reads_float_and_inf_and_nan_tokens() {
1980        let doc = read_yaml("a: 1.5\nb: .inf\nc: -.inf\nd: .nan\ne: 1.0e+3\n").unwrap();
1981        let root = doc.root();
1982        assert_eq!(
1983            *root.get_one("a").unwrap().value().unwrap(),
1984            Scalar::Float(1.5)
1985        );
1986        assert_eq!(
1987            *root.get_one("b").unwrap().value().unwrap(),
1988            Scalar::Float(f64::INFINITY)
1989        );
1990        assert_eq!(
1991            *root.get_one("c").unwrap().value().unwrap(),
1992            Scalar::Float(f64::NEG_INFINITY)
1993        );
1994        assert!(
1995            matches!(root.get_one("d").unwrap().value().unwrap(), Scalar::Float(x) if x.is_nan())
1996        );
1997        assert_eq!(
1998            *root.get_one("e").unwrap().value().unwrap(),
1999            Scalar::Float(1000.0)
2000        );
2001    }
2002
2003    #[test]
2004    fn a_bare_exponent_without_a_decimal_point_is_not_float_shaped_and_stays_a_string() {
2005        // Live-confirmed against PyYAML: its float resolver requires a
2006        // literal `.`, and a mandatory sign on the exponent when present --
2007        // "1e3" and "1.0e3" both stay plain strings.
2008        let doc = read_yaml("a: 1e3\nb: 1.0e3\n").unwrap();
2009        let root = doc.root();
2010        assert_eq!(
2011            *root.get_one("a").unwrap().value().unwrap(),
2012            Scalar::Str("1e3".to_string())
2013        );
2014        assert_eq!(
2015            *root.get_one("b").unwrap().value().unwrap(),
2016            Scalar::Str("1.0e3".to_string())
2017        );
2018    }
2019
2020    #[test]
2021    fn reads_bare_date_as_iso_string() {
2022        let doc = read_yaml("a: 2024-01-15\n").unwrap();
2023        assert_eq!(
2024            *doc.root().get_one("a").unwrap().value().unwrap(),
2025            Scalar::Date("2024-01-15".to_string())
2026        );
2027    }
2028
2029    #[test]
2030    fn genuine_date_and_datetime_values_write_bare_and_a_time_value_writes_quoted() {
2031        // `Date`/`Datetime` (issue #105) write as YAML's own bare
2032        // timestamp literal, since `write_scalar_value` trusts them as
2033        // already-canonical (see that function's doc comment); `Time` has
2034        // no native YAML spelling at all, so it always stays quoted.
2035        let v = obj(vec![
2036            ("d", Value::Date("2024-01-15".to_string())),
2037            ("dt", Value::Datetime("2024-01-15T12:00:00".to_string())),
2038            ("t", Value::Time("12:00:00".to_string())),
2039        ]);
2040        let doc = doc_of(v);
2041        let text = write_yaml(&doc, false, None).unwrap();
2042        assert!(text.contains("d: 2024-01-15\n"));
2043        assert!(text.contains("dt: 2024-01-15T12:00:00\n"));
2044        assert!(text.contains("t: \"12:00:00\""));
2045    }
2046
2047    #[test]
2048    fn reads_loose_timestamp_and_normalizes_to_canonical_iso() {
2049        // Space-separated, single-digit month/day/hour, short fraction,
2050        // bare `Z` -- all normalized the way `datetime.isoformat()` would.
2051        // (Minute/second must still be exactly two digits -- that's PyYAML's
2052        // own `tag:yaml.org,2002:timestamp` regex, not a relaxation this port
2053        // invented; live-confirmed via the module doc comment's approach.)
2054        let doc = read_yaml("a: 2001-2-3 4:05:06.7 Z\n").unwrap();
2055        assert_eq!(
2056            *doc.root().get_one("a").unwrap().value().unwrap(),
2057            Scalar::Datetime("2001-02-03T04:05:06.700000+00:00".to_string())
2058        );
2059    }
2060
2061    #[test]
2062    fn a_single_digit_minute_or_second_is_not_timestamp_shaped_and_stays_a_string() {
2063        // PyYAML's own timestamp resolver requires an exactly-2-digit
2064        // minute/second even though hour/month/day may be 1 or 2 digits --
2065        // confirmed by this port's `normalize_timestamp` regex (mirroring
2066        // PyYAML's), not a looser rule this port invented.
2067        let doc = read_yaml("a: 2001-2-3 4:5:6\n").unwrap();
2068        assert_eq!(
2069            *doc.root().get_one("a").unwrap().value().unwrap(),
2070            Scalar::Str("2001-2-3 4:5:6".to_string())
2071        );
2072    }
2073
2074    #[test]
2075    fn reads_datetime_with_no_timezone_at_all() {
2076        // A full date+time with no `Z` and no explicit offset -- exercises
2077        // `normalize_timestamp`'s "no timezone information at all" arm,
2078        // distinct from both the explicit-offset and bare-`Z` cases.
2079        let doc = read_yaml("a: 2024-01-15T12:30:00\n").unwrap();
2080        assert_eq!(
2081            *doc.root().get_one("a").unwrap().value().unwrap(),
2082            Scalar::Datetime("2024-01-15T12:30:00".to_string())
2083        );
2084    }
2085
2086    #[test]
2087    fn reads_timestamp_with_explicit_offset() {
2088        let doc = read_yaml("a: 2001-12-14T21:59:43.10-05:00\n").unwrap();
2089        assert_eq!(
2090            *doc.root().get_one("a").unwrap().value().unwrap(),
2091            Scalar::Datetime("2001-12-14T21:59:43.100000-05:00".to_string())
2092        );
2093    }
2094
2095    #[test]
2096    fn a_string_that_merely_looks_like_a_short_date_but_isnt_shaped_right_stays_a_string() {
2097        let doc = read_yaml("a: 2024-1\n").unwrap();
2098        assert_eq!(
2099            *doc.root().get_one("a").unwrap().value().unwrap(),
2100            Scalar::Str("2024-1".to_string())
2101        );
2102    }
2103
2104    // ---------------------------------------------------------- reader: structure
2105
2106    #[test]
2107    fn reads_nested_mapping_and_sequence() {
2108        let doc = read_yaml("a:\n  b:\n    c: 1\nm:\n  - 1\n  - 2\n  - 3\n").unwrap();
2109        let root = doc.root();
2110        let a = root.get_one("a").unwrap();
2111        let b = a.get_one("b").unwrap();
2112        assert_eq!(
2113            *b.get_one("c").unwrap().value().unwrap(),
2114            Scalar::Int((1).into())
2115        );
2116        let ms = root.get("m");
2117        assert_eq!(ms.len(), 3);
2118        assert_eq!(*ms[2].value().unwrap(), Scalar::Int((3).into()));
2119    }
2120
2121    #[test]
2122    fn reads_flow_style_mapping_and_sequence() {
2123        let doc = read_yaml("a: {b: 1, c: 2}\nm: [1, 2, 3]\n").unwrap();
2124        let root = doc.root();
2125        let a = root.get_one("a").unwrap();
2126        assert_eq!(
2127            *a.get_one("b").unwrap().value().unwrap(),
2128            Scalar::Int((1).into())
2129        );
2130        assert_eq!(root.get("m").len(), 3);
2131    }
2132
2133    #[test]
2134    fn bare_top_level_sequence_is_a_document_error_not_a_parse_error() {
2135        let err = read_yaml("- 1\n- 2\n").unwrap_err();
2136        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
2137    }
2138
2139    #[test]
2140    fn sequence_of_sequences_is_a_document_error() {
2141        let err = read_yaml("m:\n  - [1, 2]\n").unwrap_err();
2142        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
2143    }
2144
2145    #[test]
2146    fn empty_input_reads_as_a_null_document() {
2147        let doc = read_yaml("").unwrap();
2148        assert_eq!(*doc.root().value().unwrap(), Scalar::Null);
2149    }
2150
2151    #[test]
2152    fn explicit_empty_document_marker_reads_as_a_null_document() {
2153        // An explicit `---` document-start marker with no content produces a
2154        // `DocumentEnd` event with nothing ever pushed onto the doc stack --
2155        // a different code path than a wholly empty input (which never
2156        // produces a `DocumentStart`/`DocumentEnd` pair at all).
2157        let doc = read_yaml("---\n").unwrap();
2158        assert_eq!(*doc.root().value().unwrap(), Scalar::Null);
2159    }
2160
2161    #[test]
2162    fn a_repeated_mapping_key_is_a_syntax_error() {
2163        // C-12: block, flow, nested, in a sequence element, and quoted.
2164        for text in [
2165            "a: 1\nb: 2\na: 3\n",
2166            "{a: 1, a: 2}\n",
2167            "o:\n  k: 1\n  k: 2\n",
2168            "m:\n  - {a: 1, a: 2}\n",
2169            "a: 1\n\"a\": 2\n",
2170            "a: 1\na: [2, 3]\n",
2171            "p: &p {x: 1}\nq: {a: 1, <<: *p, a: 2}\n",
2172        ] {
2173            let e = read_yaml(text).unwrap_err();
2174            assert!(
2175                matches!(&e, OmnistError::Parse(p)
2176                    if p.code == "parse.codec-syntax" && p.message.contains("duplicate mapping key")),
2177                "{text:?}: {e:?}"
2178            );
2179        }
2180    }
2181
2182    #[test]
2183    fn the_same_key_in_two_mappings_or_differing_in_case_is_not_a_repeat() {
2184        assert!(read_yaml("x: {a: 1}\ny: {a: 2}\n").is_ok());
2185        assert!(read_yaml("a: 1\nA: 2\n").is_ok());
2186        // A key a merge supplies is overridden by the one written, not a repeat.
2187        let doc = read_yaml("p: &p {a: 1, b: 2}\nq: {<<: *p, a: 9}\n").unwrap();
2188        let q = doc.root().get_one("q").unwrap();
2189        assert_eq!(
2190            *q.get_one("a").unwrap().value().unwrap(),
2191            Scalar::Int(9.into())
2192        );
2193        assert_eq!(
2194            *q.get_one("b").unwrap().value().unwrap(),
2195            Scalar::Int(2.into())
2196        );
2197    }
2198
2199    #[test]
2200    fn omap_pairs_and_set_are_refused_with_tag_forbidden_at_the_root() {
2201        // C-13: whatever the node kind or position, path `$`, not a syntax error.
2202        for text in [
2203            "t: !!omap [{k: 1}, {j: 2}]\n",
2204            "t: !!pairs [{k: 1}, {k: 2}]\n",
2205            "t: !!set {x, y}\n",
2206            "t: !!set\n  ? x\n  ? y\n",
2207            "t: !!omap []\n",
2208            "!!omap [{k: 1}]\n",
2209            "a:\n  b: !!set {x}\n",
2210            "t: !!set x\n",
2211        ] {
2212            let e = read_yaml(text).unwrap_err();
2213            assert!(
2214                matches!(&e, OmnistError::Document(d)
2215                    if d.code.as_deref() == Some("format.tag-forbidden")
2216                        && d.path == "$"
2217                        && d.message.contains("!!")),
2218                "{text:?}: {e:?}"
2219            );
2220        }
2221        // A plain sequence of single-entry mappings is just a sequence.
2222        assert!(read_yaml("t:\n  - {k: 1}\n  - {j: 2}\n").is_ok());
2223    }
2224
2225    #[test]
2226    fn a_merge_tagged_merge_key_merges_and_a_str_tagged_one_does_not() {
2227        // D-27.
2228        let doc = read_yaml("p: &p {x: 1}\nq: {!!merge <<: *p, y: 2}\n").unwrap();
2229        let q = doc.root().get_one("q").unwrap();
2230        assert_eq!(q.labels(), vec!["x".to_string(), "y".to_string()]);
2231        let doc = read_yaml("p: &p {x: 1}\nr: &r {y: 2}\nq: {!!merge <<: [*p, *r]}\n").unwrap();
2232        let q = doc.root().get_one("q").unwrap();
2233        assert_eq!(q.labels(), vec!["x".to_string(), "y".to_string()]);
2234        let doc = read_yaml("q: {!!str <<: 2}\n").unwrap();
2235        let q = doc.root().get_one("q").unwrap();
2236        assert_eq!(q.labels(), vec!["<<".to_string()]);
2237        // A merge-tagged merge key is held to the same shape rule, and
2238        // `!!merge` on anything but `<<` is still not a supported tag.
2239        for text in ["q: {!!merge <<: 1}\n", "q: !!merge x\n"] {
2240            let e = read_yaml(text).unwrap_err();
2241            assert!(
2242                matches!(&e, OmnistError::Parse(p) if p.code == "parse.codec-syntax"),
2243                "{text:?}: {e:?}"
2244            );
2245        }
2246    }
2247
2248    #[test]
2249    fn invalid_yaml_syntax_is_a_parse_error() {
2250        let err = read_yaml("a: [1, 2\n").unwrap_err();
2251        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
2252    }
2253
2254    #[test]
2255    fn multiple_documents_is_a_parse_error() {
2256        let err = read_yaml("a: 1\n---\nb: 2\n").unwrap_err();
2257        assert!(
2258            matches!(&err, OmnistError::Parse(e) if e.message.contains("single document")),
2259            "got {err:?}"
2260        );
2261    }
2262
2263    #[test]
2264    fn nesting_far_past_max_depth_trips_the_event_stream_guard_not_the_stack() {
2265        // Deep enough that `Builder::too_deep` -- checked in the event
2266        // stream, before yaml-rust2's own recursive `load` would overflow
2267        // the stack -- fires well before any Document is even built: both
2268        // block sequences and block mappings, since each has its own
2269        // `SequenceStart`/`MappingStart` arm.
2270        let mut seq = String::new();
2271        for _ in 0..5000 {
2272            seq.push_str("- ");
2273        }
2274        let err = read_yaml(&seq).unwrap_err();
2275        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
2276
2277        let mut map = String::new();
2278        for i in 0..5000 {
2279            map.push_str(&"  ".repeat(i));
2280            map.push_str("a:\n");
2281        }
2282        let err = read_yaml(&map).unwrap_err();
2283        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
2284    }
2285
2286    #[test]
2287    fn nesting_past_max_depth_is_a_document_error() {
2288        // Genuine indentation-nested mappings, not a flat run of overwritten
2289        // "a:" keys -- each level indented two spaces deeper than the last.
2290        let mut text = String::new();
2291        for i in 0..=crate::document::MAX_DEPTH {
2292            text.push_str(&"  ".repeat(i));
2293            text.push_str("a:\n");
2294        }
2295        let err = read_yaml(&text).unwrap_err();
2296        assert!(matches!(err, OmnistError::Document(_)), "got {err:?}");
2297    }
2298
2299    #[test]
2300    fn integer_literal_over_digit_cap_is_rejected() {
2301        let text = format!("a: {}\n", "9".repeat(MAX_INT_DIGITS + 1));
2302        let err = read_yaml(&text).unwrap_err();
2303        assert!(
2304            matches!(&err, OmnistError::Document(e)
2305                if e.code.as_deref() == Some("document.limit.int-digits")
2306                    && e.path == "$.a"
2307                    && e.message.contains("4300-digit")),
2308            "got {err:?}"
2309        );
2310    }
2311
2312    /// `(code, path)` of the Document error `read_yaml` raises for `text`.
2313    fn doc_diag(text: &str) -> (String, String) {
2314        use OmnistError as E;
2315        let err = read_yaml(text).unwrap_err();
2316        let E::Document(e) = err else { panic!() };
2317        (e.code.unwrap_or_default(), e.path)
2318    }
2319
2320    fn unlabeled(path: &str) -> (String, String) {
2321        ("document.unlabeled-element".to_string(), path.to_string())
2322    }
2323
2324    #[test]
2325    fn a_non_string_key_in_the_top_level_mapping_is_at_the_root() {
2326        // Pinned by `formats-yaml/sharp-edges/norway-problem-...`.
2327        assert_eq!(doc_diag("on: 1\n"), unlabeled("$"));
2328        assert_eq!(doc_diag("a: 1\n1: x\n"), unlabeled("$"));
2329        assert_eq!(doc_diag("~: x\n"), unlabeled("$"));
2330    }
2331
2332    #[test]
2333    fn a_non_string_key_in_a_nested_mapping_carries_that_mapping_s_path() {
2334        assert_eq!(doc_diag("a: {on: 1}\n"), unlabeled("$.a"));
2335        assert_eq!(doc_diag("a:\n  on: 1\n"), unlabeled("$.a"));
2336        assert_eq!(
2337            doc_diag("a:\n  b:\n    c:\n      1: x\n"),
2338            unlabeled("$.a.b.c")
2339        );
2340        assert_eq!(doc_diag("x: 1\na:\n  y: 2\n  no: 3\n"), unlabeled("$.a"));
2341        // A label that needs quoting in a Document path (E-36), and one that
2342        // does not.
2343        assert_eq!(doc_diag("\"a.b\": {on: 1}\n"), unlabeled("$[\"a.b\"]"));
2344        assert_eq!(doc_diag("\"a b\": {on: 1}\n"), unlabeled("$.a b"));
2345    }
2346
2347    #[test]
2348    fn a_non_string_key_in_a_mapping_inside_a_sequence_follows_e10() {
2349        // E-10: the index is present when the label occurs more than once in
2350        // its node, absent when it occurs exactly once.
2351        assert_eq!(doc_diag("a: [{on: 1}]\n"), unlabeled("$.a"));
2352        assert_eq!(doc_diag("a: [{x: 1}, {on: 1}]\n"), unlabeled("$.a[1]"));
2353        assert_eq!(doc_diag("a: [{on: 1}, {x: 1}]\n"), unlabeled("$.a[0]"));
2354        assert_eq!(
2355            doc_diag("a: [{x: 1}, {y: 2}, {z: 3}, {on: 1}]\n"),
2356            unlabeled("$.a[3]")
2357        );
2358        // Deeper: a sequence under a nested key, then a mapping inside it.
2359        assert_eq!(
2360            doc_diag("a:\n  b:\n    - {x: 1}\n    - {c: {on: 1}}\n"),
2361            unlabeled("$.a.b[1].c")
2362        );
2363    }
2364
2365    #[test]
2366    fn a_non_string_key_reached_again_through_a_merge_is_reported_where_first_written() {
2367        // The anchored mapping is itself a node of the document, so it is the
2368        // first place the key is met; the merge into `m` does not move it.
2369        assert_eq!(
2370            doc_diag("base: &b {on: 1}\nm:\n  <<: *b\n"),
2371            unlabeled("$.base")
2372        );
2373    }
2374
2375    #[test]
2376    fn an_over_long_integer_key_in_a_nested_mapping_carries_the_mapping_s_path() {
2377        let options =
2378            YamlReadOptions::default().with_limits(Limits::default().with_max_int_digits(3));
2379        let err = read_yaml_with("a: {b: {1000: x}}\n", &options).unwrap_err();
2380        assert!(
2381            matches!(&err, OmnistError::Document(e)
2382                if e.code.as_deref() == Some("document.limit.int-digits") && e.path == "$.a.b"),
2383            "got {err:?}"
2384        );
2385    }
2386
2387    #[test]
2388    fn an_over_limit_integer_in_a_dropped_merge_entry_is_still_refused() {
2389        // D-13: a merged value the mapping's own key overrides (or an earlier
2390        // merge source shadows) never reaches the Document, but the integer
2391        // limit applies to the input, so the document is refused all the same.
2392        let options =
2393            YamlReadOptions::default().with_limits(Limits::default().with_max_int_digits(3));
2394        for text in [
2395            "m: {<<: {a: 1000}, a: 1}
2396",
2397            "m: {a: 1, <<: {a: 1000}}
2398",
2399            "m: {<<: [{a: 1}, {a: 1000}]}
2400",
2401            "m: {<<: {a: [1, {z: 1000}]}, a: 1}
2402",
2403            "m: {<<: {<<: {a: 1000}, a: 1}}
2404",
2405            "m: {<<: {<<: {a: 1000}}, a: 1}
2406",
2407        ] {
2408            let err = read_yaml_with(text, &options).unwrap_err();
2409            assert!(
2410                matches!(&err, OmnistError::Document(e)
2411                    if e.code.as_deref() == Some("document.limit.int-digits")),
2412                "{text:?} gave {err:?}"
2413            );
2414        }
2415        // At the limit, and when nothing is dropped, nothing changes.
2416        assert!(
2417            read_yaml_with(
2418                "m: {<<: {a: 999}, a: 1}
2419",
2420                &options
2421            )
2422            .is_ok()
2423        );
2424    }
2425
2426    #[test]
2427    fn an_over_long_integer_used_as_a_key_is_refused_by_the_digit_limit() {
2428        // Not as a missing label: the digit limit is what refuses it.
2429        let options =
2430            YamlReadOptions::default().with_limits(Limits::default().with_max_int_digits(3));
2431        let err = read_yaml_with("1000: x\n", &options).unwrap_err();
2432        assert!(
2433            matches!(&err, OmnistError::Document(e)
2434                if e.code.as_deref() == Some("document.limit.int-digits") && e.path == "$"),
2435            "got {err:?}"
2436        );
2437        // At the limit it is an ordinary non-string key.
2438        let err = read_yaml_with("999: x\n", &options).unwrap_err();
2439        assert!(
2440            matches!(&err, OmnistError::Document(e)
2441                if e.code.as_deref() == Some("document.unlabeled-element")),
2442            "got {err:?}"
2443        );
2444    }
2445
2446    #[test]
2447    fn i64_min_round_trips_through_yaml() {
2448        // Regression test for issue #26's fuzz harness finding: the
2449        // previous `parse_int_literal` stripped the sign, then parsed the
2450        // *positive* magnitude as `i64`, which overflows for `i64::MIN`
2451        // (whose magnitude, 9223372036854775808, is one past `i64::MAX`)
2452        // even though the signed value itself is representable.
2453        let doc = read_yaml("a: -9223372036854775808\n").unwrap();
2454        assert_eq!(
2455            *doc.root().get_one("a").unwrap().value().unwrap(),
2456            Scalar::Int((i64::MIN).into())
2457        );
2458    }
2459
2460    #[test]
2461    fn positive_integer_one_past_i64_max_parses() {
2462        // Issue #104: no more `i64` ceiling -- one past `i64::MAX` is now
2463        // just a real, correctly-parsed value.
2464        let doc = read_yaml("a: 9223372036854775808\n").unwrap();
2465        let value = doc.root().child("a").unwrap().value().unwrap();
2466        assert_eq!(value, &Scalar::Int(num_bigint::BigInt::from(i64::MAX) + 1));
2467    }
2468
2469    #[test]
2470    fn negative_integer_one_past_i64_min_parses() {
2471        // Issue #104: same, on the negative side -- was the `checked_neg`
2472        // overflow arm, now just a real value one past `i64::MIN`.
2473        let doc = read_yaml("a: -9223372036854775809\n").unwrap();
2474        let value = doc.root().child("a").unwrap().value().unwrap();
2475        assert_eq!(value, &Scalar::Int(num_bigint::BigInt::from(i64::MIN) - 1));
2476    }
2477
2478    #[test]
2479    fn integer_literal_over_i64_range_parses() {
2480        let text = format!("a: {}\n", "9".repeat(20));
2481        let doc = read_yaml(&text).unwrap();
2482        let value = doc.root().child("a").unwrap().value().unwrap();
2483        assert_eq!(
2484            value,
2485            &Scalar::Int(num_bigint::BigInt::parse_bytes(b"99999999999999999999", 10).unwrap())
2486        );
2487    }
2488
2489    // ---------------------------------------------------------- reader: anchors/aliases
2490
2491    #[test]
2492    fn reads_anchor_and_alias_as_a_deep_copy() {
2493        let doc = read_yaml("a: &x [1, 2, 3]\nb: *x\n").unwrap();
2494        let root = doc.root();
2495        assert_eq!(root.get("a").len(), 3);
2496        assert_eq!(root.get("b").len(), 3);
2497        assert_eq!(*root.get("b")[1].value().unwrap(), Scalar::Int((2).into()));
2498    }
2499
2500    #[test]
2501    fn unknown_alias_is_a_parse_error() {
2502        let err = read_yaml("a: *nope\n").unwrap_err();
2503        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
2504    }
2505
2506    /// Issue #42: builds the classic "billion laughs" pattern -- each
2507    /// generation's anchor references the previous generation's alias
2508    /// twice, so after `n` generations there are `2^n` leaf scalars
2509    /// materialized from a source document only `n` lines long and only
2510    /// `n` levels deep (well under `crate::document::MAX_DEPTH == 200`).
2511    /// 24 generations reaches `2^24` (16,777,216) nodes -- comfortably over
2512    /// `MAX_MATERIALIZED_NODES` (1,000,000 containers) so the guard trips partway
2513    /// through, and small enough that even the *unguarded* clone-everything
2514    /// behavior finishes (rather than hanging or exhausting memory) within
2515    /// this test's patience, which is what let this be captured red before
2516    /// the fix: before the fix this took over 4 seconds and allocated a
2517    /// many-million-node tree; after the fix it is rejected as a clean
2518    /// `ParseError` in well under a second (a debug build still has to
2519    /// walk/clone the last, still-under-the-ceiling generation, so this
2520    /// is not sub-millisecond, but it is bounded by the ceiling rather
2521    /// than by the attack's exponent), well before the full tree is
2522    /// ever materialized.
2523    fn billion_laughs_yaml(generations: usize) -> String {
2524        let mut out = String::new();
2525        out.push_str("a0: &a0 [x, x]\n");
2526        for i in 1..generations {
2527            out.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
2528        }
2529        out
2530    }
2531
2532    #[test]
2533    fn billion_laughs_alias_amplification_is_rejected_fast_issue_42() {
2534        let text = billion_laughs_yaml(24);
2535        // A source document of only 24 short lines.
2536        assert!(text.len() < 1000, "source text should be tiny: {text:?}");
2537
2538        let start = std::time::Instant::now();
2539        let err = read_yaml(&text).unwrap_err();
2540        let elapsed = start.elapsed();
2541
2542        // Since D-18/D-19 the bomb is refused by the alias expansion check
2543        // before any expansion is built, with the expansion code (the node cap
2544        // is the backstop for what passes that check; see `alias_tests`).
2545        assert!(
2546            matches!(&err, OmnistError::Document(e)
2547                if e.code.as_deref() == Some("document.limit.alias-expansion")
2548                    && e.path == "$"),
2549            "expected the alias expansion rejection, got {err:?}"
2550        );
2551        assert!(
2552            elapsed < std::time::Duration::from_secs(5),
2553            "fix should reject the bomb almost immediately, took {elapsed:?}"
2554        );
2555    }
2556
2557    #[test]
2558    fn moderate_legitimate_nested_alias_reuse_still_works() {
2559        // A handful of anchors reused a few times each -- ordinary,
2560        // legitimate YAML anchor/alias usage (e.g. shared defaults), well
2561        // within any reasonable node-count limit. Must not be affected by
2562        // the issue #42 fix.
2563        let doc = read_yaml(
2564            "base: &base\n  x: 1\n  y: 2\na: *base\nb: *base\nc: *base\nlist: &list [1, 2, 3]\nd: *list\ne: *list\n",
2565        )
2566        .unwrap();
2567        let root = doc.root();
2568        for label in ["a", "b", "c"] {
2569            let node = root.get_one(label).unwrap();
2570            assert_eq!(
2571                *node.get_one("x").unwrap().value().unwrap(),
2572                Scalar::Int((1).into())
2573            );
2574            assert_eq!(
2575                *node.get_one("y").unwrap().value().unwrap(),
2576                Scalar::Int((2).into())
2577            );
2578        }
2579        for label in ["d", "e"] {
2580            assert_eq!(root.get(label).len(), 3);
2581        }
2582    }
2583
2584    // ---------------------------------------------------------- reader: merge keys
2585
2586    #[test]
2587    fn merge_key_from_a_mapping_merges_with_local_keys_winning() {
2588        let doc =
2589            read_yaml("base: &b\n  x: 1\n  y: 2\nchild:\n  <<: *b\n  y: 20\n  z: 3\n").unwrap();
2590        let child = doc.root().get_one("child").unwrap();
2591        assert_eq!(
2592            *child.get_one("x").unwrap().value().unwrap(),
2593            Scalar::Int((1).into())
2594        );
2595        assert_eq!(
2596            *child.get_one("y").unwrap().value().unwrap(),
2597            Scalar::Int((20).into()),
2598            "an explicit local key beats the merged-in value"
2599        );
2600        assert_eq!(
2601            *child.get_one("z").unwrap().value().unwrap(),
2602            Scalar::Int((3).into())
2603        );
2604    }
2605
2606    #[test]
2607    fn merge_key_from_a_sequence_of_mappings_merges_each_in_order() {
2608        // First-listed source wins a collision between the sources
2609        // themselves (YAML merge spec), matching PyYAML's own behavior.
2610        let doc =
2611            read_yaml("a: &a\n  x: 1\nb: &b\n  x: 2\n  y: 3\nchild:\n  <<: [*a, *b]\n").unwrap();
2612        let child = doc.root().get_one("child").unwrap();
2613        assert_eq!(
2614            *child.get_one("x").unwrap().value().unwrap(),
2615            Scalar::Int((1).into())
2616        );
2617        assert_eq!(
2618            *child.get_one("y").unwrap().value().unwrap(),
2619            Scalar::Int((3).into())
2620        );
2621    }
2622
2623    #[test]
2624    fn quoted_double_angle_bracket_key_is_a_literal_string_not_a_merge() {
2625        let doc = read_yaml("a:\n  \"<<\": 1\n").unwrap();
2626        let a = doc.root().get_one("a").unwrap();
2627        assert_eq!(
2628            *a.get_one("<<").unwrap().value().unwrap(),
2629            Scalar::Int((1).into())
2630        );
2631    }
2632
2633    // omnist-ts#46: a fuzz suite intermittently found a ParseError with a
2634    // non-map merge source -- pinned here as an explicit regression: merging
2635    // from a scalar must fail predictably (a clean ParseError), never panic
2636    // or behave inconsistently.
2637    #[test]
2638    fn merge_key_from_a_non_map_scalar_source_is_a_clean_parse_error_omnist_ts_46() {
2639        let err = read_yaml("child:\n  <<: 5\n  y: 2\n").unwrap_err();
2640        assert!(
2641            matches!(&err, OmnistError::Parse(e) if e.message.contains("merge key")),
2642            "got {err:?}"
2643        );
2644    }
2645
2646    #[test]
2647    fn merge_key_from_a_sequence_containing_a_scalar_is_a_clean_parse_error() {
2648        let err = read_yaml("child:\n  <<: [1, 2]\n").unwrap_err();
2649        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
2650    }
2651
2652    #[test]
2653    fn merge_source_with_a_non_scalar_key_is_a_clean_parse_error() {
2654        // Exercises the (structurally rare) branch where a merge source's
2655        // own key isn't a plain scalar (YAML's complex-mapping-key syntax) --
2656        // `scalar_key_text` returns `None` for it during merge de-duplication,
2657        // and the final scalar-key check in `raw_to_value` still catches it.
2658        let err = read_yaml("base: &b\n  ? [1, 2]\n  : 3\nchild:\n  <<: *b\n").unwrap_err();
2659        assert!(
2660            matches!(&err, OmnistError::Parse(e) if e.message.contains("mapping key must be a scalar")),
2661            "got {err:?}"
2662        );
2663    }
2664
2665    #[test]
2666    fn merge_key_from_an_alias_to_a_scalar_is_a_clean_parse_error() {
2667        let err = read_yaml("base: &b 5\nchild:\n  <<: *b\n").unwrap_err();
2668        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
2669    }
2670
2671    // ---------------------------------------------------------- reader: explicit tags
2672
2673    #[test]
2674    fn explicit_tags_construct_the_named_type_regardless_of_spelling() {
2675        let doc = read_yaml("a: !!str yes\nb: !!int \"5\"\nc: !!bool \"true\"\n").unwrap();
2676        let root = doc.root();
2677        assert_eq!(
2678            *root.get_one("a").unwrap().value().unwrap(),
2679            Scalar::Str("yes".to_string())
2680        );
2681        assert_eq!(
2682            *root.get_one("b").unwrap().value().unwrap(),
2683            Scalar::Int((5).into())
2684        );
2685        assert_eq!(
2686            *root.get_one("c").unwrap().value().unwrap(),
2687            Scalar::Bool(true)
2688        );
2689    }
2690
2691    #[test]
2692    fn unsupported_explicit_tag_is_a_parse_error() {
2693        let err = read_yaml("a: !!binary \"x\"\n").unwrap_err();
2694        assert!(matches!(err, OmnistError::Parse(_)), "got {err:?}");
2695    }
2696
2697    #[test]
2698    fn explicit_bool_tag_accepts_the_full_yaml_1_1_spelling_set_not_just_true_false() {
2699        // Live-confirmed against PyYAML (see module doc comment on
2700        // `explicit_tag_to_value`'s "bool" arm): `!!bool` uses the exact same
2701        // `bool_values` lookup regardless of implicit vs. explicit tagging,
2702        // so "yes"/"On"/"OFF" all construct via the explicit tag too.
2703        let doc = read_yaml("a: !!bool \"yes\"\nb: !!bool \"On\"\nc: !!bool \"OFF\"\n").unwrap();
2704        let root = doc.root();
2705        assert_eq!(
2706            *root.get_one("a").unwrap().value().unwrap(),
2707            Scalar::Bool(true)
2708        );
2709        assert_eq!(
2710            *root.get_one("b").unwrap().value().unwrap(),
2711            Scalar::Bool(true)
2712        );
2713        assert_eq!(
2714            *root.get_one("c").unwrap().value().unwrap(),
2715            Scalar::Bool(false)
2716        );
2717    }
2718
2719    #[test]
2720    fn invalid_explicit_bool_spelling_is_a_parse_error() {
2721        let err = read_yaml("a: !!bool \"nonsense\"\n").unwrap_err();
2722        assert!(
2723            matches!(&err, OmnistError::Parse(e) if e.message.contains("!!bool")),
2724            "got {err:?}"
2725        );
2726    }
2727
2728    #[test]
2729    fn bare_y_or_n_is_not_a_valid_explicit_bool_spelling() {
2730        // Live-confirmed: PyYAML's `bool_values` dict has no "y"/"n" keys
2731        // even though the plain-scalar implicit resolver never even tags
2732        // these as bool in the first place -- a bare "y"/"n" reaches this
2733        // arm only via an explicit `!!bool` tag, and PyYAML raises
2734        // (`KeyError` -> `ConstructorError`) rather than accepting it.
2735        let err = read_yaml("a: !!bool \"y\"\n").unwrap_err();
2736        assert!(
2737            matches!(&err, OmnistError::Parse(e) if e.message.contains("!!bool")),
2738            "got {err:?}"
2739        );
2740    }
2741
2742    #[test]
2743    fn bare_on_key_resolves_to_boolean_and_is_rejected_norway_problem() {
2744        // issue #88: YAML 1.1's core schema resolves a bare `on:` key to
2745        // the boolean `true`, same as it would as a value -- since a label
2746        // MUST be a string, this must be rejected as a DocumentError, not
2747        // silently kept as the literal string "on".
2748        let err = read_yaml("on:\n  push: true\n").unwrap_err();
2749        assert!(
2750            matches!(&err, OmnistError::Document(e) if e.path == "$"),
2751            "got {err:?}"
2752        );
2753    }
2754
2755    #[test]
2756    fn other_implicit_bool_and_null_key_spellings_are_also_rejected() {
2757        for key in ["off", "yes", "no", "Off", "YES", "~", "null", "true"] {
2758            let text = format!("{key}:\n  push: true\n");
2759            let err = read_yaml(&text).unwrap_err();
2760            assert!(
2761                matches!(&err, OmnistError::Document(_)),
2762                "key {key:?} got {err:?}"
2763            );
2764        }
2765    }
2766
2767    #[test]
2768    fn bare_y_or_n_key_is_not_a_bool_and_stays_a_string_label() {
2769        // Live-confirmed against PyYAML/omnist: unlike "yes"/"no", bare
2770        // "y"/"n" are NOT booleans under the implicit resolver, so these
2771        // stay ordinary string keys and parse successfully.
2772        let doc = read_yaml("y:\n  push: true\n").unwrap();
2773        assert!(doc.root().get_one("y").is_ok());
2774        let doc = read_yaml("n:\n  push: true\n").unwrap();
2775        assert!(doc.root().get_one("n").is_ok());
2776    }
2777
2778    #[test]
2779    fn sexagesimal_looking_key_also_resolves_and_is_rejected() {
2780        // Interaction check between #87 and #88: a key shaped like a
2781        // sexagesimal int must go through the same resolver and be
2782        // rejected as a non-string label, consistent with the bool case.
2783        let err = read_yaml("12:00:00:\n  push: true\n").unwrap_err();
2784        assert!(
2785            matches!(&err, OmnistError::Document(e) if e.path == "$"),
2786            "got {err:?}"
2787        );
2788    }
2789
2790    #[test]
2791    fn describe_non_string_key_renders_pythonic_spellings() {
2792        assert_eq!(describe_non_string_key(&Value::Bool(true)), "True");
2793        assert_eq!(describe_non_string_key(&Value::Bool(false)), "False");
2794        assert_eq!(describe_non_string_key(&Value::Null), "None");
2795        assert_eq!(
2796            describe_non_string_key(&Value::Int((43200).into())),
2797            "43200"
2798        );
2799        assert_eq!(describe_non_string_key(&Value::Float(1.5)), "1.5");
2800        // Whole-number floats must keep the `.0` (Python's `repr(1.0)` is
2801        // `'1.0'`, not `'1'` -- `f64::to_string()` alone drops it).
2802        assert_eq!(describe_non_string_key(&Value::Float(1.0)), "1.0");
2803        assert_eq!(describe_non_string_key(&Value::Float(-2.0)), "-2.0");
2804        // Structurally unreachable via the real call site (see the
2805        // function's doc comment), exercised directly here so the wildcard
2806        // fallback arm is real, tested code, not a dead branch.
2807        assert_eq!(
2808            describe_non_string_key(&Value::Str("x".to_string())),
2809            "Str(\"x\")"
2810        );
2811    }
2812
2813    #[test]
2814    fn int_and_float_shaped_mapping_keys_are_rejected_with_python_parity_messages() {
2815        // Direct coverage for the reviewer-flagged gap: int/float-shaped
2816        // (non-bool/null) keys are also generically rejected, and the
2817        // whole-number float case must render as Python's `1.0`, not `1`.
2818        let err = read_yaml("123:\n  a: 1\n").unwrap_err();
2819        assert!(
2820            matches!(&err, OmnistError::Document(e) if e.path == "$" && e.message.contains("123")),
2821            "got {err:?}"
2822        );
2823        let err = read_yaml("1.0:\n  a: 1\n").unwrap_err();
2824        assert!(
2825            matches!(&err, OmnistError::Document(e) if e.path == "$" && e.message.contains("1.0")),
2826            "got {err:?}"
2827        );
2828    }
2829
2830    #[test]
2831    fn a_non_scalar_mapping_key_is_a_clean_parse_error() {
2832        // YAML's complex-mapping-key syntax (`? ... : ...`) allows a
2833        // sequence/mapping as a key -- this model's edges are always
2834        // string-labeled, so this is rejected with a clean ParseError.
2835        let err = read_yaml("? [1, 2]\n: 3\n").unwrap_err();
2836        assert!(
2837            matches!(&err, OmnistError::Parse(e) if e.message.contains("mapping key must be a scalar")),
2838            "got {err:?}"
2839        );
2840    }
2841
2842    // ---------------------------------------------------------- writer
2843
2844    #[test]
2845    fn round_trips_every_scalar_kind() {
2846        let v = obj(vec![
2847            ("null", Value::Null),
2848            ("bool", Value::Bool(true)),
2849            ("int", Value::Int((42).into())),
2850            ("float", Value::Float(1.5)),
2851            ("str", Value::Str("hi".to_string())),
2852        ]);
2853        let doc = doc_of(v);
2854        let text = write_yaml(&doc, false, None).unwrap();
2855        let back = read_yaml(&text).unwrap();
2856        assert!(doc.eq_doc(&back));
2857    }
2858
2859    #[test]
2860    fn round_trips_integral_float_at_and_above_1e17_boundary_issue_46() {
2861        // Regression test for issue #46 (see json.rs's twin test for the
2862        // full explanation): an integral-valued float >= 1e17 used to
2863        // render as a bare digit run and re-read as `Scalar::Int`.
2864        for x in [1.0e17, 1.0e18, -1.23e17, 9.9e16_f64] {
2865            let doc = doc_of(obj(vec![("a", Value::Float(x))]));
2866            let text = write_yaml(&doc, false, None).unwrap();
2867            let back = read_yaml(&text).unwrap();
2868            assert_eq!(
2869                *back.root().get_one("a").unwrap().value().unwrap(),
2870                Scalar::Float(x),
2871                "x={x} text={text}"
2872            );
2873        }
2874    }
2875
2876    #[test]
2877    fn round_trips_nan_and_infinity_natively_no_adjustment_needed() {
2878        let v = obj(vec![
2879            ("a", Value::Float(f64::NAN)),
2880            ("b", Value::Float(f64::INFINITY)),
2881            ("c", Value::Float(f64::NEG_INFINITY)),
2882        ]);
2883        let doc = doc_of(v);
2884        let mut rep = WriteReport::new();
2885        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
2886        assert!(rep.is_empty());
2887        let back = read_yaml(&text).unwrap();
2888        assert!(
2889            matches!(back.root().get_one("a").unwrap().value().unwrap(), Scalar::Float(x) if x.is_nan())
2890        );
2891        assert_eq!(
2892            *back.root().get_one("b").unwrap().value().unwrap(),
2893            Scalar::Float(f64::INFINITY)
2894        );
2895        assert_eq!(
2896            *back.root().get_one("c").unwrap().value().unwrap(),
2897            Scalar::Float(f64::NEG_INFINITY)
2898        );
2899    }
2900
2901    #[test]
2902    fn round_trips_strings_that_look_like_other_scalar_kinds() {
2903        let v = obj(vec![
2904            ("a", Value::Str("yes".to_string())),
2905            ("b", Value::Str("null".to_string())),
2906            ("c", Value::Str("123".to_string())),
2907            ("d", Value::Str("1.5".to_string())),
2908            ("e", Value::Str("".to_string())),
2909            ("f", Value::Str("2024-01-15".to_string())),
2910        ]);
2911        let doc = doc_of(v);
2912        let text = write_yaml(&doc, false, None).unwrap();
2913        let back = read_yaml(&text).unwrap();
2914        assert!(doc.eq_doc(&back), "text was:\n{text}");
2915    }
2916
2917    #[test]
2918    fn round_trips_repeated_labels_as_a_yaml_sequence() {
2919        let doc = doc_of(obj(vec![(
2920            "m",
2921            Value::Array(vec![
2922                Value::Int((1).into()),
2923                Value::Int((2).into()),
2924                Value::Int((3).into()),
2925            ]),
2926        )]));
2927        let text = write_yaml(&doc, false, None).unwrap();
2928        let back = read_yaml(&text).unwrap();
2929        assert!(doc.eq_doc(&back));
2930    }
2931
2932    #[test]
2933    fn round_trips_nested_mappings_and_sequences_of_mappings() {
2934        let v = obj(vec![
2935            (
2936                "a",
2937                obj(vec![
2938                    ("b", Value::Int((1).into())),
2939                    ("c", Value::Int((2).into())),
2940                ]),
2941            ),
2942            (
2943                "items",
2944                Value::Array(vec![
2945                    obj(vec![
2946                        ("x", Value::Int((1).into())),
2947                        ("y", Value::Int((2).into())),
2948                    ]),
2949                    obj(vec![
2950                        ("x", Value::Int((3).into())),
2951                        ("y", Value::Int((4).into())),
2952                    ]),
2953                ]),
2954            ),
2955        ]);
2956        let doc = doc_of(v);
2957        let text = write_yaml(&doc, false, None).unwrap();
2958        let back = read_yaml(&text).unwrap();
2959        assert!(doc.eq_doc(&back), "text was:\n{text}");
2960    }
2961
2962    #[test]
2963    fn writes_empty_object_and_array_compactly() {
2964        let doc = doc_of(obj(vec![("o", Value::Object(IndexMap::new()))]));
2965        let text = write_yaml(&doc, false, None).unwrap();
2966        assert!(text.contains("o: {}"));
2967    }
2968
2969    #[test]
2970    fn nel_string_triggers_a_warning_and_still_round_trips() {
2971        let s = format!("a{}b", '\u{0085}');
2972        let doc = doc_of(obj(vec![("s", Value::Str(s.clone()))]));
2973        let mut rep = WriteReport::new();
2974        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
2975        assert_eq!(rep.len(), 1);
2976        assert_eq!(rep.adjustments()[0].code, "format.string-line-break-char");
2977        let back = read_yaml(&text).unwrap();
2978        assert_eq!(
2979            *back.root().get_one("s").unwrap().value().unwrap(),
2980            Scalar::Str(s)
2981        );
2982    }
2983
2984    #[test]
2985    fn strict_write_with_nel_raises_and_carries_the_report() {
2986        let s = format!("x{}y", '\u{0085}');
2987        let doc = doc_of(obj(vec![("s", Value::Str(s))]));
2988        let err = write_yaml(&doc, true, None).unwrap_err();
2989        let rep = err.report().expect("strict WriteError carries a report");
2990        assert_eq!(rep.len(), 1);
2991    }
2992
2993    #[test]
2994    fn strict_write_with_no_adjustments_succeeds() {
2995        let doc = doc_of(obj(vec![("a", Value::Int((1).into()))]));
2996        let text = write_yaml(&doc, true, None).unwrap();
2997        assert!(text.contains("a: 1"));
2998    }
2999
3000    #[test]
3001    fn check_yaml_reports_without_producing_output() {
3002        let s = format!("a{}b", '\u{0085}');
3003        let doc = doc_of(obj(vec![("s", Value::Str(s))]));
3004        let rep = check_yaml(&doc);
3005        assert_eq!(rep.len(), 1);
3006        assert_eq!(rep.adjustments()[0].path, "$.s");
3007    }
3008
3009    #[test]
3010    fn deeply_nested_document_write_reuses_doc_construction_depth_guard() {
3011        let mut v = Value::Int((0).into());
3012        for _ in 0..=crate::document::MAX_DEPTH {
3013            v = obj(vec![("a", v)]);
3014        }
3015        assert!(Doc::of(&v).is_err());
3016    }
3017
3018    // ---------------------------------------------------------- omnist-ts#43: wide document
3019
3020    // omnist-ts#43: YAML read was a ~3x-25x performance outlier vs OML/JSON on
3021    // wide documents. Not a hard timing assertion here (Rust is a different
3022    // performance regime) -- this is the *correctness*-under-scale angle:
3023    // a wide document reads and round-trips correctly, so a similarly bad
3024    // implementation (e.g. quadratic re-scanning per field) doesn't silently
3025    // produce wrong output even if it's slow.
3026    #[test]
3027    fn wide_document_smoke_test_reads_and_round_trips_every_field() {
3028        let n = 5_000;
3029        let mut text = String::new();
3030        for i in 0..n {
3031            text.push_str(&format!("field{i}: {i}\n"));
3032        }
3033        let doc = read_yaml(&text).unwrap();
3034        let root = doc.root();
3035        assert_eq!(root.labels().len(), n);
3036        for i in [0, n / 2, n - 1] {
3037            assert_eq!(
3038                *root.get_one(&format!("field{i}")).unwrap().value().unwrap(),
3039                Scalar::Int((i as i64).into())
3040            );
3041        }
3042        let out = write_yaml(&doc, false, None).unwrap();
3043        let back = read_yaml(&out).unwrap();
3044        assert!(doc.eq_doc(&back));
3045    }
3046
3047    #[test]
3048    fn wide_flat_sequence_smoke_test() {
3049        let n = 5_000;
3050        let mut text = String::from("m:\n");
3051        for i in 0..n {
3052            text.push_str(&format!("  - {i}\n"));
3053        }
3054        let doc = read_yaml(&text).unwrap();
3055        assert_eq!(doc.root().get("m").len(), n);
3056    }
3057
3058    // ---------------------------------------------------------- coverage: explicit tags/errors
3059
3060    #[test]
3061    fn invalid_explicit_float_literal_is_a_parse_error() {
3062        let err = read_yaml("a: !!float \"not-a-float\"\n").unwrap_err();
3063        assert!(
3064            matches!(&err, OmnistError::Parse(e) if e.message.contains("invalid float literal")),
3065            "got {err:?}"
3066        );
3067    }
3068
3069    #[test]
3070    fn explicit_float_tag_accepts_inf_and_nan_and_negative() {
3071        let doc = read_yaml("a: !!float \"-1.5\"\n").unwrap();
3072        assert_eq!(
3073            *doc.root().get_one("a").unwrap().value().unwrap(),
3074            Scalar::Float(-1.5)
3075        );
3076    }
3077
3078    // ---------------------------------------------------------- coverage: timestamp edge cases
3079
3080    // Live-confirmed against PyYAML (see `normalize_timestamp`'s doc
3081    // comment): a timestamp-*shaped* string naming a calendar/clock value
3082    // that doesn't exist is a clean ParseError, not a silent string
3083    // fallback -- `yaml.safe_load` calls `datetime.date`/`datetime.datetime`
3084    // construction on the captured fields, and that raises `ValueError`
3085    // for an out-of-range month/day/hour/minute/timezone, failing the
3086    // *entire document*, not just retyping this one scalar as a string.
3087
3088    #[test]
3089    fn timestamp_with_invalid_month_is_a_parse_error() {
3090        let err = read_yaml("a: 2024-13-01\n").unwrap_err();
3091        assert!(
3092            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
3093            "got {err:?}"
3094        );
3095    }
3096
3097    #[test]
3098    fn timestamp_with_year_zero_is_a_parse_error() {
3099        let err = read_yaml("a: 0000-01-01\n").unwrap_err();
3100        assert!(
3101            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
3102            "got {err:?}"
3103        );
3104    }
3105
3106    #[test]
3107    fn timestamp_with_a_day_that_doesnt_exist_in_the_month_is_a_parse_error() {
3108        // February 30th never exists, regardless of leap year -- exercises
3109        // the day-count upper bound (`schema::valid_ymd`'s `days_in_month`),
3110        // not just the "day < 1" lower bound.
3111        let err = read_yaml("a: 2024-02-30\n").unwrap_err();
3112        assert!(
3113            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
3114            "got {err:?}"
3115        );
3116    }
3117
3118    #[test]
3119    fn timestamp_february_29_on_a_non_leap_year_is_a_parse_error() {
3120        let err = read_yaml("a: 2023-02-29\n").unwrap_err();
3121        assert!(
3122            matches!(&err, OmnistError::Parse(e) if e.message.contains("calendar date")),
3123            "got {err:?}"
3124        );
3125    }
3126
3127    #[test]
3128    fn timestamp_february_29_on_a_leap_year_normalizes_fine() {
3129        let doc = read_yaml("a: 2024-02-29\n").unwrap();
3130        assert_eq!(
3131            *doc.root().get_one("a").unwrap().value().unwrap(),
3132            Scalar::Date("2024-02-29".to_string())
3133        );
3134    }
3135
3136    #[test]
3137    fn timestamp_with_out_of_range_hour_is_a_parse_error() {
3138        let err = read_yaml("a: 2024-01-01T25:00:00\n").unwrap_err();
3139        assert!(
3140            matches!(&err, OmnistError::Parse(e) if e.message.contains("time of day")),
3141            "got {err:?}"
3142        );
3143    }
3144
3145    #[test]
3146    fn timestamp_with_out_of_range_minute_is_a_parse_error() {
3147        let err = read_yaml("a: 2024-01-01T00:61:00\n").unwrap_err();
3148        assert!(
3149            matches!(&err, OmnistError::Parse(e) if e.message.contains("time of day")),
3150            "got {err:?}"
3151        );
3152    }
3153
3154    #[test]
3155    fn timestamp_with_out_of_range_timezone_offset_is_a_parse_error() {
3156        let err = read_yaml("a: 2024-01-01T00:00:00+25:00\n").unwrap_err();
3157        assert!(
3158            matches!(&err, OmnistError::Parse(e) if e.message.contains("timezone offset")),
3159            "got {err:?}"
3160        );
3161    }
3162
3163    #[test]
3164    fn timestamp_with_hour_only_timezone_offset_normalizes_with_zero_minutes() {
3165        let doc = read_yaml("a: 2024-01-01T00:00:00+05\n").unwrap();
3166        assert_eq!(
3167            *doc.root().get_one("a").unwrap().value().unwrap(),
3168            Scalar::Datetime("2024-01-01T00:00:00+05:00".to_string())
3169        );
3170    }
3171
3172    // ---------------------------------------------------------- coverage: writer
3173
3174    #[test]
3175    fn nel_in_a_label_triggers_a_warning_and_still_round_trips() {
3176        let label = format!("a{}b", '\u{0085}');
3177        let doc = doc_of(obj(vec![(label.as_str(), Value::Int((1).into()))]));
3178        let mut rep = WriteReport::new();
3179        let text = write_yaml(&doc, false, Some(&mut rep)).unwrap();
3180        assert_eq!(rep.len(), 1);
3181        assert_eq!(rep.adjustments()[0].code, "format.string-line-break-char");
3182        let back = read_yaml(&text).unwrap();
3183        assert_eq!(
3184            *back.root().get_one(&label).unwrap().value().unwrap(),
3185            Scalar::Int((1).into())
3186        );
3187    }
3188
3189    #[test]
3190    fn write_node_on_a_bare_empty_array_writes_the_flow_empty_token() {
3191        // `Doc`'s own model never produces a *standalone* empty-array node
3192        // (an empty `Value::Array` under a key expands into zero edges at
3193        // construction time -- see `document.rs`'s own note on this) --
3194        // white-box exercising `write_node`'s empty-array arm directly here,
3195        // the same "test the arm directly since the public API can't reach
3196        // it" pattern `document.rs`'s `internal_edges_mut_rejects_a_leaf_directly`
3197        // and `json.rs`'s handling of the analogous case establish.
3198        let mut out = String::new();
3199        write_node(&Value::Array(vec![]), 0, &mut out, true);
3200        assert_eq!(out, "[]\n");
3201    }
3202
3203    #[test]
3204    fn round_trips_strings_needing_every_quoting_trigger() {
3205        let cases = [
3206            "-leading-dash",
3207            "?leading-question",
3208            ":leading-colon",
3209            ",leading-comma",
3210            "[leading-bracket",
3211            "]leading-bracket",
3212            "{leading-brace",
3213            "}leading-brace",
3214            "#leading-hash",
3215            "&leading-amp",
3216            "*leading-star",
3217            "!leading-bang",
3218            "|leading-pipe",
3219            ">leading-gt",
3220            "'leading-quote",
3221            "\"leading-dquote",
3222            "%leading-percent",
3223            "@leading-at",
3224            "`leading-backtick",
3225            " leading-space",
3226            "trailing-space ",
3227            "embedded: colon-space",
3228            "trailing-colon:",
3229            "embedded #hash-space",
3230            "line\nbreak",
3231            "tab\ttab",
3232            "quote\"quote",
3233            "back\\slash",
3234            "control\u{01}char",
3235        ];
3236        for s in cases {
3237            let doc = doc_of(obj(vec![("s", Value::Str(s.to_string()))]));
3238            let text = write_yaml(&doc, false, None).unwrap();
3239            let back = read_yaml(&text).unwrap();
3240            assert!(
3241                doc.eq_doc(&back),
3242                "round trip failed for {s:?}, text was:\n{text}"
3243            );
3244        }
3245    }
3246
3247    #[test]
3248    fn write_scalar_value_panics_on_a_non_leaf_value() {
3249        // `write_scalar_value` is only ever called on a leaf via the public
3250        // `write_yaml` path (every call site passes a scalar) -- white-box
3251        // confirming the documented invariant directly, same rationale as
3252        // the empty-array test above.
3253        let result = std::panic::catch_unwind(|| {
3254            let mut out = String::new();
3255            write_scalar_value(&Value::Object(IndexMap::new()), &mut out);
3256        });
3257        assert!(result.is_err());
3258    }
3259
3260    // ---------------------------------------------------------- coverage: Builder white-box
3261
3262    // The following four tests drive `Builder`'s private event-handling
3263    // methods directly rather than through `read_yaml`/`Parser`. Each covers
3264    // a branch that is structurally unreachable via any real YAML text --
3265    // confirmed empirically (see `on_event_impl`'s doc comment and this
3266    // module's doc comment on the crate-choice rationale): `yaml_rust2`'s own
3267    // scanner already rejects an alias to an undefined anchor, and every
3268    // document's event stream always nests its root exactly once before a
3269    // `DocumentEnd`/pushes only `Sequence`/`Mapping` as containers. Testing
3270    // the arm directly (rather than leaving it an untested dead branch)
3271    // matches `document.rs`'s `internal_edges_mut_rejects_a_leaf_directly`
3272    // precedent.
3273    use yaml_rust2::parser::Event;
3274    use yaml_rust2::scanner::Marker;
3275
3276    /// `Marker` has no public constructor, so a real one is captured from an
3277    /// actual (trivial) parse -- the tests below only care about the event
3278    /// stream reaching `Builder`'s methods, not about which `Marker` value
3279    /// they carry.
3280    fn test_marker() -> Marker {
3281        struct Capture(Option<Marker>);
3282        impl MarkedEventReceiver for Capture {
3283            fn on_event(&mut self, _ev: Event, mark: Marker) {
3284                self.0.get_or_insert(mark);
3285            }
3286        }
3287        let mut cap = Capture(None);
3288        Parser::new("x".chars()).load(&mut cap, false).unwrap();
3289        cap.0
3290            .expect("a trivial scalar document always emits at least one event")
3291    }
3292
3293    /// The alias check refuses every non-mapping merge shape before the
3294    /// merge resolver runs (D-18a); these two arms are its backstop, proven
3295    /// by calling the resolver directly, as for the other `unreachable!`s.
3296    #[test]
3297    #[should_panic(expected = "the alias check refused a scalar merge value")]
3298    fn merge_source_entries_panics_on_a_scalar_the_check_refused() {
3299        let scalar = Raw::Scalar("1".to_string(), TScalarStyle::Plain, None);
3300        let _ = merge_source_entries(&scalar, 0, 200, &mut Vec::new());
3301    }
3302
3303    #[test]
3304    #[should_panic(expected = "the alias check refused a non-mapping merge member")]
3305    fn merge_source_entries_panics_on_a_member_the_check_refused() {
3306        let member = Raw::Scalar("1".to_string(), TScalarStyle::Plain, None);
3307        let _ = merge_source_entries(&Raw::Sequence(vec![member]), 0, 200, &mut Vec::new());
3308    }
3309
3310    #[test]
3311    fn builder_document_end_with_an_empty_stack_pushes_a_null_scalar() {
3312        let mut b = Builder::new(&Resolved::DEFAULT);
3313        b.on_event_impl(Event::DocumentEnd, test_marker());
3314        assert_eq!(b.docs.len(), 1);
3315        assert!(matches!(&b.docs[0], Raw::Scalar(s, TScalarStyle::Plain, None) if s.is_empty()));
3316    }
3317
3318    #[test]
3319    #[should_panic(expected = "a single document's stack never nests more than one root")]
3320    fn builder_document_end_with_more_than_one_stack_entry_panics() {
3321        let mut b = Builder::new(&Resolved::DEFAULT);
3322        b.doc_stack
3323            .push((Raw::Scalar(String::new(), TScalarStyle::Plain, None), 0));
3324        b.doc_stack
3325            .push((Raw::Scalar(String::new(), TScalarStyle::Plain, None), 0));
3326        b.on_event_impl(Event::DocumentEnd, test_marker());
3327    }
3328
3329    #[test]
3330    #[should_panic(expected = "a Scalar is never a container on doc_stack")]
3331    fn builder_insert_onto_a_scalar_container_panics() {
3332        let mut b = Builder::new(&Resolved::DEFAULT);
3333        b.doc_stack
3334            .push((Raw::Scalar("x".to_string(), TScalarStyle::Plain, None), 0));
3335        b.insert(
3336            Raw::Scalar("y".to_string(), TScalarStyle::Plain, None),
3337            0,
3338            test_marker(),
3339        );
3340    }
3341
3342    #[test]
3343    fn builder_alias_to_an_anchor_with_no_entry_is_a_document_error_not_a_panic() {
3344        // Calling `on_event_impl` directly with an id that has no entry: the
3345        // same state a self-referential definition reaches through real
3346        // input (see `self_referential_anchors_are_rejected_not_panicked`).
3347        let mut b = Builder::new(&Resolved::DEFAULT);
3348        b.on_event_impl(Event::Alias(999), test_marker());
3349        let e = b.self_reference.as_ref().expect("recorded");
3350        assert_eq!(e.code.as_deref(), Some("document.limit.alias-expansion"));
3351        assert_eq!(e.path, "$");
3352        // Once set, further events are ignored.
3353        b.on_event_impl(Event::Alias(999), test_marker());
3354    }
3355
3356    #[test]
3357    fn an_explicit_int_tag_on_non_integer_text_is_a_syntax_error_not_a_panic() {
3358        for src in [
3359            "a: !!int x\n",
3360            "a: !!int -\n",
3361            "a: !!int 0xZZ\n",
3362            "a: !!int ''\n",
3363        ] {
3364            let e = read_yaml(src).unwrap_err();
3365            assert!(
3366                matches!(&e, OmnistError::Parse(p) if p.code == "parse.codec-syntax"),
3367                "{src:?}: {e:?}"
3368            );
3369        }
3370        assert_eq!(
3371            read_yaml("a: !!int 0x1f\n").unwrap().to_raw(),
3372            crate::document::RawNode::Edges(vec![(
3373                "a".to_string(),
3374                crate::document::RawNode::Leaf(Scalar::Int(31.into()))
3375            )])
3376        );
3377    }
3378
3379    #[test]
3380    fn self_referential_anchors_are_rejected_not_panicked() {
3381        // Both inputs used to panic the library on an `.expect()`.
3382        for src in [
3383            "a: &A\n  b: *A\n",
3384            "a: &A\n  <<: *A\n  x: 1\n",
3385            "a: &A [*A]\n",
3386            "a: &A\n  b: &B\n    c: *A\n",
3387        ] {
3388            let e = read_yaml(src).unwrap_err();
3389            assert!(
3390                matches!(&e, OmnistError::Document(d)
3391                    if d.code.as_deref() == Some("document.limit.alias-expansion") && d.path == "$"),
3392                "{src:?}: {e:?}"
3393            );
3394        }
3395        // A completed anchor reused afterwards is still fine.
3396        assert!(read_yaml("a: &A\n  b: 1\nc: *A\n").is_ok());
3397    }
3398
3399    // -------------------------------------------------- coverage: issue #42 node-count guard
3400
3401    /// Calling `charge` directly once `self.error` is already `Some(..)`
3402    /// exercises the short-circuit at the very top of `charge` (returns
3403    /// `false` without touching `node_count` or overwriting `error`) --
3404    /// unreachable through `on_event_impl` alone, since its own top-of-
3405    /// function `self.error.is_some()` check means `charge` is never
3406    /// invoked a second time once tripped in the normal event-driven path.
3407    #[test]
3408    fn charge_after_already_tripped_is_a_pure_no_op() {
3409        let mut b = Builder::new(&Resolved::DEFAULT);
3410        assert!(!b.charge(MAX_MATERIALIZED_NODES + 1, test_marker()));
3411        let first_error = format!("{:?}", b.error);
3412        let count_after_first_trip = b.node_count;
3413        assert!(!b.charge(1, test_marker()));
3414        assert_eq!(
3415            format!("{:?}", b.error),
3416            first_error,
3417            "error must not change"
3418        );
3419        assert_eq!(
3420            b.node_count, count_after_first_trip,
3421            "node_count must not change once already tripped"
3422        );
3423    }
3424
3425    /// Drives the guard to trip from `Event::SequenceStart`'s `charge` call
3426    /// specifically (as opposed to the size-charging `Event::Alias` path
3427    /// the top-level `billion_laughs_...` integration test exercises),
3428    /// confirming every one of the three plain-node `charge` call sites is
3429    /// independently reachable.
3430    #[test]
3431    fn sequence_start_can_itself_trip_the_node_count_guard() {
3432        let mut b = Builder::new(&Resolved::DEFAULT);
3433        b.node_count = MAX_MATERIALIZED_NODES;
3434        b.on_event_impl(Event::SequenceStart(0, None), test_marker());
3435        assert!(
3436            matches!(&b.error, Some(e) if e.message.contains("materializes more than")),
3437            "got {:?}",
3438            b.error
3439        );
3440        // The doc_stack push it guards must not have happened.
3441        assert!(b.doc_stack.is_empty());
3442    }
3443
3444    /// Same as above, for `Event::MappingStart`.
3445    #[test]
3446    fn mapping_start_can_itself_trip_the_node_count_guard() {
3447        let mut b = Builder::new(&Resolved::DEFAULT);
3448        b.node_count = MAX_MATERIALIZED_NODES;
3449        b.on_event_impl(Event::MappingStart(0, None), test_marker());
3450        assert!(
3451            matches!(&b.error, Some(e) if e.message.contains("materializes more than")),
3452            "got {:?}",
3453            b.error
3454        );
3455        assert!(b.doc_stack.is_empty());
3456        assert!(b.key_stack.is_empty());
3457    }
3458
3459    #[test]
3460    fn n189_the_cap_counts_containers_not_keys_or_scalar_values() {
3461        let mut b = Builder::new(&Resolved::DEFAULT);
3462        let mark = test_marker();
3463        b.on_event_impl(Event::MappingStart(0, None), mark);
3464        for i in 0..10 {
3465            b.on_event_impl(
3466                Event::Scalar(format!("k{i}"), TScalarStyle::Plain, 0, None),
3467                mark,
3468            );
3469            b.on_event_impl(
3470                Event::Scalar(format!("v{i}"), TScalarStyle::Plain, 0, None),
3471                mark,
3472            );
3473        }
3474        assert_eq!(b.node_count, 1, "one mapping, twenty scalars: one node");
3475    }
3476
3477    #[test]
3478    fn n189_the_node_cap_boundary_is_exact() {
3479        let mark = test_marker();
3480        let mut b = Builder::new(&Resolved::DEFAULT);
3481        b.node_count = MAX_MATERIALIZED_NODES - 1;
3482        b.on_event_impl(Event::MappingStart(0, None), mark);
3483        assert!(b.error.is_none(), "exactly the cap is accepted");
3484        assert_eq!(b.node_count, MAX_MATERIALIZED_NODES);
3485        b.on_event_impl(Event::SequenceStart(0, None), mark);
3486        assert!(
3487            matches!(&b.error, Some(e) if e.code.as_deref() == Some("document.limit.nodes")),
3488            "one past the cap is refused: {:?}",
3489            b.error
3490        );
3491    }
3492
3493    #[test]
3494    fn n189_the_default_node_cap_is_the_spec_reference_default() {
3495        assert_eq!(MAX_MATERIALIZED_NODES, 1_000_000);
3496    }
3497
3498    #[test]
3499    fn n189_an_alias_is_charged_for_the_containers_it_clones() {
3500        let mut b = Builder::new(&Resolved::DEFAULT);
3501        let mark = test_marker();
3502        // &1 [x, {a: b}] holds a sequence and a mapping: two containers.
3503        b.on_event_impl(Event::SequenceStart(1, None), mark);
3504        b.on_event_impl(
3505            Event::Scalar("x".into(), TScalarStyle::Plain, 0, None),
3506            mark,
3507        );
3508        b.on_event_impl(Event::MappingStart(0, None), mark);
3509        b.on_event_impl(
3510            Event::Scalar("a".into(), TScalarStyle::Plain, 0, None),
3511            mark,
3512        );
3513        b.on_event_impl(
3514            Event::Scalar("b".into(), TScalarStyle::Plain, 0, None),
3515            mark,
3516        );
3517        b.on_event_impl(Event::MappingEnd, mark);
3518        b.on_event_impl(Event::SequenceEnd, mark);
3519        assert_eq!(b.node_count, 2);
3520    }
3521
3522    // ---------------------------------------------------------- coverage: writer edge cases
3523
3524    #[test]
3525    fn round_trips_an_integral_float_with_trailing_dot_zero() {
3526        let doc = doc_of(obj(vec![("f", Value::Float(2.0))]));
3527        let text = write_yaml(&doc, false, None).unwrap();
3528        assert!(text.contains("f: 2.0"), "text was:\n{text}");
3529        let back = read_yaml(&text).unwrap();
3530        assert!(doc.eq_doc(&back));
3531    }
3532
3533    #[test]
3534    fn quoted_string_escapes_every_special_character_in_one_pass() {
3535        // A leading `-` forces quoting; once quoted, this exercises every
3536        // remaining escape arm in `write_yaml_string`'s per-char loop in a
3537        // single string: backslash, tab, double-quote, and a low control
3538        // character (NEL and newline are already covered by dedicated tests).
3539        let s = "-a\tb\\c\"d\u{01}e";
3540        let doc = doc_of(obj(vec![("s", Value::Str(s.to_string()))]));
3541        let text = write_yaml(&doc, false, None).unwrap();
3542        let back = read_yaml(&text).unwrap();
3543        assert!(doc.eq_doc(&back), "text was:\n{text}");
3544    }
3545
3546    #[test]
3547    fn write_seq_child_on_a_bare_non_empty_array_item_writes_it_on_the_next_line() {
3548        // The Document model never produces a sequence item that is itself a
3549        // *standalone* non-empty array (sequence-of-sequences has no
3550        // labeled-edge form -- see `sequence_of_sequences_is_a_document_error`
3551        // above), so this arm of `write_seq_child` is unreachable via any
3552        // real `Doc` -- white-box exercising it directly, same rationale as
3553        // `write_node_on_a_bare_empty_array_writes_the_flow_empty_token`.
3554        let mut out = String::new();
3555        write_seq_child(
3556            &Value::Array(vec![Value::Int((1).into()), Value::Int((2).into())]),
3557            0,
3558            &mut out,
3559        );
3560        assert_eq!(out, "\n  - 1\n  - 2\n");
3561    }
3562
3563    #[test]
3564    fn test_yaml_merge_key_deduplication_order() {
3565        let src = r#"
3566base1: &b1
3567  a: 1
3568  b: 2
3569  dup: "from_b1"
3570base2: &b2
3571  b: 20
3572  c: 3
3573  dup: "from_b2"
3574child:
3575  <<: [*b1, *b2]
3576  own: 0
3577  a: 100
3578"#;
3579        let doc = read_yaml(src).unwrap();
3580        let root = doc.root();
3581        let child = root.get_one("child").unwrap();
3582        let labels = child.labels();
3583        // Merged entries come first, in source order (b1's a, b, dup, then
3584        // b2's c -- its b and dup are collisions the earlier alias wins); the
3585        // local `a` keeps the merged `a`'s position with the local value; the
3586        // non-colliding own key `own` follows.
3587        assert_eq!(labels, vec!["a", "b", "dup", "c", "own"]);
3588        assert_eq!(
3589            *child.get_one("a").unwrap().value().unwrap(),
3590            Scalar::Int((100).into())
3591        );
3592        assert_eq!(
3593            *child.get_one("b").unwrap().value().unwrap(),
3594            Scalar::Int((2).into())
3595        );
3596        assert_eq!(
3597            *child.get_one("dup").unwrap().value().unwrap(),
3598            Scalar::Str("from_b1".into())
3599        );
3600        assert_eq!(
3601            *child.get_one("c").unwrap().value().unwrap(),
3602            Scalar::Int((3).into())
3603        );
3604    }
3605
3606    // ---------------------------------------------- D-3: format.interleaving-lost
3607    // (issue #156, spec Sec8.3.8. Same MUST as formats-json/basic/
3608    // cross-label-interleaving-lost-and-reported -- YAML shares JSON's
3609    // `to_grouped` grouping, so the loss and the report are identical
3610    // in shape; see json.rs's mirrored tests.)
3611
3612    fn interleaved_doc() -> Doc {
3613        Doc::from_raw(crate::document::RawNode::Edges(vec![
3614            (
3615                "m".to_string(),
3616                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
3617            ),
3618            (
3619                "x".to_string(),
3620                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
3621            ),
3622            (
3623                "m".to_string(),
3624                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
3625            ),
3626        ]))
3627        .unwrap()
3628    }
3629
3630    fn contiguous_repeat_doc() -> Doc {
3631        Doc::from_raw(crate::document::RawNode::Edges(vec![
3632            (
3633                "m".to_string(),
3634                crate::document::RawNode::Leaf(Scalar::Str("A".to_string())),
3635            ),
3636            (
3637                "m".to_string(),
3638                crate::document::RawNode::Leaf(Scalar::Str("B".to_string())),
3639            ),
3640            (
3641                "x".to_string(),
3642                crate::document::RawNode::Leaf(Scalar::Str("X".to_string())),
3643            ),
3644        ]))
3645        .unwrap()
3646    }
3647
3648    #[test]
3649    fn reports_interleaving_lost_on_write() {
3650        let doc = interleaved_doc();
3651        let mut report = crate::report::WriteReport::new();
3652        write_yaml(&doc, false, Some(&mut report)).unwrap();
3653        let adjustments = report.adjustments();
3654        assert_eq!(adjustments.len(), 1);
3655        assert_eq!(adjustments[0].path, "$");
3656        assert_eq!(adjustments[0].code, "format.interleaving-lost");
3657        assert_eq!(adjustments[0].severity, crate::report::Severity::Warning);
3658    }
3659
3660    #[test]
3661    fn check_yaml_reports_interleaving_lost() {
3662        let rep = check_yaml(&interleaved_doc());
3663        assert_eq!(rep.adjustments().len(), 1);
3664        assert_eq!(rep.adjustments()[0].code, "format.interleaving-lost");
3665    }
3666
3667    #[test]
3668    fn contiguous_repeated_label_does_not_report_interleaving_lost() {
3669        let doc = contiguous_repeat_doc();
3670        let mut report = crate::report::WriteReport::new();
3671        write_yaml(&doc, false, Some(&mut report)).unwrap();
3672        assert!(report.is_empty());
3673        assert!(check_yaml(&doc).is_empty());
3674    }
3675}
3676
3677#[cfg(test)]
3678mod v035_tests {
3679    use super::*;
3680    use crate::document::{Doc, Scalar, Value};
3681
3682    fn one(label: &str, v: Value) -> Doc {
3683        let mut m = IndexMap::new();
3684        m.insert(label.to_string(), v);
3685        Doc::of(&Value::Object(m)).unwrap()
3686    }
3687
3688    fn written(v: Value) -> String {
3689        write_yaml(&one("a", v), false, None).unwrap()
3690    }
3691
3692    #[test]
3693    fn characters_yaml_does_not_print_are_escaped_in_lowercase() {
3694        for (c, want) in [
3695            ('\u{0}', "\"x\\x00y\""),
3696            ('\u{1}', "\"x\\x01y\""),
3697            ('\u{8}', "\"x\\x08y\""),
3698            ('\u{b}', "\"x\\x0by\""),
3699            ('\u{1f}', "\"x\\x1fy\""),
3700            ('\u{7f}', "\"x\\x7fy\""),
3701            ('\u{80}', "\"x\\x80y\""),
3702            ('\u{9f}', "\"x\\x9fy\""),
3703            ('\u{85}', "\"x\\Ny\""),
3704            ('\u{fffe}', "\"x\\ufffey\""),
3705            ('\u{ffff}', "\"x\\uffffy\""),
3706        ] {
3707            assert_eq!(
3708                written(Value::Str(format!("x{c}y"))),
3709                format!("a: {want}"),
3710                "{c:?}"
3711            );
3712        }
3713        // Printable neighbours stay plain.
3714        for c in ['\u{a0}', '\u{fffd}', 'é'] {
3715            assert_eq!(written(Value::Str(format!("x{c}y"))), format!("a: x{c}y"));
3716        }
3717    }
3718
3719    #[test]
3720    fn a_time_is_written_double_quoted_with_its_seconds_and_reported() {
3721        for (t, want) in [
3722            ("12:00:00", "a: \"12:00:00\""),
3723            ("12:00", "a: \"12:00:00\""),
3724            ("12:00:00.5", "a: \"12:00:00.5\""),
3725            ("12:00:00.123456+01:00", "a: \"12:00:00.123456+01:00\""),
3726        ] {
3727            let doc = one("a", Value::Time(t.to_string()));
3728            let mut rep = WriteReport::new();
3729            assert_eq!(write_yaml(&doc, false, Some(&mut rep)).unwrap(), want);
3730            assert_eq!(rep.adjustments().len(), 1);
3731            assert_eq!(rep.adjustments()[0].path, "$.a");
3732            assert_eq!(rep.adjustments()[0].code, "format.temporal-stringified");
3733            assert_eq!(check_yaml(&doc).adjustments().len(), 1);
3734        }
3735        assert_eq!(
3736            written(Value::Datetime("2024-01-15 12:00".to_string())),
3737            "a: 2024-01-15T12:00:00"
3738        );
3739    }
3740
3741    #[test]
3742    fn a_sexagesimal_float_reads_as_a_number_and_the_look_alikes_stay_strings() {
3743        for (text, want) in [
3744            ("1:30.5", 90.5),
3745            ("-1:30.5", -90.5),
3746            ("+1:30.5", 90.5),
3747            ("1:30:15.25", 5415.25),
3748            ("1_0:30.5", 630.5),
3749            ("190:20:30.15", 685230.15),
3750            ("1:30.", 90.0),
3751        ] {
3752            let doc = read_yaml(&format!("a: {text}\n")).unwrap();
3753            assert_eq!(
3754                *doc.root().get_one("a").unwrap().value().unwrap(),
3755                Scalar::Float(want),
3756                "{text}"
3757            );
3758        }
3759        for text in [
3760            "0o17", "08", "09", "0:5", "1e3", "1.0e3", "1:60.5", "1:30.5x",
3761        ] {
3762            let doc = read_yaml(&format!("a: {text}\n")).unwrap();
3763            assert_eq!(
3764                *doc.root().get_one("a").unwrap().value().unwrap(),
3765                Scalar::Str(text.to_string()),
3766            );
3767        }
3768    }
3769
3770    #[test]
3771    fn a_string_that_reads_as_a_sexagesimal_float_is_written_quoted() {
3772        assert_eq!(written(Value::Str("1:30.5".to_string())), "a: \"1:30.5\"");
3773    }
3774
3775    #[test]
3776    fn a_fraction_past_six_digits_fails_the_read_and_is_quoted_on_write() {
3777        let err = read_yaml("t: 2024-01-15T12:00:00.1234567+01:00\n")
3778            .unwrap_err()
3779            .to_string();
3780        assert!(err.contains("fractional second"), "{err}");
3781        // Zeros past the sixth digit are insignificant.
3782        assert!(read_yaml("t: 2024-01-15T12:00:00.1234560\n").is_ok());
3783        let doc = one(
3784            "t",
3785            Value::Datetime("2024-01-15T12:00:00.1234567+01:00".to_string()),
3786        );
3787        let mut report = WriteReport::new();
3788        let text = write_yaml(&doc, false, Some(&mut report)).unwrap();
3789        assert_eq!(text, "t: \"2024-01-15T12:00:00.1234567+01:00\"");
3790        assert_eq!(report.adjustments()[0].code, "format.temporal-stringified");
3791        assert_eq!(check_yaml(&doc).adjustments().len(), 1);
3792    }
3793}