Skip to main content

noyalib/cst/
document.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Public `Document` handle and parse / mutation entry points.
5
6use core::fmt::Write as _;
7
8use crate::cst::builder::{document_boundaries, parse_full};
9use crate::cst::emit::{Emit, EmitCtx, emit_key};
10use crate::cst::green::{GreenChild, GreenNode};
11use crate::cst::syntax::SyntaxKind;
12use crate::cst::{YAML_BLANK, is_yaml_blank};
13use crate::de::ParserConfig;
14use crate::doc_boundary::strip_bom;
15use crate::error::{Error, Location, Result};
16use crate::parser::ParseConfig;
17use crate::path::{QueryPath, QuerySegment, parse_query_path, push_key};
18use crate::prelude::*;
19use crate::span_context::SpanTree;
20use crate::value::{Mapping, Number, Value};
21
22mod edit;
23mod path;
24mod transaction;
25mod validation;
26
27use path::{decode_single_quoted, resolve_path_in_green};
28pub use transaction::EditSession;
29
30/// A YAML document with byte-faithful source preservation, typed
31/// data access, and path-targeted edits.
32///
33/// `Document` carries three coordinated views of the same input:
34/// an immutable green tree that reproduces the source byte-for-byte,
35/// a typed [`Value`] for data access, and an internal span tree
36/// that maps any [`Value`]-shaped path back to a byte range. Edits
37/// flow through [`Document::replace_span`] (the primitive) and
38/// [`Document::set`] (the path-shaped wrapper); untouched bytes —
39/// indentation, comments, blank lines, sibling entries — are
40/// preserved verbatim.
41///
42/// Every path-taking method reads the grammar described in
43/// [`crate::path`]: `server.port`, `items[0].name`, and, for a key the
44/// grammar would otherwise read as structure, a bracket-quoted segment
45/// such as `labels["app.kubernetes.io/name"]`.
46///
47/// # Examples
48///
49/// Read-only round-trip:
50///
51/// ```
52/// use noyalib::cst::parse_document;
53///
54/// let src = "name: noyalib  # the project\nversion: 0.0.1\n";
55/// let doc = parse_document(src).unwrap();
56/// assert_eq!(doc.to_string(), src);
57/// ```
58///
59/// Path-targeted edit:
60///
61/// ```
62/// use noyalib::cst::parse_document;
63///
64/// let mut doc = parse_document("name: foo\nversion: 0.0.1\n").unwrap();
65/// doc.set("version", "0.0.2").unwrap();
66/// assert_eq!(doc.to_string(), "name: foo\nversion: 0.0.2\n");
67/// ```
68#[derive(Debug)]
69pub struct Document {
70    source: Arc<str>,
71    green: GreenNode,
72    /// Cache for the typed [`Value`] view + path resolver [`SpanTree`].
73    /// Initial parsing and validated edits populate it eagerly. Internal
74    /// operations may invalidate it, in which case the next typed read
75    /// rebuilds both views under this document's parser configuration.
76    cache: core::cell::RefCell<Option<(Value, SpanTree)>>,
77    /// Outcome of the most recent edit's localised-repair attempt.
78    /// `None` for a freshly-parsed document or after a full
79    /// re-parse fallback.
80    last_repair_scope: core::cell::Cell<Option<RepairScope>>,
81    /// The parser configuration this document was opened with.
82    /// Every re-parse of the source — the typed-cache refresh behind
83    /// [`Document::as_value`], [`Document::validate`], the full
84    /// re-parse safety net behind [`Document::replace_span`], and
85    /// the value guard on comment edits — runs under the same
86    /// limits, so a document that only opens with a relaxed budget
87    /// (see [`parse_document_with_config`]) stays readable and
88    /// editable after its first edit.
89    config: ParseConfig,
90}
91
92impl Clone for Document {
93    fn clone(&self) -> Self {
94        Self {
95            source: Arc::clone(&self.source),
96            green: self.green.clone(),
97            cache: core::cell::RefCell::new(self.cache.borrow().clone()),
98            last_repair_scope: core::cell::Cell::new(self.last_repair_scope.get()),
99            config: self.config.clone(),
100        }
101    }
102}
103
104/// The scope at which the most recent edit was repaired.
105///
106/// Smaller scopes are faster — `Scalar` only re-parses the leaf;
107/// `Document` is equivalent to a full re-parse. Surfaced via
108/// [`Document::last_repair_scope`] for tests and tooling.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum RepairScope {
111    /// Reserved — scalar-granularity repair is not yet implemented.
112    Scalar,
113    /// The smallest ancestor that contained the edit was a
114    /// `MappingEntry` or `SequenceItem`.
115    Entry,
116    /// The smallest ancestor that contained the edit was a
117    /// `BlockMapping` / `BlockSequence` / flow collection.
118    Collection,
119    /// Edit fell back to (or escalated to) a full document re-parse.
120    Document,
121}
122
123/// Deliberate failures, for the tests that verify the rollbacks.
124///
125/// Every splicing mutator wraps its edit in the same three-part guard:
126/// splice, re-parse, compare the loaded value against a snapshot taken
127/// before the edit. Each part has a failure arm that restores the
128/// snapshot and reports what went wrong — and each of those arms fires
129/// only when the *mutator's own* logic has produced something wrong,
130/// which is to say never, from outside.
131///
132/// That left the whole safety net untested: nothing confirmed that a
133/// failed splice actually puts the document back. These switches make
134/// the two fallible steps fail on demand so the rollback arms can be
135/// driven and, more to the point, *checked*.
136///
137/// Compiled only under `cfg(test)` — the crate's own test target, which
138/// the coverage job builds and runs like any other. A consumer's build
139/// contains none of it, and neither does the library as an integration
140/// test links it. The switches are thread-local and default to off, so
141/// a test that does not ask for a failure cannot be given one by a test
142/// running beside it.
143#[cfg(test)]
144pub(crate) mod fault {
145    use core::cell::Cell;
146
147    thread_local! {
148        static FAIL_SPLICE: Cell<bool> = const { Cell::new(false) };
149        static FAIL_VALIDATE: Cell<bool> = const { Cell::new(false) };
150        static FAIL_ORACLE: Cell<bool> = const { Cell::new(false) };
151    }
152
153    pub(crate) fn splice_should_fail() -> bool {
154        FAIL_SPLICE.with(Cell::get)
155    }
156
157    pub(crate) fn validate_should_fail() -> bool {
158        FAIL_VALIDATE.with(Cell::get)
159    }
160
161    pub(crate) fn oracle_should_mismatch() -> bool {
162        FAIL_ORACLE.with(Cell::get)
163    }
164
165    /// Run `f` with [`Document::replace_span`] failing.
166    ///
167    /// The switch is cleared on the way out even if `f` panics, so one
168    /// failing assertion cannot leave the rest of the suite injecting
169    /// failures.
170    pub(crate) fn while_splicing_fails<R>(f: impl FnOnce() -> R) -> R {
171        struct Reset;
172        impl Drop for Reset {
173            fn drop(&mut self) {
174                FAIL_SPLICE.with(|c| c.set(false));
175            }
176        }
177        FAIL_SPLICE.with(|c| c.set(true));
178        let _reset = Reset;
179        f()
180    }
181
182    /// Run `f` with the post-edit oracle rejecting whatever it is given.
183    ///
184    /// This is the third of the three guards, and the one that catches a
185    /// splice which parses but does not *mean* what was asked for. It
186    /// cannot be reached from outside at all: getting there requires the
187    /// mutator to have produced a document that re-parses into the wrong
188    /// value, which is the bug the guard exists to stop.
189    pub(crate) fn while_the_oracle_rejects<R>(f: impl FnOnce() -> R) -> R {
190        struct Reset;
191        impl Drop for Reset {
192            fn drop(&mut self) {
193                FAIL_ORACLE.with(|c| c.set(false));
194            }
195        }
196        FAIL_ORACLE.with(|c| c.set(true));
197        let _reset = Reset;
198        f()
199    }
200
201    /// Run `f` with [`Document::validate`] failing.
202    pub(crate) fn while_validation_fails<R>(f: impl FnOnce() -> R) -> R {
203        struct Reset;
204        impl Drop for Reset {
205            fn drop(&mut self) {
206                FAIL_VALIDATE.with(|c| c.set(false));
207            }
208        }
209        FAIL_VALIDATE.with(|c| c.set(true));
210        let _reset = Reset;
211        f()
212    }
213}
214
215/// Whether `source` contains a duplicate mapping key.
216///
217/// Loading a document with duplicate keys succeeds and collapses them,
218/// so a value-level comparison cannot tell that one was introduced.
219/// Re-reading under a policy that refuses duplicates can.
220fn duplicate_keys_present(source: &str, base: &ParseConfig) -> bool {
221    let mut config = base.clone();
222    config.duplicate_key_policy = crate::parser::InternalDuplicateKeyPolicy::Error;
223    crate::parser::parse_exactly_one_value(source, &config).is_err_and(|error| {
224        matches!(
225            error.kind(),
226            crate::ErrorKind::DuplicateKey | crate::ErrorKind::KeyCollision
227        )
228    })
229}
230
231/// Whether the post-edit oracle rejects the edit: the document loaded
232/// back differs from the value the mutator said it would produce.
233///
234/// Every guard compares through this so the rollback arms can be driven
235/// in tests; see [`fault::while_the_oracle_rejects`].
236pub(crate) fn oracle_rejects(actual: &Value, expected: &Value) -> bool {
237    #[cfg(test)]
238    if fault::oracle_should_mismatch() {
239        return true;
240    }
241    actual != expected
242}
243
244impl Document {
245    /// Start an atomic batch of byte-range edits.
246    ///
247    /// Every range is measured against the document source at the start of
248    /// the session. The document itself remains unchanged until
249    /// [`EditSession::commit`] builds and validates the complete candidate in
250    /// one commit-time validation. Dropping the session, calling
251    /// [`EditSession::abort`], or receiving a commit error leaves the
252    /// document byte-for-byte unchanged.
253    ///
254    /// Use this when several independent spans can be resolved before any of
255    /// them are changed. Path-based edits that depend on earlier edits should
256    /// continue to use the ordinary [`Document`] mutators.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use noyalib::cst::parse_document;
262    ///
263    /// let mut doc = parse_document("first: one\nsecond: two\n").unwrap();
264    /// let first = doc.span_at("first").unwrap();
265    /// let second = doc.span_at("second").unwrap();
266    ///
267    /// let mut edit = doc.edit();
268    /// edit.replace_span(first.0, first.1, "1").unwrap();
269    /// edit.replace_span(second.0, second.1, "2").unwrap();
270    /// edit.commit().unwrap();
271    ///
272    /// assert_eq!(doc.source(), "first: 1\nsecond: 2\n");
273    /// ```
274    pub fn edit(&mut self) -> EditSession<'_> {
275        EditSession::new(self)
276    }
277
278    /// Borrow the root [`GreenNode`].
279    ///
280    /// # Examples
281    ///
282    /// ```
283    /// use noyalib::cst::{parse_document, SyntaxKind};
284    ///
285    /// let doc = parse_document("foo: 1\n").unwrap();
286    /// assert_eq!(doc.syntax().kind(), SyntaxKind::Document);
287    /// ```
288    #[must_use]
289    pub fn syntax(&self) -> &GreenNode {
290        &self.green
291    }
292
293    /// The parser configuration this document was opened with, for
294    /// sibling modules that load the source again (the comment-edit
295    /// value guard, schema coercion).
296    pub(crate) fn config(&self) -> &ParseConfig {
297        &self.config
298    }
299
300    /// Borrow the typed [`Value`] view of the document.
301    ///
302    /// If the typed cache is absent, this triggers a one-shot parse of
303    /// the current source into the internal `Value` / `SpanTree` cache.
304    /// Subsequent calls on the same document are O(1) until the cache is
305    /// invalidated.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use noyalib::cst::parse_document;
311    ///
312    /// let doc = parse_document("name: noyalib\n").unwrap();
313    /// assert_eq!(doc.as_value()["name"].as_str(), Some("noyalib"));
314    /// ```
315    #[must_use]
316    pub fn as_value(&self) -> core::cell::Ref<'_, Value> {
317        self.ensure_cache();
318        core::cell::Ref::map(self.cache.borrow(), |opt| {
319            &opt.as_ref().expect("ensure_cache populated").0
320        })
321    }
322
323    /// The original source bytes for this document. After an edit
324    /// reflects the *current* source.
325    ///
326    /// # Examples
327    ///
328    /// ```
329    /// use noyalib::cst::parse_document;
330    ///
331    /// let src = "key: 1\n";
332    /// let doc = parse_document(src).unwrap();
333    /// assert_eq!(doc.source(), src);
334    /// ```
335    #[must_use]
336    pub fn source(&self) -> &str {
337        &self.source
338    }
339
340    /// Resolve a `path` to the byte range of the value at that path,
341    /// if any.
342    ///
343    /// Path syntax matches the rest of the crate (`foo.bar`,
344    /// `items[0]`, `items[0].name`). Wildcard / recursive-descent
345    /// segments are not supported here — they have no single span.
346    ///
347    /// A duplicated mapping key resolves to its *last* occurrence,
348    /// the same occurrence the typed view keeps (`as_value` loads
349    /// with the default `DuplicateKeyPolicy::Last`, the YAML 1.2
350    /// behaviour) — the returned span always denotes the node that
351    /// `as_value` selects for the path.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use noyalib::cst::parse_document;
357    ///
358    /// let doc = parse_document("name: noyalib\nversion: 0.0.1\n").unwrap();
359    /// let (s, e) = doc.span_at("version").unwrap();
360    /// assert_eq!(&doc.source()[s..e], "0.0.1");
361    /// ```
362    ///
363    /// A duplicate key resolves to the occurrence the typed view
364    /// keeps:
365    ///
366    /// ```
367    /// use noyalib::cst::parse_document;
368    ///
369    /// let doc = parse_document("k: one\nk: two\n").unwrap();
370    /// let (s, e) = doc.span_at("k").unwrap();
371    /// assert_eq!(&doc.source()[s..e], "two");
372    /// assert_eq!(doc.get("k"), Some("two"));
373    /// ```
374    #[must_use]
375    pub fn span_at(&self, path: &str) -> Option<(usize, usize)> {
376        let segments = parse_query_path(path);
377        // Phase A.3 — green-tree path resolution. The common case
378        // (plain block mappings, block sequences) resolves without
379        // touching the typed cache: a single walk over the
380        // structural CST is enough. Tooling that drives many edits
381        // through `set` / `set_value` no longer warms the typed
382        // cache between iterations.
383        if let Some((s, e)) = resolve_path_in_green(&self.green, &segments, &self.source) {
384            return Some(trim_value_span(&self.source, s, e));
385        }
386        // Fallback for paths the green-tree walker doesn't
387        // currently handle — e.g. quoted keys with escapes,
388        // aliases, merge-keys. The cache is populated lazily.
389        self.ensure_cache();
390        let cache = self.cache.borrow();
391        let (value, span_tree) = cache.as_ref().expect("ensure_cache populated");
392        // Reads resolve an alias through to its anchor (issue #149); the
393        // through-alias flag only matters for writes (see `write_span`).
394        let ((s, e), _through_alias) = resolve_span(value, span_tree, &segments)?;
395        let s = content_start(&self.source, s, e);
396        // A zero-width span is an implicit null, which has no bytes to read
397        // (#165). The resolver now hands the position over for the write
398        // paths' benefit; discarding it is this reader's job.
399        if s == e {
400            return None;
401        }
402        Some(trim_value_span(&self.source, s, e))
403    }
404
405    /// Return the byte span of a mapping entry's **key** token, the
406    /// read-only companion to [`span_at`](Self::span_at) (which returns
407    /// the *value* span). `source()[start..end]` is the key exactly as
408    /// written — quotes included for a quoted key.
409    ///
410    /// This exposes, read-only, the same key site
411    /// [`rename_key`](Self::rename_key) rewrites; it is the span tooling
412    /// needs to report duplicate keys with positions or to drive a
413    /// "rename key" code action without walking the green tree by hand.
414    ///
415    /// Returns `None` when the path does not resolve to a block-mapping
416    /// entry with a simple scalar key — a sequence index, an alias
417    /// (`*name`) site (which owns no key bytes of its own), a key
418    /// produced by a `<<` merge, or a path that does not resolve at all.
419    ///
420    /// ```
421    /// use noyalib::cst::parse_document;
422    ///
423    /// let doc = parse_document("name: foo\n\"quoted key\": 1\n").unwrap();
424    /// let (s, e) = doc.key_span("name").unwrap();
425    /// assert_eq!(&doc.source()[s..e], "name");
426    /// let (s, e) = doc.key_span("quoted key").unwrap();
427    /// assert_eq!(&doc.source()[s..e], "\"quoted key\"");
428    /// assert_eq!(doc.key_span("missing"), None);
429    /// ```
430    #[must_use]
431    pub fn key_span(&self, path: &str) -> Option<(usize, usize)> {
432        // A sentinel `new_key` that cannot equal any real sibling, so
433        // `entry_key_site`'s duplicate-refusal branch is never taken and
434        // it behaves as a pure key-span resolver. Any resolution error
435        // (alias / merge-provided / not-a-mapping-entry / not found) and
436        // the zero-width span the loader records for a non-scalar key
437        // both map to `None`.
438        const KEY_SPAN_SENTINEL: &str = "\0\0noyalib::key_span sentinel\0\0";
439        let segments = parse_query_path(path);
440        if segments.is_empty() {
441            return None;
442        }
443        self.ensure_cache();
444        let cache = self.cache.borrow();
445        let (value, span_tree) = cache.as_ref().expect("ensure_cache populated");
446        match entry_key_site(value, span_tree, &segments, KEY_SPAN_SENTINEL) {
447            Ok((s, e)) if s != e => Some((s, e)),
448            _ => None,
449        }
450    }
451
452    /// Return the source slice of the value at `path`.
453    ///
454    /// # Examples
455    ///
456    /// ```
457    /// use noyalib::cst::parse_document;
458    ///
459    /// let doc = parse_document("items:\n  - one\n  - two\n").unwrap();
460    /// assert_eq!(doc.get("items[1]"), Some("two"));
461    /// ```
462    #[must_use]
463    pub fn get(&self, path: &str) -> Option<&str> {
464        let (s, e) = self.span_at(path)?;
465        Some(&self.source[s..e])
466    }
467
468    /// Last successful repair scope, if any. Useful for tests and
469    /// instrumentation; returns `None` for a freshly-parsed
470    /// document or when the most recent edit fell back to a full
471    /// re-parse.
472    #[must_use]
473    pub fn last_repair_scope(&self) -> Option<RepairScope> {
474        self.last_repair_scope.get()
475    }
476
477    /// Replace the value at `path` with `fragment`.
478    ///
479    /// `fragment` is spliced verbatim into the source — the caller
480    /// supplies the YAML representation. This deliberately matches no
481    /// scalar style automatically; choose double-quoted, plain, or
482    /// block style to suit.
483    ///
484    /// # Prefer [`Document::set_value`] for values
485    ///
486    /// Verbatim means the fragment is YAML, not text. `set(p, "true")`
487    /// writes the boolean, `set(p, "")` writes null, and
488    /// `set(p, "v # x")` writes `v` with a comment after it. If you
489    /// have a *value* rather than a spelling, [`set_value`] renders it
490    /// — quoting, escaping and choosing a block style as needed — so
491    /// it reads back as exactly what you passed in.
492    ///
493    /// [`set_value`]: Document::set_value
494    ///
495    /// # The fragment cannot reach outside `path`
496    ///
497    /// A fragment containing a newline could previously give the
498    /// document new entries:
499    ///
500    /// ```text
501    /// set("a", "v\nc: 3")  on  "a: 1\nb: 2\n"   ->   a: v
502    ///                                                  c: 3
503    ///                                                  b: 2
504    /// ```
505    ///
506    /// The re-parse guard could not catch that, because the result is
507    /// valid YAML. An oracle now checks that restoring the original
508    /// value at `path` reproduces the original document; if the
509    /// fragment changed anything elsewhere, the edit is refused and the
510    /// document is left untouched. Restructuring the target itself —
511    /// scalar to mapping, say — remains allowed.
512    ///
513    /// # Structurally invalid fragments are atomic failures
514    ///
515    /// The splice is verbatim, but the complete document is validated
516    /// before the edit commits. A fragment that is not a well-formed
517    /// YAML node returns an error and leaves the document unchanged:
518    ///
519    /// ```
520    /// use noyalib::cst::parse_document;
521    ///
522    /// let mut doc = parse_document("a: 1\nb: 2\n").unwrap();
523    /// assert!(doc.set("a", "[unclosed").is_err());
524    /// assert_eq!(doc.to_string(), "a: 1\nb: 2\n");
525    /// assert!(doc.validate().is_ok());
526    /// ```
527    ///
528    /// [`Document::set_value`] and the other `_value` mutators remain
529    /// preferable when the caller already has a typed value because
530    /// they do not need to interpret a YAML fragment.
531    ///
532    /// # Errors
533    ///
534    /// - `Error::Parse(...)` with "path not found" if `path` does
535    ///   not resolve in the current document.
536    /// - The same errors as [`Document::replace_span`] otherwise.
537    ///
538    /// # Examples
539    ///
540    /// ```
541    /// use noyalib::cst::parse_document;
542    ///
543    /// let mut doc = parse_document("name: foo\nversion: 0.0.1\n").unwrap();
544    /// doc.set("version", "0.0.2").unwrap();
545    /// assert_eq!(doc.to_string(), "name: foo\nversion: 0.0.2\n");
546    /// ```
547    pub fn set(&mut self, path: &str, fragment: &str) -> Result<()> {
548        let (s, e) = self.write_span(path)?;
549
550        // Verbatim splicing is this method's contract, and a fragment
551        // that legitimately restructures the *target* — scalar to
552        // mapping, say — must keep working. What must not happen is a
553        // fragment reaching outside the target:
554        //
555        //     set("a", "v\nc: 3")  on  "a: 1\nb: 2\n"
556        //
557        // spliced a newline and gave the document a new key `c`. The
558        // re-parse guard cannot see it, because the result is valid
559        // YAML — just not the document anyone asked for.
560        //
561        // The oracle: take the edited document, put the original value
562        // back at `path`, and require the result to equal the original
563        // document. Anything the fragment changed elsewhere survives
564        // that restoration and shows up as a mismatch.
565        //
566        // Callers wanting a value rendered safely should use
567        // `set_value`, which quotes and picks a scalar style.
568        self.ensure_cache();
569        let segments = parse_query_path(path);
570        let before_shape = {
571            let cache = self.cache.borrow();
572            let (value, _) = cache.as_ref().expect("ensure_cache populated");
573            shape_excluding(value, &segments)
574        };
575
576        let snapshot = self.clone();
577        // Filling in an implicit null: the span abuts the `:` / `-`, so the
578        // separator is this writer's to supply. Splicing stays verbatim.
579        let filled;
580        let fragment = if s == e {
581            filled = fill_in(fragment);
582            filled.as_str()
583        } else {
584            fragment
585        };
586        self.replace_span(s, e, fragment)?;
587
588        let after_shape = shape_excluding(&self.as_value(), &segments);
589        if after_shape != before_shape {
590            *self = snapshot;
591            return Err(Error::Parse(format!(
592                "set: the fragment for `{path}` added or removed entries \
593                 elsewhere in the document — it was left unchanged. Use \
594                 `set_value` to write a value without splicing YAML."
595            )));
596        }
597
598        // The shape fingerprint above cannot see a *shadowed* sibling. A
599        // fragment can only add lines, so it cannot reshape `b` in place
600        // — but it can write a second `b`, and duplicate keys collapse
601        // when the document is loaded, leaving the fingerprint identical:
602        //
603        //     set("a", "2\nb: changed")  on  "a: 1\nb:\n  c: 1\n"
604        //
605        // used to return `Ok` and produce a document with two `b` keys.
606        //
607        // Duplicate keys are legal here and some documents carry them on
608        // purpose, so the test is not "are there duplicates" but "did
609        // this edit introduce one": clean before and dirty after.
610        if !duplicate_keys_present(&snapshot.source, &self.config)
611            && duplicate_keys_present(&self.source, &self.config)
612        {
613            *self = snapshot;
614            return Err(Error::Parse(format!(
615                "set: the fragment for `{path}` introduced a duplicate key, \
616                 shadowing an entry elsewhere in the document — it was left \
617                 unchanged. Use `set_value` to write a value without \
618                 splicing YAML."
619            )));
620        }
621        Ok(())
622    }
623
624    /// Resolve `path` to a byte span for a **write**, refusing when the value
625    /// is (or resolves through) an alias reference.
626    ///
627    /// `span_at` resolves an alias *through* to its anchor's value span (issue
628    /// #149) — the right target for a read, but splicing there would rewrite
629    /// the **anchor's** bytes, a different key. The green-tree fast path never
630    /// yields an alias (it bails on `AliasMark`), so only the typed-cache
631    /// fallback can; `resolve_span`'s `through_alias` flag is the single source
632    /// of truth for that, so the two paths cannot disagree.
633    fn write_span(&self, path: &str) -> Result<(usize, usize)> {
634        self.write_span_resolved(path).map(|(span, _)| span)
635    }
636
637    /// [`write_span`](Self::write_span), also saying whether the span is
638    /// the `*name` token of an alias-valued entry. Replacing that token
639    /// is the one alias write that touches only the addressed entry; a
640    /// path that resolves *through* an alias is still refused here.
641    fn write_span_resolved(&self, path: &str) -> Result<((usize, usize), bool)> {
642        let segments = parse_query_path(path);
643        if let Some((s, e)) = resolve_path_in_green(&self.green, &segments, &self.source) {
644            return Ok((trim_value_span(&self.source, s, e), false));
645        }
646        self.ensure_cache();
647        let cache = self.cache.borrow();
648        let (value, span_tree) = cache.as_ref().expect("ensure_cache populated");
649        let ((s, e), hit) = resolve_span(value, span_tree, &segments)
650            .ok_or_else(|| Error::Parse(format!("path not found: {path}")))?;
651        match hit {
652            // The entry's own value byte in the source is the reference
653            // token; replacing it is what "replace the alias explicitly"
654            // means, and it edits nothing the path does not name.
655            AliasHit::Target { at } => return Ok((at, true)),
656            AliasHit::Through => {
657                return Err(Error::Parse(format!(
658                    "cannot set `{path}`: its value resolves through an alias \
659                     reference to the anchor's bytes, which belong to a different \
660                     key; edit the anchor definition instead"
661                )));
662            }
663            AliasHit::No => {}
664        }
665        let s = content_start(&self.source, s, e);
666        if s == e {
667            return implicit_null_insertion_point(&self.source, s)
668                .map(|span| (span, false))
669                .ok_or_else(|| Error::Parse(format!("path not found: {path}")));
670        }
671        Ok((trim_value_span(&self.source, s, e), false))
672    }
673
674    /// Replace the value at `path` with a typed [`Value`], formatting
675    /// the YAML fragment to match the existing scalar style at the
676    /// target site.
677    ///
678    /// Style matching:
679    /// - `PlainScalar` — emit plain when safe, double-quoted otherwise.
680    /// - `SingleQuotedScalar` — wrap in `'…'` (only string values).
681    /// - `DoubleQuotedScalar` — wrap in `"…"` with standard escapes
682    ///   (only string values).
683    /// - `LiteralScalar` / `FoldedScalar` — a single-line replacement is
684    ///   emitted plain (or quoted when unsafe); a multi-line one is
685    ///   re-emitted as a literal block when representable, and refused
686    ///   otherwise. Folded style is not yet reproduced — a changed value
687    ///   at a `>` site comes back `|` or plain.
688    ///
689    /// Setting a value equal to the one already loaded is a **no-op**:
690    /// the source is left byte-identical, so the author's spelling
691    /// (`1.10`, `0x1F`, `~`, an implicit null, a `>-` folded scalar)
692    /// survives a save that does not change the value. Equality is
693    /// [`Value`]'s own, so whether `1.0` over a loaded `1` is a no-op
694    /// follows `Number`'s `PartialEq` for the active features.
695    ///
696    /// Non-string values (numbers, booleans, null) are emitted plain
697    /// regardless of the existing style — quoting them would change
698    /// the parsed type round-trip.
699    ///
700    /// Inside a `[…]` / `{…}` flow collection the same styles apply,
701    /// except that a plain spelling is also refused when the string
702    /// contains `,` `[` `]` `{` or `}` (structural anywhere in flow
703    /// context), and a multi-line string is double-quoted with `\n`
704    /// escapes, because block scalars do not exist in flow context:
705    ///
706    /// ```
707    /// use noyalib::cst::parse_document;
708    /// use noyalib::Value;
709    ///
710    /// let mut doc = parse_document("m: {a: 1, b: 2}\n").unwrap();
711    /// doc.set_value("m.a", &Value::String("x, y".into())).unwrap();
712    /// assert_eq!(doc.to_string(), "m: {a: \"x, y\", b: 2}\n");
713    /// doc.set_value("m.b", &Value::String("two\nlines".into())).unwrap();
714    /// assert_eq!(doc.to_string(), "m: {a: \"x, y\", b: \"two\\nlines\"}\n");
715    /// ```
716    ///
717    /// # Filling in an implicit null
718    ///
719    /// An absent block-mapping value (`a:`) or empty sequence item (`- `) has
720    /// no bytes to replace, so the value is *inserted* after the `:` / `-`
721    /// instead — before any comment on the line, and with no style to inherit,
722    /// so the neighbour rule above decides the spelling on its own.
723    /// [`span_at`](Self::span_at) still reports `None` there: the node has
724    /// nothing to read, which is a separate question from where a write goes.
725    ///
726    /// # A trailing comment beside a new block literal
727    ///
728    /// A multi-line string is written as a literal block scalar, which
729    /// runs to the end of its last content line. A comment that trailed
730    /// the old one-line value (`title: Hello # note`) therefore moves to
731    /// the block scalar's header line, where YAML permits one:
732    ///
733    /// ```
734    /// use noyalib::cst::parse_document;
735    /// use noyalib::Value;
736    ///
737    /// let mut doc = parse_document("title: Hello # note\n").unwrap();
738    /// doc.set_value("title", &Value::String("multi\nline".into())).unwrap();
739    /// assert_eq!(doc.to_string(), "title: |- # note\n  multi\n  line\n");
740    /// assert_eq!(doc.as_value()["title"].as_str(), Some("multi\nline"));
741    /// ```
742    ///
743    /// # Collections (#328)
744    ///
745    /// A `Value::Sequence` / `Value::Mapping` replaces an existing
746    /// **collection** node in that node's own style — flow stays flow
747    /// (`tags: [a, b]` set to `[a, c]` emits `tags: [a, c]`), block
748    /// stays block at the old value's column. The splice is verified
749    /// to load back as the document with exactly this path replaced,
750    /// or it is rolled back. Replacing a *scalar* with a collection is
751    /// still refused: a value that must move onto its own lines is a
752    /// layout decision `set` expresses with a fragment.
753    ///
754    /// ```
755    /// use noyalib::cst::parse_document;
756    /// use noyalib::{Value, from_str};
757    ///
758    /// let mut doc = parse_document("tags: [a, b]\nname: x\n").unwrap();
759    /// let tags: Value = from_str("[a, c]").unwrap();
760    /// doc.set_value("tags", &tags).unwrap();
761    /// assert_eq!(doc.to_string(), "tags: [a, c]\nname: x\n");
762    /// ```
763    ///
764    /// # Anchored nodes (#338)
765    ///
766    /// A write into a value that `*name` alias sites share lands at
767    /// every one of them, so it is refused — the same policy
768    /// [`rename_key`](Self::rename_key), [`remove`](Self::remove) and
769    /// the inserters follow. Call
770    /// [`materialise_aliases_of`](Self::materialise_aliases_of) first
771    /// to give each site its own copy. Setting a value **equal** to
772    /// the current one stays a no-op wherever it points.
773    ///
774    /// # Errors
775    ///
776    /// - Path not found.
777    /// - The target sits inside an anchored value with live alias
778    ///   references.
779    /// - Target is a block scalar being replaced by a multi-line
780    ///   string it cannot represent.
781    /// - Caller passed a `Sequence` / `Mapping` and the target is a
782    ///   scalar (use `set` with a pre-formatted fragment to grow a
783    ///   scalar into a collection).
784    /// - The same errors as [`Document::replace_span`] otherwise.
785    ///
786    /// # Examples
787    ///
788    /// ```
789    /// use noyalib::cst::parse_document;
790    /// use noyalib::Value;
791    ///
792    /// let mut doc = parse_document("name: noyalib\nversion: 0.0.1\n").unwrap();
793    /// doc.set_value("version", &Value::String("0.0.2".into())).unwrap();
794    /// assert_eq!(doc.to_string(), "name: noyalib\nversion: 0.0.2\n");
795    ///
796    /// // An equal value does not touch the bytes.
797    /// let mut doc = parse_document("ratio: 1.10\n").unwrap();
798    /// doc.set_value("ratio", &Value::from(1.1_f64)).unwrap();
799    /// assert_eq!(doc.to_string(), "ratio: 1.10\n");
800    /// ```
801    pub fn set_value(&mut self, path: &str, value: &Value) -> Result<()> {
802        let ((s, e), alias_target) = self.write_span_resolved(path)?;
803        // A value equal to the one already there is a no-op, and a no-op
804        // must leave the bytes alone: re-rendering an equal value through
805        // the formatter rewrites spellings the author chose (`1.10` to
806        // `1.1`, `0x1F` to `31`, `+1` to `1`, `~` to `null`, a `>-`
807        // folded scalar to a plain one) without changing the loaded
808        // document (#337). `write_span` has already vetted the path, so
809        // alias refusals and path errors are unaffected.
810        let segments = parse_query_path(path);
811        let target_is_collection = {
812            self.ensure_cache();
813            let cache = self.cache.borrow();
814            let (root, _) = cache.as_ref().expect("ensure_cache populated");
815            let current = typed_value_at(root, &segments);
816            if current == Some(value) {
817                return Ok(());
818            }
819            matches!(current, Some(Value::Sequence(_) | Value::Mapping(_)))
820        };
821        // A collection value replaces a collection node in the node's
822        // own style — flow stays flow, block stays block (#328). The
823        // scalar formatter below cannot spell one, so branch off here.
824        // An alias-valued entry takes a scalar replacement only: the
825        // token is one leaf, and a collection grown in its place is not
826        // an in-place edit.
827        if matches!(value, Value::Sequence(_) | Value::Mapping(_)) {
828            refuse_collection_over_alias_token(path, alias_target)?;
829            return self.replace_collection_value(path, value, s, e);
830        }
831        // One policy for anchored nodes (#338): a write into a value
832        // that `*name` sites share lands at every one of them, so it is
833        // refused with the same guidance rename_key and the inserters
834        // give. (An equal value already returned above: a no-op is
835        // harmless wherever it points.)
836        self.refuse_inside_aliased_anchor("set_value", path, s)?;
837        // A block collection occupies its own lines, and the resolver
838        // widens its span to the first of them
839        // (`extend_to_line_start`) so that a *read* slice is uniformly
840        // indented and re-parses to the value it denotes. A scalar
841        // spliced over that span therefore starts where the collection's
842        // line started, which is the key's own column: `k:` / `  a: 1`
843        // came back as `k:` / `5`. This parser reads that; PyYAML and
844        // libyaml reject it, and one level down it surfaces as an
845        // "inconsistent indentation" error over a document that has
846        // none.
847        //
848        // The value goes where the collection's content sat instead, one
849        // indent step past the key when the collection sat at the key's
850        // own column. That is the correction `remove` already makes when
851        // it empties a sole entry, and it is what `set_value` already
852        // does for a scalar that was on its own line (`k:` / `  hello`
853        // becomes `k:` / `  5`): the new value goes where the old value's
854        // content began. This is the one span that had been widened past
855        // that rule.
856        let own_line = target_is_collection
857            && !segments.is_empty()
858            && s != e
859            && s == start_of_line(&self.source, s);
860        // An empty span is an implicit null's insertion point, not a value to
861        // overwrite: there is no scalar leaf there to read a style from, and
862        // no `: ` separator either, since the span starts right after the
863        // indicator. Everything else about the site — the neighbours, the
864        // column — is read the same way.
865        let filling_in = s == e;
866        let kind = if filling_in || own_line || alias_target {
867            // No existing bytes means no quoting *intent* to preserve, which
868            // is exactly the state `PlainScalar` denotes to the neighbour rule
869            // below. A span widened to a line start has none either: the leaf
870            // there is the indentation, which `is_block_site` rejects and the
871            // formatter reports as "target site is not a scalar leaf" — which
872            // is why a *string* over a block collection errored while a number
873            // corrupted, the arm disagreeing with itself. An alias token being
874            // replaced carries no quoting intent for the incoming scalar
875            // either.
876            SyntaxKind::PlainScalar
877        } else {
878            leaf_kind_at(&self.green, s).ok_or_else(|| {
879                Error::Parse("could not locate green-tree leaf at target span".into())
880            })?
881        };
882        // Neighbour-aware styling: when the site is currently emitted
883        // plain (so there is no quoting *intent* to preserve) and a
884        // sibling style dominates the surrounding `BlockMapping`,
885        // match the neighbours.
886        let neighbour = if own_line {
887            // At a line start the sibling walk resolves the *inner*
888            // collection, so it would hand back the quoting style of the
889            // value being replaced.
890            None
891        } else {
892            sibling_dominant_scalar_kind(&self.green, s).filter(|_| kind == SyntaxKind::PlainScalar)
893        };
894        let entry_col = if own_line {
895            own_line_replacement_column(&self.source, s, self.indent_unit())
896        } else {
897            entry_indent_column(&self.source, s)
898        };
899        let ctx = SiteContext {
900            kind,
901            neighbour,
902            entry_col,
903            in_flow: in_flow_collection(&self.green, s),
904        };
905        let fragment = format_value_for_site(value, &ctx)?;
906        // A block literal owns every byte through the end of its last
907        // content line, so a comment that trailed the old one-line value
908        // would be swallowed into the new value. Move it onto the header
909        // line, where YAML allows a comment, and widen the splice to
910        // cover it (#333).
911        let (fragment, e) = match trailing_comment_span(&self.source, e) {
912            Some((comment_start, line_end)) if fragment.starts_with('|') => (
913                hoist_comment_onto_header(&fragment, &self.source[comment_start..line_end]),
914                line_end,
915            ),
916            _ => (fragment, e),
917        };
918        let fragment = if filling_in {
919            fill_in(&fragment)
920        } else if own_line {
921            // The splice starts at the line's first byte, so the fragment
922            // carries the indentation itself. A block literal's body already
923            // sits at `entry_col + 2`; only its header line needs placing.
924            format!("{}{fragment}", " ".repeat(entry_col))
925        } else {
926            fragment
927        };
928        // Last, so it covers the block literal, the single-quoted
929        // multi-line scalar and the hoisted comment above alike.
930        let fragment = respell_breaks(&fragment, document_break(&self.source));
931        self.replace_span(s, e, &fragment)
932    }
933
934    /// The collection arm of [`set_value`](Self::set_value) (#328): a
935    /// `Value::Sequence` / `Value::Mapping` replaces an existing
936    /// collection node in that node's own style. `tags: [a, b]` set to
937    /// `[a, c]` stays flow; a block sequence stays block, re-indented
938    /// to the old value's column. The candidate document is parsed and
939    /// compared against the expected typed value (the document with
940    /// exactly this path replaced) *before* the splice, so a rendering
941    /// the site cannot hold leaves the source byte-identical.
942    ///
943    /// Replacing a **scalar** with a collection is still refused — a
944    /// value that must move onto its own lines is a layout decision
945    /// `set` expresses with a fragment.
946    fn replace_collection_value(
947        &mut self,
948        path: &str,
949        value: &Value,
950        s: usize,
951        e: usize,
952    ) -> Result<()> {
953        let segments = parse_query_path(path);
954        let (expected, tree_span) = {
955            self.ensure_cache();
956            let cache = self.cache.borrow();
957            let (root, span_tree) = cache.as_ref().expect("ensure_cache populated");
958            if !matches!(
959                typed_value_at(root, &segments),
960                Some(Value::Sequence(_) | Value::Mapping(_))
961            ) {
962                return Err(Error::Parse(
963                    "set_value cannot replace a scalar with a collection (use `set` with a \
964                     fragment)"
965                        .into(),
966                ));
967            }
968            let mut expected = root.clone();
969            match path_value_mut(&mut expected, &segments) {
970                Some(slot) => *slot = value.clone(),
971                None => return Err(Error::Parse(format!("path not found: {path}"))),
972            }
973            let tree_span = resolve_span(root, span_tree, &segments)
974                .map(|((s, e), _)| (content_start(&self.source, s, e), e));
975            (expected, tree_span)
976        };
977        // The green resolver mis-spans an indentless block sequence
978        // (`tags:\n- a`), so prefer the loader's span tree for
979        // collection nodes; then step past any leading indent the span
980        // swept up, so the splice starts at the node's first byte and
981        // the column below is the node's own.
982        let (s, e) = tree_span.map_or((s, e), |(ts, te)| trim_value_span(&self.source, ts, te));
983        let s = s + self.source[s..e].bytes().take_while(|&b| b == b' ').count();
984        // One policy for anchored nodes (#338): a write into a value
985        // that `*name` sites share is refused with the same guidance
986        // every other mutator gives.
987        self.refuse_inside_aliased_anchor("set_value", path, s)?;
988        // The node's own style decides the rendering: a `[` / `{` at
989        // the span start, or any flow ancestor, means flow (block
990        // collections cannot nest inside flow ones).
991        let flow_site = matches!(self.source.as_bytes().get(s), Some(b'[' | b'{'))
992            || in_flow_collection(&self.green, s);
993        let fragment = if flow_site {
994            let cfg = crate::SerializerConfig::new().flow_style(crate::FlowStyle::Flow);
995            crate::to_string_value_with_config(value, &cfg)?
996                .trim_end_matches('\n')
997                .to_owned()
998        } else {
999            // Block rendering starts at column 0; shift every
1000            // continuation line to the old value's own column so the
1001            // fragment sits where the node it replaces sat.
1002            let line_start = self.source[..s].rfind('\n').map_or(0, |i| i + 1);
1003            let column = s - line_start;
1004            let cfg = crate::SerializerConfig::new()
1005                .indent(self.indent_unit())
1006                .flow_style(crate::FlowStyle::Block);
1007            let rendered = crate::to_string_value_with_config(value, &cfg)?;
1008            let pad = " ".repeat(column);
1009            rendered
1010                .trim_end_matches('\n')
1011                .replace('\n', &format!("\n{pad}"))
1012        };
1013        // The scalar arm's rule, on the arm that renders through the
1014        // serializer. It must land *here*, above the candidate: the
1015        // oracle proves something only if the bytes it parses are the
1016        // bytes that get spliced.
1017        let fragment = respell_breaks(&fragment, document_break(&self.source));
1018        // Oracle before the splice: the candidate must load back as
1019        // the document with exactly this one path replaced.
1020        let mut candidate = String::with_capacity(self.source.len() + fragment.len());
1021        candidate.push_str(&self.source[..s]);
1022        candidate.push_str(&fragment);
1023        candidate.push_str(&self.source[e..]);
1024        let reparsed = parse_document_inner(&candidate, self.config.clone()).map_err(|err| {
1025            Error::Parse(format!(
1026                "set_value: replacing `{path}` with the rendered collection would not \
1027                 re-parse ({err}); the document was left unchanged"
1028            ))
1029        })?;
1030        if oracle_rejects(&reparsed.as_value(), &expected) {
1031            return Err(Error::Parse(format!(
1032                "set_value: replacing `{path}` failed the integrity check — the rendered \
1033                 collection did not load back as the value given; the document was left \
1034                 unchanged"
1035            )));
1036        }
1037        self.replace_span(s, e, &fragment)
1038    }
1039
1040    /// Set or create the entry addressed by a prevalidated [`QueryPath`].
1041    ///
1042    /// This is the strict-input counterpart to [`set_path`](Self::set_path):
1043    /// parse untrusted input once, handle [`crate::PathError`] explicitly,
1044    /// then apply the validated path without ambiguity between malformed and
1045    /// missing input.
1046    pub fn set_query_path(&mut self, path: &QueryPath, value: &Value) -> Result<()> {
1047        self.set_path(&path.to_string(), value)
1048    }
1049
1050    /// Like [`set_value`](Self::set_value), but creates every missing
1051    /// mapping level along `path` on the way (#327, ADR-0009).
1052    ///
1053    /// A frontmatter writer setting `menu.visible` must not care
1054    /// whether `menu:` exists yet — the writer it replaces creates it.
1055    /// `set_path` resolves the deepest existing ancestor and:
1056    ///
1057    /// - **whole path exists** — behaves exactly like
1058    ///   [`set_value`](Self::set_value) (upsert, equal-value no-op,
1059    ///   scalar-only replacement);
1060    /// - **a block-mapping ancestor exists** — inserts the remaining
1061    ///   chain through
1062    ///   [`insert_entry_value`](Self::insert_entry_value), which owns
1063    ///   the indentation, quoting, and the typed-oracle guard;
1064    /// - **the document is empty** (nothing but comments, blank lines,
1065    ///   or a bare `---`) — appends the rendered chain after the
1066    ///   existing bytes, so a comment header survives its document's
1067    ///   first key.
1068    ///
1069    /// The style machinery is the same one every `*_value` mutator
1070    /// uses: quoting stays with [`Emit`], new levels indent at the
1071    /// document's [`indent_unit`](Self::indent_unit), and the edit is
1072    /// verified to change exactly the addressed path before it is
1073    /// kept.
1074    ///
1075    /// # Errors
1076    ///
1077    /// - An existing path segment resolves to a scalar (`title.x`
1078    ///   where `title` is a string) or to a null value other than the
1079    ///   empty document root; the source is left byte-identical.
1080    /// - A missing segment is a sequence index — `set_path` creates
1081    ///   mappings, never sequence items.
1082    /// - The nearest existing ancestor is a flow collection or an
1083    ///   empty `{}` — the flow inserters are tracked by #338; the
1084    ///   refusal is clean.
1085    /// - The same errors as [`set_value`](Self::set_value) /
1086    ///   [`insert_entry_value`](Self::insert_entry_value) otherwise.
1087    ///
1088    /// # Examples
1089    ///
1090    /// ```
1091    /// use noyalib::cst::parse_document;
1092    /// use noyalib::Value;
1093    ///
1094    /// // Creates the missing `menu:` level.
1095    /// let mut doc = parse_document("title: x\n").unwrap();
1096    /// doc.set_path("menu.visible", &Value::Bool(true)).unwrap();
1097    /// assert_eq!(doc.to_string(), "title: x\nmenu:\n  visible: true\n");
1098    ///
1099    /// // An empty document receives its first key.
1100    /// let mut doc = parse_document("").unwrap();
1101    /// doc.set_path("menu.visible", &Value::Bool(true)).unwrap();
1102    /// assert_eq!(doc.to_string(), "menu:\n  visible: true\n");
1103    ///
1104    /// // An existing leaf is an ordinary upsert.
1105    /// let mut doc = parse_document("menu:\n  visible: false\n").unwrap();
1106    /// doc.set_path("menu.visible", &Value::Bool(true)).unwrap();
1107    /// assert_eq!(doc.to_string(), "menu:\n  visible: true\n");
1108    /// ```
1109    pub fn set_path(&mut self, path: &str, value: &Value) -> Result<()> {
1110        if let Err(e) = self.validate() {
1111            return Err(Error::Parse(format!(
1112                "set_path: the document does not parse, so `{path}` cannot be resolved \
1113                 ({e}); the document was left unchanged"
1114            )));
1115        }
1116        let segments = parse_query_path(path);
1117        if segments.is_empty() {
1118            return Err(Error::Parse(
1119                "set_path requires a non-empty path (the document root is not an entry)".into(),
1120            ));
1121        }
1122        self.ensure_cache();
1123        let missing_from = {
1124            let cache = self.cache.borrow();
1125            let (root, _) = cache.as_ref().expect("ensure_cache populated");
1126            first_missing_segment(root, &segments, path)?
1127        };
1128        if missing_from == segments.len() {
1129            return self.set_value(path, value);
1130        }
1131        // Everything still to create must be a mapping key: a sequence
1132        // has no natural "missing item" to materialise.
1133        for segment in &segments[missing_from..] {
1134            match segment {
1135                QuerySegment::Key(_) => {}
1136                QuerySegment::Index(i) => {
1137                    return Err(Error::Parse(format!(
1138                        "set_path: `{path}` needs sequence item [{i}] created, and set_path \
1139                         creates mappings only — push the item with `push_back_value` first"
1140                    )));
1141                }
1142                QuerySegment::Wildcard | QuerySegment::RecursiveDescent => {
1143                    return Err(Error::Parse(format!(
1144                        "set_path: `{path}` contains a wildcard or recursive-descent segment, \
1145                         which does not address a single entry"
1146                    )));
1147                }
1148            }
1149        }
1150        // Wrap the value in one mapping per missing level below the
1151        // first, innermost out: `menu.theme.dark` over an existing root
1152        // becomes insert(root, "menu", {theme: {dark: value}}).
1153        let mut nested = value.clone();
1154        for segment in segments[missing_from + 1..].iter().rev() {
1155            let QuerySegment::Key(key) = segment else {
1156                unreachable!("non-key segments rejected above")
1157            };
1158            let mut level = Mapping::new();
1159            let _ = level.insert(key.clone(), nested);
1160            nested = Value::Mapping(level);
1161        }
1162        let QuerySegment::Key(first_missing) = &segments[missing_from] else {
1163            unreachable!("non-key segments rejected above")
1164        };
1165
1166        let root_is_null = {
1167            let cache = self.cache.borrow();
1168            let (root, _) = cache.as_ref().expect("ensure_cache populated");
1169            matches!(root, Value::Null)
1170        };
1171        if missing_from == 0 && root_is_null {
1172            return self.append_first_entry(path, first_missing, nested);
1173        }
1174        let ancestor_path = format_query_prefix(&segments[..missing_from]);
1175        self.insert_entry_value(&ancestor_path, first_missing, &nested)
1176    }
1177
1178    /// The empty-document arm of [`set_path`](Self::set_path): render
1179    /// the whole new chain and append it after the existing bytes
1180    /// (comments, blank lines, a bare `---`), so nothing an author
1181    /// wrote is disturbed. The candidate is parsed and compared to the
1182    /// expected typed value *before* the document is touched — a null
1183    /// the author spelled out (`null`, `~`) fails that check and is
1184    /// refused with the source byte-identical.
1185    fn append_first_entry(&mut self, path: &str, key: &str, nested: Value) -> Result<()> {
1186        let mut expected_root = Mapping::new();
1187        let _ = expected_root.insert(key.to_owned(), nested);
1188        let expected = Value::Mapping(expected_root);
1189        let config = crate::SerializerConfig::new().indent(self.indent_unit());
1190        let mut rendered = crate::to_string_value_with_config(&expected, &config)?;
1191        if !rendered.ends_with('\n') {
1192            rendered.push('\n');
1193        }
1194        let needs_break = !self.source.is_empty() && !self.source.ends_with('\n');
1195        let fragment = if needs_break {
1196            format!("\n{rendered}")
1197        } else {
1198            rendered
1199        };
1200        let mut candidate = String::with_capacity(self.source.len() + fragment.len());
1201        candidate.push_str(&self.source);
1202        candidate.push_str(&fragment);
1203        let candidate_value: Value =
1204            crate::parser::parse_exactly_one_value(&candidate, &self.config).map_err(|e| {
1205                Error::Parse(format!(
1206                    "set_path: `{path}` cannot be created here — the document's root is not a \
1207                     mapping and not empty ({e}); the document was left unchanged"
1208                ))
1209            })?;
1210        if oracle_rejects(&candidate_value, &expected) {
1211            return Err(Error::Parse(format!(
1212                "set_path: `{path}` cannot be created here — the document's root already \
1213                 holds a non-mapping value; the document was left unchanged"
1214            )));
1215        }
1216        let end = self.source.len();
1217        self.replace_span(end, end, &fragment)
1218    }
1219
1220    /// Remove the value at `path` along with its surrounding entry
1221    /// (key + colon for mappings, `-` indicator for sequences).
1222    /// Trailing whitespace and the line break are removed too so the
1223    /// surrounding entries close up with no orphan blank line.
1224    ///
1225    /// # What counts as part of the entry
1226    ///
1227    /// An entry owns the trivia a reader would say belongs to it, so a
1228    /// removal leaves no orphan and steals nothing from its neighbours:
1229    ///
1230    /// - **Head comment.** A contiguous run of full-line comments
1231    ///   directly above the entry, at its own indentation, is removed
1232    ///   with it. Left behind, such a comment does not merely litter —
1233    ///   it silently becomes documentation for the *next* entry. A blank
1234    ///   line detaches the run, so a document header set off by one
1235    ///   survives the removal of the first entry.
1236    /// - **Kept blank lines.** A keep-chomped (`|+` / `>+`) block
1237    ///   scalar's trailing blank lines are content, not separation, and
1238    ///   go with the entry rather than being stranded after it.
1239    /// - **Trailing comments stay.** A comment *after* the entry's last
1240    ///   content line lies outside its value span (see
1241    ///   [`Document::span_at`]) and conventionally documents whatever
1242    ///   comes next, so it is left in place. A comment *interleaved*
1243    ///   inside a multi-line value is inside the span and goes with the
1244    ///   entry.
1245    ///
1246    /// ```
1247    /// use noyalib::cst::parse_document;
1248    ///
1249    /// // The comment documenting `database` goes with it …
1250    /// let mut doc = parse_document("# connection settings\ndatabase:\n  host: x\ncache: 1\n").unwrap();
1251    /// doc.remove("database").unwrap();
1252    /// assert_eq!(doc.to_string(), "cache: 1\n");
1253    ///
1254    /// // … but one that documents the following entry does not.
1255    /// let mut doc = parse_document("outer:\n  a: 1\n  # note for next\nnext: 2\n").unwrap();
1256    /// doc.remove("outer").unwrap();
1257    /// assert_eq!(doc.to_string(), "  # note for next\nnext: 2\n");
1258    /// ```
1259    ///
1260    /// Coverage (issue #221 sub-ask 4 is complete as of v0.0.23):
1261    /// - **Multi-line values and nested block collections** are removed
1262    ///   — the whole entry, from its key / `-` indicator through the
1263    ///   last line the value owns.
1264    /// - **Flow-collection members** are removed (`{x: 1, y: 2}` →
1265    ///   `{y: 2}`, `[1, 2, 3]` → `[1, 3]`). The member's own span goes,
1266    ///   plus exactly one separator: the comma after it, or — for the
1267    ///   last member — the comma before it. A separator sitting on
1268    ///   another line is not matched, so a multi-line flow collection
1269    ///   refuses rather than splicing something it cannot see.
1270    /// - **The last entry of a collection** empties that collection
1271    ///   rather than deleting its bytes: `a:\n  x: 1` becomes
1272    ///   `a:\n  {}`, and a sole sequence item leaves `[]`. Deleting the
1273    ///   bytes would leave a dangling `a:`, which re-parses as **null**
1274    ///   — a type change rather than a removal. The document's trailing
1275    ///   newline survives.
1276    ///
1277    /// Every path except the single-line block fast path is guarded by
1278    /// an eager re-parse **and** a typed-value oracle (the document
1279    /// minus exactly this one path); a splice that would change anything
1280    /// else rolls back and the document is left untouched. The fast path
1281    /// is kept only where the entry demonstrably owns its whole line,
1282    /// because the oracle's expectation is wrong for a duplicated key.
1283    ///
1284    /// # Errors
1285    ///
1286    /// - Path not found.
1287    /// - The entry sits inside an anchored value with live alias
1288    ///   references — removing it here would remove it at every
1289    ///   `*name` site too (#338); call
1290    ///   [`materialise_aliases_of`](Self::materialise_aliases_of)
1291    ///   first.
1292    /// - A flow separator that cannot be located on the member's line.
1293    /// - The same parse-after-edit errors as
1294    ///   [`Document::replace_span`]; on failure the document is left
1295    ///   unchanged.
1296    ///
1297    /// # Examples
1298    ///
1299    /// ```
1300    /// use noyalib::cst::parse_document;
1301    ///
1302    /// let mut doc = parse_document("a: 1\nb: 2\nc: 3\n").unwrap();
1303    /// doc.remove("b").unwrap();
1304    /// assert_eq!(doc.to_string(), "a: 1\nc: 3\n");
1305    /// ```
1306    pub fn remove(&mut self, path: &str) -> Result<()> {
1307        self.ensure_cache();
1308        let segments = parse_query_path(path);
1309        let removal = {
1310            let cache = self.cache.borrow();
1311            let (value, span_tree) = cache.as_ref().expect("ensure_cache populated");
1312            entry_line_span(value, span_tree, &self.source, &segments, None)?
1313        };
1314        // One policy for anchored nodes (#338): an entry inside a value
1315        // that `*name` sites share disappears from every one of them,
1316        // so the removal is refused with the same guidance rename_key
1317        // and the inserters give.
1318        let removal_start = removal.start();
1319        // A whole-line removal starts at the line's first byte — before
1320        // the indentation, which sits outside the anchored content span
1321        // — so probe from the entry's first content byte instead.
1322        let probe = removal_start
1323            + self.source[removal_start..]
1324                .bytes()
1325                .take_while(|&b| b == b' ')
1326                .count();
1327        self.refuse_inside_aliased_anchor("remove", path, probe)?;
1328        // What to splice, and what to put back; see `Removal::splice`.
1329        let (line_start, line_end, _multiline, replacement) = removal.splice();
1330        // Fast path only when the entry demonstrably owns its line.
1331        //
1332        // This used to be `if !multiline`, on the reasoning that
1333        // deleting one line cannot surprise anyone. A flow collection
1334        // breaks that: in `a: {x: 1, y: 2}` the entry `a.x` shares a
1335        // line with its siblings *and* its parent, so "delete the line"
1336        // removed the whole `a` entry — and for a single-entry
1337        // document, the whole document — while returning `Ok`. Silent
1338        // data loss.
1339        //
1340        // The test is whether the entry's own key starts the line. If
1341        // it does, the line is the entry and splicing it is safe. If it
1342        // does not, something else shares the line and the typed oracle
1343        // below has to arbitrate.
1344        //
1345        // Keeping a fast path at all matters: the oracle compares
1346        // against "the document with this path absent", which is the
1347        // wrong expectation for a duplicated key. `remove("k")` on
1348        // `k: one\nk: two` deletes the winning occurrence and leaves
1349        // the shadowed one, so the oracle would refuse an edit that is
1350        // both intended and tested.
1351        let owns_its_line = matches!(removal, Removal::Line { .. })
1352            && self
1353                .key_span(path)
1354                .is_some_and(|(ks, _)| is_yaml_blank(&self.source[line_start..ks]));
1355        if !_multiline && owns_its_line {
1356            return self.replace_span(line_start, line_end, "");
1357        }
1358
1359        self.guarded_remove(path, &segments, (line_start, line_end), &replacement)
1360    }
1361
1362    /// Splice `replacement` over `start..end` for [`Self::remove`],
1363    /// guarded by a snapshot, an eager re-parse and the typed oracle.
1364    fn guarded_remove(
1365        &mut self,
1366        path: &str,
1367        segments: &[QuerySegment],
1368        (start, end): (usize, usize),
1369        replacement: &str,
1370    ) -> Result<()> {
1371        // Multi-line / nested block value: the splice removes several
1372        // lines, so guard it with a snapshot, an eager re-parse, and a
1373        // typed oracle — the document with exactly this path removed.
1374        let expected = {
1375            let cache = self.cache.borrow();
1376            let (value, _) = cache.as_ref().expect("ensure_cache populated");
1377            expected_after_remove(value, segments)?
1378        };
1379        let snapshot = self.clone();
1380        if let Err(e) = self.replace_span(start, end, replacement) {
1381            *self = snapshot;
1382            return Err(Error::Parse(format!(
1383                "remove: removing `{path}` could not be spliced ({e}); \
1384                 the document was left unchanged"
1385            )));
1386        }
1387        if let Err(e) = self.validate() {
1388            *self = snapshot;
1389            return Err(Error::Parse(format!(
1390                "remove: removing `{path}` left the document unable to re-parse ({e}); \
1391                 the document was left unchanged"
1392            )));
1393        }
1394        if oracle_rejects(&self.as_value(), &expected) {
1395            *self = snapshot;
1396            return Err(Error::Parse(format!(
1397                "remove: removing `{path}` failed the integrity check — the edit would \
1398                 change data beyond the removed entry; the document was left unchanged"
1399            )));
1400        }
1401        Ok(())
1402    }
1403
1404    /// Rename the key of the mapping entry at `path` to `new_key`,
1405    /// leaving every other byte — the `:`, the value, whitespace,
1406    /// comments, and sibling entries — untouched.
1407    ///
1408    /// `path` addresses the entry the same way [`Document::set`] and
1409    /// [`Document::remove`] address it: the path points at the
1410    /// entry's *value*; the operation rewrites that entry's *key*
1411    /// token.
1412    ///
1413    /// `new_key`'s spelling is *style-matched to the key it
1414    /// replaces*: a plain key stays plain when `new_key`'s plain
1415    /// spelling re-parses to exactly that string, a single-quoted
1416    /// key stays single-quoted, a double-quoted key stays
1417    /// double-quoted. Quoting is forced only when the plain
1418    /// spelling would not re-parse to `new_key` (`a: b`, `-flag`,
1419    /// `8080`, `true`) — a plain site then falls back to double
1420    /// quotes.
1421    ///
1422    /// Renaming a key to its current spelling is a no-op — `Ok(())`
1423    /// with no bytes modified. "Current spelling" is decided on the
1424    /// *decoded* key, so a plain `true:` renamed to `"true"` stays
1425    /// plain rather than being requoted. The guarantee applies to
1426    /// every path that resolves to a mapping entry; paths that fail
1427    /// to resolve at all (alias-addressed content, keys produced by
1428    /// a `<<` merge) report their resolution error instead.
1429    ///
1430    /// After the splice the document must re-parse cleanly **and**
1431    /// its typed value must equal the old value with exactly that
1432    /// one key renamed — same entry position, same value. If either
1433    /// check fails, the document is rolled back to its previous
1434    /// state and an error is returned.
1435    ///
1436    /// Restrictions in this phase:
1437    /// - Both block-mapping and flow-mapping entries rename (#338);
1438    ///   in flow context a new key whose plain spelling would read as
1439    ///   flow structure (`,` `[` `]` `{` `}`) is double-quoted.
1440    /// - The entry's key must be a simple scalar token (plain,
1441    ///   single-quoted, or double-quoted). Alias keys (`*name :`)
1442    ///   are rejected. Explicit complex keys (`? [a, b]`) are not
1443    ///   addressable by the path syntax in the first place — their
1444    ///   stringified form contains bracket segments, which the path
1445    ///   parser reads as sequence indices — so they cannot be
1446    ///   renamed; the surrounding mapping's other entries rename
1447    ///   normally.
1448    ///
1449    /// # Errors
1450    ///
1451    /// - Path not found, or it does not address a mapping entry
1452    ///   (e.g. it ends in a sequence index).
1453    /// - `path` contains a bracket segment that is neither a
1454    ///   non-negative integer nor a quoted key (`servers[web]`) — the
1455    ///   shared path parser drops such a segment, which would rename
1456    ///   the *parent* key, so `rename_key` refuses it outright. A key
1457    ///   the grammar would misread is addressed as `servers["web"]`.
1458    /// - `new_key` is `<<`: the loader treats a `<<` key as a merge
1459    ///   directive whatever its quote style, so the rename cannot
1460    ///   round-trip.
1461    /// - `new_key` contains a non-printable character (any control
1462    ///   character other than tab, `U+007F`, or a `U+0080..=U+009F`
1463    ///   C1 control) — YAML's printable set excludes them and no
1464    ///   scalar style can spell them here.
1465    /// - Restrictions above.
1466    /// - The containing mapping already has a *different* entry
1467    ///   whose key equals `new_key` — the rename would create a
1468    ///   duplicate and silently change data. Reported separately
1469    ///   when that sibling comes from a `<<` merge rather than from
1470    ///   the mapping's own source entries.
1471    /// - The addressed key has no entry of its own because a `<<`
1472    ///   merge key produced it — the key lives in the merged
1473    ///   mapping, so that is where it must be renamed.
1474    /// - The path is reached *through* an alias (`*name`): the
1475    ///   bytes at that site belong to the anchor, so the anchor's
1476    ///   own entry must be renamed instead.
1477    /// - The entry lies inside an anchored value that has alias
1478    ///   references — the rename would propagate to every `*name`
1479    ///   site. Call [`Document::materialise_aliases_of`] first.
1480    /// - The re-parse / integrity guard above; the document is left
1481    ///   unchanged.
1482    /// - The document no longer parses (an earlier edit left it in
1483    ///   the optimistically-committed broken state — see
1484    ///   [`Document::validate`]).
1485    ///
1486    /// # Examples
1487    ///
1488    /// ```
1489    /// use noyalib::cst::parse_document;
1490    ///
1491    /// let mut doc = parse_document("name: foo  # the project\nversion: 0.0.1\n").unwrap();
1492    /// doc.rename_key("name", "title").unwrap();
1493    /// assert_eq!(doc.to_string(), "title: foo  # the project\nversion: 0.0.1\n");
1494    /// ```
1495    ///
1496    /// A new key that is not plain-safe is quoted automatically:
1497    ///
1498    /// ```
1499    /// use noyalib::cst::parse_document;
1500    ///
1501    /// let mut doc = parse_document("name: foo\n").unwrap();
1502    /// doc.rename_key("name", "a: b").unwrap();
1503    /// assert_eq!(doc.to_string(), "\"a: b\": foo\n");
1504    /// assert_eq!(doc.as_value()["a: b"].as_str(), Some("foo"));
1505    /// ```
1506    pub fn rename_key(&mut self, path: &str, new_key: &str) -> Result<()> {
1507        // An earlier edit may have left the document in the
1508        // optimistically-committed broken state (see `validate`), in
1509        // which case `ensure_cache` would panic. Surface it as an
1510        // error instead — `rename_key` returns `Result` and
1511        // documents no panics.
1512        self.validate().map_err(|e| {
1513            Error::Parse(format!(
1514                "rename_key: the document does not parse, so `{path}` cannot be resolved \
1515                 ({e}); the document was left unchanged"
1516            ))
1517        })?;
1518        let segments = parse_rename_path(path)?;
1519
1520        // Spelling refusals that no scalar style can work around,
1521        // checked before any resolution so the diagnosis names the
1522        // argument rather than whatever the splice happened to break.
1523        if new_key == MERGE_KEY_SPELLING {
1524            return Err(Error::Parse(format!(
1525                "rename_key: `{MERGE_KEY_SPELLING}` cannot be used as a key name — the loader \
1526                 treats any `{MERGE_KEY_SPELLING}` key as a merge directive whatever its quote \
1527                 style, so the renamed entry would not round-trip as a key"
1528            )));
1529        }
1530        if let Some(bad) = first_non_printable(new_key) {
1531            return Err(Error::Parse(format!(
1532                "rename_key: the new key contains the non-printable character U+{:04X}, which \
1533                 is outside YAML's printable character set — mapping keys may not carry control \
1534                 characters (tab excepted)",
1535                bad as u32
1536            )));
1537        }
1538
1539        // Resolve the entry's key span via the typed cache — the
1540        // same resolver family `remove` uses (`entry_line_span`
1541        // computes this key span and discards its end; here it is
1542        // the target). The sibling-duplicate refusal happens during
1543        // resolution, where the containing mapping is at hand.
1544        let (key_start, key_end) = {
1545            let cache = self.cache.borrow();
1546            let (value, span_tree) = cache.as_ref().expect("validate populated the cache");
1547            entry_key_site(value, span_tree, &segments, new_key)?
1548        };
1549        if key_start == key_end {
1550            // The loader records a zero-width key span for keys that
1551            // are not a single scalar node — in practice alias keys
1552            // (`*name :`); an explicit complex key (`? [a, b]`) has
1553            // one too, but its stringified form is not addressable
1554            // by the path syntax, so it never reaches here.
1555            return Err(Error::Parse(format!(
1556                "rename_key: the key at `{path}` is not a simple scalar token \
1557                 (alias keys cannot be renamed)"
1558            )));
1559        }
1560
1561        // Green-tree guards: the addressed key must be a scalar
1562        // token that belongs to a *block* mapping entry.
1563        let (token_kind, (tok_start, tok_end), parent_kind) =
1564            token_at_with_parent(&self.green, key_start, 0).ok_or_else(|| {
1565                Error::Parse(format!(
1566                    "rename_key: could not locate the key token for `{path}`"
1567                ))
1568            })?;
1569
1570        // The scanner captures a plain scalar at end-of-line with
1571        // its trailing line break (see `anchored_scalar_text`) — an
1572        // explicit key (`? foo`) ends its line, so keep separator
1573        // whitespace out of the splice.
1574        let (tok_start, tok_end) = trim_trailing_blank(&self.source, tok_start, tok_end);
1575
1576        // No-op check, decided on the *decoded* key rather than on
1577        // the spelling `format_key_for_site` would produce: a plain
1578        // `true:` renamed to `"true"` must stay plain, not be
1579        // requoted into a different YAML type. It runs before the
1580        // remaining refusals so a same-name rename is `Ok(())`
1581        // wherever the entry resolves at all — including inside a
1582        // flow mapping, whose renames are otherwise a follow-up.
1583        if let Some(current) = decode_key_token(&self.source[tok_start..tok_end], token_kind)
1584            && current == new_key
1585        {
1586            return Ok(());
1587        }
1588
1589        // Both block-mapping entries and flow-mapping entries are
1590        // renameable (#338): the key token is a scalar span either
1591        // way, and the splice-then-oracle tail below is style-blind.
1592        if !matches!(
1593            parent_kind,
1594            SyntaxKind::MappingEntry | SyntaxKind::FlowMapping
1595        ) {
1596            return Err(Error::Parse(format!(
1597                "rename_key: `{path}` does not address a mapping entry key"
1598            )));
1599        }
1600        if !matches!(
1601            token_kind,
1602            SyntaxKind::PlainScalar
1603                | SyntaxKind::SingleQuotedScalar
1604                | SyntaxKind::DoubleQuotedScalar
1605        ) {
1606            return Err(Error::Parse(format!(
1607                "rename_key: the key at `{path}` is not a simple scalar token \
1608                 (alias keys cannot be renamed)"
1609            )));
1610        }
1611
1612        // An entry inside an anchored value is shared with every
1613        // `*name` site: renaming the key here renames it at all of
1614        // them, which the integrity oracle would reject as an
1615        // unrelated "duplicate key". Diagnose the real cause first.
1616        if let Some((anchor, alias_count)) = self.aliased_anchor_covering(tok_start) {
1617            return Err(Error::Parse(format!(
1618                "rename_key: `{path}` is inside the value anchored by `&{anchor}`, which has \
1619                 {alias_count} alias reference(s) — renaming the key here would rename it at \
1620                 every `*{anchor}` site too; call `materialise_aliases_of(\"{anchor}\")` first \
1621                 to give each site its own copy, then rename"
1622            )));
1623        }
1624
1625        // Spell the new key, style-matched to the token it replaces
1626        // (plain stays plain when the plain spelling re-parses to
1627        // `new_key`, quoted stays quoted in the same style). In flow
1628        // context `,` `[` `]` `{` `}` are structural anywhere in a
1629        // plain scalar, so a plain spelling unsafe there is quoted.
1630        let replacement = format_key_for_site(new_key, token_kind);
1631        let replacement = if parent_kind == SyntaxKind::FlowMapping
1632            && !replacement.starts_with(['"', '\''])
1633            && !is_plain_safe_in_flow(&replacement)
1634        {
1635            format_double_quoted(new_key)
1636        } else {
1637            replacement
1638        };
1639        if replacement == self.source[tok_start..tok_end] {
1640            // Spelling-identical after formatting — nothing to splice.
1641            return Ok(());
1642        }
1643
1644        // Snapshot for rollback, and the integrity oracle: the old
1645        // typed value with exactly this one key renamed in place.
1646        let snapshot = self.clone();
1647        let expected = {
1648            let cache = self.cache.borrow();
1649            let (value, _) = cache.as_ref().expect("validate populated the cache");
1650            expected_after_rename(value, &segments, new_key)?
1651        };
1652
1653        // Post-splice guards. Every failure below is reported in
1654        // `rename_key`'s own terms — a raw loader error would say
1655        // nothing about the path, the new key, or the rollback.
1656        if let Err(e) = self.replace_span(tok_start, tok_end, &replacement) {
1657            *self = snapshot;
1658            return Err(Error::Parse(format!(
1659                "rename_key: renaming `{path}` to `{new_key}` could not be spliced ({e}); \
1660                 the document was left unchanged"
1661            )));
1662        }
1663
1664        // `replace_span` has already validated the complete document;
1665        // compare its typed view against the semantic oracle and roll
1666        // back on any mismatch.
1667        if let Err(e) = self.validate() {
1668            *self = snapshot;
1669            return Err(Error::Parse(format!(
1670                "rename_key: renaming `{path}` to `{new_key}` left the document unable to \
1671                 re-parse ({e}); the document was left unchanged"
1672            )));
1673        }
1674        let matches_expected = *self.as_value() == expected;
1675        if !matches_expected {
1676            *self = snapshot;
1677            return Err(Error::Parse(format!(
1678                "rename_key: renaming `{path}` to `{new_key}` failed the integrity \
1679                 check — the edit would change data beyond the single renamed key \
1680                 (e.g. a duplicate of the old key elsewhere in the mapping); \
1681                 the document was left unchanged"
1682            )));
1683        }
1684        Ok(())
1685    }
1686
1687    /// Swap two items of the block sequence at `path`, exchanging each
1688    /// item's **whole entry** — its own lines, its head-comment run
1689    /// included. Every other item, and the surrounding structure, stay
1690    /// byte-identical.
1691    ///
1692    /// An item owns the same range here that [`remove`](Self::remove)
1693    /// deletes — `owned_entry_range` computes both. That is deliberate:
1694    /// the two
1695    /// have to agree about who a comment belongs to, or the same bytes
1696    /// are the entry's property under one call and the slot's under the
1697    /// other. A reorder that moved only value bytes would leave each
1698    /// comment annotating whichever item landed beneath it, silently
1699    /// and at `Ok`.
1700    ///
1701    /// A **flow** sequence has no per-item lines to exchange, so its
1702    /// members keep the narrower value-span swap.
1703    ///
1704    /// Guarded like the other mutators: after the two splices the
1705    /// document must re-parse **and** its typed value must equal the
1706    /// original with exactly items `i` and `j` exchanged, or the edit
1707    /// is rolled back and the document is left untouched.
1708    ///
1709    /// Swapping an index with itself, or two items whose values are
1710    /// already equal, is a no-op that returns `Ok(())`.
1711    ///
1712    /// # Errors
1713    ///
1714    /// - `path` does not resolve to a sequence.
1715    /// - `i` or `j` is out of bounds for that sequence.
1716    /// - The bytes of an item could not be located.
1717    /// - The splice would not re-parse, or fails the integrity check
1718    ///   above (both roll back).
1719    ///
1720    /// # Examples
1721    ///
1722    /// ```
1723    /// use noyalib::cst::parse_document;
1724    ///
1725    /// let mut doc = parse_document("- a\n- b\n- c\n").unwrap();
1726    /// doc.swap_items("", 0, 2).unwrap();
1727    /// assert_eq!(doc.source(), "- c\n- b\n- a\n");
1728    /// ```
1729    ///
1730    /// A comment travels with the item it documents:
1731    ///
1732    /// ```
1733    /// use noyalib::cst::parse_document;
1734    ///
1735    /// let mut doc = parse_document("# about one\n- one\n# about two\n- two\n").unwrap();
1736    /// doc.swap_items("", 0, 1).unwrap();
1737    /// assert_eq!(doc.source(), "# about two\n- two\n# about one\n- one\n");
1738    /// ```
1739    pub fn swap_items(&mut self, path: &str, i: usize, j: usize) -> Result<()> {
1740        let segments = parse_query_path(path);
1741        self.ensure_cache();
1742        let len = {
1743            let cache = self.cache.borrow();
1744            let (value, _) = cache.as_ref().expect("ensure_cache populated");
1745            sequence_len_at(value, &segments, path)?
1746        };
1747        if i >= len || j >= len {
1748            return Err(Error::Parse(format!(
1749                "swap_items: index out of bounds for the sequence at `{path}` \
1750                 (length {len}): requested {i} and {j}"
1751            )));
1752        }
1753        if i == j {
1754            return Ok(());
1755        }
1756
1757        let (pi, pj) = (item_child_path(path, i), item_child_path(path, j));
1758        let span_i = self.span_at(&pi).ok_or_else(|| {
1759            Error::Parse(format!("swap_items: could not locate item {i} of `{path}`"))
1760        })?;
1761        let span_j = self.span_at(&pj).ok_or_else(|| {
1762            Error::Parse(format!("swap_items: could not locate item {j} of `{path}`"))
1763        })?;
1764
1765        // What each range receives. Whole entries when both items own
1766        // their lines; bare value spans for a flow member, which does
1767        // not.
1768        let (range_i, range_j, into_i, into_j) = match self.owned_item_ranges(&pi, &pj) {
1769            Some((ri, rj)) => {
1770                let (body_i, term_i) = split_line_terminator(&self.source[ri.0..ri.1]);
1771                let (body_j, term_j) = split_line_terminator(&self.source[rj.0..rj.1]);
1772                // Each *position* keeps its own line terminator while the
1773                // bodies move. The last entry of a document may have none,
1774                // and carrying the breaks along with the bodies would join
1775                // two lines into one: `- a\n- b` would swap to `- b- a`.
1776                (
1777                    ri,
1778                    rj,
1779                    format!("{body_j}{term_i}"),
1780                    format!("{body_i}{term_j}"),
1781                )
1782            }
1783            None => (
1784                span_i,
1785                span_j,
1786                self.source()[span_j.0..span_j.1].to_string(),
1787                self.source()[span_i.0..span_i.1].to_string(),
1788            ),
1789        };
1790
1791        // Integrity oracle: the old value with items i and j exchanged.
1792        let expected = {
1793            let cache = self.cache.borrow();
1794            let (value, _) = cache.as_ref().expect("ensure_cache populated");
1795            expected_after_swap(value, &segments, i, j, path)?
1796        };
1797
1798        let snapshot = self.clone();
1799        // Replace the *later* range first so the earlier range's byte
1800        // offsets stay valid for the second splice.
1801        let (lo, hi, lo_text, hi_text) = if range_i.0 < range_j.0 {
1802            (range_i, range_j, &into_i, &into_j)
1803        } else {
1804            (range_j, range_i, &into_j, &into_i)
1805        };
1806        for (span, text) in [(hi, hi_text), (lo, lo_text)] {
1807            if let Err(e) = self.replace_span(span.0, span.1, text) {
1808                *self = snapshot;
1809                return Err(Error::Parse(format!(
1810                    "swap_items: swapping items {i} and {j} of `{path}` could not be \
1811                     spliced ({e}); the document was left unchanged"
1812                )));
1813            }
1814        }
1815        if let Err(e) = self.validate() {
1816            *self = snapshot;
1817            return Err(Error::Parse(format!(
1818                "swap_items: swapping items {i} and {j} of `{path}` left the document \
1819                 unable to re-parse ({e}); the document was left unchanged"
1820            )));
1821        }
1822        if oracle_rejects(&self.as_value(), &expected) {
1823            *self = snapshot;
1824            return Err(Error::Parse(format!(
1825                "swap_items: swapping items {i} and {j} of `{path}` failed the integrity \
1826                 check — the exchange would change data beyond the two items; \
1827                 the document was left unchanged"
1828            )));
1829        }
1830        Ok(())
1831    }
1832
1833    /// The whole-line ranges the items at `pi` and `pj` own, or `None`
1834    /// when either has no lines of its own.
1835    ///
1836    /// `None` is the flow-member case — `[one, two]`, where the items
1837    /// share a line with each other and with the brackets — and it is
1838    /// also the safe answer for a path that does not resolve, leaving
1839    /// the caller on the value-span path it used before.
1840    fn owned_item_ranges(&self, pi: &str, pj: &str) -> Option<((usize, usize), (usize, usize))> {
1841        let cache = self.cache.borrow();
1842        let (value, span_tree) = cache.as_ref()?;
1843        let owned = |p: &str| match entry_line_span(
1844            value,
1845            span_tree,
1846            &self.source,
1847            &parse_query_path(p),
1848            None,
1849        ) {
1850            Ok(Removal::Line { start, end, .. }) => Some((start, end)),
1851            _ => None,
1852        };
1853        let (ri, rj) = (owned(pi)?, owned(pj)?);
1854        // Two distinct items own disjoint ranges — a comment run between
1855        // them belongs to the one below it, and `owned_value_end` keeps
1856        // the one above from claiming it. Checked rather than assumed:
1857        // overlapping ranges would have the second splice write into
1858        // bytes the first had already moved, which the typed oracle
1859        // cannot catch because the result still parses.
1860        if ri.0 < rj.1 && rj.0 < ri.1 {
1861            return None;
1862        }
1863        Some((ri, rj))
1864    }
1865
1866    /// Move the item at `from` to index `to` in the block sequence at
1867    /// `path`, shifting the items in between by one. The move is
1868    /// applied as a run of adjacent [`swap_items`](Self::swap_items)
1869    /// steps, so it inherits that method's guarantees — each item's
1870    /// whole entry moves, its comments with it, structure is preserved,
1871    /// and each step is guarded — and the whole move is **atomic**: if
1872    /// any step is refused, the document is rolled back to its state
1873    /// before the call.
1874    ///
1875    /// Moving an index to itself is a no-op that returns `Ok(())`.
1876    ///
1877    /// # Errors
1878    ///
1879    /// - `path` does not resolve to a sequence.
1880    /// - `from` or `to` is out of bounds for that sequence.
1881    /// - Any underlying swap is refused; the document is left unchanged.
1882    ///
1883    /// # Examples
1884    ///
1885    /// ```
1886    /// use noyalib::cst::parse_document;
1887    ///
1888    /// let mut doc = parse_document("- a\n- b\n- c\n- d\n").unwrap();
1889    /// doc.move_item("", 0, 2).unwrap();
1890    /// assert_eq!(doc.source(), "- b\n- c\n- a\n- d\n");
1891    /// ```
1892    pub fn move_item(&mut self, path: &str, from: usize, to: usize) -> Result<()> {
1893        let segments = parse_query_path(path);
1894        self.ensure_cache();
1895        let len = {
1896            let cache = self.cache.borrow();
1897            let (value, _) = cache.as_ref().expect("ensure_cache populated");
1898            sequence_len_at(value, &segments, path)?
1899        };
1900        if from >= len || to >= len {
1901            return Err(Error::Parse(format!(
1902                "move_item: index out of bounds for the sequence at `{path}` \
1903                 (length {len}): from {from}, to {to}"
1904            )));
1905        }
1906        if from == to {
1907            return Ok(());
1908        }
1909
1910        let snapshot = self.clone();
1911        let mut failure = None;
1912        if from < to {
1913            for k in from..to {
1914                if let Err(e) = self.swap_items(path, k, k + 1) {
1915                    failure = Some(e);
1916                    break;
1917                }
1918            }
1919        } else {
1920            let mut k = from;
1921            while k > to {
1922                if let Err(e) = self.swap_items(path, k, k - 1) {
1923                    failure = Some(e);
1924                    break;
1925                }
1926                k -= 1;
1927            }
1928        }
1929        if let Some(e) = failure {
1930            *self = snapshot;
1931            return Err(Error::Parse(format!(
1932                "move_item: moving item {from} to {to} in `{path}` failed ({e}); \
1933                 the document was left unchanged"
1934            )));
1935        }
1936        Ok(())
1937    }
1938
1939    /// The anchor covering byte `pos` that has at least one `*name`
1940    /// reference, with that reference count.
1941    ///
1942    /// `Document::rename_key` uses this to refuse a rename whose
1943    /// bytes are shared with alias sites *before* splicing, so the
1944    /// user gets the anchor's name instead of a downstream integrity
1945    /// complaint. `None` when `pos` is outside every anchored value,
1946    /// or the anchors covering it have no aliases (then the rename
1947    /// is local and safe).
1948    fn aliased_anchor_covering(&self, pos: usize) -> Option<(String, usize)> {
1949        for anchor in self.anchors() {
1950            let Some((start, end)) = anchored_content_span(&self.green, 0, anchor.mark_span.0)
1951            else {
1952                continue;
1953            };
1954            if pos < start || pos >= end {
1955                continue;
1956            }
1957            let count = self.aliases_of(&anchor.name).len();
1958            if count > 0 {
1959                return Some((anchor.name, count));
1960            }
1961        }
1962        None
1963    }
1964
1965    /// Append a new item to the block sequence at `path`.
1966    ///
1967    /// `fragment` is the YAML representation of the *value* — the
1968    /// `- ` indicator and the surrounding indentation are synthesized
1969    /// from the existing items so the new line matches the file's
1970    /// shape. Block sequences only in this phase; flow sequences
1971    /// (`[…]`) and empty sequences are rejected.
1972    ///
1973    /// # Errors
1974    ///
1975    /// - `path` does not resolve to a sequence.
1976    /// - The sequence is a flow collection (`[…]`).
1977    /// - The sequence has no existing items to anchor indentation on.
1978    /// - The fragment changed the document beyond the single item
1979    ///   asked for — reaching outside the sequence (`"v\nqq: 7"`) or
1980    ///   smuggling extra items into it (`"v\n  - w"`); the document
1981    ///   is left unchanged.
1982    /// - The same parse-after-edit errors as
1983    ///   [`Document::replace_span`].
1984    ///
1985    /// # Examples
1986    ///
1987    /// ```
1988    /// use noyalib::cst::parse_document;
1989    ///
1990    /// let mut doc = parse_document("items:\n  - one\n  - two\n").unwrap();
1991    /// doc.push_back("items", "three").unwrap();
1992    /// assert_eq!(doc.to_string(), "items:\n  - one\n  - two\n  - three\n");
1993    /// ```
1994    pub fn push_back(&mut self, path: &str, fragment: &str) -> Result<()> {
1995        let p = path.to_owned();
1996        let f = fragment.to_owned();
1997        self.guarded_insert(path, "push_back", InsertGrowth::SeqPlusOne, move |d| {
1998            d.push_back_inner(&p, &f)
1999        })
2000    }
2001
2002    fn push_back_inner(&mut self, path: &str, fragment: &str) -> Result<()> {
2003        self.ensure_cache();
2004        let seq_len = {
2005            let cache = self.cache.borrow();
2006            let (value, _) = cache.as_ref().expect("ensure_cache populated");
2007            let target = path_value(value, path)
2008                .ok_or_else(|| Error::Parse(format!("path not found: {path}")))?;
2009            match target {
2010                Value::Sequence(s) => s.len(),
2011                _ => {
2012                    return Err(Error::Parse(
2013                        "push_back: target path is not a sequence".into(),
2014                    ));
2015                }
2016            }
2017        };
2018        if seq_len == 0 {
2019            return Err(Error::Parse(
2020                "push_back: empty sequence has no anchor for indentation — use `set` with a fragment instead"
2021                    .into(),
2022            ));
2023        }
2024        // Find the byte range of the LAST existing item to anchor
2025        // dash indentation and the splice position.
2026        let item_path = format!("{path}[{}]", seq_len - 1);
2027        let (last_start, last_end) = self
2028            .span_at(&item_path)
2029            .ok_or_else(|| Error::Parse("push_back: could not resolve last item span".into()))?;
2030        let dash_col = column_of_preceding_dash(&self.source, last_start).ok_or_else(|| {
2031            Error::Parse(
2032                "push_back: only block sequences are supported (no `-` anchor before last item)"
2033                    .into(),
2034            )
2035        })?;
2036        let line_end = end_of_line(&self.source, last_end);
2037        let indent: String = " ".repeat(dash_col);
2038        let lead = leading_break_for_splice(&self.source, line_end);
2039        let nl = document_break(&self.source);
2040        let new_line = format!("{lead}{indent}- {fragment}{nl}");
2041        self.replace_span(line_end, line_end, &new_line)
2042    }
2043
2044    /// Detect the indentation unit (in spaces) used by this document.
2045    ///
2046    /// Walks the source line-by-line, looks for any pair of
2047    /// consecutive non-empty/non-comment lines where the second is
2048    /// more deeply indented than the first, and returns the smallest
2049    /// such delta — that is the file's "indent step", typically 2 or
2050    /// 4 spaces. A document with no nested structure (or only
2051    /// top-level keys) has no detectable step; the default `2` is
2052    /// returned in that case.
2053    ///
2054    /// Used internally by the [`crate::cst::Entry`] insertion paths
2055    /// to keep the inserted YAML's inner indentation consistent with
2056    /// what the rest of the file already uses (2-space file → 2-space
2057    /// inserts; 4-space file → 4-space inserts). Exposed publicly so
2058    /// callers building their own emission paths can match the same
2059    /// convention.
2060    ///
2061    /// # Examples
2062    ///
2063    /// ```
2064    /// use noyalib::cst::parse_document;
2065    ///
2066    /// let two_space = parse_document(
2067    ///     "metadata:\n  labels:\n    app: noyalib\n",
2068    /// ).unwrap();
2069    /// assert_eq!(two_space.indent_unit(), 2);
2070    ///
2071    /// let four_space = parse_document(
2072    ///     "metadata:\n    labels:\n        app: noyalib\n",
2073    /// ).unwrap();
2074    /// assert_eq!(four_space.indent_unit(), 4);
2075    ///
2076    /// // No nested structure — defaults to 2.
2077    /// let flat = parse_document("a: 1\nb: 2\n").unwrap();
2078    /// assert_eq!(flat.indent_unit(), 2);
2079    /// ```
2080    #[must_use]
2081    pub fn indent_unit(&self) -> usize {
2082        detect_indent_unit(&self.source)
2083    }
2084
2085    /// Inspect the document and return the dominant scalar quote
2086    /// style — `Plain`, `SingleQuoted`, or `DoubleQuoted`. Used by
2087    /// the [`crate::cst::Entry`] insert helpers to make new
2088    /// scalars adopt the file's existing convention rather than
2089    /// the serializer's hard-coded default.
2090    ///
2091    /// The detection scans every plain / single-quoted /
2092    /// double-quoted scalar leaf in the green tree, picks the
2093    /// majority, and breaks ties in favour of the simpler form
2094    /// (`Plain` > `SingleQuoted` > `DoubleQuoted`). Empty
2095    /// documents and documents with no string-shaped scalars
2096    /// default to `Plain`.
2097    ///
2098    /// # Examples
2099    ///
2100    /// ```
2101    /// use noyalib::cst::parse_document;
2102    /// use noyalib::ScalarStyle;
2103    ///
2104    /// let single = parse_document("a: 'one'\nb: 'two'\n").unwrap();
2105    /// assert_eq!(single.dominant_quote_style(), ScalarStyle::SingleQuoted);
2106    ///
2107    /// let double = parse_document("a: \"one\"\nb: \"two\"\n").unwrap();
2108    /// assert_eq!(double.dominant_quote_style(), ScalarStyle::DoubleQuoted);
2109    ///
2110    /// let plain = parse_document("a: one\nb: two\n").unwrap();
2111    /// assert_eq!(plain.dominant_quote_style(), ScalarStyle::Plain);
2112    /// ```
2113    #[must_use]
2114    pub fn dominant_quote_style(&self) -> crate::ScalarStyle {
2115        detect_dominant_quote_style(&self.green)
2116    }
2117
2118    /// Inspect the document and return the dominant collection
2119    /// style — `FlowStyle::Block` or `FlowStyle::Auto`
2120    /// (equivalent to "flow"). Used by `Entry::insert_value` to
2121    /// decide whether a typed mapping / sequence emission should
2122    /// use block or flow form.
2123    ///
2124    /// The detection counts top-level `BlockMapping` /
2125    /// `BlockSequence` vs `FlowMapping` / `FlowSequence` leaves
2126    /// and picks the majority. Empty / scalar-only documents
2127    /// default to `Block`.
2128    ///
2129    /// # Examples
2130    ///
2131    /// ```
2132    /// use noyalib::cst::parse_document;
2133    /// use noyalib::FlowStyle;
2134    ///
2135    /// let block = parse_document("a:\n  - 1\n  - 2\n").unwrap();
2136    /// assert_eq!(block.dominant_flow_style(), FlowStyle::Block);
2137    ///
2138    /// let flow = parse_document("a: [1, 2, 3]\nb: [4, 5]\n").unwrap();
2139    /// assert_eq!(flow.dominant_flow_style(), FlowStyle::Auto);
2140    /// ```
2141    #[must_use]
2142    pub fn dominant_flow_style(&self) -> crate::FlowStyle {
2143        detect_dominant_flow_style(&self.green)
2144    }
2145
2146    /// Insert a new `key: fragment` entry into the block mapping at
2147    /// `mapping_path`. The mapping-side analogue of
2148    /// [`Document::push_back`].
2149    ///
2150    /// Behaves like `set` when the key already exists (the value is
2151    /// replaced losslessly). When the key is new, a sibling line is
2152    /// spliced after the last existing entry, with the indent matched
2153    /// to the last entry's key column so the file stays canonical.
2154    /// Block mappings only in this phase; flow mappings (`{…}`) and
2155    /// empty mappings are rejected.
2156    ///
2157    /// Only the *fragment* is verbatim YAML. The key is a **name**: a
2158    /// spelling whose plain form would not re-parse to it (`a: b`,
2159    /// `[x]`, a leading `- `) is quoted automatically, exactly as
2160    /// [`Document::rename_key`] documents for its new key, and the
2161    /// existing-key check reads the mapping's own entries — a key
2162    /// holding `.` or `[` (`app.io/name`, ubiquitous in Kubernetes
2163    /// labels) inserts as that literal key rather than resolving
2164    /// through the path syntax.
2165    ///
2166    /// # The fragment cannot reach outside the entry
2167    ///
2168    /// After the splice, the document's shape outside `mapping_path`
2169    /// must be unchanged and the mapping must have gained exactly the
2170    /// one entry asked for — a fragment or key that smuggles sibling
2171    /// entries (a line break the splice never intended, `v\rc: 3`) is
2172    /// refused and the document left untouched, the same oracle
2173    /// [`Document::set`] and [`Document::push_back`] apply.
2174    ///
2175    /// # Errors
2176    ///
2177    /// - `mapping_path` does not resolve to a mapping.
2178    /// - The mapping is empty (no anchor for indentation; use `set`
2179    ///   with a fragment instead).
2180    /// - `key` is `<<` (the loader reads any `<<` key as a merge
2181    ///   directive, whatever its quote style) or carries a
2182    ///   non-printable character.
2183    /// - `key` already exists but contains `.` or `[`, which the path
2184    ///   syntax cannot address to replace its value — `remove` the
2185    ///   entry and insert it afresh, or splice it with `set`.
2186    /// - The fragment added or removed entries beyond the one asked
2187    ///   for (the integrity oracle above); the document is left
2188    ///   unchanged.
2189    /// - The same parse-after-edit errors as
2190    ///   [`Document::replace_span`].
2191    ///
2192    /// # Examples
2193    ///
2194    /// ```
2195    /// use noyalib::cst::parse_document;
2196    ///
2197    /// let mut doc = parse_document(
2198    ///     "metadata:\n  labels:\n    app: noyalib\n",
2199    /// ).unwrap();
2200    /// doc.insert_entry("metadata.labels", "env", "prod").unwrap();
2201    /// let out = doc.to_string();
2202    /// assert!(out.contains("app: noyalib"));
2203    /// assert!(out.contains("env: prod"));
2204    /// ```
2205    pub fn insert_entry(&mut self, mapping_path: &str, key: &str, fragment: &str) -> Result<()> {
2206        // The key half is a *name*, not YAML. `rename_key` and
2207        // `insert_entry_value` already refuse the spellings no quote
2208        // style can carry; the verbatim tier held the last hole — a
2209        // key holding a line break spliced sibling entries the caller
2210        // never asked for.
2211        if key == MERGE_KEY_SPELLING {
2212            return Err(Error::Parse(format!(
2213                "insert_entry: `{MERGE_KEY_SPELLING}` cannot be used as a key name — the loader \
2214                 treats any `{MERGE_KEY_SPELLING}` key as a merge directive whatever its quote \
2215                 style, so the entry would not round-trip as a key"
2216            )));
2217        }
2218        if let Some(bad) = first_non_printable(key) {
2219            return Err(Error::Parse(format!(
2220                "insert_entry: the key contains the non-printable character U+{:04X}, which is \
2221                 outside YAML's printable character set — mapping keys may not carry control \
2222                 characters (tab excepted)",
2223                bad as u32
2224            )));
2225        }
2226
2227        // Existing-key upsert, decided on the mapping's own entries
2228        // and addressed through `push_key`, which quotes a key holding
2229        // `.`, `[`, `]`, or `*` (`app.io/name`, ubiquitous in Kubernetes
2230        // labels) so the composed path resolves to that entry and never
2231        // to whatever *nested* entry a plain `"{path}.{key}"` happened
2232        // to name.
2233        let child_path = child_path_of(mapping_path, key);
2234        self.ensure_cache();
2235        let in_mapping = {
2236            let cache = self.cache.borrow();
2237            let (doc_value, _) = cache.as_ref().expect("ensure_cache populated");
2238            let target = if mapping_path.is_empty() {
2239                Some(doc_value)
2240            } else {
2241                path_value(doc_value, mapping_path)
2242            };
2243            matches!(target, Some(Value::Mapping(m)) if m.get(key).is_some())
2244        };
2245        // A key token of its own means the entry is here (an implicit
2246        // null included); a key present in the typed view *without*
2247        // one is inherited through a `<<` merge, and the insert
2248        // appends an explicit override instead.
2249        if in_mapping && self.key_span(&child_path).is_some() {
2250            return self.set(&child_path, fragment);
2251        }
2252
2253        let p = mapping_path.to_owned();
2254        let k = key.to_owned();
2255        let f = fragment.to_owned();
2256        self.guarded_insert(
2257            mapping_path,
2258            "insert_entry",
2259            InsertGrowth::MapEntry(key),
2260            move |d| d.insert_entry_splice(&p, &k, &f),
2261        )
2262    }
2263
2264    /// The new-key splice for [`Document::insert_entry`]: a sibling
2265    /// line after the mapping's last entry. Runs under
2266    /// [`Document::guarded_insert`].
2267    fn insert_entry_splice(&mut self, mapping_path: &str, key: &str, fragment: &str) -> Result<()> {
2268        // The anchor comes from `mapping_insert_anchor`, which reads
2269        // the target mapping's own entries out of the span tree; this
2270        // used to take the last key from the typed view and compose it
2271        // back into a path string, which no key holding a `.` or `[`
2272        // survives.
2273        self.ensure_cache();
2274        let (key_col, line_end, _) = self.mapping_insert_anchor(mapping_path)?;
2275        let indent: String = " ".repeat(key_col);
2276        // The key adopts the site's spelling, quoted when its plain
2277        // form would not re-parse to the name given — the same
2278        // courtesy `rename_key` documents for its new key.
2279        let ctx = self.emit_ctx_at(key_col, mapping_path);
2280        let key = emit_key(key, &ctx);
2281
2282        // Single-line values (scalars, flow collections, anything
2283        // without an interior newline) splice inline. Multi-line
2284        // fragments — typically the YAML emission of a nested block
2285        // mapping or sequence — splice as `{key}:\n{children}` with
2286        // the children re-indented by `key_col + indent_unit` so the
2287        // nested structure lines up with the surrounding file's
2288        // convention (Phase 2.2).
2289        // The fragment is a caller-supplied or generated emission and is
2290        // always `\n`-separated; only the breaks this splice *adds* take
2291        // the document's spelling.
2292        let nl = document_break(&self.source);
2293        let new_line = if fragment.contains('\n') {
2294            let unit = detect_indent_unit(&self.source);
2295            let inner_indent: String = " ".repeat(key_col + unit);
2296            // Strip leading blank lines so a caller that prefixed `\n`
2297            // to force block form (see `Entry::insert_value` for a
2298            // single-entry collection) does not introduce a stray
2299            // blank between the key and its first child.
2300            let body = fragment.trim_start_matches('\n');
2301            let mut buf = format!("{indent}{key}:{nl}");
2302            for line in body.split('\n') {
2303                if line.is_empty() {
2304                    buf.push_str(nl);
2305                } else {
2306                    buf.push_str(&inner_indent);
2307                    buf.push_str(line);
2308                    buf.push_str(nl);
2309                }
2310            }
2311            buf
2312        } else {
2313            format!("{indent}{key}: {fragment}{nl}")
2314        };
2315        let lead = leading_break_for_splice(&self.source, line_end);
2316        self.replace_span(line_end, line_end, &format!("{lead}{new_line}"))
2317    }
2318
2319    /// Insert a new sequence item immediately after the item at
2320    /// `item_path` (e.g. `"items[1]"`).
2321    ///
2322    /// `fragment` is the YAML representation of the value; the
2323    /// `- ` indicator and indentation are derived from the item at
2324    /// `item_path`.
2325    ///
2326    /// # Errors
2327    ///
2328    /// - `item_path` does not end in an index.
2329    /// - The path does not resolve to a sequence item in a block
2330    ///   sequence.
2331    /// - The fragment changed the document beyond the single item
2332    ///   asked for — the same containment oracle
2333    ///   [`Document::push_back`] documents; the document is left
2334    ///   unchanged.
2335    /// - The same parse-after-edit errors as
2336    ///   [`Document::replace_span`].
2337    ///
2338    /// # Examples
2339    ///
2340    /// ```
2341    /// use noyalib::cst::parse_document;
2342    ///
2343    /// let mut doc = parse_document("items:\n  - one\n  - three\n").unwrap();
2344    /// doc.insert_after("items[0]", "two").unwrap();
2345    /// assert_eq!(
2346    ///     doc.to_string(),
2347    ///     "items:\n  - one\n  - two\n  - three\n",
2348    /// );
2349    /// ```
2350    pub fn insert_after(&mut self, item_path: &str, fragment: &str) -> Result<()> {
2351        // The container is the parent sequence: `items[2]` -> `items`.
2352        let container = item_path
2353            .rfind('[')
2354            .map_or_else(|| item_path.to_owned(), |b| item_path[..b].to_owned());
2355        let p = item_path.to_owned();
2356        let f = fragment.to_owned();
2357        self.guarded_insert(
2358            &container,
2359            "insert_after",
2360            InsertGrowth::SeqPlusOne,
2361            move |d| d.insert_after_inner(&p, &f),
2362        )
2363    }
2364
2365    fn insert_after_inner(&mut self, item_path: &str, fragment: &str) -> Result<()> {
2366        let segments = parse_query_path(item_path);
2367        if !matches!(segments.last(), Some(QuerySegment::Index(_))) {
2368            return Err(Error::Parse(
2369                "insert_after: path must end with a sequence index, e.g. `items[2]`".into(),
2370            ));
2371        }
2372        let (item_start, item_end) = self
2373            .span_at(item_path)
2374            .ok_or_else(|| Error::Parse(format!("path not found: {item_path}")))?;
2375        let dash_col = column_of_preceding_dash(&self.source, item_start).ok_or_else(|| {
2376            Error::Parse(
2377                "insert_after: only block sequences are supported (no `-` anchor before item)"
2378                    .into(),
2379            )
2380        })?;
2381        let line_end = end_of_line(&self.source, item_end);
2382        let indent: String = " ".repeat(dash_col);
2383        let lead = leading_break_for_splice(&self.source, line_end);
2384        let nl = document_break(&self.source);
2385        let new_line = format!("{lead}{indent}- {fragment}{nl}");
2386        self.replace_span(line_end, line_end, &new_line)
2387    }
2388
2389    // ── Auto-formatting insertion (the `Emit` tier) ─────────────────
2390
2391    /// The emission context for a site starting at `column`: the
2392    /// conventions of the collection being edited, so an insertion looks
2393    /// like the lines beside it.
2394    ///
2395    /// `site` is the path of the collection receiving the entry.
2396    /// When it holds scalars they decide the quote style — **plain ones
2397    /// included**. Only when the site offers no evidence at all (an empty
2398    /// collection) does this fall back to the document-wide vote.
2399    ///
2400    /// The document-wide vote is deliberately unchanged. It counts quoted
2401    /// scalars against each other and ignores plain ones, so one quoted
2402    /// line anywhere decided the spelling of every later insertion: on a
2403    /// Kubernetes manifest, `value: "30"` in a container's env block
2404    /// dictated the spelling of a label four lines from the top (#290).
2405    /// `EmitCtx`'s own doc says an implementation should "match the file
2406    /// it is landing in" — landing in is a *site*, and the whole document
2407    /// was the wrong radius rather than the wrong idea.
2408    ///
2409    /// `Document::dominant_quote_style` is public with documented
2410    /// behaviour and three doctests pinning it, so its meaning stays as
2411    /// it is; this narrows what *insertion* asks, not what the function
2412    /// answers.
2413    fn emit_ctx_at(&self, column: usize, site: &str) -> EmitCtx {
2414        EmitCtx::new(
2415            self.quote_style_for_site(site),
2416            self.dominant_flow_style(),
2417            self.indent_unit(),
2418            column,
2419        )
2420    }
2421
2422    /// Quote style for an insertion landing in the collection at `path`,
2423    /// falling back to the document when that collection has no scalar
2424    /// values to learn from.
2425    ///
2426    /// Reads the collection's entry **values** from the span tree rather
2427    /// than counting scalar tokens in a byte range. A byte range cannot
2428    /// tell a key from a value, and mapping keys are almost always plain
2429    /// — so `a: "one"` / `b: "two"` counts two plain keys against two
2430    /// quoted values and ties to plain, which is the opposite of what the
2431    /// site says. Values are the only scalars an insertion imitates.
2432    fn quote_style_for_site(&self, path: &str) -> crate::ScalarStyle {
2433        let counts = {
2434            let cache = self.cache.borrow();
2435            cache.as_ref().and_then(|(value, tree)| {
2436                let (_, sub) = resolve_tree(value, tree, &parse_query_path(path))?;
2437                let mut counts = (0_usize, 0_usize, 0_usize);
2438                let mut tally = |t: &SpanTree| {
2439                    // Only leaves are scalars; a nested collection has no
2440                    // spelling of its own to copy.
2441                    if let SpanTree::Leaf(start, _) = t {
2442                        match self.source.as_bytes().get(*start) {
2443                            Some(b'"') => counts.2 += 1,
2444                            Some(b'\'') => counts.1 += 1,
2445                            Some(_) => counts.0 += 1,
2446                            None => {}
2447                        }
2448                    }
2449                };
2450                match sub {
2451                    SpanTree::Mapping { entries, .. } => {
2452                        for (_, v) in entries {
2453                            tally(v);
2454                        }
2455                    }
2456                    SpanTree::Sequence { items, .. } => {
2457                        for item in items {
2458                            tally(item);
2459                        }
2460                    }
2461                    _ => return None,
2462                }
2463                Some(counts)
2464            })
2465        };
2466        match counts {
2467            Some((plain, single, double)) if plain + single + double > 0 => {
2468                // Plain needs a *strict* majority. A tie means the site is
2469                // genuinely mixed — `a: 1` beside `b: 'two'` — and there
2470                // the existing quoting is the better guide than a
2471                // preference for bare scalars. #290 is about unrelated
2472                // lines deciding the spelling, not about mixed sites, and
2473                // every case it reports has plain winning outright.
2474                if plain > single && plain > double {
2475                    crate::ScalarStyle::Plain
2476                } else if single >= double {
2477                    crate::ScalarStyle::SingleQuoted
2478                } else {
2479                    crate::ScalarStyle::DoubleQuoted
2480                }
2481            }
2482            _ => self.dominant_quote_style(),
2483        }
2484    }
2485
2486    /// Insert `key: value` into the block mapping at `mapping_path`,
2487    /// formatting **both** halves so they re-parse to exactly the key
2488    /// and value given.
2489    ///
2490    /// The typed counterpart of [`Document::insert_entry`], which
2491    /// splices its `&str` arguments verbatim: `insert_entry(m, "k",
2492    /// "a: b")` grows a nested mapping, where
2493    /// `insert_entry_value(m, "k", "a: b")` inserts the *string*
2494    /// `"a: b"`. Quoting follows the file's dominant scalar style
2495    /// except where that style would misrepresent the data, in which
2496    /// case quoting is forced (see [`Emit`]).
2497    ///
2498    /// When `key` already exists its value is replaced in place;
2499    /// otherwise a sibling line is appended after the mapping's last
2500    /// entry, indented to match.
2501    ///
2502    /// After the splice the document must re-parse **and** its typed
2503    /// value must equal the pre-edit value with exactly this one entry
2504    /// set, or the edit is rolled back — the guard the verbatim path
2505    /// cannot offer, since a fragment that restructures the document
2506    /// is still valid YAML.
2507    ///
2508    /// An existing key is an **upsert**: its value is rewritten in place,
2509    /// including when that value is an implicit null (`a:`), which is an entry
2510    /// the mapping already has rather than one to append. A key it only
2511    /// *inherits* through a `<<` merge has no entry here at all, so an
2512    /// explicit one is created to override it.
2513    ///
2514    /// A new key into a **flow** mapping — `{a: 1}`, `{}`, or a whole
2515    /// document spelled as one — splices `, key: value` before the
2516    /// closing brace (#338); only single-line flow mappings accept
2517    /// inserts.
2518    ///
2519    /// # Errors
2520    ///
2521    /// - `mapping_path` does not resolve to a mapping, or an empty
2522    ///   **block**-context mapping leaves no entry to anchor
2523    ///   indentation on (use [`Document::set`] with a fragment).
2524    /// - The flow mapping at `mapping_path` spans more than one line.
2525    /// - `key` is `<<` (the loader reads any `<<` key as a merge
2526    ///   directive, whatever its quote style) or carries a
2527    ///   non-printable character.
2528    /// - The value has no auto-formatted spelling (see [`Emit::emit`]).
2529    /// - The splice would not re-parse, or fails the integrity check
2530    ///   above; the document is left unchanged.
2531    ///
2532    /// # Examples
2533    ///
2534    /// ```
2535    /// use noyalib::cst::parse_document;
2536    ///
2537    /// let mut doc = parse_document("labels:\n  app: noyalib\n").unwrap();
2538    /// doc.insert_entry_value("labels", "version", "8080").unwrap();
2539    /// // Quoted: the plain spelling would load as a number.
2540    /// assert_eq!(
2541    ///     doc.to_string(),
2542    ///     "labels:\n  app: noyalib\n  version: \"8080\"\n",
2543    /// );
2544    /// ```
2545    pub fn insert_entry_value<E: Emit + ?Sized>(
2546        &mut self,
2547        mapping_path: &str,
2548        key: &str,
2549        value: &E,
2550    ) -> Result<()> {
2551        self.check_insert_entry_key(mapping_path, key)?;
2552
2553        let expected_child = value.expected_value()?;
2554        let expected = {
2555            let cache = self.cache.borrow();
2556            let (doc_value, _) = cache.as_ref().expect("validate populated the cache");
2557            expected_after_insert_entry(doc_value, mapping_path, key, &expected_child)?
2558        };
2559        let child_path = child_path_of(mapping_path, key);
2560        let existing = self.existing_entry_span(mapping_path, key, &child_path);
2561        let is_collection = matches!(expected_child, Value::Sequence(_) | Value::Mapping(_));
2562        if existing.is_some() && is_collection {
2563            return Err(Error::Parse(format!(
2564                "insert_entry_value: `{key}` already exists in `{mapping_path}` and its value \
2565                 is being replaced with a collection — growing a scalar entry into a nested \
2566                 block is not an in-place edit; `remove` the entry first, or splice the \
2567                 layout you want with `set`"
2568            )));
2569        }
2570
2571        // A new key into a **flow** mapping — `{a: 1}`, `{}`, or the
2572        // whole document being one — splices `, key: value` before the
2573        // closing brace instead of appending a line (#338). Upserts of
2574        // an existing key fall through: `set` already writes into flow
2575        // sites.
2576        if existing.is_none()
2577            && let Some(span) = self.flow_collection_span(mapping_path, b'{')
2578        {
2579            return self.insert_flow_entry(mapping_path, key, &expected_child, &expected, span);
2580        }
2581        let (column, anchor_pos, probe) = self.entry_insert_anchor(existing, mapping_path, key)?;
2582        self.refuse_inside_aliased_anchor("insert_entry_value", mapping_path, probe)?;
2583        // Learn the spelling from the mapping being edited, not the whole
2584        // document (#290).
2585        let ctx = self.emit_ctx_at(column, mapping_path);
2586        let fragment = value.emit(&ctx)?;
2587        let key_spelling = emit_key(key, &ctx);
2588        let snapshot = self.clone();
2589        let spliced = if existing.is_some() {
2590            // Replace in place. The fragment's continuation lines (a
2591            // block scalar's body) shift to the existing key's column,
2592            // landing at `key_col + 2` — the depth `set_value` writes.
2593            let inline = indent_continuation_lines(&fragment, column, document_break(&self.source));
2594            self.set(&child_path, &inline)
2595        } else {
2596            self.splice_new_entry(anchor_pos, column, &key_spelling, &fragment, is_collection)
2597        };
2598        self.finish_entry_insert(spliced, snapshot, &expected, key, mapping_path)
2599    }
2600
2601    /// Refuse an `insert_entry_value` whose document does not parse or
2602    /// whose key cannot round-trip as a key.
2603    fn check_insert_entry_key(&mut self, mapping_path: &str, key: &str) -> Result<()> {
2604        if let Err(e) = self.validate() {
2605            return Err(Error::Parse(format!(
2606                "insert_entry_value: the document does not parse, so `{mapping_path}` cannot \
2607                 be resolved ({e}); the document was left unchanged"
2608            )));
2609        }
2610        if key == MERGE_KEY_SPELLING {
2611            return Err(Error::Parse(format!(
2612                "insert_entry_value: `{MERGE_KEY_SPELLING}` cannot be used as a key name — the \
2613                 loader treats any `{MERGE_KEY_SPELLING}` key as a merge directive whatever its \
2614                 quote style, so the entry would not round-trip as a key"
2615            )));
2616        }
2617        if let Some(bad) = first_non_printable(key) {
2618            return Err(Error::Parse(format!(
2619                "insert_entry_value: the key contains the non-printable character U+{:04X}, \
2620                 which is outside YAML's printable character set — mapping keys may not carry \
2621                 control characters (tab excepted)",
2622                bad as u32
2623            )));
2624        }
2625        Ok(())
2626    }
2627
2628    /// The span `insert_entry_value` rewrites when `key` is already an
2629    /// entry of the mapping at `mapping_path`.
2630    fn existing_entry_span(
2631        &self,
2632        mapping_path: &str,
2633        key: &str,
2634        child_path: &str,
2635    ) -> Option<(usize, usize)> {
2636        // Does the mapping already carry this key?
2637        let in_mapping = {
2638            let cache = self.cache.borrow();
2639            let (doc_value, _) = cache.as_ref().expect("validate populated the cache");
2640            let target = if mapping_path.is_empty() {
2641                Some(doc_value)
2642            } else {
2643                path_value(doc_value, mapping_path)
2644            };
2645            matches!(target, Some(Value::Mapping(m)) if m.get(key).is_some())
2646        };
2647        // A key present in the typed view but with no *value* span used to be
2648        // read as inherited through a `<<` merge — nothing to replace, so an
2649        // explicit entry overrides it. Since #165 that no longer identifies a
2650        // merge on its own: an implicit null (`a:`) has no value span either,
2651        // and appending there produced a second `a` key at `Ok`. The key token
2652        // separates them. A merged-in key has none, because it is not in this
2653        // mapping's source at all; an implicit null has one, so the entry is
2654        // already here and `set` writes into it. The path is spelled by
2655        // `push_key`, so a key holding `.`, `[`, `]`, or `*` addresses
2656        // itself.
2657        if in_mapping {
2658            self.span_at(child_path).or_else(|| {
2659                self.key_span(child_path)
2660                    .and_then(|_| self.write_span(child_path).ok())
2661            })
2662        } else {
2663            None
2664        }
2665    }
2666
2667    /// Splice `key: value` into the single-line flow mapping spanning
2668    /// `fs..fe` at `mapping_path`.
2669    fn insert_flow_entry(
2670        &mut self,
2671        mapping_path: &str,
2672        key: &str,
2673        expected_child: &Value,
2674        expected: &Value,
2675        (fs, fe): (usize, usize),
2676    ) -> Result<()> {
2677        self.refuse_multiline_flow("insert_entry_value", mapping_path, fs, fe)?;
2678        self.refuse_inside_aliased_anchor("insert_entry_value", mapping_path, fs)?;
2679        let ctx = self.emit_ctx_at(0, mapping_path);
2680        let key_spelling = emit_key(key, &ctx);
2681        let rendered = Self::emit_flow_member(expected_child)?;
2682        let body_is_empty = is_yaml_blank(&self.source[fs + 1..fe - 1]);
2683        let member = if body_is_empty {
2684            format!("{key_spelling}: {rendered}")
2685        } else {
2686            format!(", {key_spelling}: {rendered}")
2687        };
2688        let snapshot = self.clone();
2689        self.guarded_item_splice(
2690            |doc| doc.replace_span(fe - 1, fe - 1, &member),
2691            expected,
2692            &snapshot,
2693            &format!("insert_entry_value: inserting `{key}` into `{mapping_path}`"),
2694        )
2695    }
2696
2697    /// The insertion point for [`Self::insert_entry_value`]:
2698    /// `(column, anchor_pos, probe)`.
2699    fn entry_insert_anchor(
2700        &self,
2701        existing: Option<(usize, usize)>,
2702        mapping_path: &str,
2703        key: &str,
2704    ) -> Result<(usize, usize, usize)> {
2705        // The column the emission indents against, and the byte
2706        // position the edit touches: an existing key keeps its own
2707        // column and is rewritten at its value span, a new one takes
2708        // the last addressable sibling's column and is spliced at the
2709        // end of that sibling's line.
2710        match existing {
2711            Some((start, _)) => Ok((
2712                column_of_key_at(&self.source, start).ok_or_else(|| {
2713                    Error::Parse(format!(
2714                        "insert_entry_value: could not locate the column of the existing key \
2715                         `{key}` in `{mapping_path}`"
2716                    ))
2717                })?,
2718                start,
2719                start,
2720            )),
2721            None => self.mapping_insert_anchor(mapping_path),
2722        }
2723    }
2724
2725    /// Splice a new block entry `key_spelling` at `anchor_pos`, indented
2726    /// to `column`: `key:` with the emission as its children for a
2727    /// collection, `key: value` otherwise.
2728    fn splice_new_entry(
2729        &mut self,
2730        anchor_pos: usize,
2731        column: usize,
2732        key_spelling: &str,
2733        fragment: &str,
2734        is_collection: bool,
2735    ) -> Result<()> {
2736        let indent = " ".repeat(column);
2737        if is_collection {
2738            // `key:` then the emission as its children, one indent
2739            // step in from the key.
2740            let inner = " ".repeat(column + self.indent_unit());
2741            let lead = leading_break_for_splice(&self.source, anchor_pos);
2742            let nl = document_break(&self.source);
2743            let mut line = format!("{lead}{indent}{key_spelling}:{nl}");
2744            for body_line in fragment.split('\n') {
2745                if body_line.is_empty() {
2746                    line.push_str(nl);
2747                } else {
2748                    line.push_str(&inner);
2749                    line.push_str(body_line);
2750                    line.push_str(nl);
2751                }
2752            }
2753            self.replace_span(anchor_pos, anchor_pos, &line)
2754        } else {
2755            let nl = document_break(&self.source);
2756            let inline = indent_continuation_lines(fragment, column, nl);
2757            let lead = leading_break_for_splice(&self.source, anchor_pos);
2758            let line = format!("{lead}{indent}{key_spelling}: {inline}{nl}");
2759            self.replace_span(anchor_pos, anchor_pos, &line)
2760        }
2761    }
2762
2763    /// Hold an `insert_entry_value` splice to the re-parse and typed
2764    /// oracle guards, rolling back to `snapshot` on any failure.
2765    fn finish_entry_insert(
2766        &mut self,
2767        spliced: Result<()>,
2768        snapshot: Self,
2769        expected: &Value,
2770        key: &str,
2771        mapping_path: &str,
2772    ) -> Result<()> {
2773        if let Err(e) = spliced {
2774            *self = snapshot;
2775            return Err(Error::Parse(format!(
2776                "insert_entry_value: inserting `{key}` into `{mapping_path}` could not be \
2777                 spliced ({e}); the document was left unchanged"
2778            )));
2779        }
2780        if let Err(e) = self.validate() {
2781            *self = snapshot;
2782            return Err(Error::Parse(format!(
2783                "insert_entry_value: inserting `{key}` into `{mapping_path}` left the document \
2784                 unable to re-parse ({e}); the document was left unchanged"
2785            )));
2786        }
2787        if oracle_rejects(&self.as_value(), expected) {
2788            *self = snapshot;
2789            return Err(Error::Parse(format!(
2790                "insert_entry_value: inserting `{key}` into `{mapping_path}` failed the \
2791                 integrity check — the spliced entry did not load back as the value given \
2792                 (e.g. a key the mapping already inherits through a `{MERGE_KEY_SPELLING}` \
2793                 merge, or a layout the emitter could not reproduce at this indent); the \
2794                 document was left unchanged"
2795            )));
2796        }
2797        Ok(())
2798    }
2799
2800    /// Append `value` to the block sequence at `path`, formatted so it
2801    /// re-parses to exactly that value.
2802    ///
2803    /// The typed counterpart of [`Document::push_back`], which splices
2804    /// its `&str` verbatim: `push_back("items", "- x")` grows a nested
2805    /// sequence, where `push_back_value("items", "- x")` appends the
2806    /// *string* `"- x"`. Guarded by the same re-parse plus typed-value
2807    /// oracle as [`Document::insert_entry_value`].
2808    ///
2809    /// A **flow** sequence takes `, value` before its closing bracket
2810    /// instead of a new `- ` line, and `[]` receives its first member
2811    /// (#338); only single-line flow collections accept inserts.
2812    ///
2813    /// # Errors
2814    ///
2815    /// - `path` does not resolve to a sequence, or an empty **block**
2816    ///   sequence leaves no item to anchor indentation on.
2817    /// - The flow sequence at `path` spans more than one line.
2818    /// - The value has no auto-formatted spelling (see [`Emit::emit`]).
2819    /// - The splice would not re-parse, or fails the integrity check;
2820    ///   the document is left unchanged.
2821    ///
2822    /// # Examples
2823    ///
2824    /// ```
2825    /// use noyalib::cst::parse_document;
2826    ///
2827    /// let mut doc = parse_document("items:\n  - one\n").unwrap();
2828    /// doc.push_back_value("items", "two: 2").unwrap();
2829    /// assert_eq!(doc.to_string(), "items:\n  - one\n  - \"two: 2\"\n");
2830    /// ```
2831    pub fn push_back_value<E: Emit + ?Sized>(&mut self, path: &str, value: &E) -> Result<()> {
2832        if let Err(e) = self.validate() {
2833            return Err(Error::Parse(format!(
2834                "push_back_value: the document does not parse, so `{path}` cannot be resolved \
2835                 ({e}); the document was left unchanged"
2836            )));
2837        }
2838        let expected_item = value.expected_value()?;
2839        let (expected, len) = {
2840            let cache = self.cache.borrow();
2841            let (doc_value, _) = cache.as_ref().expect("validate populated the cache");
2842            let len = sequence_len_at(doc_value, &parse_query_path(path), path)?;
2843            (
2844                expected_after_insert_item(doc_value, path, len, &expected_item)?,
2845                len,
2846            )
2847        };
2848        // A **flow** sequence — `[a, b]` or `[]` — takes `, value`
2849        // before the closing bracket instead of a new `- ` line (#338).
2850        if let Some((fs, fe)) = self.flow_collection_span(path, b'[') {
2851            self.refuse_multiline_flow("push_back_value", path, fs, fe)?;
2852            self.refuse_inside_aliased_anchor("push_back_value", path, fs)?;
2853            let rendered = Self::emit_flow_member(&expected_item)?;
2854            let member = if len == 0 {
2855                rendered
2856            } else {
2857                format!(", {rendered}")
2858            };
2859            let snapshot = self.clone();
2860            return self.guarded_item_splice(
2861                |doc| doc.replace_span(fe - 1, fe - 1, &member),
2862                &expected,
2863                &snapshot,
2864                &format!("push_back_value: appending to `{path}`"),
2865            );
2866        }
2867        if len == 0 {
2868            return Err(Error::Parse(format!(
2869                "push_back_value: the sequence at `{path}` is empty, so it has no item to \
2870                 anchor indentation on — use `set` with a fragment instead"
2871            )));
2872        }
2873        let (column, anchor_pos) = self.sequence_item_anchor(path, len - 1)?;
2874        self.refuse_inside_aliased_anchor("push_back_value", path, anchor_pos)?;
2875        let fragment = self.emit_sequence_item(value, column, path)?;
2876
2877        let snapshot = self.clone();
2878        self.guarded_item_splice(
2879            |doc| doc.push_back(path, &fragment),
2880            &expected,
2881            &snapshot,
2882            &format!("push_back_value: appending to `{path}`"),
2883        )
2884    }
2885
2886    /// Insert `value` immediately after the sequence item at
2887    /// `item_path` (e.g. `"items[1]"`), formatted so it re-parses to
2888    /// exactly that value.
2889    ///
2890    /// The typed counterpart of [`Document::insert_after`], guarded by
2891    /// the same re-parse plus typed-value oracle as
2892    /// [`Document::insert_entry_value`].
2893    ///
2894    /// Inside a single-line **flow** sequence the new member follows
2895    /// the addressed item's own span: `[a, b]` after item 0 becomes
2896    /// `[a, v, b]` (#338).
2897    ///
2898    /// # Errors
2899    ///
2900    /// - `item_path` does not end in an index, or does not resolve to
2901    ///   a sequence item.
2902    /// - The flow sequence spans more than one line.
2903    /// - The value has no auto-formatted spelling (see [`Emit::emit`]).
2904    /// - The splice would not re-parse, or fails the integrity check;
2905    ///   the document is left unchanged.
2906    ///
2907    /// # Examples
2908    ///
2909    /// ```
2910    /// use noyalib::cst::parse_document;
2911    ///
2912    /// let mut doc = parse_document("items:\n  - one\n  - three\n").unwrap();
2913    /// doc.insert_after_value("items[0]", "two").unwrap();
2914    /// assert_eq!(doc.to_string(), "items:\n  - one\n  - two\n  - three\n");
2915    /// ```
2916    pub fn insert_after_value<E: Emit + ?Sized>(
2917        &mut self,
2918        item_path: &str,
2919        value: &E,
2920    ) -> Result<()> {
2921        if let Err(e) = self.validate() {
2922            return Err(Error::Parse(format!(
2923                "insert_after_value: the document does not parse, so `{item_path}` cannot be \
2924                 resolved ({e}); the document was left unchanged"
2925            )));
2926        }
2927        let segments = parse_query_path(item_path);
2928        let Some(&QuerySegment::Index(index)) = segments.last() else {
2929            return Err(Error::Parse(
2930                "insert_after_value: path must end with a sequence index, e.g. `items[2]`".into(),
2931            ));
2932        };
2933        let seq_path = sequence_parent_path(item_path);
2934        let expected_item = value.expected_value()?;
2935        let expected = {
2936            let cache = self.cache.borrow();
2937            let (doc_value, _) = cache.as_ref().expect("validate populated the cache");
2938            expected_after_insert_item(doc_value, &seq_path, index + 1, &expected_item)?
2939        };
2940        // Inside a **flow** sequence the new member follows the
2941        // addressed item's own span: `[a, b]` after item 0 becomes
2942        // `[a, v, b]` (#338).
2943        if let Some((fs, fe)) = self.flow_collection_span(&seq_path, b'[') {
2944            self.refuse_multiline_flow("insert_after_value", item_path, fs, fe)?;
2945            self.refuse_inside_aliased_anchor("insert_after_value", item_path, fs)?;
2946            let (_, item_end) = self
2947                .span_at(item_path)
2948                .ok_or_else(|| Error::Parse(format!("path not found: {item_path}")))?;
2949            let rendered = Self::emit_flow_member(&expected_item)?;
2950            let member = format!(", {rendered}");
2951            let snapshot = self.clone();
2952            return self.guarded_item_splice(
2953                |doc| doc.replace_span(item_end, item_end, &member),
2954                &expected,
2955                &snapshot,
2956                &format!("insert_after_value: inserting after `{item_path}`"),
2957            );
2958        }
2959        let (column, anchor_pos) = self.sequence_item_anchor(&seq_path, index)?;
2960        self.refuse_inside_aliased_anchor("insert_after_value", item_path, anchor_pos)?;
2961        let fragment = self.emit_sequence_item(value, column, &seq_path)?;
2962
2963        let snapshot = self.clone();
2964        self.guarded_item_splice(
2965            |doc| doc.insert_after(item_path, &fragment),
2966            &expected,
2967            &snapshot,
2968            &format!("insert_after_value: inserting after `{item_path}`"),
2969        )
2970    }
2971
2972    /// The span of the collection at `path` when it is spelled in
2973    /// **flow** style opening with `open` (`b'{'` or `b'['`), resolved
2974    /// through the loader's span tree. `None` for block collections
2975    /// and unresolvable paths. The span starts exactly at the opening
2976    /// bracket and ends after the closing one (#338).
2977    fn flow_collection_span(&self, path: &str, open: u8) -> Option<(usize, usize)> {
2978        self.ensure_cache();
2979        let cache = self.cache.borrow();
2980        let (value, span_tree) = cache.as_ref().expect("caller validated the document");
2981        let segments = parse_query_path(path);
2982        let ((s, e), _) = resolve_span(value, span_tree, &segments)?;
2983        let s = content_start(&self.source, s, e);
2984        let (s, e) = trim_value_span(&self.source, s, e);
2985        let s = s + self.source[s..e].bytes().take_while(|&b| b == b' ').count();
2986        let close = if open == b'{' { b'}' } else { b']' };
2987        (self.source.as_bytes().get(s) == Some(&open)
2988            && e > s
2989            && self.source.as_bytes().get(e - 1) == Some(&close))
2990        .then_some((s, e))
2991    }
2992
2993    /// The one refusal every single-line flow splice shares: a flow
2994    /// collection spread over several lines has separators this module
2995    /// cannot see from the span alone, so an insert there is refused
2996    /// rather than guessed at — the stance `remove` already takes.
2997    fn refuse_multiline_flow(&self, what: &str, path: &str, s: usize, e: usize) -> Result<()> {
2998        if self.source[s..e].contains('\n') {
2999            return Err(Error::Parse(format!(
3000                "{what}: the flow collection at `{path}` spans more than one line; only \
3001                 single-line flow collections accept inserts — reformat it, or splice with \
3002                 `set`"
3003            )));
3004        }
3005        Ok(())
3006    }
3007
3008    /// Render `value` as a single-line flow member: collections in
3009    /// flow style, scalars with the serializer's quoting (a value the
3010    /// plain spelling would misread is quoted, line breaks force
3011    /// double quotes).
3012    fn emit_flow_member(value: &Value) -> Result<String> {
3013        // A bare string goes through the flow-context speller: the
3014        // serializer renders a root scalar for block context, where
3015        // `b, c` is plain-safe — inside `[…]` it is two members.
3016        if let Value::String(s) = value {
3017            return Ok(format_string_in_flow(s, SyntaxKind::PlainScalar));
3018        }
3019        let cfg = crate::SerializerConfig::new().flow_style(crate::FlowStyle::Flow);
3020        let rendered = crate::to_string_value_with_config(value, &cfg)?;
3021        let rendered = rendered.trim_end_matches('\n');
3022        if rendered.contains('\n') {
3023            return Err(Error::Parse(
3024                "cannot spell this value on a single line inside a flow collection".into(),
3025            ));
3026        }
3027        Ok(rendered.to_owned())
3028    }
3029
3030    /// Refuse an edit at `pos` when it sits inside a value that is
3031    /// anchored and aliased elsewhere.
3032    ///
3033    /// Such an edit lands at every `*name` site at once, which the
3034    /// integrity oracle would then report as an unrelated mismatch.
3035    /// Naming the anchor up front turns a puzzling refusal into an
3036    /// actionable one — the courtesy `rename_key` already extends.
3037    fn refuse_inside_aliased_anchor(&self, what: &str, path: &str, pos: usize) -> Result<()> {
3038        if let Some((anchor, alias_count)) = self.aliased_anchor_covering(pos) {
3039            return Err(Error::Parse(format!(
3040                "{what}: `{path}` is inside the value anchored by `&{anchor}`, which has \
3041                 {alias_count} alias reference(s) — inserting here would insert at every \
3042                 `*{anchor}` site too; call `materialise_aliases_of(\"{anchor}\")` first to \
3043                 give each site its own copy, then insert"
3044            )));
3045        }
3046        Ok(())
3047    }
3048
3049    /// Run `splice`, then hold it to the re-parse and typed-value
3050    /// guards shared by the sequence insertion mutators, rolling back
3051    /// to `snapshot` and reporting in `what`'s terms on any failure.
3052    fn guarded_item_splice<F>(
3053        &mut self,
3054        splice: F,
3055        expected: &Value,
3056        snapshot: &Self,
3057        what: &str,
3058    ) -> Result<()>
3059    where
3060        F: FnOnce(&mut Self) -> Result<()>,
3061    {
3062        if let Err(e) = splice(self) {
3063            *self = snapshot.clone();
3064            return Err(Error::Parse(format!(
3065                "{what} could not be spliced ({e}); the document was left unchanged"
3066            )));
3067        }
3068        if let Err(e) = self.validate() {
3069            *self = snapshot.clone();
3070            return Err(Error::Parse(format!(
3071                "{what} left the document unable to re-parse ({e}); the document was left \
3072                 unchanged"
3073            )));
3074        }
3075        if *self.as_value() != *expected {
3076            *self = snapshot.clone();
3077            return Err(Error::Parse(format!(
3078                "{what} failed the integrity check — the spliced item did not load back as the \
3079                 value given (e.g. a layout the emitter could not reproduce at this indent); \
3080                 the document was left unchanged"
3081            )));
3082        }
3083        Ok(())
3084    }
3085
3086    /// Emit `value` for a `- ` sequence-item site whose indicator sits
3087    /// at `column`, carrying any continuation lines to the item's own
3088    /// content indent so the splice template's single line grows into
3089    /// a correctly-indented block.
3090    fn emit_sequence_item<E: Emit + ?Sized>(
3091        &self,
3092        value: &E,
3093        column: usize,
3094        site: &str,
3095    ) -> Result<String> {
3096        let ctx = self.emit_ctx_at(column, site);
3097        let fragment = value.emit(&ctx)?;
3098        // `push_back` / `insert_after` splice `{indent}- {fragment}`,
3099        // so the first line is already placed; every later line must
3100        // clear the `- ` indicator itself.
3101        Ok(indent_continuation_lines(
3102            &fragment,
3103            column + 2,
3104            document_break(&self.source),
3105        ))
3106    }
3107
3108    /// The item the insertion mutators anchor against: the column of
3109    /// item `index`'s `-` indicator in the block sequence at `path`,
3110    /// and the byte where that item's value starts.
3111    fn sequence_item_anchor(&self, path: &str, index: usize) -> Result<(usize, usize)> {
3112        let item_path = item_child_path(path, index);
3113        let (start, _) = self.span_at(&item_path).ok_or_else(|| {
3114            Error::Parse(format!(
3115                "could not locate item {index} of `{path}` to anchor the new item's indentation"
3116            ))
3117        })?;
3118        let column = column_of_preceding_dash(&self.source, start).ok_or_else(|| {
3119            Error::Parse(format!(
3120                "only block sequences are supported (no `-` anchor before item {index} of \
3121                 `{path}`)"
3122            ))
3123        })?;
3124        Ok((column, start))
3125    }
3126
3127    /// Where a new sibling entry goes in the block mapping at `path`:
3128    /// the column of the last addressable entry's key, the end of the
3129    /// line that entry closes on, and the byte its value starts at.
3130    /// The first two are the anchor `insert_entry` derives its indent
3131    /// and splice position from; the third is a probe position that is
3132    /// definitely *inside* the mapping, for the anchor/alias check.
3133    fn mapping_insert_anchor(&self, path: &str) -> Result<(usize, usize, usize)> {
3134        self.ensure_cache();
3135        let cache = self.cache.borrow();
3136        let (value, span_tree) = cache.as_ref().expect("caller validated the document");
3137        let segments = parse_query_path(path);
3138        let (target, target_tree) = if path.is_empty() {
3139            (value, span_tree)
3140        } else {
3141            resolve_tree(value, span_tree, &segments)
3142                .ok_or_else(|| Error::Parse(format!("path not found: {path}")))?
3143        };
3144        let Value::Mapping(m) = target else {
3145            return Err(Error::Parse(format!(
3146                "`{path}` is not a mapping, so it has no entry to anchor a new key on"
3147            )));
3148        };
3149        if m.is_empty() {
3150            return Err(Error::Parse(format!(
3151                "`{path}` is an empty mapping, so it has no entry to anchor indentation \
3152                 on — use `set` with a fragment instead"
3153            )));
3154        }
3155        let SpanTree::Mapping { entries, .. } = target_tree else {
3156            return Err(Error::Parse(format!(
3157                "`{path}` is not a mapping in the source, so it has no entry to anchor a \
3158                 new key on"
3159            )));
3160        };
3161        // Search from the back for an entry with bytes of its own, over
3162        // the *span tree* rather than by composing each key back into a
3163        // path string and re-parsing it. A key containing `.` or `[` —
3164        // the `app.kubernetes.io/name` convention — cannot survive that
3165        // round trip, so a mapping of such keys used to look as if none
3166        // of its entries had source bytes at all.
3167        //
3168        // Entries the typed view gained through a `<<` merge are absent
3169        // here by construction: this tree is built from the source.
3170        let anchor = entries
3171            .iter()
3172            .rev()
3173            .find_map(|((key_start, key_end), child_tree)| {
3174                let (start, end) = span_tree_bounds(child_tree);
3175                if start == end {
3176                    // An implicit null (`b:` with no value) owns no value
3177                    // bytes, but its *key* is a real line at the right
3178                    // column — and it is the line a new sibling belongs
3179                    // after. Taking the column from the key directly:
3180                    // `column_of_key_at` infers a key's column from a
3181                    // *value* offset, so it would walk past this one.
3182                    let line_start = start_of_line(&self.source, *key_start);
3183                    let bom = if line_start == 0 {
3184                        strip_bom(self.source.as_bytes())
3185                    } else {
3186                        0
3187                    };
3188                    let column = key_start.saturating_sub(line_start + bom);
3189                    return Some((column, *key_end, *key_start));
3190                }
3191                let (start, end) = trim_value_span(&self.source, start, end);
3192                let column = column_of_key_at(&self.source, start)?;
3193                Some((column, end, start))
3194            });
3195        let (column, end, start) = anchor.ok_or_else(|| {
3196            Error::Parse(format!(
3197                "no entry of the mapping at `{path}` has source bytes of its own to anchor \
3198                 indentation on — every entry is inherited through a \
3199                 `{MERGE_KEY_SPELLING}` merge — use `set` with a fragment instead"
3200            ))
3201        })?;
3202        // Two fixes to the same line; they compose rather than compete.
3203        // `anchor_line_end` settles which line the anchor entry owns, so
3204        // a sibling lands above a trailing comment (#418).
3205        // `extend_past_kept_blank_lines` then steps over blank lines a
3206        // keep-chomped scalar owns, so the sibling does not land inside
3207        // another entry's value (#429). Order matters: the keep-chomped
3208        // step takes the line the anchor walk settled on.
3209        let line_end = anchor_line_end(&self.source, start, end, column);
3210        Ok((
3211            column,
3212            extend_past_kept_blank_lines(&self.source, start, line_end),
3213            start,
3214        ))
3215    }
3216}
3217
3218impl fmt::Display for Document {
3219    /// Re-emit the document. For any input that parses successfully,
3220    /// the result equals the original bytes verbatim. `Display`
3221    /// drives `Document::to_string()` via the standard `ToString`
3222    /// blanket impl.
3223    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3224        f.write_str(&self.green.text(&self.source))
3225    }
3226}
3227
3228/// Parse a YAML stream into an editable [`Document`].
3229///
3230/// Runs under the default parser configuration — the same limits
3231/// as [`crate::from_str`]. Use [`parse_document_with_config`] to
3232/// change them.
3233///
3234/// # Errors
3235///
3236/// Returns the same parse errors as [`crate::from_str`] — the green
3237/// tree is built off the same scanner, so every strictness fix in
3238/// the regular parser applies here too.
3239///
3240/// # Examples
3241///
3242/// ```
3243/// use noyalib::cst::parse_document;
3244///
3245/// assert_eq!(parse_document("a: 1\n").unwrap().to_string(), "a: 1\n");
3246/// ```
3247pub fn parse_document(input: &str) -> Result<Document> {
3248    parse_document_inner(input, ParseConfig::default())
3249}
3250
3251/// Parse one YAML document into an editable [`Document`] under `config`,
3252/// mirroring [`crate::from_str_with_config`].
3253///
3254/// The document keeps the configuration: every later re-parse of
3255/// its source — the typed view behind [`Document::as_value`],
3256/// [`Document::validate`], the safety-net re-parse an edit falls
3257/// back to, and the value guard on comment edits — runs under the
3258/// same limits. A document that only opens with a relaxed budget
3259/// therefore stays readable and editable after its first edit
3260/// instead of tripping the default limits on the next read.
3261///
3262/// Every knob of [`ParserConfig`] applies: the resource budgets,
3263/// the alias-to-anchor ratio heuristic, the duplicate-key and
3264/// merge-key policies, the schema toggles, and any policies.
3265///
3266/// # Errors
3267///
3268/// Returns the same parse errors as [`crate::from_str_with_config`]
3269/// under the same configuration, including rejection of a stream with
3270/// more than one document. Use [`parse_stream_with_config`] for streams.
3271///
3272/// # Examples
3273///
3274/// A values file that reuses each anchored default block more than
3275/// ten times trips the default alias-to-anchor ratio heuristic
3276/// (`Some(10.0)`) even though it is ordinary, valid YAML. Disable
3277/// the heuristic and keep every absolute budget:
3278///
3279/// ```
3280/// use noyalib::cst::{parse_document, parse_document_with_config};
3281/// use noyalib::{BudgetBreach, Error, ParserConfig};
3282///
3283/// let mut src = String::from("base: &b\n  k: v\ntenants:\n");
3284/// for i in 0..11 {
3285///     src.push_str(&format!("  t{i}:\n    <<: *b\n"));
3286/// }
3287///
3288/// assert!(matches!(
3289///     parse_document(&src),
3290///     Err(Error::Budget(BudgetBreach::AliasAnchorRatio { .. }))
3291/// ));
3292///
3293/// let cfg = ParserConfig::new().alias_anchor_ratio(None);
3294/// let doc = parse_document_with_config(&src, &cfg).unwrap();
3295/// assert_eq!(doc.to_string(), src);
3296/// assert_eq!(doc.as_value()["tenants"]["t10"]["k"].as_str(), Some("v"));
3297/// ```
3298pub fn parse_document_with_config(input: &str, config: &ParserConfig) -> Result<Document> {
3299    parse_document_inner(input, ParseConfig::from(config))
3300}
3301
3302/// Shared body of [`parse_document`] and
3303/// [`parse_document_with_config`]: parse under `config` and hand the
3304/// configuration to the document for its own re-parses.
3305fn parse_document_inner(input: &str, config: ParseConfig) -> Result<Document> {
3306    let parsed = parse_full(input, &config)?;
3307    Ok(Document {
3308        source: parsed.source,
3309        green: parsed.green,
3310        // Initial parse already produced the typed view — seed the
3311        // cache so the first read after a fresh parse is free.
3312        cache: core::cell::RefCell::new(Some((parsed.value, parsed.span_tree))),
3313        last_repair_scope: core::cell::Cell::new(None),
3314        config,
3315    })
3316}
3317
3318/// Parse a YAML stream and return one [`Document`] per logical
3319/// document.
3320///
3321/// Boundaries follow YAML 1.2.2 §9.1: an explicit `...` end marker
3322/// closes the current document, and a fresh `---` opens the next.
3323/// Trivia (comments, blank lines) between an explicit `...` and the
3324/// next document is treated as the next document's prologue;
3325/// trailing trivia at end-of-stream is attached to the last
3326/// document so concatenating each document's source reproduces the
3327/// original input byte-for-byte.
3328///
3329/// # Errors
3330///
3331/// Same as [`parse_document`].
3332///
3333/// # Examples
3334///
3335/// Single document:
3336///
3337/// ```
3338/// use noyalib::cst::parse_stream;
3339///
3340/// let src = "---\nfoo: 1\n";
3341/// let docs = parse_stream(src).unwrap();
3342/// assert_eq!(docs.len(), 1);
3343/// assert_eq!(docs[0].to_string(), src);
3344/// ```
3345///
3346/// Two documents — split on `---`:
3347///
3348/// ```
3349/// use noyalib::cst::{parse_stream, Document};
3350///
3351/// let src = "---\nfoo: 1\n---\nbar: 2\n";
3352/// let docs = parse_stream(src).unwrap();
3353/// assert_eq!(docs.len(), 2);
3354/// assert_eq!(docs[0].as_value()["foo"].as_i64(), Some(1));
3355/// assert_eq!(docs[1].as_value()["bar"].as_i64(), Some(2));
3356/// let joined: String = docs.iter().map(Document::source).collect();
3357/// assert_eq!(joined, src);
3358/// ```
3359pub fn parse_stream(input: &str) -> Result<Vec<Document>> {
3360    parse_stream_inner(
3361        input,
3362        &ParseConfig::default(),
3363        ParserConfig::default().max_stream_bytes,
3364    )
3365}
3366
3367/// Parse a YAML stream into one [`Document`] per logical document
3368/// under `config`, mirroring [`crate::load_all_with_config`].
3369///
3370/// Boundaries are found exactly as in [`parse_stream`]; each
3371/// document is then parsed as by [`parse_document_with_config`] and
3372/// keeps the configuration for its own re-parses.
3373///
3374/// # Errors
3375///
3376/// Same as [`parse_document_with_config`].
3377///
3378/// # Examples
3379///
3380/// ```
3381/// use noyalib::cst::{parse_stream_with_config, Document};
3382/// use noyalib::ParserConfig;
3383///
3384/// let cfg = ParserConfig::new().alias_anchor_ratio(None);
3385/// let src = "---\nfoo: 1\n---\nbar: 2\n";
3386/// let docs = parse_stream_with_config(src, &cfg).unwrap();
3387/// assert_eq!(docs.len(), 2);
3388/// let joined: String = docs.iter().map(Document::source).collect();
3389/// assert_eq!(joined, src);
3390/// ```
3391pub fn parse_stream_with_config(input: &str, config: &ParserConfig) -> Result<Vec<Document>> {
3392    parse_stream_inner(input, &ParseConfig::from(config), config.max_stream_bytes)
3393}
3394
3395/// Shared body of [`parse_stream`] and [`parse_stream_with_config`].
3396///
3397/// Each document is parsed from its own slice of `input`, so an error's
3398/// locations come back relative to that slice; they are re-anchored on
3399/// `input` before the error is returned, so a caller sees the same
3400/// positions the typed loaders report for the same bytes.
3401///
3402/// The stream as a whole is held to `max_stream_bytes` and its
3403/// document count to `max_documents`, both checked before any document
3404/// is parsed; each document is held to `max_document_length` by
3405/// [`parse_full`].
3406pub(crate) fn parse_stream_inner(
3407    input: &str,
3408    config: &ParseConfig,
3409    max_stream_bytes: usize,
3410) -> Result<Vec<Document>> {
3411    if input.len() > max_stream_bytes {
3412        return Err(Error::Parse(format!(
3413            "stream of {} bytes exceeds max_stream_bytes of {max_stream_bytes}",
3414            input.len()
3415        )));
3416    }
3417    let bounds = document_boundaries(input)?;
3418    if bounds.len() <= 1 {
3419        return Ok(vec![parse_document_inner(input, config.clone())?]);
3420    }
3421    let documents = bounds.iter().filter(|(s, e)| s != e).count();
3422    if documents > config.max_documents {
3423        return Err(Error::Budget(crate::BudgetBreach::MaxDocuments {
3424            limit: config.max_documents,
3425            observed: documents,
3426        }));
3427    }
3428    // One tally for the whole stream, so `max_events`, `max_nodes`,
3429    // `max_total_scalar_bytes` and `max_merge_keys` hold across
3430    // documents as they do in `load_all`. Each document keeps a config
3431    // without it: its later re-parses (edits) are charged on their own.
3432    let mut shared = config.clone();
3433    shared.stream_tally = Some(Arc::default());
3434    let mut out = Vec::with_capacity(bounds.len());
3435    for (s, e) in bounds {
3436        if s == e {
3437            continue;
3438        }
3439        let mut doc = parse_document_inner(&input[s..e], shared.clone())
3440            .map_err(|err| cross_document_anchor_hint(err.relocate(input, s), &input[..s]))?;
3441        doc.config.stream_tally = None;
3442        out.push(doc);
3443    }
3444    Ok(out)
3445}
3446
3447/// When an alias names an anchor that an earlier document defined,
3448/// point at that definition: each document is parsed from its own
3449/// slice, so the parser cannot know, but the error should say it the
3450/// way the typed loaders do (YAML 1.2.2 §3.2.2.2: anchors do not cross
3451/// `---`).
3452fn cross_document_anchor_hint(err: Error, earlier: &str) -> Error {
3453    let Error::UnknownAnchorAt {
3454        name,
3455        location,
3456        suggestion: None,
3457    } = err
3458    else {
3459        return err;
3460    };
3461    let needle = format!("&{name}");
3462    let mut from = 0;
3463    let mut found = None;
3464    while let Some(i) = earlier[from..].find(&needle) {
3465        let at = from + i;
3466        let end = at + needle.len();
3467        let after_ok = earlier[end..]
3468            .chars()
3469            .next()
3470            .is_none_or(|c| YAML_BLANK.contains(&c) || matches!(c, ',' | '[' | ']' | '{' | '}'));
3471        let before_ok = at == 0
3472            || earlier[..at].chars().next_back().is_some_and(|c| {
3473                YAML_BLANK.contains(&c) || matches!(c, '-' | ',' | '[' | '{' | '?' | ':')
3474            });
3475        if after_ok && before_ok {
3476            found = Some(at);
3477        }
3478        from = end;
3479    }
3480    Error::UnknownAnchorAt {
3481        name: name.clone(),
3482        location,
3483        suggestion: found.map(|at| (name, Location::from_index(earlier, at))),
3484    }
3485}
3486
3487fn walk_tokens(
3488    node: &GreenNode,
3489    base: usize,
3490    visit: &mut dyn FnMut(SyntaxKind, core::ops::Range<usize>),
3491) {
3492    let mut pos = base;
3493    for child in node.children() {
3494        let len = child.text_len();
3495        match child {
3496            GreenChild::Token { kind, .. } => {
3497                visit(*kind, pos..pos + len);
3498            }
3499            GreenChild::Node(inner) => walk_tokens(inner, pos, visit),
3500        }
3501        pos += len;
3502    }
3503}
3504
3505// ── Path resolution ─────────────────────────────────────────────────
3506
3507fn trim_trailing_blank(source: &str, start: usize, mut end: usize) -> (usize, usize) {
3508    let bytes = source.as_bytes();
3509    while end > start {
3510        match bytes[end - 1] {
3511            b' ' | b'\t' | b'\n' | b'\r' => end -= 1,
3512            _ => break,
3513        }
3514    }
3515    (start, end)
3516}
3517
3518/// Trim trailing separator whitespace from a *value* span, except for
3519/// keep-chomped (`|+` / `>+`) block scalars, whose trailing line breaks are
3520/// content rather than separation. Trimming those would yield a slice that
3521/// re-parses to a shorter, different value (`"kept\n"` instead of the true
3522/// `"kept\n\n\n"`).
3523fn trim_value_span(source: &str, start: usize, end: usize) -> (usize, usize) {
3524    if is_keep_chomped_block_scalar(source, start, end) {
3525        (start, end)
3526    } else {
3527        trim_trailing_blank(source, start, end)
3528    }
3529}
3530
3531/// The empty span a value would be written into at an implicit null.
3532///
3533/// An absent block-mapping value or empty sequence item has no bytes of its
3534/// own, so there is nothing to *replace* — but there is somewhere to insert,
3535/// and the loader already records where: the zero-width leaf sits on the `:`
3536/// or `-` indicator the value would have followed. The insertion point is the
3537/// byte after it, which is before any trailing comment on the line, so a value
3538/// written there lands ahead of the comment rather than behind it.
3539///
3540/// `None` when `pos` is not one of those two indicators. A zero-width span can
3541/// in principle arrive from elsewhere, and inserting at a position this
3542/// function has not identified would splice into the middle of something.
3543///
3544/// A caller writing here must supply the separator itself: the span abuts the
3545/// indicator, and unlike a replacement span there is no `: ` already in the
3546/// source. [`fill_in`] is that one byte, so the two writers cannot disagree
3547/// about it.
3548fn implicit_null_insertion_point(source: &str, pos: usize) -> Option<(usize, usize)> {
3549    match source.as_bytes().get(pos) {
3550        Some(b':' | b'-') => Some((pos + 1, pos + 1)),
3551        _ => None,
3552    }
3553}
3554
3555/// The span the comment API anchors on for `path`: the value's own span,
3556/// or, when the entry has no value bytes, the point one would be written
3557/// at.
3558///
3559/// An entry written `k:` with nothing after it is an implicit null. It is
3560/// a real entry with a key token of its own, and `write_span` has resolved
3561/// it since #310/#311, which is what lets `set_value` fill it in. The
3562/// comment API never learned the same thing, so a `# todo` sitting beside
3563/// such an entry was invisible to `comments_at` and unreachable by the
3564/// mutators.
3565///
3566/// Restricted to entries that have a **key**. A `-` with nothing after it
3567/// is the same shape reached by the other indicator, but neither go-yaml
3568/// nor ruamel.yaml produces a usable result for it: setting a comment
3569/// there orphans the existing one onto a line of its own, and removing the
3570/// item leaves it behind. Nobody has a good answer for the sequence case,
3571/// so this does not invent one.
3572// Issue #425.
3573impl Document {
3574    pub(super) fn comment_anchor_span(&self, path: &str) -> Option<(usize, usize)> {
3575        if let Some(span) = self.span_at(path) {
3576            return Some(span);
3577        }
3578        // No value bytes. Only a mapping entry qualifies, and only when the
3579        // indicator is really there to write after.
3580        let (_, key_end) = self.key_span(path)?;
3581        let rest = self.source.get(key_end..)?;
3582        let colon = key_end + rest.find(':')?;
3583        if is_yaml_blank(&rest[..colon - key_end]) {
3584            implicit_null_insertion_point(&self.source, colon)
3585        } else {
3586            None
3587        }
3588    }
3589
3590    /// The byte a leading comment run is measured upward from: the entry's
3591    /// own key token when the path names one, and the comment anchor span
3592    /// otherwise.
3593    ///
3594    /// A leading comment decorates the **entry**, so the line it sits above
3595    /// is the entry's first line, which is the key's. For a scalar, a flow
3596    /// collection or an implicit null the key and the value share that line
3597    /// and either would do. For a block collection they do not: the value
3598    /// starts on the next line, so measuring from it asks about the wrong
3599    /// line and the run above the key is out of reach.
3600    ///
3601    /// A path that names no key, such as a sequence item, keeps the value
3602    /// span it always used.
3603    pub(super) fn leading_comment_anchor(&self, path: &str) -> Option<usize> {
3604        if let Some((key_start, _)) = self.key_span(path) {
3605            return Some(key_start);
3606        }
3607        self.comment_anchor_span(path).map(|(start, _)| start)
3608    }
3609}
3610
3611/// A value written at an [`implicit_null_insertion_point`], separated from the
3612/// indicator it follows.
3613fn fill_in(fragment: &str) -> String {
3614    format!(" {fragment}")
3615}
3616
3617/// The `#` comment that follows `pos` on the same line, if only inline
3618/// whitespace separates them: `(comment_start, line_end)`, where
3619/// `line_end` excludes the line break. `pos` is the end of a value span
3620/// (or an implicit null's insertion point), so a `#` reached this way
3621/// can only be a comment -- the scanner ended the value before it.
3622fn trailing_comment_span(source: &str, pos: usize) -> Option<(usize, usize)> {
3623    let bytes = source.as_bytes();
3624    let mut i = pos;
3625    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
3626        i += 1;
3627    }
3628    if i >= bytes.len() || bytes[i] != b'#' {
3629        return None;
3630    }
3631    let mut line_end = i;
3632    while line_end < bytes.len() && !matches!(bytes[line_end], b'\n' | b'\r') {
3633        line_end += 1;
3634    }
3635    Some((i, line_end))
3636}
3637
3638/// Put `comment` after the header of the block literal `fragment`:
3639/// `|-\n  a` with `# c` becomes `|- # c\n  a`. A fragment without a
3640/// line break is not a block literal and is returned unchanged.
3641fn hoist_comment_onto_header(fragment: &str, comment: &str) -> String {
3642    match fragment.split_once('\n') {
3643        Some((header, body)) => format!("{header} {comment}\n{body}"),
3644        None => fragment.to_string(),
3645    }
3646}
3647
3648/// Whether `[start, end)` denotes a keep-chomped block scalar: it begins with
3649/// a `|` / `>` block indicator carrying a `+` chomping indicator on the header
3650/// line (`|+`, `>+`, `|+2`, `|2+`). A value span's start is the block
3651/// indicator itself (the scanner marks it there), and no plain/quoted scalar
3652/// or collection value begins with a bare `|` / `>`, so this cannot misfire on
3653/// other node kinds.
3654fn is_keep_chomped_block_scalar(source: &str, start: usize, end: usize) -> bool {
3655    let bytes = source.as_bytes();
3656    // The value span's start may have been widened leftward over an anchor
3657    // (`&name`) / tag (`!Tag`, `!!str`) property prefix (see `entry_value`), so
3658    // the block indicator is not necessarily at `start`. Skip those property
3659    // tokens before inspecting for `|` / `>`, otherwise an anchored/tagged
3660    // keep-chomped scalar (`key: &anc |+`) is misclassified and its kept
3661    // trailing blank lines are trimmed.
3662    let start = skip_value_property_prefix(bytes, start, end);
3663    if start >= end || (bytes[start] != b'|' && bytes[start] != b'>') {
3664        return false;
3665    }
3666    // A `+` anywhere on the header line (before the first line break) is the
3667    // keep-chomping indicator.
3668    for &b in &bytes[start + 1..end] {
3669        match b {
3670            b'\n' | b'\r' => return false,
3671            b'+' => return true,
3672            _ => {}
3673        }
3674    }
3675    false
3676}
3677
3678/// Advance past leading anchor (`&name`) / tag (`!Tag`, `!!str`) property
3679/// tokens and the whitespace between them, returning the index of the value
3680/// content proper. Value spans are widened leftward over these properties, so
3681/// callers inspecting the value's first byte must skip them first.
3682fn skip_value_property_prefix(bytes: &[u8], mut start: usize, end: usize) -> usize {
3683    loop {
3684        while start < end && matches!(bytes[start], b' ' | b'\t') {
3685            start += 1;
3686        }
3687        if start < end && matches!(bytes[start], b'&' | b'!') {
3688            start += 1;
3689            while start < end && !matches!(bytes[start], b' ' | b'\t' | b'\n' | b'\r') {
3690                start += 1;
3691            }
3692        } else {
3693            return start;
3694        }
3695    }
3696}
3697
3698/// The `(start, end)` bounds of a span tree, transparently unwrapping alias
3699/// indirection.
3700fn span_tree_bounds(t: &SpanTree) -> (usize, usize) {
3701    match t {
3702        SpanTree::Leaf(s, e) => (*s, *e),
3703        SpanTree::Sequence { start, end, .. } | SpanTree::Mapping { start, end, .. } => {
3704            (*start, *end)
3705        }
3706        SpanTree::Alias { target, .. } => span_tree_bounds(target),
3707    }
3708}
3709
3710/// Resolve `segments` to the `(value, span tree)` pair they address.
3711///
3712/// The span-tree twin of [`resolve_span`], for callers that need the
3713/// addressed node's *structure* rather than its span — the sole one today
3714/// is `mapping_insert_anchor`, which reads the target mapping's entries
3715/// directly instead of rebuilding a path string per key.
3716fn resolve_tree<'a>(
3717    value: &'a Value,
3718    span_tree: &'a SpanTree,
3719    segments: &[QuerySegment],
3720) -> Option<(&'a Value, &'a SpanTree)> {
3721    if let SpanTree::Alias { target, .. } = span_tree {
3722        return resolve_tree(value, target, segments);
3723    }
3724    let Some((head, tail)) = segments.split_first() else {
3725        return Some((value, span_tree));
3726    };
3727    match (head, value, span_tree) {
3728        (QuerySegment::Key(k), Value::Mapping(m), SpanTree::Mapping { entries, .. }) => {
3729            // `m` (an IndexMap) preserves insertion order, matching the
3730            // parallel order in `entries` (see `span_context::walk`).
3731            for ((mk, mv), (_, child_tree)) in m.iter().zip(entries.iter()) {
3732                if mk == k {
3733                    return resolve_tree(mv, child_tree, tail);
3734                }
3735            }
3736            None
3737        }
3738        (QuerySegment::Index(i), Value::Sequence(seq), SpanTree::Sequence { items, .. }) => {
3739            resolve_tree(seq.get(*i)?, items.get(*i)?, tail)
3740        }
3741        _ => None,
3742    }
3743}
3744
3745/// Resolve `segments` to a byte span in the typed cache. The returned `bool`
3746/// is `true` when resolution passed *through* an alias reference (the span
3747/// then belongs to the anchor, not the addressed key) — correct to return for
3748/// a read, but a write must refuse it.
3749/// A node's [`SpanTree`] span starts at its properties (`&anchor`,
3750/// `!tag`) since v0.0.30 — the span model anchors error locations at
3751/// the property, matching serde_yaml/libyaml marks. CST reads and
3752/// edits target the *content*: plain scalars can never begin with
3753/// `&` or `!` (indicator characters), so leading property tokens are
3754/// unambiguous and skipped here. A props-only node (an empty tagged
3755/// scalar) keeps its original span so implicit-null handling is
3756/// unchanged.
3757fn content_start(source: &str, start: usize, end: usize) -> usize {
3758    let bytes = source.as_bytes();
3759    let mut i = start;
3760    while i < end {
3761        match bytes[i] {
3762            b'!' if i + 1 < end && bytes[i + 1] == b'<' => {
3763                while i < end && bytes[i] != b'>' {
3764                    i += 1;
3765                }
3766                if i < end {
3767                    i += 1;
3768                }
3769            }
3770            b'&' | b'!' => {
3771                while i < end && !bytes[i].is_ascii_whitespace() {
3772                    i += 1;
3773                }
3774            }
3775            _ => return i,
3776        }
3777        while i < end && bytes[i].is_ascii_whitespace() {
3778            i += 1;
3779        }
3780    }
3781    start
3782}
3783
3784/// How a span resolution met an alias, if it met one.
3785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3786enum AliasHit {
3787    /// No alias on the path.
3788    No,
3789    /// The addressed node itself is the alias reference; `at` is the
3790    /// `*name` token's own span, the one site a write may touch.
3791    Target {
3792        /// The token's byte span.
3793        at: (usize, usize),
3794    },
3795    /// The path resolved *through* an alias to bytes inside the anchor's
3796    /// value, which belong to a different key; writes refuse.
3797    Through,
3798}
3799
3800/// Refuse a collection written over an alias token (`alias_target`).
3801///
3802/// The `*name` token is one leaf, so a scalar can replace it in place;
3803/// a collection grown there is not an in-place edit of the addressed
3804/// entry.
3805fn refuse_collection_over_alias_token(path: &str, alias_target: bool) -> Result<()> {
3806    if !alias_target {
3807        return Ok(());
3808    }
3809    Err(Error::Parse(format!(
3810        "set_value: the value of `{path}` is an alias reference, and a \
3811         collection cannot replace the `*name` token in place; edit the \
3812         anchor definition, or `remove` the entry and insert the collection"
3813    )))
3814}
3815
3816fn resolve_span(
3817    value: &Value,
3818    span_tree: &SpanTree,
3819    segments: &[QuerySegment],
3820) -> Option<((usize, usize), AliasHit)> {
3821    // An alias site substitutes the anchor's (value, tree). Resolve against
3822    // the anchor, but say how the alias was met. The addressed node itself
3823    // being the alias is a different case from the path passing *through*
3824    // one: the former has a token of its own a write may replace, the
3825    // latter resolves to bytes inside the anchor's value, which belong to
3826    // a different key at every depth, so `ref.nested` and `[*a][0]` stay
3827    // refused by the write paths.
3828    if let SpanTree::Alias { at, target } = span_tree {
3829        if segments.is_empty() {
3830            return resolve_span(value, target, segments)
3831                .map(|(span, _)| (span, AliasHit::Target { at: *at }));
3832        }
3833        return resolve_span(value, target, segments).map(|(span, _)| (span, AliasHit::Through));
3834    }
3835    if segments.is_empty() {
3836        return match span_tree {
3837            // A zero-width leaf marks an implicit null (an absent
3838            // block-mapping value or empty sequence item): the node has no
3839            // source bytes of its own. Its *position* is still the `:` / `-`
3840            // indicator it followed, which is where a value would be written,
3841            // so the resolver reports it and each caller decides. `span_at`
3842            // discards it (#165: an implicit null has no span to read);
3843            // `write_span` turns it into an insertion point.
3844            SpanTree::Leaf(s, e) => Some(((*s, *e), AliasHit::No)),
3845            SpanTree::Sequence { start, end, .. } | SpanTree::Mapping { start, end, .. } => {
3846                Some(((*start, *end), AliasHit::No))
3847            }
3848            SpanTree::Alias { .. } => None, // unwrapped above
3849        };
3850    }
3851    let (head, tail) = segments.split_first()?;
3852    match (head, value, span_tree) {
3853        (QuerySegment::Key(k), Value::Mapping(m), SpanTree::Mapping { entries, .. }) => {
3854            // `m` (an IndexMap) preserves insertion order, matching
3855            // the parallel order in `entries` (see `span_context::walk`).
3856            for ((mk, mv), (_, child_tree)) in m.iter().zip(entries.iter()) {
3857                if mk == k {
3858                    return resolve_span(mv, child_tree, tail);
3859                }
3860            }
3861            None
3862        }
3863        (QuerySegment::Index(i), Value::Sequence(seq), SpanTree::Sequence { items, .. }) => {
3864            let v = seq.get(*i)?;
3865            let t = items.get(*i)?;
3866            resolve_span(v, t, tail)
3867        }
3868        // Wildcard / recursive descent are unsupported because they
3869        // do not resolve to a *single* span; the caller would need a
3870        // multi-span API.
3871        _ => None,
3872    }
3873}
3874
3875// ── Entry-line resolution (used by `remove`) ────────────────────────
3876
3877/// Find the byte range of the *entire* mapping entry or sequence entry
3878/// addressed by `segments` — including its key / `-` indicator,
3879/// leading indentation, and trailing line break — so a caller can
3880/// splice the empty string in to delete it.
3881/// How `remove` should splice a given entry out of the source.
3882///
3883/// Three shapes, because "delete the entry" means three different byte
3884/// edits depending on what the entry shares its line with:
3885///
3886/// - [`Removal::Line`] — the entry owns whole lines; delete them.
3887/// - [`Removal::FlowMember`] — the entry lives inside `{…}` / `[…]`
3888///   alongside its siblings, so only its own span plus one separator
3889///   may go.
3890/// - [`Removal::SoleEntry`] — the entry is the last one in its
3891///   collection. Deleting its bytes would leave `a:` behind, which
3892///   re-parses as *null*, not as an empty collection — a type change,
3893///   not a removal. The collection is replaced with an explicit `{}`
3894///   or `[]` instead.
3895#[derive(Debug, Clone, Copy)]
3896enum Removal {
3897    Line {
3898        start: usize,
3899        end: usize,
3900        multiline: bool,
3901    },
3902    FlowMember {
3903        start: usize,
3904        end: usize,
3905    },
3906    /// The entry's first line is shared with a sequence `-` indicator
3907    /// (`- name: x`, `- - a`): only the entry's own bytes go, through
3908    /// its owned range plus the following sibling's indentation, so
3909    /// the sibling moves up onto the indicator's line. Always guarded
3910    /// by the typed oracle.
3911    SpanWithinLine {
3912        start: usize,
3913        end: usize,
3914    },
3915    SoleEntry {
3916        start: usize,
3917        end: usize,
3918        empty: &'static str,
3919        /// Columns of indentation to re-emit before `empty`.
3920        ///
3921        /// The splice can now start *above* the entry, at the head of its
3922        /// comment run, so the entry's own leading whitespace is inside
3923        /// the replaced range and has to be written back — otherwise
3924        /// `a:\n  # doc\n  x: 1` collapses to `a:\n{}`, which is not
3925        /// `a`'s value at all.
3926        indent: usize,
3927    },
3928}
3929
3930impl Removal {
3931    /// The first byte the removal splices.
3932    fn start(&self) -> usize {
3933        match self {
3934            Self::Line { start, .. }
3935            | Self::FlowMember { start, .. }
3936            | Self::SpanWithinLine { start, .. }
3937            | Self::SoleEntry { start, .. } => *start,
3938        }
3939    }
3940
3941    /// What to splice, and what to put back:
3942    /// `(start, end, multiline, replacement)`. Only `Line` can take the
3943    /// unguarded fast path in [`Document::remove`]; the others always
3944    /// face the oracle, because they edit *inside* a line shared with
3945    /// other data.
3946    fn splice(self) -> (usize, usize, bool, String) {
3947        match self {
3948            Self::Line {
3949                start,
3950                end,
3951                multiline,
3952            } => (start, end, multiline, String::new()),
3953            Self::FlowMember { start, end } | Self::SpanWithinLine { start, end } => {
3954                (start, end, true, String::new())
3955            }
3956            Self::SoleEntry {
3957                start,
3958                end,
3959                empty,
3960                indent,
3961            } => (start, end, true, format!("{}{empty}", " ".repeat(indent))),
3962        }
3963    }
3964}
3965
3966/// Widen a flow member's span to take exactly one separator with it,
3967/// and its whole line when the splice would leave that line blank.
3968///
3969/// `{x: 1, y: 2}` minus `x` must become `{y: 2}`, not `{, y: 2}`. The
3970/// comma *after* the member is preferred; the last member takes the one
3971/// *before* it instead. A lone member has no separator to take — that is
3972/// [`Removal::SoleEntry`]'s job, not this one.
3973///
3974/// Only spaces and tabs are crossed while looking. A separator parked on
3975/// another line (a multi-line flow collection) is deliberately not
3976/// matched: the resulting splice would still be guarded by the typed
3977/// oracle, so the outcome is a refusal rather than a mangled document.
3978///
3979/// The separator walk alone is not the whole answer for a **wrapped**
3980/// collection, where a member can be the only thing on its line — see
3981/// [`absorb_emptied_line`].
3982fn flow_member_range(source: &str, start: usize, end: usize) -> (usize, usize) {
3983    let bytes = source.as_bytes();
3984
3985    // Forward: `x: 1, y: 2` → take `, ` after the member.
3986    let mut i = end;
3987    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
3988        i += 1;
3989    }
3990    if i < bytes.len() && bytes[i] == b',' {
3991        i += 1;
3992        while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
3993            i += 1;
3994        }
3995        return absorb_emptied_line(source, start, i);
3996    }
3997
3998    // Backward: the member is last, so take the `, ` before it.
3999    let mut j = start;
4000    while j > 0 && matches!(bytes[j - 1], b' ' | b'\t') {
4001        j -= 1;
4002    }
4003    if j > 0 && bytes[j - 1] == b',' {
4004        return absorb_emptied_line(source, j - 1, end);
4005    }
4006
4007    absorb_emptied_line(source, start, end)
4008}
4009
4010/// Give a flow member its whole line when removing it would leave that
4011/// line holding nothing but indentation.
4012///
4013/// A flow collection wrapped over several lines puts one member per
4014/// line, so splicing out just the member's bytes leaves the line's
4015/// indentation behind as a whitespace-only line:
4016///
4017/// ```text
4018/// ports: [        remove("ports[0]")     ports: [
4019///   80,                   ->              ␣␣
4020///   443,                                  443,
4021/// ]                                     ]
4022/// ```
4023///
4024/// The result still loads — this is not corruption — but it writes
4025/// trailing whitespace onto a line that had none, which is what
4026/// `git diff --check`, `yamllint` and most pre-commit hooks exist to
4027/// catch. A lossless CST that edits one member should not hand its
4028/// caller a diff their own lint rejects.
4029///
4030/// The condition is deliberately "the member is alone on its line",
4031/// not "the collection is wrapped". Anything else surviving on the line
4032/// keeps the line:
4033///
4034/// - `ports: [80,` — the opening indicator is on it, so it stays.
4035/// - `  443]` — the closing indicator is on it, so it stays.
4036/// - `  80, # http` — the comment is on it, so it stays, and what a
4037///   comment left behind by a removal *means* stays the caller's
4038///   question rather than being decided here by a whitespace rule.
4039///
4040/// The line terminator goes with the line, `\r\n` included; taking the
4041/// `\n` and leaving the `\r` would plant a lone CR in a CRLF document.
4042fn absorb_emptied_line(source: &str, start: usize, end: usize) -> (usize, usize) {
4043    let bytes = source.as_bytes();
4044
4045    // Everything before the member on its line must be indentation.
4046    let line_start = source[..start].rfind('\n').map_or(0, |nl| nl + 1);
4047    if !source[line_start..start]
4048        .bytes()
4049        .all(|b| matches!(b, b' ' | b'\t'))
4050    {
4051        return (start, end);
4052    }
4053
4054    // And everything after it must be indentation up to the terminator.
4055    // A `]`, a `#`, or a sibling member here means the line still has
4056    // content, and the member does not own it.
4057    let mut i = end;
4058    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
4059        i += 1;
4060    }
4061    match bytes.get(i) {
4062        Some(b'\n') => (line_start, i + 1),
4063        Some(b'\r') if bytes.get(i + 1) == Some(&b'\n') => (line_start, i + 2),
4064        // No terminator to take (end of source): leave the range alone
4065        // rather than swallow an indent with nothing to fold it into.
4066        _ => (start, end),
4067    }
4068}
4069
4070/// Is the collection at `start` written in flow style (`{…}` / `[…]`)?
4071fn is_flow_collection(source: &str, start: usize) -> bool {
4072    source
4073        .as_bytes()
4074        .get(start)
4075        .is_some_and(|b| matches!(b, b'{' | b'['))
4076}
4077
4078/// The collection's span with any trailing whitespace given back.
4079///
4080/// A collection's span can run to the end of the line holding its last
4081/// value, newline included. Overwriting that range with `{}` would take
4082/// the document's final newline with it — `only: 1\n` becoming `{}`
4083/// rather than `{}\n`. Harmless to a parser, but this is a lossless CST:
4084/// a vanished trailing newline is a whole-file diff and trips
4085/// `.gitattributes` and CI end-of-file checks.
4086fn collection_span_trimmed(source: &str, start: usize, end: usize) -> (usize, usize) {
4087    let trimmed = source[..end].trim_end_matches(YAML_BLANK).len();
4088    (start, trimmed.max(start))
4089}
4090
4091/// The span to replace when a collection's **last** entry is removed,
4092/// plus the indentation the replacement must re-emit.
4093///
4094/// Two things the collection's own span does not give us:
4095///
4096/// * **The head-comment run.** A collection starts at its first entry's
4097///   *content*, which is below any comment describing that entry. The
4098///   `Removal::Line` path owns that run via `absorb_head_comments`, so
4099///   without this the same comment on the same entry was taken when the
4100///   entry had a sibling and stranded when it did not — left describing
4101///   an empty collection (#280). The typed oracle cannot catch it: a
4102///   comment is not in the typed value.
4103/// * **The indentation.** Starting the splice at the head of the comment
4104///   run puts the entry's own leading whitespace inside the replaced
4105///   range, so it has to be written back or `a:` loses its value.
4106///
4107/// A comment detached by a blank line, or at a different column, is not
4108/// the entry's — `absorb_head_comments` already stops at both.
4109///
4110/// `parent_key` is the byte offset of the key this collection is the value
4111/// of, when it has one. It decides the indentation: a block collection may
4112/// share its key's column, but the `{}` / `[]` that replaces it may not.
4113fn sole_entry_range(
4114    source: &str,
4115    coll_start: usize,
4116    coll_end: usize,
4117    parent_key: Option<usize>,
4118) -> (usize, usize, usize) {
4119    let (start, end) = collection_span_trimmed(source, coll_start, coll_end);
4120
4121    // Flow collections sit inline — `a: {x: 1}` starts at the `{`, part
4122    // way along a line whose earlier bytes belong to the *key*. There is
4123    // no head-comment run above such an entry to own, and the bytes
4124    // before `{` are not indentation to re-emit: treating them as such
4125    // would rewrite `a: {x: 1}` as `   {}` and lose the key.
4126    if is_flow_collection(source, coll_start) {
4127        return (start, end, 0);
4128    }
4129
4130    let line_start = start_of_line(source, coll_start);
4131    let own_indent = coll_start - line_start;
4132
4133    // A block sequence is allowed to sit at its key's own column — `on:` /
4134    // `- push`, the GitHub Actions and Ansible idiom. What replaces it is
4135    // not: `{}` / `[]` is a block *mapping value*, and one that shares its
4136    // key's column does not re-parse as that key's value. So the entry's
4137    // own indent is the right answer only when it already clears the key.
4138    //
4139    // The comment run is still absorbed at the entry's own column, which is
4140    // where those comment lines actually sit.
4141    let indent = match parent_key.map(|key| column_of(source, key)) {
4142        Some(key_column) if own_indent <= key_column => key_column + 2,
4143        _ => own_indent,
4144    };
4145    (
4146        absorb_head_comments(source, line_start, own_indent),
4147        end,
4148        indent,
4149    )
4150}
4151
4152/// The column a byte offset sits in, with a leading BOM discounted.
4153///
4154/// A BOM is zero-width, so `on:` after one is in column 0, not column 3 —
4155/// counting its bytes toward the column is the mistake #123 fixed in the
4156/// scanner, and the same one is available here. Everything else that can
4157/// precede a key on its line (indent spaces, `- `, `? `) is one byte per
4158/// column, so the subtraction is exact.
4159fn column_of(source: &str, offset: usize) -> usize {
4160    let line_start = start_of_line(source, offset);
4161    let bom = if line_start == 0 {
4162        strip_bom(source.as_bytes())
4163    } else {
4164        0
4165    };
4166    offset.saturating_sub(line_start + bom)
4167}
4168
4169/// The refusal for a `remove` path segment that names a key the mapping
4170/// received through a `<<` merge key. Such a key is in the loaded
4171/// `Value` but has no entry of its own in this mapping's source, so
4172/// indexing the span-entry list by its position would run past the end
4173/// (issue #334).
4174fn merge_provided_key(k: &str) -> Error {
4175    Error::Parse(format!(
4176        "remove: key {k:?} was produced by a `<<` merge key and has no entry \
4177         of its own to remove in this mapping — remove it at the anchor's \
4178         definition, or override it here explicitly"
4179    ))
4180}
4181
4182fn entry_line_span(
4183    value: &Value,
4184    span_tree: &SpanTree,
4185    source: &str,
4186    segments: &[QuerySegment],
4187    parent_key: Option<usize>,
4188) -> Result<Removal> {
4189    if segments.is_empty() {
4190        return Err(Error::Parse(
4191            "remove requires a non-empty path (cannot remove the document root)".into(),
4192        ));
4193    }
4194
4195    let (head, tail) = segments
4196        .split_first()
4197        .ok_or_else(|| Error::Parse("path not found".into()))?;
4198
4199    // Recurse into nested mappings / sequences until the segment list
4200    // identifies the *parent* of the entry to remove.
4201    if !tail.is_empty() {
4202        // The key descended through is the parent key of everything below
4203        // it — which is what the sole-entry arm needs to know how deep an
4204        // empty collection has to sit. A sequence item is not a key, so
4205        // descending through one clears it.
4206        let (child_value, child_tree, child_key) = match (head, value, span_tree) {
4207            (QuerySegment::Key(k), Value::Mapping(m), SpanTree::Mapping { entries, .. }) => {
4208                let pos = m
4209                    .iter()
4210                    .position(|(mk, _)| mk == k)
4211                    .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?;
4212                // Keys past the span-entry list were provided by a `<<`
4213                // merge key: they exist in the loaded mapping but own no
4214                // bytes here, so there is nothing to descend into.
4215                let ((key_start, _), child_tree) =
4216                    entries.get(pos).ok_or_else(|| merge_provided_key(k))?;
4217                (
4218                    m.iter().nth(pos).map(|(_, v)| v).expect("pos in range"),
4219                    child_tree,
4220                    Some(*key_start),
4221                )
4222            }
4223            (QuerySegment::Index(i), Value::Sequence(seq), SpanTree::Sequence { items, .. }) => (
4224                seq.get(*i).ok_or_else(|| {
4225                    Error::Parse(format!("path not found: index {i} out of bounds"))
4226                })?,
4227                items.get(*i).ok_or_else(|| {
4228                    Error::Parse(format!("path not found: index {i} out of bounds"))
4229                })?,
4230                None,
4231            ),
4232            _ => return Err(Error::Parse("path not found".into())),
4233        };
4234        return entry_line_span(child_value, child_tree, source, tail, child_key);
4235    }
4236
4237    // Final segment — locate this entry's key / dash and value.
4238    match (head, value, span_tree) {
4239        (
4240            QuerySegment::Key(k),
4241            Value::Mapping(m),
4242            SpanTree::Mapping {
4243                start: coll_start,
4244                end: coll_end,
4245                entries,
4246            },
4247        ) => {
4248            let pos = m
4249                .iter()
4250                .position(|(mk, _)| mk == k)
4251                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?;
4252            // The loaded mapping lists merge-provided keys after the
4253            // explicit ones, and the span tree holds only the explicit
4254            // entries, so a position past the entry list is a key that
4255            // `<<` supplied. It owns no bytes in this mapping: refuse
4256            // before the sole-entry arm below, which would otherwise
4257            // read a merge-only mapping as "one entry" and replace the
4258            // `<<` line itself with `{}`.
4259            let ((key_start, _key_end), child_tree) =
4260                entries.get(pos).ok_or_else(|| merge_provided_key(k))?;
4261            // The sole key of a mapping that shares its line with a
4262            // sequence `-` indicator (`- name: x`): the mapping's bytes
4263            // are exactly the entry's own, and the item must stay, so
4264            // the entry is replaced with `{}` in place instead of
4265            // splicing whole lines (#336).
4266            if m.len() <= 1
4267                && !is_flow_collection(source, *coll_start)
4268                && let Some(((key_start, _), entry_tree)) = entries.first()
4269                && locate_preceding_dash(source, *key_start).is_some()
4270            {
4271                let (vs, rve) = span_tree_bounds(entry_tree);
4272                let end = owned_value_end(source, vs, rve);
4273                return Ok(Removal::SoleEntry {
4274                    start: *key_start,
4275                    end,
4276                    empty: "{}",
4277                    indent: 0,
4278                });
4279            }
4280            // Last entry: the collection itself becomes `{}`. Deleting the
4281            // bytes would leave a dangling `a:` that re-parses as null.
4282            if m.len() <= 1 {
4283                let (start, end, indent) =
4284                    sole_entry_range(source, *coll_start, *coll_end, parent_key);
4285                return Ok(Removal::SoleEntry {
4286                    start,
4287                    end,
4288                    empty: "{}",
4289                    indent,
4290                });
4291            }
4292            // An alias value resolves *through* to its anchor, so the
4293            // resolved span belongs to the anchor's bytes on some other
4294            // line. The entry's own value byte is the `*name` token, whose
4295            // span the alias node records; removing the entry through it
4296            // deletes the reference and nothing of the anchor's. (Before
4297            // the token span was recorded, the arithmetic here silently
4298            // degenerated: for `a: &x 1` / `b: *x` the resolved span (6,7)
4299            // sat *before* the key at 8, `owned_entry_range` produced an
4300            // empty range, and the splice removed nothing while returning
4301            // `Ok`. Found by the `fuzz_editors` shrink invariant.)
4302            let (value_start, raw_value_end) = match child_tree {
4303                SpanTree::Alias { at, .. } => *at,
4304                _ => span_tree_bounds(child_tree),
4305            };
4306            if is_flow_collection(source, *coll_start) {
4307                let member_end = owned_value_end(source, value_start, raw_value_end);
4308                let (s, e) = flow_member_range(source, *key_start, member_end);
4309                return Ok(Removal::FlowMember { start: s, end: e });
4310            }
4311            // A mapping that is a sequence item carries its first key on
4312            // the `- ` indicator's line. Deleting that entry's whole line
4313            // would take the indicator -- and so the item -- with it (the
4314            // splice failed re-parse or the integrity check before, #336).
4315            // Instead only the entry's own bytes go, through its owned
4316            // range plus the following sibling's indentation, so the next
4317            // key moves up beside the indicator.
4318            if locate_preceding_dash(source, *key_start).is_some() {
4319                let (_, end, _) = owned_entry_range(source, *key_start, value_start, raw_value_end);
4320                return Ok(Removal::SpanWithinLine {
4321                    start: *key_start,
4322                    end: skip_line_indent(source, end),
4323                });
4324            }
4325            let (start, end, multiline) =
4326                owned_entry_range(source, *key_start, value_start, raw_value_end);
4327            Ok(Removal::Line {
4328                start,
4329                end,
4330                multiline,
4331            })
4332        }
4333        (
4334            QuerySegment::Index(i),
4335            Value::Sequence(seq),
4336            SpanTree::Sequence {
4337                start: coll_start,
4338                end: coll_end,
4339                items,
4340            },
4341        ) => {
4342            if *i >= seq.len() {
4343                return Err(Error::Parse(format!(
4344                    "path not found: index {i} out of bounds"
4345                )));
4346            }
4347            if seq.len() <= 1 {
4348                let (start, end, indent) =
4349                    sole_entry_range(source, *coll_start, *coll_end, parent_key);
4350                return Ok(Removal::SoleEntry {
4351                    start,
4352                    end,
4353                    empty: "[]",
4354                    indent,
4355                });
4356            }
4357            let item_tree = items
4358                .get(*i)
4359                .ok_or_else(|| Error::Parse(format!("path not found: index {i} out of bounds")))?;
4360            // As for a mapping entry above: the item's own value byte is
4361            // the `*name` token, and removing the item through it deletes
4362            // the reference, never the anchor's bytes.
4363            let (value_start, raw_value_end) = match item_tree {
4364                SpanTree::Alias { at, .. } => *at,
4365                _ => span_tree_bounds(item_tree),
4366            };
4367            if is_flow_collection(source, *coll_start) {
4368                // No `-` indicator in flow style; the item's own span is
4369                // the member.
4370                let member_end = owned_value_end(source, value_start, raw_value_end);
4371                let (s, e) = flow_member_range(source, value_start, member_end);
4372                return Ok(Removal::FlowMember { start: s, end: e });
4373            }
4374            // The `-` indicator sits before the value on the same line,
4375            // separated by inline whitespace. Walk backward to find it.
4376            // An implicit-null item owns no bytes and its empty span sits
4377            // past the line break, so the walk may cross one (#336).
4378            let dash_pos = locate_preceding_dash(source, value_start)
4379                .or_else(|| {
4380                    (value_start == raw_value_end)
4381                        .then(|| locate_dash_at_or_across_break(source, value_start))
4382                        .flatten()
4383                })
4384                .ok_or_else(|| {
4385                    Error::Parse(
4386                        "remove: could not locate '-' indicator preceding sequence item".into(),
4387                    )
4388                })?;
4389            // A sequence nested in a sequence shares its first item's
4390            // line with the enclosing dash (`- - a`). As for a mapping's
4391            // first key above, only the item's own bytes go (#336).
4392            if locate_preceding_dash(source, dash_pos).is_some() {
4393                let (_, end, _) = owned_entry_range(source, dash_pos, value_start, raw_value_end);
4394                return Ok(Removal::SpanWithinLine {
4395                    start: dash_pos,
4396                    end: skip_line_indent(source, end),
4397                });
4398            }
4399            let (start, end, multiline) =
4400                owned_entry_range(source, dash_pos, value_start, raw_value_end);
4401            Ok(Removal::Line {
4402                start,
4403                end,
4404                multiline,
4405            })
4406        }
4407        _ => Err(Error::Parse("path not found".into())),
4408    }
4409}
4410
4411/// The whole-line source range an entry owns, plus whether that range
4412/// spans more than one line (which selects `remove`'s guarded path).
4413///
4414/// `entry_start` points at the entry's key (mapping) or `-` indicator
4415/// (sequence); `value_start..raw_value_end` is its value's span as the
4416/// span tree reports it.
4417///
4418/// An entry owns more than the bytes of its key and value:
4419///
4420/// - the contiguous run of full-line comments directly above it, at its
4421///   own indentation — its *head comment*. Leaving those behind does not
4422///   merely litter: the comment silently becomes documentation for the
4423///   *next* entry. A blank line detaches the run, so a document header
4424///   set off by one is not swept up with the first entry.
4425/// - a keep-chomped (`|+` / `>+`) block scalar's kept trailing blank
4426///   lines, which are value content rather than separation. Leaving them
4427///   behind strands blank lines the removed entry brought with it.
4428///
4429/// It does **not** own comment lines that follow its last content line.
4430/// Those lie outside the value span — [`Document::span_at`] already
4431/// excludes them — and conventionally document whatever comes next, so
4432/// removing them would delete something the caller did not address. A
4433/// comment *interleaved* inside a multi-line value is inside the span and
4434/// goes with the entry.
4435fn owned_entry_range(
4436    source: &str,
4437    entry_start: usize,
4438    value_start: usize,
4439    raw_value_end: usize,
4440) -> (usize, usize, bool) {
4441    let bytes = source.as_bytes();
4442    let value_end = owned_value_end(source, value_start, raw_value_end);
4443
4444    // Extend through the line break holding the value's last content byte
4445    // — unless `value_end` already sits on a line boundary, which happens
4446    // only for a keep-chomped scalar whose kept blank lines end there.
4447    // Extending then would swallow the following entry's first line.
4448    let end = if value_end > 0 && bytes[value_end - 1] == b'\n' {
4449        value_end
4450    } else {
4451        end_of_line(source, value_end)
4452    };
4453
4454    let first_line_start = start_of_line(source, entry_start);
4455    let indent = entry_indent_column(source, entry_start);
4456    let start = absorb_head_comments(source, first_line_start, indent);
4457
4458    // The single-line fast path in `remove` stays available only for a
4459    // range that really is one line: an absorbed head comment or a kept
4460    // blank line makes the splice multi-line and sends it through the
4461    // re-parse guard.
4462    let body = &source[start..end];
4463    let multiline = body.strip_suffix('\n').unwrap_or(body).contains('\n');
4464    (start, end, multiline)
4465}
4466
4467/// Where an entry's value ends for the purposes of
4468/// [`owned_entry_range`]: the span tree's raw end walked back over
4469/// separator whitespace and over any comment-only lines beyond the
4470/// value's last content line.
4471///
4472/// A keep-chomped block scalar is returned untouched — its trailing line
4473/// breaks are content, and trimming them would strand them in the
4474/// document after the entry is removed.
4475fn owned_value_end(source: &str, value_start: usize, raw_value_end: usize) -> usize {
4476    if is_keep_chomped_block_scalar(source, value_start, raw_value_end) {
4477        return raw_value_end;
4478    }
4479    let bytes = source.as_bytes();
4480    let mut end = raw_value_end;
4481    loop {
4482        while end > value_start && matches!(bytes[end - 1], b' ' | b'\t' | b'\n' | b'\r') {
4483            end -= 1;
4484        }
4485        // `end` now sits just past a line's last content byte. If that
4486        // line holds nothing but a comment, it is trailing trivia rather
4487        // than value content; drop it and look at the line above. The
4488        // `line_start > value_start` guard keeps the walk from reaching
4489        // into the entry's own first line.
4490        let line_start = start_of_line(source, end);
4491        if line_start <= value_start
4492            || !source[line_start..end]
4493                .trim_start_matches(YAML_BLANK)
4494                .starts_with('#')
4495        {
4496            return end;
4497        }
4498        end = line_start;
4499    }
4500}
4501
4502/// Split a whole-line range into its content and the line break that
4503/// ends it, if any.
4504///
4505/// Used by [`Document::swap_items`], which exchanges the two bodies but
4506/// leaves each terminator where it is. The final entry of a document may
4507/// carry none — `- a\n- b` — and moving the breaks with the bodies would
4508/// splice that into the single line `- b- a`.
4509fn split_line_terminator(text: &str) -> (&str, &str) {
4510    if let Some(body) = text.strip_suffix("\r\n") {
4511        (body, "\r\n")
4512    } else if let Some(body) = text.strip_suffix('\n') {
4513        (body, "\n")
4514    } else {
4515        (text, "")
4516    }
4517}
4518
4519/// Walk `start` up over the contiguous run of full-line comments directly
4520/// above an entry, each beginning at column `indent`.
4521///
4522/// Stops at a blank line, a non-comment line, or a comment at a different
4523/// column — so a comment detached by a blank line stays put, and so does
4524/// one belonging to an enclosing or nested level.
4525fn absorb_head_comments(source: &str, mut start: usize, indent: usize) -> usize {
4526    while start > 0 {
4527        // `start` is always 0 or one past a `\n`, so the preceding line is
4528        // `[prev_line_start, start - 1)` with the break excluded.
4529        let prev_line_start = start_of_line(source, start - 1);
4530        let line = source[prev_line_start..start - 1].trim_end_matches('\r');
4531        let content = line.trim_start_matches([' ', '\t']);
4532        if !content.starts_with('#') || line.len() - content.len() != indent {
4533            break;
4534        }
4535        start = prev_line_start;
4536    }
4537    start
4538}
4539
4540// ── Key-site resolution (used by `rename_key`) ──────────────────────
4541
4542/// The YAML merge key. Spelled out here because `rename_key` refuses
4543/// it as a *new* key name: the loader matches the decoded string, so
4544/// quoting cannot demote a `<<` key back to an ordinary one.
4545const MERGE_KEY_SPELLING: &str = "<<";
4546
4547/// Parse `path` for [`Document::rename_key`] with a stricter bracket
4548/// rule than the shared [`parse_query_path`].
4549///
4550/// `parse_query_path` *drops* a bracket segment whose content is not
4551/// an index (`servers[web]` collapses to `servers`). For a read that
4552/// is a harmless miss, but a rename would then rewrite the *parent*
4553/// key — a silent, destructive edit the caller never asked for. Here
4554/// the typo is an error naming the offending segment.
4555fn parse_rename_path(path: &str) -> Result<Vec<QuerySegment>> {
4556    let mut rest = path;
4557    while let Some(open) = rest.find('[') {
4558        let after = &rest[open + 1..];
4559        if after.starts_with(['"', '\'']) {
4560            // A quoted key segment: skip its body, `\` escapes included,
4561            // so a `]` inside the quotes does not end the segment.
4562            rest = after_quoted_key(after);
4563            continue;
4564        }
4565        let close = after.find(']').unwrap_or(after.len());
4566        let content = &after[..close];
4567        if content.parse::<usize>().is_err() {
4568            return Err(Error::Parse(format!(
4569                "rename_key: `{path}` contains the bracket segment `[{content}]`, which is \
4570                 neither a sequence index nor a quoted key — a bracket segment must hold a \
4571                 non-negative integer or a quoted key (`[\"{content}\"]`), and a plain mapping \
4572                 key is addressed with dot notation (`parent.child`)"
4573            )));
4574        }
4575        rest = &after[close..];
4576    }
4577    Ok(parse_query_path(path))
4578}
4579
4580/// The remainder of `s` after the quoted key it begins with (its first
4581/// byte is the opening quote): everything past the closing quote, `\`
4582/// escapes honoured. An unterminated key leaves nothing.
4583fn after_quoted_key(s: &str) -> &str {
4584    let mut chars = s.char_indices();
4585    let Some((_, quote)) = chars.next() else {
4586        return "";
4587    };
4588    while let Some((i, c)) = chars.next() {
4589        if c == '\\' {
4590            let _ = chars.next();
4591        } else if c == quote {
4592            return &s[i + c.len_utf8()..];
4593        }
4594    }
4595    ""
4596}
4597
4598/// The first character of `key` outside YAML's printable set
4599/// (§5.1 `c-printable`): any control character other than tab,
4600/// including `U+007F` and the `U+0080..=U+009F` C1 block, and the
4601/// two non-characters `U+FFFE` and `U+FFFF` (#379).
4602///
4603/// [`Document::rename_key`] refuses such a key rather than trying to
4604/// spell it: a key is meant to be read by people and tools alike, and
4605/// a hex escape in key position helps neither.
4606fn first_non_printable(key: &str) -> Option<char> {
4607    key.chars()
4608        .find(|&c| c != '\t' && (c.is_control() || matches!(c, '\u{fffe}' | '\u{ffff}')))
4609}
4610
4611/// Decode a mapping-key token's source text to the string it
4612/// denotes, per its quote style. `None` for token kinds that are not
4613/// a simple scalar (alias marks and the like), which have no decoded
4614/// spelling to compare against.
4615///
4616/// [`Document::rename_key`] compares this against `new_key` to
4617/// decide the byte-preserving no-op. Comparing *formatted* spellings
4618/// instead would requote a plain `true:` into `"true":` on a rename
4619/// to its own name — a data change, since the key's YAML type
4620/// switches from bool to string.
4621fn decode_key_token(raw: &str, kind: SyntaxKind) -> Option<String> {
4622    match kind {
4623        // A plain scalar's source text is its content, and a key
4624        // token never spans lines (implicit keys are single-line;
4625        // an explicit `? foo` key's trailing break is trimmed off
4626        // before this point).
4627        SyntaxKind::PlainScalar => Some(raw.to_owned()),
4628        SyntaxKind::SingleQuotedScalar => decode_single_quoted(raw).map(Cow::into_owned),
4629        // Double-quoted escapes (`\t`, `é`, …) need the real
4630        // scalar parser; the token is self-delimiting, so loading it
4631        // as a bare scalar document yields exactly the decoded key.
4632        SyntaxKind::DoubleQuotedScalar => {
4633            let cfg = ParseConfig::default();
4634            match crate::parser::parse_one_value(raw, &cfg).ok()? {
4635                Value::String(s) => Some(s),
4636                _ => None,
4637            }
4638        }
4639        _ => None,
4640    }
4641}
4642
4643/// The byte span of the node an `&name` anchor decorates, given the
4644/// anchor mark's start offset. The anchored node is the first
4645/// non-trivia sibling that follows the mark inside the same green
4646/// node — a scalar token, or a nested collection.
4647///
4648/// Returns `None` when the mark is unknown or decorates nothing
4649/// (an anchored implicit null has no bytes of its own).
4650fn anchored_content_span(
4651    node: &GreenNode,
4652    base: usize,
4653    mark_start: usize,
4654) -> Option<(usize, usize)> {
4655    let mut pos = base;
4656    let mut seen_mark = false;
4657    for child in node.children() {
4658        let len = child.text_len();
4659        if seen_mark {
4660            let trivia = matches!(
4661                child,
4662                GreenChild::Token { kind, .. }
4663                    if matches!(
4664                        kind,
4665                        SyntaxKind::Whitespace
4666                            | SyntaxKind::Newline
4667                            | SyntaxKind::Comment
4668                            | SyntaxKind::TagMark
4669                    )
4670            );
4671            if !trivia {
4672                return Some((pos, pos + len));
4673            }
4674        } else if pos == mark_start
4675            && matches!(child, GreenChild::Token { kind, .. } if *kind == SyntaxKind::AnchorMark)
4676        {
4677            seen_mark = true;
4678        }
4679        pos += len;
4680    }
4681    if seen_mark {
4682        // The mark was the last meaningful child — nothing anchored.
4683        return None;
4684    }
4685    // Not at this level: descend into the child that contains it.
4686    let mut pos = base;
4687    for child in node.children() {
4688        let len = child.text_len();
4689        if let GreenChild::Node(inner) = child
4690            && pos <= mark_start
4691            && mark_start < pos + len
4692        {
4693            return anchored_content_span(inner, pos, mark_start);
4694        }
4695        pos += len;
4696    }
4697    None
4698}
4699
4700/// Resolve `segments` to the byte span of the *key* token of the
4701/// mapping entry it addresses, refusing renames that would collide
4702/// with an existing sibling key.
4703///
4704/// The path addresses the entry the same way `set` / `remove` do —
4705/// it points at the entry's value; the returned span is the entry's
4706/// key. Mirrors [`entry_line_span`]'s recursion but keeps the key
4707/// span that resolver discards.
4708fn entry_key_site(
4709    value: &Value,
4710    span_tree: &SpanTree,
4711    segments: &[QuerySegment],
4712    new_key: &str,
4713) -> Result<(usize, usize)> {
4714    // An alias site substitutes the anchor's (value, tree) — the
4715    // same unwrapping `resolve_span` does for reads. A *write* here
4716    // would splice the anchor's bytes, which belong to a different
4717    // entry, so refuse with that diagnosis rather than letting the
4718    // wrapper fall through to the catch-all "path not found".
4719    if matches!(span_tree, SpanTree::Alias { .. }) {
4720        return Err(Error::Parse(
4721            "rename_key: the path addresses alias-expanded content — an `*name` site reflects \
4722             the anchor's entries and owns no key bytes of its own; rename the corresponding \
4723             entry at the anchor's own definition instead"
4724                .into(),
4725        ));
4726    }
4727
4728    let (head, tail) = segments.split_first().ok_or_else(|| {
4729        Error::Parse("rename_key requires a non-empty path addressing a mapping entry".into())
4730    })?;
4731
4732    // Recurse into nested mappings / sequences until the segment
4733    // list identifies the entry whose key is being renamed.
4734    if !tail.is_empty() {
4735        let (child_value, child_tree) = match (head, value, span_tree) {
4736            (QuerySegment::Key(k), Value::Mapping(m), SpanTree::Mapping { entries, .. }) => {
4737                let pos = m
4738                    .iter()
4739                    .position(|(mk, _)| mk == k)
4740                    .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?;
4741                let (_, child_tree) = entries.get(pos).ok_or_else(|| {
4742                    // Keys past the span-entry list were introduced
4743                    // by a `<<` merge key — they have no source
4744                    // entry of their own in this mapping. Spelled
4745                    // exactly as the final-segment arm spells it: an
4746                    // intermediate segment is the same condition.
4747                    Error::Parse(format!(
4748                        "rename_key: key {k:?} was produced by a `<<` merge key and has \
4749                         no entry of its own to rename in this mapping"
4750                    ))
4751                })?;
4752                (
4753                    m.iter().nth(pos).map(|(_, v)| v).expect("pos in range"),
4754                    child_tree,
4755                )
4756            }
4757            (QuerySegment::Index(i), Value::Sequence(seq), SpanTree::Sequence { items, .. }) => (
4758                seq.get(*i).ok_or_else(|| {
4759                    Error::Parse(format!("path not found: index {i} out of bounds"))
4760                })?,
4761                items.get(*i).ok_or_else(|| {
4762                    Error::Parse(format!("path not found: index {i} out of bounds"))
4763                })?,
4764            ),
4765            _ => return Err(Error::Parse("path not found".into())),
4766        };
4767        return entry_key_site(child_value, child_tree, tail, new_key);
4768    }
4769
4770    // Final segment — locate this entry's key span in the parent
4771    // mapping and refuse a rename that would duplicate a sibling.
4772    match (head, value, span_tree) {
4773        (QuerySegment::Key(k), Value::Mapping(m), SpanTree::Mapping { entries, .. }) => {
4774            let pos = m
4775                .iter()
4776                .position(|(mk, _)| mk == k)
4777                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?;
4778            if k != new_key && m.contains_key(new_key) {
4779                // A key beyond the span-entry list came from a `<<`
4780                // merge: the mapping has no entry of its own by that
4781                // name, so the result would not be a duplicate — it
4782                // would be an explicit key *overriding* the merged
4783                // value. Still refused, but not for that reason.
4784                let merge_provided = m
4785                    .get_index_of(new_key)
4786                    .is_some_and(|idx| idx >= entries.len());
4787                if merge_provided {
4788                    return Err(Error::Parse(format!(
4789                        "rename_key: {new_key:?} is provided by a `<<` merge key in this \
4790                         mapping — renaming {k:?} to it would create an explicit entry that \
4791                         overrides the merged value instead of renaming in place"
4792                    )));
4793                }
4794                return Err(Error::Parse(format!(
4795                    "rename_key: the mapping already has an entry named {new_key:?} — \
4796                     renaming {k:?} would create a duplicate key"
4797                )));
4798            }
4799            let (key_span, _) = entries.get(pos).ok_or_else(|| {
4800                Error::Parse(format!(
4801                    "rename_key: key {k:?} was produced by a `<<` merge key and has \
4802                     no entry of its own to rename in this mapping"
4803                ))
4804            })?;
4805            Ok(*key_span)
4806        }
4807        (QuerySegment::Index(_), _, _) => Err(Error::Parse(
4808            "rename_key: path must address a mapping entry, not a sequence item".into(),
4809        )),
4810        _ => Err(Error::Parse("path not found".into())),
4811    }
4812}
4813
4814/// Locate the token leaf containing byte `target` and return its
4815/// kind, byte range, and the [`SyntaxKind`] of its immediate green
4816/// parent. The parent kind lets [`Document::rename_key`] tell a
4817/// block-mapping key (parent `MappingEntry`) from a flow-mapping
4818/// key (parent `FlowMapping` — flow content is kept flat, see
4819/// [`SyntaxKind::FlowMapping`]).
4820fn token_at_with_parent(
4821    node: &GreenNode,
4822    target: usize,
4823    base: usize,
4824) -> Option<(SyntaxKind, (usize, usize), SyntaxKind)> {
4825    let mut pos = base;
4826    for child in node.children() {
4827        let len = child.text_len();
4828        if pos <= target && target < pos + len {
4829            return match child {
4830                GreenChild::Token { kind, .. } => Some((*kind, (pos, pos + len), node.kind())),
4831                GreenChild::Node(inner) => token_at_with_parent(inner, target, pos),
4832            };
4833        }
4834        pos += len;
4835    }
4836    None
4837}
4838
4839/// YAML spelling for a mapping key that replaces a key token of
4840/// `kind`, style-matched to the site: a quoted site keeps its quote
4841/// style, and a plain site stays plain when the plain spelling
4842/// re-parses to exactly `key` (delegating to [`is_plain_safe`]),
4843/// falling back to double quotes when it would not.
4844fn format_key_for_site(key: &str, kind: SyntaxKind) -> String {
4845    // Single-quoted YAML cannot represent control characters (and a
4846    // line break inside single quotes folds — the decoded key would
4847    // differ); fall back to double quotes for those.
4848    let single_representable = !key.bytes().any(|b| b < 0x20 || b == 0x7F);
4849    match kind {
4850        SyntaxKind::SingleQuotedScalar if single_representable => format_single_quoted(key),
4851        SyntaxKind::DoubleQuotedScalar => format_double_quoted(key),
4852        _ => {
4853            if is_plain_safe(key) {
4854                key.to_owned()
4855            } else {
4856                format_double_quoted(key)
4857            }
4858        }
4859    }
4860}
4861
4862/// The typed value the document must load to after renaming the
4863/// entry at `segments` to `new_key`: the pre-edit value with exactly
4864/// that one key renamed — same entry position, same value. Used as
4865/// the post-splice integrity oracle by [`Document::rename_key`].
4866fn expected_after_rename(value: &Value, segments: &[QuerySegment], new_key: &str) -> Result<Value> {
4867    let (last, parents) = segments.split_last().ok_or_else(|| {
4868        Error::Parse("rename_key requires a non-empty path addressing a mapping entry".into())
4869    })?;
4870    let QuerySegment::Key(old_key) = last else {
4871        return Err(Error::Parse(
4872            "rename_key: path must address a mapping entry, not a sequence item".into(),
4873        ));
4874    };
4875    let mut expected = value.clone();
4876    let mut cur = &mut expected;
4877    for seg in parents {
4878        cur = match (seg, cur) {
4879            (QuerySegment::Key(k), Value::Mapping(m)) => m
4880                .get_mut(k)
4881                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?,
4882            (QuerySegment::Index(i), Value::Sequence(seq)) => seq
4883                .get_mut(*i)
4884                .ok_or_else(|| Error::Parse(format!("path not found: index {i} out of bounds")))?,
4885            _ => return Err(Error::Parse("path not found".into())),
4886        };
4887    }
4888    let Value::Mapping(m) = cur else {
4889        return Err(Error::Parse("path not found".into()));
4890    };
4891    let mut renamed = Mapping::with_capacity(m.len());
4892    for (k, v) in m.iter() {
4893        if k == old_key {
4894            let _ = renamed.insert(new_key, v.clone());
4895        } else {
4896            let _ = renamed.insert(k.clone(), v.clone());
4897        }
4898    }
4899    *m = renamed;
4900    Ok(expected)
4901}
4902
4903/// Build the `items[i]` path for `Document::span_at`, handling the
4904/// root-sequence case where `path` is empty.
4905fn item_child_path(path: &str, i: usize) -> String {
4906    if path.is_empty() {
4907        format!("[{i}]")
4908    } else {
4909        format!("{path}[{i}]")
4910    }
4911}
4912
4913/// Length of the sequence addressed by `segments`, or an error naming
4914/// `path` if it does not resolve to a sequence.
4915fn sequence_len_at(value: &Value, segments: &[QuerySegment], path: &str) -> Result<usize> {
4916    let mut cur = value;
4917    for seg in segments {
4918        cur = match (seg, cur) {
4919            (QuerySegment::Key(k), Value::Mapping(m)) => m
4920                .get(k)
4921                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?,
4922            (QuerySegment::Index(i), Value::Sequence(seq)) => seq
4923                .get(*i)
4924                .ok_or_else(|| Error::Parse(format!("path not found: index {i} out of bounds")))?,
4925            _ => {
4926                return Err(Error::Parse(format!(
4927                    "swap_items: `{path}` does not resolve to a sequence"
4928                )));
4929            }
4930        };
4931    }
4932    match cur {
4933        Value::Sequence(seq) => Ok(seq.len()),
4934        _ => Err(Error::Parse(format!(
4935            "swap_items: `{path}` does not address a sequence"
4936        ))),
4937    }
4938}
4939
4940/// The typed value with items `i` and `j` of the sequence at
4941/// `segments` exchanged — the integrity oracle for `swap_items`.
4942fn expected_after_swap(
4943    value: &Value,
4944    segments: &[QuerySegment],
4945    i: usize,
4946    j: usize,
4947    path: &str,
4948) -> Result<Value> {
4949    let mut expected = value.clone();
4950    let mut cur = &mut expected;
4951    for seg in segments {
4952        cur = match (seg, cur) {
4953            (QuerySegment::Key(k), Value::Mapping(m)) => m
4954                .get_mut(k)
4955                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?,
4956            (QuerySegment::Index(idx), Value::Sequence(seq)) => {
4957                seq.get_mut(*idx).ok_or_else(|| {
4958                    Error::Parse(format!("path not found: index {idx} out of bounds"))
4959                })?
4960            }
4961            _ => {
4962                return Err(Error::Parse(format!(
4963                    "swap_items: `{path}` does not resolve to a sequence"
4964                )));
4965            }
4966        };
4967    }
4968    let Value::Sequence(seq) = cur else {
4969        return Err(Error::Parse(format!(
4970            "swap_items: `{path}` does not address a sequence"
4971        )));
4972    };
4973    let vi = seq
4974        .get(i)
4975        .cloned()
4976        .ok_or_else(|| Error::Parse(format!("swap_items: index {i} out of bounds")))?;
4977    let vj = seq
4978        .get(j)
4979        .cloned()
4980        .ok_or_else(|| Error::Parse(format!("swap_items: index {j} out of bounds")))?;
4981    *seq.get_mut(i).expect("index i checked above") = vj;
4982    *seq.get_mut(j).expect("index j checked above") = vi;
4983    Ok(expected)
4984}
4985
4986/// The typed value with `key` set to `child` in the mapping at
4987/// `mapping_path` — the integrity oracle for
4988/// [`Document::insert_entry_value`].
4989///
4990/// An existing key keeps its position (the insertion replaces its
4991/// value in place, as `insert_entry` does); a new key lands last.
4992fn expected_after_insert_entry(
4993    value: &Value,
4994    mapping_path: &str,
4995    key: &str,
4996    child: &Value,
4997) -> Result<Value> {
4998    let mut expected = value.clone();
4999    let cur = if mapping_path.is_empty() {
5000        &mut expected
5001    } else {
5002        path_value_mut(&mut expected, &parse_query_path(mapping_path))
5003            .ok_or_else(|| Error::Parse(format!("path not found: {mapping_path}")))?
5004    };
5005    let Value::Mapping(m) = cur else {
5006        return Err(Error::Parse(format!(
5007            "`{mapping_path}` does not address a mapping"
5008        )));
5009    };
5010    let _ = m.insert(key, child.clone());
5011    Ok(expected)
5012}
5013
5014/// The typed value with `item` inserted at `index` of the sequence at
5015/// `seq_path` — the integrity oracle for
5016/// [`Document::push_back_value`] and
5017/// [`Document::insert_after_value`].
5018fn expected_after_insert_item(
5019    value: &Value,
5020    seq_path: &str,
5021    index: usize,
5022    item: &Value,
5023) -> Result<Value> {
5024    let mut expected = value.clone();
5025    let cur = if seq_path.is_empty() {
5026        &mut expected
5027    } else {
5028        path_value_mut(&mut expected, &parse_query_path(seq_path))
5029            .ok_or_else(|| Error::Parse(format!("path not found: {seq_path}")))?
5030    };
5031    let Value::Sequence(seq) = cur else {
5032        return Err(Error::Parse(format!(
5033            "`{seq_path}` does not address a sequence"
5034        )));
5035    };
5036    if index > seq.len() {
5037        return Err(Error::Parse(format!(
5038            "index {index} is past the end of the sequence at `{seq_path}` (length {})",
5039            seq.len()
5040        )));
5041    }
5042    seq.insert(index, item.clone());
5043    Ok(expected)
5044}
5045
5046/// Mutable analogue of [`path_value`], resolving pre-parsed segments
5047/// against a `Value` tree.
5048fn path_value_mut<'a>(value: &'a mut Value, segments: &[QuerySegment]) -> Option<&'a mut Value> {
5049    let mut cur = value;
5050    for seg in segments {
5051        cur = match (seg, cur) {
5052            (QuerySegment::Key(k), Value::Mapping(m)) => m.get_mut(k)?,
5053            (QuerySegment::Index(i), Value::Sequence(seq)) => seq.get_mut(*i)?,
5054            _ => return None,
5055        };
5056    }
5057    Some(cur)
5058}
5059
5060/// The sequence path an item path addresses, i.e. `items[2]` →
5061/// `items`, `[0]` → `` (the root sequence).
5062fn sequence_parent_path(item_path: &str) -> String {
5063    match item_path.rfind('[') {
5064        Some(i) => item_path[..i].to_owned(),
5065        None => item_path.to_owned(),
5066    }
5067}
5068
5069/// Re-indent every line of `fragment` after the first to `indent`
5070/// spaces, leaving the first line alone because the splice template
5071/// has already placed it (after a `- ` indicator or a `key: `).
5072///
5073/// Blank lines stay blank — trailing whitespace on an empty line is
5074/// noise the emitters never introduce deliberately.
5075fn indent_continuation_lines(fragment: &str, indent: usize, nl: &str) -> String {
5076    if !fragment.contains('\n') {
5077        return fragment.to_owned();
5078    }
5079    let pad = " ".repeat(indent);
5080    let mut out = String::with_capacity(fragment.len() + indent * 4);
5081    for (i, line) in fragment.split('\n').enumerate() {
5082        if i > 0 {
5083            // An emission is always `\n`-separated; the breaks it grows
5084            // when spliced take the document's spelling instead.
5085            out.push_str(nl);
5086            if !line.is_empty() {
5087                out.push_str(&pad);
5088            }
5089        }
5090        out.push_str(line);
5091    }
5092    out
5093}
5094
5095/// The typed value with the entry at `segments` removed — the integrity
5096/// oracle for the multi-line `remove` path.
5097/// A structural fingerprint of `value` with the subtree at `segments`
5098/// elided.
5099///
5100/// Compares *shape* — which keys exist, how long sequences are — and
5101/// deliberately not scalar contents. That distinction is the whole
5102/// point:
5103///
5104/// - a fragment smuggling `\nc: 3` into the source **adds a key**, which
5105///   changes the shape and is refused;
5106/// - editing an anchored value **changes scalars** at existing paths
5107///   when its aliases are re-read, which leaves the shape identical and
5108///   is allowed. That is a documented feature, and an earlier
5109///   value-comparing version of this oracle wrongly rejected it.
5110impl Document {
5111    /// Run `edit`, then require the document's shape outside
5112    /// `container_path` to be unchanged.
5113    ///
5114    /// The inserters legitimately change the shape *of the container
5115    /// they insert into* — that is their job — so the container's
5116    /// subtree is elided and everything else must match. Without this a
5117    /// fragment containing a newline escapes:
5118    ///
5119    /// ```text
5120    /// push_back("s", "v\nqq: 7")  on  "s:\n  - 1\nz: 9\n"
5121    /// ```
5122    ///
5123    /// appended `- v` to the sequence *and* gave the document a
5124    /// top-level `qq`, returning `Ok`, because the result is valid
5125    /// YAML.
5126    fn guarded_insert<F>(
5127        &mut self,
5128        container_path: &str,
5129        what: &str,
5130        growth: InsertGrowth<'_>,
5131        edit: F,
5132    ) -> Result<()>
5133    where
5134        F: FnOnce(&mut Self) -> Result<()>,
5135    {
5136        self.ensure_cache();
5137        let segments = parse_query_path(container_path);
5138        let (before_shape, before_container) = {
5139            let cache = self.cache.borrow();
5140            let (value, _) = cache.as_ref().expect("ensure_cache populated");
5141            let container = if container_path.is_empty() {
5142                Some(value)
5143            } else {
5144                path_value(value, container_path)
5145            };
5146            let before = container.and_then(|c| match (&growth, c) {
5147                (InsertGrowth::SeqPlusOne, Value::Sequence(s)) => {
5148                    Some(ContainerBefore::Seq(s.len()))
5149                }
5150                (InsertGrowth::MapEntry(_), Value::Mapping(m)) => Some(ContainerBefore::Map(
5151                    m.keys().map(|k| k.as_str().to_owned()).collect(),
5152                )),
5153                _ => None,
5154            });
5155            (shape_excluding(value, &segments), before)
5156        };
5157
5158        let snapshot = self.clone();
5159        if let Err(error) = edit(self) {
5160            *self = snapshot;
5161            return Err(error);
5162        }
5163
5164        // The splice is document-valid by contract. Parse under the
5165        // document's configuration for the independent shape oracle.
5166        let after_value = match crate::parser::parse_exactly_one_value(&self.source, &self.config) {
5167            Ok(value) => value,
5168            Err(error) => {
5169                *self = snapshot;
5170                return Err(error);
5171            }
5172        };
5173        if shape_excluding(&after_value, &segments) != before_shape {
5174            *self = snapshot;
5175            return Err(Error::Parse(format!(
5176                "{what}: the fragment added or removed entries outside \
5177                 `{container_path}` — the document was left unchanged. Use \
5178                 the `_value` variant to write a value without splicing YAML."
5179            )));
5180        }
5181        // The shape above elides the container — changing it is the
5182        // insert's job — so pin that change to exactly the one entry
5183        // asked for. Without this the smuggling moves *inside*:
5184        //
5185        //     push_back("s", "v\n  - w")
5186        //
5187        // kept every byte at the container's indent and appended two
5188        // items at `Ok`.
5189        if let Some(before) = before_container {
5190            let after_container = if container_path.is_empty() {
5191                Some(&after_value)
5192            } else {
5193                path_value(&after_value, container_path)
5194            };
5195            let grown_exactly = match (&growth, &before, after_container) {
5196                (InsertGrowth::SeqPlusOne, ContainerBefore::Seq(n), Some(Value::Sequence(s))) => {
5197                    s.len() == n + 1
5198                }
5199                (
5200                    InsertGrowth::MapEntry(key),
5201                    ContainerBefore::Map(keys),
5202                    Some(Value::Mapping(m)),
5203                ) => {
5204                    // Expected: the pre-edit keys plus `key`.
5205                    // Already-present covers the `<<`-override insert,
5206                    // which replaces an inherited value without
5207                    // growing the typed view. Order-insensitive: where
5208                    // a merge places its inherited keys is the
5209                    // loader's business, not this oracle's.
5210                    let mut expected: Vec<&str> = keys.iter().map(String::as_str).collect();
5211                    if !expected.contains(key) {
5212                        expected.push(key);
5213                    }
5214                    let mut after: Vec<&str> = m.keys().map(|k| k.as_str()).collect();
5215                    expected.sort_unstable();
5216                    after.sort_unstable();
5217                    expected == after
5218                }
5219                _ => false,
5220            };
5221            if !grown_exactly {
5222                *self = snapshot;
5223                return Err(Error::Parse(format!(
5224                    "{what}: the fragment changed `{container_path}` beyond the single \
5225                     entry asked for — the document was left unchanged. Use the \
5226                     `_value` variant to write a value without splicing YAML."
5227                )));
5228            }
5229        }
5230        Ok(())
5231    }
5232}
5233
5234/// What one insert may do to its container — the growth half of
5235/// [`Document::guarded_insert`]'s oracle.
5236enum InsertGrowth<'a> {
5237    /// The container sequence must end up exactly one item longer.
5238    SeqPlusOne,
5239    /// The container mapping's keys must become exactly the pre-edit
5240    /// keys plus this one.
5241    MapEntry(&'a str),
5242}
5243
5244/// The container's pre-edit fingerprint for [`InsertGrowth`].
5245enum ContainerBefore {
5246    Seq(usize),
5247    Map(Vec<String>),
5248}
5249
5250fn shape_excluding(value: &Value, segments: &[QuerySegment]) -> String {
5251    fn walk(v: &Value, skip: &[QuerySegment], out: &mut String) {
5252        // Reaching the elided path contributes a fixed marker, so the
5253        // target may change freely — including scalar to mapping.
5254        if skip.is_empty() {
5255            out.push_str("<target>");
5256            return;
5257        }
5258        match v {
5259            Value::Mapping(m) => {
5260                out.push('{');
5261                for (k, val) in m {
5262                    out.push_str(k.as_str());
5263                    out.push(':');
5264                    // On the path: recurse with the remaining segments, so
5265                    // the elided target contributes its marker. Off it:
5266                    // render the whole subtree, so a change anywhere
5267                    // inside a sibling shows up in the fingerprint.
5268                    //
5269                    // This used to compute `next` first — `&skip[1..]` on a
5270                    // match, `&[]` otherwise — and branch on
5271                    // `next.len() < skip.len()`. That is true in *both*
5272                    // cases (`skip` is never empty here; `walk` returns
5273                    // early when it is), so `walk_all` was dead and every
5274                    // sibling collapsed to the `<target>` marker. The
5275                    // fingerprint then recorded only the top-level key
5276                    // names, not the shapes under them.
5277                    match skip.first() {
5278                        Some(QuerySegment::Key(sk)) if sk.as_str() == k.as_str() => {
5279                            walk(val, &skip[1..], out);
5280                        }
5281                        _ => walk_all(val, out),
5282                    }
5283                    out.push(',');
5284                }
5285                out.push('}');
5286            }
5287            Value::Sequence(s) => {
5288                out.push('[');
5289                for (i, val) in s.iter().enumerate() {
5290                    match skip.first() {
5291                        Some(QuerySegment::Index(si)) if *si == i => {
5292                            walk(val, &skip[1..], out);
5293                        }
5294                        _ => walk_all(val, out),
5295                    }
5296                    out.push(',');
5297                }
5298                out.push(']');
5299            }
5300            _ => out.push('_'),
5301        }
5302    }
5303
5304    /// Shape of a subtree with nothing elided.
5305    fn walk_all(v: &Value, out: &mut String) {
5306        match v {
5307            Value::Mapping(m) => {
5308                out.push('{');
5309                for (k, val) in m {
5310                    out.push_str(k.as_str());
5311                    out.push(':');
5312                    walk_all(val, out);
5313                    out.push(',');
5314                }
5315                out.push('}');
5316            }
5317            Value::Sequence(s) => {
5318                out.push('[');
5319                for val in s {
5320                    walk_all(val, out);
5321                    out.push(',');
5322                }
5323                out.push(']');
5324            }
5325            _ => out.push('_'),
5326        }
5327    }
5328
5329    let mut out = String::new();
5330    walk(value, segments, &mut out);
5331    out
5332}
5333
5334/// The typed value at `segments`, walking mappings by key and sequences
5335/// by index. `None` when the path does not resolve. Read-only sibling of
5336/// [`expected_after_remove`]'s navigation loop; used by `set_value`'s
5337/// no-op short-circuit.
5338fn typed_value_at<'v>(value: &'v Value, segments: &[QuerySegment]) -> Option<&'v Value> {
5339    let mut cur = value;
5340    for seg in segments {
5341        cur = match (seg, cur) {
5342            (QuerySegment::Key(k), Value::Mapping(m)) => m.get(k)?,
5343            (QuerySegment::Index(i), Value::Sequence(seq)) => seq.get(*i)?,
5344            _ => return None,
5345        };
5346    }
5347    Some(cur)
5348}
5349
5350fn expected_after_remove(value: &Value, segments: &[QuerySegment]) -> Result<Value> {
5351    let (last, parents) = segments
5352        .split_last()
5353        .ok_or_else(|| Error::Parse("remove requires a non-empty path".into()))?;
5354    let mut expected = value.clone();
5355    let mut cur = &mut expected;
5356    for seg in parents {
5357        cur = match (seg, cur) {
5358            (QuerySegment::Key(k), Value::Mapping(m)) => m
5359                .get_mut(k)
5360                .ok_or_else(|| Error::Parse(format!("path not found: missing key {k:?}")))?,
5361            (QuerySegment::Index(i), Value::Sequence(seq)) => seq
5362                .get_mut(*i)
5363                .ok_or_else(|| Error::Parse(format!("path not found: index {i} out of bounds")))?,
5364            _ => return Err(Error::Parse("path not found".into())),
5365        };
5366    }
5367    match (last, cur) {
5368        (QuerySegment::Key(k), Value::Mapping(m)) => {
5369            let mut rebuilt = Mapping::with_capacity(m.len().saturating_sub(1));
5370            for (mk, mv) in m.iter() {
5371                if mk != k {
5372                    let _ = rebuilt.insert(mk.clone(), mv.clone());
5373                }
5374            }
5375            *m = rebuilt;
5376            Ok(expected)
5377        }
5378        (QuerySegment::Index(i), Value::Sequence(seq)) => {
5379            if *i >= seq.len() {
5380                return Err(Error::Parse(format!(
5381                    "path not found: index {i} out of bounds"
5382                )));
5383            }
5384            let _ = seq.remove(*i);
5385            Ok(expected)
5386        }
5387        _ => Err(Error::Parse("path not found".into())),
5388    }
5389}
5390
5391/// Index of the first `segments` entry with no existing node under
5392/// `root`, walking the typed view. `segments.len()` means the whole
5393/// path resolves. An existing segment whose value cannot be descended
5394/// through — a scalar, a null (other than the empty document root), a
5395/// sequence where a key is asked for — is an error naming it, so
5396/// [`Document::set_path`] refuses before touching a byte (#327).
5397fn first_missing_segment(root: &Value, segments: &[QuerySegment], path: &str) -> Result<usize> {
5398    let mut cursor = root;
5399    for (i, segment) in segments.iter().enumerate() {
5400        match segment {
5401            QuerySegment::Key(key) => match cursor {
5402                Value::Mapping(map) => match map.get(key.as_str()) {
5403                    Some(child) => cursor = child,
5404                    None => return Ok(i),
5405                },
5406                // The null *document* has no bytes claiming the root is
5407                // null; everything is missing. An explicit `null` is
5408                // told apart later, by the pre-edit candidate check.
5409                Value::Null if i == 0 => return Ok(0),
5410                Value::Null => {
5411                    return Err(Error::Parse(format!(
5412                        "set_path: `{}` resolves to a null value — filling an implicit \
5413                         null with a new mapping is a fragment edit for now; splice it \
5414                         with `set` (`{path}` was not written)",
5415                        format_query_prefix(&segments[..i]),
5416                    )));
5417                }
5418                _ => {
5419                    return Err(Error::Parse(format!(
5420                        "set_path: `{}` resolves to a non-mapping, so `{path}` cannot \
5421                         descend through it; the document was left unchanged",
5422                        format_query_prefix(&segments[..i]),
5423                    )));
5424                }
5425            },
5426            QuerySegment::Index(index) => match cursor {
5427                Value::Sequence(seq) => match seq.get(*index) {
5428                    Some(child) => cursor = child,
5429                    None => return Ok(i),
5430                },
5431                _ => {
5432                    return Err(Error::Parse(format!(
5433                        "set_path: `{}` resolves to a non-sequence, so `{path}` cannot \
5434                         index into it; the document was left unchanged",
5435                        format_query_prefix(&segments[..i]),
5436                    )));
5437                }
5438            },
5439            QuerySegment::Wildcard | QuerySegment::RecursiveDescent => {
5440                return Err(Error::Parse(format!(
5441                    "set_path: `{path}` contains a wildcard or recursive-descent segment, \
5442                     which does not address a single entry"
5443                )));
5444            }
5445        }
5446    }
5447    Ok(segments.len())
5448}
5449
5450/// Render `segments` back into the path syntax (`a.b[2].c`,
5451/// `a["x.y"]`) so an ancestor prefix can be handed to the existing
5452/// path-addressed mutators. A key the grammar would read as structure
5453/// is re-spelled as the quoted segment it was parsed from.
5454fn format_query_prefix(segments: &[QuerySegment]) -> String {
5455    let mut out = String::new();
5456    for segment in segments {
5457        match segment {
5458            QuerySegment::Key(key) => push_key(&mut out, key),
5459            QuerySegment::Index(index) => {
5460                let _ = write!(out, "[{index}]");
5461            }
5462            QuerySegment::Wildcard | QuerySegment::RecursiveDescent => {}
5463        }
5464    }
5465    out
5466}
5467
5468/// Address the entry `key` of the mapping at `mapping_path`, in the
5469/// spelling the path grammar reads back as that key.
5470fn child_path_of(mapping_path: &str, key: &str) -> String {
5471    let mut path = mapping_path.to_owned();
5472    push_key(&mut path, key);
5473    path
5474}
5475
5476/// Walk backward from `value_start` past inline whitespace and find
5477/// the `-` indicator that opened this sequence entry. Returns its
5478/// byte offset, or `None` if no dash is found on the same line.
5479/// Resolve `path` against `value` and return the addressed value.
5480/// Mirrors the resolution logic of `span_at` but works directly on
5481/// the typed [`Value`] tree.
5482fn path_value<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
5483    let segments = parse_query_path(path);
5484    let mut cur = value;
5485    for seg in &segments {
5486        match (seg, cur) {
5487            (QuerySegment::Key(k), Value::Mapping(m)) => {
5488                let (_k, v) = m.iter().find(|(mk, _)| *mk == k)?;
5489                cur = v;
5490            }
5491            (QuerySegment::Index(i), Value::Sequence(seq)) => {
5492                cur = seq.get(*i)?;
5493            }
5494            _ => return None,
5495        }
5496    }
5497    Some(cur)
5498}
5499
5500/// Column of the `-` indicator on the same line as `value_start`,
5501/// found by walking backward over inline whitespace. `None` if no
5502/// dash precedes the value on its line.
5503fn column_of_preceding_dash(source: &str, value_start: usize) -> Option<usize> {
5504    let dash_pos = locate_preceding_dash(source, value_start)?;
5505    let bytes = source.as_bytes();
5506    let mut line_start = dash_pos;
5507    while line_start > 0 && bytes[line_start - 1] != b'\n' {
5508        line_start -= 1;
5509    }
5510    Some(dash_pos - line_start)
5511}
5512
5513/// Walk every line in `source`, find pairs of consecutive
5514/// non-empty/non-comment lines where the second is more deeply
5515/// indented than the first, and return the smallest such delta —
5516/// the file's indent step. Defaults to `2` when nothing is detected
5517/// (single-level documents, all-top-level mappings).
5518///
5519/// Tab-indented lines are skipped: tabs cannot serve as YAML
5520/// indentation per spec §6.1, and trying to mix them into the
5521/// detection produces nonsense for the typical-case mixed-edit
5522/// scenario.
5523fn detect_indent_unit(source: &str) -> usize {
5524    let mut prev_indent: Option<usize> = None;
5525    let mut min_step: Option<usize> = None;
5526    for line in source.lines() {
5527        // Count leading spaces; bail on tab-indented lines.
5528        let mut spaces = 0;
5529        let bytes = line.as_bytes();
5530        let mut tab_seen = false;
5531        for &b in bytes {
5532            if b == b' ' {
5533                spaces += 1;
5534            } else if b == b'\t' {
5535                tab_seen = true;
5536                break;
5537            } else {
5538                break;
5539            }
5540        }
5541        if tab_seen {
5542            // Tab line — leaves prev_indent unchanged so the next
5543            // pair compares across the tab line.
5544            continue;
5545        }
5546        // Skip blank and comment-only lines.
5547        let trimmed = &line[spaces..];
5548        if trimmed.is_empty() || trimmed.starts_with('#') {
5549            continue;
5550        }
5551        if let Some(prev) = prev_indent
5552            && spaces > prev
5553        {
5554            let step = spaces - prev;
5555            min_step = Some(min_step.map_or(step, |m| m.min(step)));
5556        }
5557        prev_indent = Some(spaces);
5558    }
5559    min_step.unwrap_or(2)
5560}
5561
5562/// Column of the *key* that owns the value at `value_start`.
5563///
5564/// Two layouts to handle:
5565///
5566/// - **Inline:** `key: value` — key and value share a line. The key's
5567///   column is the leading-space count on that line.
5568/// - **Nested block:** `key:\n  child: …` — the value's first byte
5569///   sits on a child line, indented past the key. The key's column is
5570///   the leading-space count of an *earlier* non-blank/non-comment
5571///   line whose indent is *smaller* than the value-line's indent.
5572///
5573/// Walks backwards from `value_start`, skipping blank and comment
5574/// lines, and returns the first content line's column that is shallower
5575/// than the value line's column. Falls back to the value line's own
5576/// column for the inline case.
5577///
5578/// Returns `None` if `value_start` is out of range.
5579fn column_of_key_at(source: &str, value_start: usize) -> Option<usize> {
5580    let bytes = source.as_bytes();
5581    if value_start > bytes.len() {
5582        return None;
5583    }
5584
5585    // Locate the line that contains value_start.
5586    let line_start = |pos: usize| -> usize {
5587        let mut s = pos;
5588        while s > 0 && bytes[s - 1] != b'\n' {
5589            s -= 1;
5590        }
5591        s
5592    };
5593    let leading_spaces = |start: usize| -> usize {
5594        let mut c = 0;
5595        while start + c < bytes.len() && bytes[start + c] == b' ' {
5596            c += 1;
5597        }
5598        c
5599    };
5600
5601    let value_line_start = line_start(value_start);
5602    let value_col = leading_spaces(value_line_start);
5603
5604    // If there is real content (not just whitespace) on the value line
5605    // at or before `value_start`, the key is inline on this same line.
5606    let mut probe = value_line_start + value_col;
5607    let mut inline_content = false;
5608    while probe < value_start {
5609        let b = bytes[probe];
5610        if b != b' ' && b != b'\t' {
5611            inline_content = true;
5612            break;
5613        }
5614        probe += 1;
5615    }
5616    if inline_content {
5617        return Some(value_col);
5618    }
5619
5620    // Nested case: walk backward by line, skipping blanks and
5621    // comment-only lines, until we find content at a *smaller* column.
5622    if value_line_start == 0 {
5623        return Some(value_col);
5624    }
5625    let mut cursor = value_line_start - 1; // past the trailing '\n'
5626    loop {
5627        // Find the start of the line ending at `cursor`.
5628        let mut prev_start = cursor;
5629        while prev_start > 0 && bytes[prev_start - 1] != b'\n' {
5630            prev_start -= 1;
5631        }
5632        let prev_col = leading_spaces(prev_start);
5633        let first_content = prev_start + prev_col;
5634        let after_content = cursor; // cursor still points at the '\n' index
5635        let is_blank = first_content >= after_content;
5636        let is_comment = !is_blank && bytes[first_content] == b'#';
5637        if !is_blank && !is_comment && prev_col < value_col {
5638            return Some(prev_col);
5639        }
5640        if prev_start == 0 {
5641            return Some(value_col);
5642        }
5643        cursor = prev_start - 1;
5644    }
5645}
5646
5647/// The column a replacement must sit at when the value it replaces
5648/// occupies its own lines.
5649///
5650/// Normally that is the old value's own indent. A block *sequence* is
5651/// allowed to sit at its key's own column — `on:` / `- push`, the
5652/// GitHub Actions and Ansible idiom — and a value written there does
5653/// not re-parse as that key's value, so the answer is one indent step
5654/// past the key instead. [`sole_entry_range`] makes the same call when
5655/// `remove` empties a sole entry, which is why `on:` / `- push` becomes
5656/// `on:` / `  []` rather than `on:` / `[]`.
5657///
5658/// `line_start` is the first byte of the line the old value begins.
5659fn own_line_replacement_column(source: &str, line_start: usize, unit: usize) -> usize {
5660    let own = skip_line_indent(source, line_start) - line_start;
5661    let key = column_of_key_at(source, line_start).unwrap_or(own);
5662    if own <= key { key + unit } else { own }
5663}
5664
5665/// Walk every scalar leaf in the green tree and pick the
5666/// dominant *quoted* style. Plain mapping keys overwhelm any
5667/// real signal from the values so we deliberately ignore them —
5668/// the question we want to answer is "when the user *did* quote
5669/// a value, did they reach for `'…'` or `\"…\"`?". Documents
5670/// with no quoted scalars at all default to `Plain` (the
5671/// simplest form, matching what most YAML files do for short
5672/// values).
5673fn detect_dominant_quote_style(root: &GreenNode) -> crate::ScalarStyle {
5674    let mut single = 0_usize;
5675    let mut double = 0_usize;
5676    walk_tokens(root, 0, &mut |kind, _| match kind {
5677        SyntaxKind::SingleQuotedScalar => single += 1,
5678        SyntaxKind::DoubleQuotedScalar => double += 1,
5679        _ => {}
5680    });
5681    if single == 0 && double == 0 {
5682        return crate::ScalarStyle::Plain;
5683    }
5684    if single >= double {
5685        crate::ScalarStyle::SingleQuoted
5686    } else {
5687        crate::ScalarStyle::DoubleQuoted
5688    }
5689}
5690
5691/// Walk every collection leaf and pick the majority shape —
5692/// block (`BlockMapping` / `BlockSequence`) vs flow
5693/// (`FlowMapping` / `FlowSequence`). The result drives the
5694/// "block vs flow" decision in [`crate::cst::Entry::insert_value`]
5695/// when emitting a typed collection.
5696fn detect_dominant_flow_style(root: &GreenNode) -> crate::FlowStyle {
5697    let mut block = 0_usize;
5698    let mut flow = 0_usize;
5699    walk_collections(root, &mut |kind| match kind {
5700        SyntaxKind::BlockMapping | SyntaxKind::BlockSequence => block += 1,
5701        SyntaxKind::FlowMapping | SyntaxKind::FlowSequence => flow += 1,
5702        _ => {}
5703    });
5704    if flow > block {
5705        crate::FlowStyle::Auto
5706    } else {
5707        crate::FlowStyle::Block
5708    }
5709}
5710
5711/// Walk every node (not token) in the green tree, calling
5712/// `visit` with each composite node's `SyntaxKind`.
5713fn walk_collections(node: &GreenNode, visit: &mut dyn FnMut(SyntaxKind)) {
5714    visit(node.kind());
5715    for child in node.children() {
5716        if let GreenChild::Node(inner) = child {
5717            walk_collections(inner, visit);
5718        }
5719    }
5720}
5721
5722/// The end of the anchor entry's own last line: the line a new sibling is
5723/// spliced after.
5724///
5725/// Not simply `end_of_line(end)`. The two span sources disagree about
5726/// where a nested block collection stops. `resolve_path_in_green` trims it
5727/// to its content, but the loader's span tree runs on to the next token,
5728/// sweeping up the blank and comment lines that follow. That difference is
5729/// invisible for a scalar entry, where there is nothing to sweep, and for
5730/// an entry with nothing after it. It shows when the mapping's last entry
5731/// is a *nested block collection* with a comment beneath it: the anchor
5732/// landed past the comment, so the new key was written after it and the
5733/// comment became that key's head comment instead of the document's
5734/// (#418).
5735///
5736/// The last line the entry owns is therefore the last one in `start..end`
5737/// that is not blank and not a comment sitting *outside* the entry.
5738/// Indentation is what tells those apart, and `column` is the entry's own:
5739///
5740/// - a comment indented *strictly deeper* than the entry sits inside its
5741///   block, so a new sibling goes after it;
5742/// - a comment at the entry's own column or shallower is not inside it,
5743///   and a sibling goes before it. At the entry's own column it could be
5744///   read either way, and going before is what the anchor did until #288.
5745///
5746/// The line holding `start` always counts, because the entry begins there,
5747/// and a blank line is trivia — except inside a keep-chomped block scalar,
5748/// which is handled before the walk begins.
5749///
5750/// Indentation here counts a tab as one column, where `column_of_key_at`
5751/// counts leading spaces only. The two can therefore disagree about a
5752/// tab-indented comment. That is deliberate: a tab is not valid YAML
5753/// indentation (#428), so there is no right column to give it, and this
5754/// keeps the measure total rather than guessing.
5755///
5756/// Only comments outside the entry move, which is the reported class: a
5757/// document-final comment after a nested block, at the mapping's own
5758/// column or at the document's. A comment indented inside the block keeps
5759/// the behaviour it has had since #288, which is the better answer there
5760/// and not what the report is about.
5761// Issue #418, a regression from #288.
5762fn anchor_line_end(source: &str, start: usize, end: usize, column: usize) -> usize {
5763    // A blank line is trivia only when it cannot be content. Inside a
5764    // keep-chomped block scalar (`|+`, `>+`) the trailing blanks *are*
5765    // the value — which is why `trim_value_span` hands such a span back
5766    // untrimmed — and dropping them here would splice into the middle of
5767    // the scalar and silently shorten a value the caller never named.
5768    // The scalar can sit anywhere under the anchor, not only at its top,
5769    // so the question is asked of the whole span.
5770    let blank_may_be_content = span_holds_keep_chomped_scalar(source, start, end);
5771    let mut best = end_of_line(source, start);
5772    let mut i = best;
5773    while i < end {
5774        let line_end = end_of_line(source, i);
5775        let line = &source[i..line_end];
5776        let text = line.trim_matches(YAML_BLANK);
5777        let owned = if text.is_empty() {
5778            blank_may_be_content
5779        } else if text.starts_with('#') {
5780            line.len() - line.trim_start_matches([' ', '\t']).len() > column
5781        } else {
5782            true
5783        };
5784        if owned {
5785            best = line_end;
5786        }
5787        if line_end == i {
5788            break;
5789        }
5790        i = line_end;
5791    }
5792    best
5793}
5794
5795/// Whether `start..end` holds the header of a keep-chomped block scalar,
5796/// which makes a blank line inside that span possibly the scalar's own
5797/// content rather than trivia.
5798///
5799/// Deliberately errs toward `true`: a `|+` inside a quoted string counts,
5800/// and so does one under an entry whose blanks are not in fact its own.
5801/// The asymmetry decides the direction. A false `true` places a new key
5802/// below a blank line, which is a matter of taste; a false `false`
5803/// truncates a value, which is data loss.
5804fn span_holds_keep_chomped_scalar(source: &str, start: usize, end: usize) -> bool {
5805    let bytes = source.as_bytes();
5806    let mut i = start;
5807    while i < end {
5808        let line_end = end_of_line(source, i).min(end);
5809        // `is_keep_chomped_block_scalar` expects the value's first byte,
5810        // so offer it each indicator on the line in turn; it rejects
5811        // anything that is not a header.
5812        //
5813        // Each offer gets a three-byte window rather than the rest of the
5814        // line. A header is the indicator plus, in either order, one
5815        // indentation digit and one chomping character, so a `+` that
5816        // belongs to it is always within two bytes. Handing over the
5817        // whole line instead would make a line of `|||...|` cost a scan
5818        // per pipe, and this runs on every insert.
5819        for (offset, _) in bytes[i..line_end]
5820            .iter()
5821            .enumerate()
5822            .filter(|(_, b)| matches!(**b, b'|' | b'>'))
5823        {
5824            let at = i + offset;
5825            if is_keep_chomped_block_scalar(source, at, (at + 3).min(line_end)) {
5826                return true;
5827            }
5828        }
5829        if line_end == i {
5830            break;
5831        }
5832        i = line_end;
5833    }
5834    false
5835}
5836
5837/// Position of the byte immediately past the next `\n` at or after
5838/// `pos`. If `pos` already points past a newline, returns `pos`.
5839/// At end-of-input, returns `source.len()`.
5840/// The line break this document uses, for a splice that adds a line.
5841///
5842/// A splice derives its indentation from the site rather than assuming
5843/// two spaces; the terminator deserves the same treatment. Returns
5844/// `"\r\n"` only when the document is *wholly* CRLF — at least one break,
5845/// and every `\n` preceded by `\r` — so the answer is the document's
5846/// convention rather than a guess. An LF document, a mixed one, and a
5847/// single unterminated line all yield `"\n"`: mixed input has no
5848/// convention to honour, and picking one would rewrite bytes the caller
5849/// did not ask about.
5850fn document_break(source: &str) -> &'static str {
5851    let bytes = source.as_bytes();
5852    let mut saw = false;
5853    for (i, &b) in bytes.iter().enumerate() {
5854        if b == b'\n' {
5855            if i == 0 || bytes[i - 1] != b'\r' {
5856                return "\n";
5857            }
5858            saw = true;
5859        }
5860    }
5861    if saw { "\r\n" } else { "\n" }
5862}
5863
5864/// A finished fragment with its own line breaks re-spelled in the
5865/// document's convention ([`document_break`]).
5866///
5867/// The emitters are `\n`-separated by design (see
5868/// [`indent_continuation_lines`]), and a splice that *adds* a line takes
5869/// the document's spelling instead. An insertion learned that in #261;
5870/// a replacement grows lines too, whenever the value written has more of
5871/// them than the value replaced, and did not.
5872///
5873/// Applied to the finished fragment rather than inside a formatter, so
5874/// that every producer on the path is covered: the block literal's body,
5875/// a multi-line single-quoted scalar, and the break a hoisted comment
5876/// sits above. [`format_block_literal`] itself stays `\n`-only, because
5877/// the insertion path shares it and re-spells later.
5878///
5879/// A blind substitution is safe because no fragment can carry a raw
5880/// `\r`: every string containing one is routed to double-quoted style
5881/// and escaped, by `format_string_for_site` and by the serializer alike
5882/// (#335). So `\r\r\n` is unreachable.
5883fn respell_breaks(fragment: &str, nl: &str) -> String {
5884    if nl == "\n" || !fragment.contains('\n') {
5885        return fragment.to_owned();
5886    }
5887    fragment.replace('\n', nl)
5888}
5889
5890/// The line break a splice at `pos` must supply for itself, if any.
5891///
5892/// [`end_of_line`] returns the byte after the line's `\n`, or the end
5893/// of the source when the last line has no terminator. Splicing a new
5894/// entry at that second position would land it on the tail of the last
5895/// line (`a: 1  b: 2`), so the new text has to open with a break of its
5896/// own — in the document's own spelling ([`document_break`]).
5897/// Everywhere else this is empty.
5898fn leading_break_for_splice(source: &str, pos: usize) -> &'static str {
5899    if pos == 0 || source.as_bytes()[pos - 1] == b'\n' {
5900        ""
5901    } else {
5902        document_break(source)
5903    }
5904}
5905
5906/// Position of the first byte of the line containing `pos`: `0`, or the
5907/// byte just past the preceding `\n`. The mirror of [`end_of_line`].
5908fn start_of_line(source: &str, pos: usize) -> usize {
5909    let bytes = source.as_bytes();
5910    let mut i = pos.min(bytes.len());
5911    while i > 0 && bytes[i - 1] != b'\n' {
5912        i -= 1;
5913    }
5914    i
5915}
5916
5917/// Advances past blank lines that a keep-chomped block scalar owns.
5918///
5919/// A blank line is normally trivia, and a new sibling key may be spliced
5920/// in above it. Under `|+` or `>+` it is not trivia — chomping is what
5921/// decides, and "keep" means the trailing line breaks are part of the
5922/// value. Splicing above them moves them out of the scalar and into the
5923/// document, so the key that was asked for is added correctly and a
5924/// *different* key's value silently loses a newline (#429).
5925///
5926/// Nothing fails when that happens: the document still parses, every
5927/// byte is still present, and a diff of the raw text looks like an
5928/// ordinary insertion. Only the value changed.
5929///
5930/// The scan is bounded to the anchor entry's own span and only looks for
5931/// a header, so it errs toward treating blanks as content. That
5932/// direction is the safe one: a false positive puts a new key one line
5933/// lower than a reader might expect, which is cosmetic; a false negative
5934/// corrupts a value.
5935fn extend_past_kept_blank_lines(source: &str, start: usize, line_end: usize) -> usize {
5936    if !span_holds_keep_chomped_header(source, start, line_end) {
5937        return line_end;
5938    }
5939    let bytes = source.as_bytes();
5940    let mut i = line_end;
5941    while i < bytes.len() {
5942        let stop = end_of_line(source, i);
5943        if !is_yaml_blank(&source[i..stop]) {
5944            break;
5945        }
5946        i = stop;
5947        if stop == line_end {
5948            break;
5949        }
5950    }
5951    i
5952}
5953
5954/// Whether `start..end` contains a `|+` or `>+` block-scalar header.
5955///
5956/// Deliberately crude: a `|+` inside a quoted string counts. Getting
5957/// this wrong in the permissive direction costs a blank line's placement;
5958/// getting it wrong the other way costs a value.
5959fn span_holds_keep_chomped_header(source: &str, start: usize, end: usize) -> bool {
5960    let end = end.min(source.len());
5961    if start >= end {
5962        return false;
5963    }
5964    let region = &source[start..end];
5965    region.contains("|+") || region.contains(">+")
5966}
5967
5968fn end_of_line(source: &str, pos: usize) -> usize {
5969    let bytes = source.as_bytes();
5970    let mut i = pos;
5971    while i < bytes.len() && bytes[i] != b'\n' {
5972        i += 1;
5973    }
5974    if i < bytes.len() { i + 1 } else { i }
5975}
5976
5977/// [`locate_preceding_dash`] for an implicit-null item, which owns no
5978/// bytes: its empty value span sits *at* the `-` indicator itself (the
5979/// scanner marks the missing value there), or -- when trailing blanks
5980/// intervene -- past the line break. Both shapes resolve to the dash
5981/// (#336).
5982fn locate_dash_at_or_across_break(source: &str, value_start: usize) -> Option<usize> {
5983    let bytes = source.as_bytes();
5984    if bytes.get(value_start) == Some(&b'-') {
5985        return Some(value_start);
5986    }
5987    let mut i = value_start;
5988    if i > 0 && bytes[i - 1] == b'\n' {
5989        i -= 1;
5990        if i > 0 && bytes[i - 1] == b'\r' {
5991            i -= 1;
5992        }
5993    }
5994    locate_preceding_dash(source, i)
5995}
5996
5997/// The position just past the indentation of the line starting at
5998/// `line_start`: spaces and tabs skipped, stopping at content or the
5999/// line break.
6000fn skip_line_indent(source: &str, line_start: usize) -> usize {
6001    let bytes = source.as_bytes();
6002    let mut i = line_start;
6003    while i < bytes.len() && matches!(bytes[i], b' ' | b'\t') {
6004        i += 1;
6005    }
6006    i
6007}
6008
6009fn locate_preceding_dash(source: &str, value_start: usize) -> Option<usize> {
6010    let bytes = source.as_bytes();
6011    let mut i = value_start;
6012    while i > 0 {
6013        i -= 1;
6014        match bytes[i] {
6015            b' ' | b'\t' => {}
6016            b'-' => return Some(i),
6017            b'\n' | b'\r' => return None,
6018            _ => return None,
6019        }
6020    }
6021    None
6022}
6023
6024// ── Green-tree leaf lookup ──────────────────────────────────────────
6025
6026/// Return the [`SyntaxKind`] of the leaf containing byte position
6027/// `target` in `node`. Walks the green tree recursively with a
6028/// running offset.
6029fn leaf_kind_at(node: &GreenNode, target: usize) -> Option<SyntaxKind> {
6030    let mut pos = 0;
6031    for child in node.children() {
6032        let len = child.text_len();
6033        match child {
6034            GreenChild::Token { kind, .. } => {
6035                if pos <= target && target < pos + len {
6036                    return Some(*kind);
6037                }
6038            }
6039            GreenChild::Node(inner) => {
6040                if pos <= target && target < pos + len {
6041                    return leaf_kind_at(inner, target - pos);
6042                }
6043            }
6044        }
6045        pos += len;
6046    }
6047    None
6048}
6049
6050/// If the leaf at byte `target` lives inside a `BlockMapping`'s
6051/// `MappingEntry`, scan the *other* entries' value scalars and
6052/// return their dominant scalar style — but only when that style is
6053/// `SingleQuotedScalar` or `DoubleQuotedScalar`. A plain-dominant
6054/// neighbourhood returns `None` (plain is the default fallback for
6055/// a plain site, so the caller does not need a hint).
6056fn sibling_dominant_scalar_kind(node: &GreenNode, target: usize) -> Option<SyntaxKind> {
6057    let (mapping, entry) = enclosing_mapping_and_entry(node, target, 0)?;
6058    dominant_sibling_value_kind(mapping, entry)
6059}
6060
6061/// Walk the tree and return `(BlockMapping, MappingEntry)` ancestors
6062/// of the leaf at byte `target`, when both exist. Recursion is
6063/// linear in the tree height plus the children scanned per level.
6064fn enclosing_mapping_and_entry(
6065    node: &GreenNode,
6066    target: usize,
6067    base: usize,
6068) -> Option<(&GreenNode, &GreenNode)> {
6069    fn walk<'a>(
6070        node: &'a GreenNode,
6071        target: usize,
6072        base: usize,
6073        cur_mapping: Option<&'a GreenNode>,
6074        cur_entry: Option<&'a GreenNode>,
6075    ) -> Option<(&'a GreenNode, &'a GreenNode)> {
6076        let mut pos = base;
6077        for child in node.children() {
6078            let len = child.text_len();
6079            if pos <= target && target < pos + len {
6080                match child {
6081                    GreenChild::Token { .. } => {
6082                        if let (Some(m), Some(e)) = (cur_mapping, cur_entry) {
6083                            return Some((m, e));
6084                        }
6085                        return None;
6086                    }
6087                    GreenChild::Node(inner) => {
6088                        let new_mapping = if inner.kind() == SyntaxKind::BlockMapping {
6089                            Some(inner)
6090                        } else {
6091                            cur_mapping
6092                        };
6093                        let new_entry = if inner.kind() == SyntaxKind::MappingEntry {
6094                            Some(inner)
6095                        } else {
6096                            cur_entry
6097                        };
6098                        if let Some(found) = walk(inner, target, pos, new_mapping, new_entry) {
6099                            return Some(found);
6100                        }
6101                    }
6102                }
6103            }
6104            pos += len;
6105        }
6106        None
6107    }
6108    walk(node, target, base, None, None)
6109}
6110
6111/// Tally value-scalar kinds of every `MappingEntry` child of
6112/// `mapping` *except* the entry being modified. Return the
6113/// dominant quoted style if and only if it is uniquely the most
6114/// frequent and there are at least two siblings vouching for it.
6115fn dominant_sibling_value_kind(mapping: &GreenNode, exclude: &GreenNode) -> Option<SyntaxKind> {
6116    let exclude_ptr: *const GreenNode = exclude;
6117    let mut plain = 0usize;
6118    let mut single = 0usize;
6119    let mut double = 0usize;
6120    for child in mapping.children() {
6121        if let GreenChild::Node(entry) = child {
6122            if entry.kind() != SyntaxKind::MappingEntry {
6123                continue;
6124            }
6125            // Cheap pointer-equality check — both come from the same
6126            // `Arc<[GreenChild]>` storage in this tree, so identity
6127            // comparison is reliable.
6128            let entry_ptr: *const GreenNode = entry;
6129            if core::ptr::eq(entry_ptr, exclude_ptr) {
6130                continue;
6131            }
6132            match entry_value_scalar_kind(entry) {
6133                Some(SyntaxKind::PlainScalar) => plain += 1,
6134                Some(SyntaxKind::SingleQuotedScalar) => single += 1,
6135                Some(SyntaxKind::DoubleQuotedScalar) => double += 1,
6136                _ => {}
6137            }
6138        }
6139    }
6140    // Need at least two siblings agreeing on a quoted style and a
6141    // strict plurality over the other quoted style and over plain.
6142    if single >= 2 && single > double && single > plain {
6143        return Some(SyntaxKind::SingleQuotedScalar);
6144    }
6145    if double >= 2 && double > single && double > plain {
6146        return Some(SyntaxKind::DoubleQuotedScalar);
6147    }
6148    None
6149}
6150
6151/// Within a `MappingEntry`, return the syntax kind of the value
6152/// scalar (the leaf that follows `:`). `None` if the value is a
6153/// nested collection or otherwise not a single scalar leaf.
6154fn entry_value_scalar_kind(entry: &GreenNode) -> Option<SyntaxKind> {
6155    let mut after_colon = false;
6156    for child in entry.children() {
6157        match child {
6158            GreenChild::Token { kind, .. } => {
6159                if *kind == SyntaxKind::ColonIndicator {
6160                    after_colon = true;
6161                    continue;
6162                }
6163                if after_colon
6164                    && matches!(
6165                        kind,
6166                        SyntaxKind::PlainScalar
6167                            | SyntaxKind::SingleQuotedScalar
6168                            | SyntaxKind::DoubleQuotedScalar
6169                            | SyntaxKind::LiteralScalar
6170                            | SyntaxKind::FoldedScalar
6171                    )
6172                {
6173                    return Some(*kind);
6174                }
6175                // Whitespace / newline / comment leaves are skipped.
6176            }
6177            GreenChild::Node(_) => {
6178                if after_colon {
6179                    // Nested collection — value is not a single scalar.
6180                    return None;
6181                }
6182            }
6183        }
6184    }
6185    None
6186}
6187
6188// ── Value → YAML scalar fragment ────────────────────────────────────
6189
6190/// Context the formatter consults when picking a YAML representation
6191/// for a replacement value at a particular site.
6192struct SiteContext {
6193    /// The existing leaf's syntax kind at the splice site.
6194    kind: SyntaxKind,
6195    /// A dominant sibling scalar style, when one is unambiguous.
6196    /// Only consulted when [`Self::kind`] is `PlainScalar`.
6197    neighbour: Option<SyntaxKind>,
6198    /// Column of the first non-whitespace byte on the line that
6199    /// owns the splice site. Used to decide block-scalar
6200    /// continuation indent.
6201    entry_col: usize,
6202    /// Whether the leaf sits inside a `[…]` / `{…}` flow collection.
6203    /// Flow context forbids block scalars and gives `,` `[` `]` `{`
6204    /// `}` structural meaning anywhere in a plain scalar, so the
6205    /// spelling rules differ from block context (#332).
6206    in_flow: bool,
6207}
6208
6209fn format_value_for_site(value: &Value, ctx: &SiteContext) -> Result<String> {
6210    match value {
6211        Value::Null => Ok("null".to_string()),
6212        Value::Bool(true) => Ok("true".to_string()),
6213        Value::Bool(false) => Ok("false".to_string()),
6214        Value::Number(n) => Ok(format_number(n)),
6215        Value::String(s) => format_string_for_site(s, ctx),
6216        Value::Sequence(_) | Value::Mapping(_) => Err(Error::Parse(
6217            "set_value cannot replace a scalar with a collection (use `set` with a fragment)"
6218                .into(),
6219        )),
6220        Value::Tagged(t) => format_value_for_site(t.value(), ctx),
6221    }
6222}
6223
6224pub(super) fn format_number(n: &Number) -> String {
6225    // `Number`'s `Display` matches the YAML 1.2 plain representation
6226    // for the integer/float variants we emit here.
6227    n.to_string()
6228}
6229
6230fn format_string_for_site(s: &str, ctx: &SiteContext) -> Result<String> {
6231    // CR and the Unicode line separators survive only double-quoted,
6232    // escaped: a literal block normalises `\r` into its own line
6233    // breaks, and NEL/LS/PS pass through plain or single-quoted styles
6234    // as raw bytes that read back as line breaks (#335). The C1 block
6235    // and the non-characters U+FFFE/U+FFFF are outside `c-printable`
6236    // (§5.1), so they take the same route with a hex escape (#379).
6237    if s.chars().any(|c| {
6238        matches!(
6239            c,
6240            '\r' | '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
6241        ) || ('\u{7f}'..='\u{9f}').contains(&c)
6242            || (c < '\u{20}' && c != '\t' && c != '\n')
6243    }) {
6244        return Ok(format_double_quoted(s));
6245    }
6246
6247    if ctx.in_flow {
6248        return Ok(format_string_in_flow(s, ctx.kind));
6249    }
6250    // Multi-line string in a block context: prefer a literal block
6251    // scalar (`|` / `|-`) over `\n`-escaped double quotes — a
6252    // Renovate-style edit that lifts a one-line value into many
6253    // lines should look like the rest of the file would have, not
6254    // an escaped one-liner.
6255    if s.contains('\n') && can_use_block_literal(s) && is_block_site(ctx.kind) {
6256        return Ok(format_block_literal(s, ctx.entry_col));
6257    }
6258
6259    match ctx.kind {
6260        SyntaxKind::PlainScalar => {
6261            // Neighbour preference only kicks in when the current
6262            // site is plain — i.e. there is no existing quoting
6263            // intent to preserve. A surrounding mapping that
6264            // unambiguously prefers one quoted style nudges the new
6265            // value into the same style.
6266            match ctx.neighbour {
6267                Some(SyntaxKind::SingleQuotedScalar) if !s.contains('\n') => {
6268                    Ok(format_single_quoted(s))
6269                }
6270                Some(SyntaxKind::DoubleQuotedScalar) => Ok(format_double_quoted(s)),
6271                _ => {
6272                    if is_plain_safe(s) {
6273                        Ok(s.to_string())
6274                    } else {
6275                        Ok(format_double_quoted(s))
6276                    }
6277                }
6278            }
6279        }
6280        SyntaxKind::SingleQuotedScalar => Ok(format_single_quoted(s)),
6281        SyntaxKind::DoubleQuotedScalar => Ok(format_double_quoted(s)),
6282        SyntaxKind::LiteralScalar | SyntaxKind::FoldedScalar => {
6283            // Replacing a block scalar with a *single-line* string
6284            // is a legitimate edit (e.g. truncating a longer note
6285            // back to one line). Emit the natural plain/quoted
6286            // shape rather than a one-line block literal.
6287            if !s.contains('\n') {
6288                if is_plain_safe(s) {
6289                    Ok(s.to_string())
6290                } else {
6291                    Ok(format_double_quoted(s))
6292                }
6293            } else if can_use_block_literal(s) {
6294                Ok(format_block_literal(s, ctx.entry_col))
6295            } else {
6296                Err(Error::Parse(
6297                    "set_value: existing block scalar can only be replaced with a string \
6298                     whose content lines do not begin with whitespace or control characters yet"
6299                        .into(),
6300                ))
6301            }
6302        }
6303        _ => Err(Error::Parse(
6304            "set_value: target site is not a scalar leaf".into(),
6305        )),
6306    }
6307}
6308
6309/// Spell `s` for a leaf inside a flow collection. Block scalars do
6310/// not exist in flow context, so a multi-line value is double-quoted
6311/// with `\n` escapes; a one-line value keeps the leaf's quoting style,
6312/// and a plain leaf stays plain only when [`is_plain_safe_in_flow`]
6313/// says the flow indicators cannot misread it (#332).
6314fn format_string_in_flow(s: &str, kind: SyntaxKind) -> String {
6315    if s.contains('\n') {
6316        return format_double_quoted(s);
6317    }
6318    match kind {
6319        SyntaxKind::SingleQuotedScalar => format_single_quoted(s),
6320        SyntaxKind::DoubleQuotedScalar => format_double_quoted(s),
6321        _ => {
6322            if is_plain_safe_in_flow(s) {
6323                s.to_string()
6324            } else {
6325                format_double_quoted(s)
6326            }
6327        }
6328    }
6329}
6330
6331/// Whether the leaf at byte `target` has a `FlowMapping` or
6332/// `FlowSequence` ancestor. Block collections cannot nest inside flow
6333/// ones, so any flow ancestor means the leaf is in flow context.
6334fn in_flow_collection(node: &GreenNode, target: usize) -> bool {
6335    let mut pos = 0;
6336    for child in node.children() {
6337        let len = child.text_len();
6338        if pos <= target && target < pos + len {
6339            return match child {
6340                GreenChild::Token { .. } => false,
6341                GreenChild::Node(inner) => {
6342                    matches!(
6343                        inner.kind(),
6344                        SyntaxKind::FlowMapping | SyntaxKind::FlowSequence
6345                    ) || in_flow_collection(inner, target - pos)
6346                }
6347            };
6348        }
6349        pos += len;
6350    }
6351    false
6352}
6353
6354/// `true` when the existing leaf's syntax kind belongs to a
6355/// block-context scalar — block mappings/sequences are the only
6356/// place a literal `|` block scalar makes sense.
6357fn is_block_site(kind: SyntaxKind) -> bool {
6358    matches!(
6359        kind,
6360        SyntaxKind::PlainScalar
6361            | SyntaxKind::SingleQuotedScalar
6362            | SyntaxKind::DoubleQuotedScalar
6363            | SyntaxKind::LiteralScalar
6364            | SyntaxKind::FoldedScalar
6365    )
6366}
6367
6368/// Conservative check: a string is safely representable as a literal
6369/// block scalar only when none of its lines begin with a horizontal
6370/// whitespace character (which would require an explicit indent
6371/// indicator we do not yet emit), it contains no control characters
6372/// other than `\n`, and its trailing-newline count is zero or one
6373/// (matched by the `|-` and `|` chomping indicators respectively).
6374pub(super) fn can_use_block_literal(s: &str) -> bool {
6375    if s.is_empty() {
6376        return false;
6377    }
6378    // Reject control characters except `\n` and `\t` between content.
6379    for &b in s.as_bytes() {
6380        if (b < 0x20 && b != b'\n' && b != b'\t') || b == 0x7F {
6381            return false;
6382        }
6383    }
6384    // Strip up to one trailing newline; reject more than one.
6385    let trimmed = s.strip_suffix('\n').unwrap_or(s);
6386    if trimmed.ends_with('\n') {
6387        return false;
6388    }
6389    // A block literal needs at least one content line. `"\n"` strips to
6390    // nothing, which would emit a header with an empty body —
6391    //
6392    //     a: |
6393    //     b: 2
6394    //
6395    // and that does not parse. Double quoting carries it instead. Found
6396    // by the `set_value` round-trip property test.
6397    if trimmed.is_empty() {
6398        return false;
6399    }
6400    // No line may start with a space or tab — that requires an
6401    // explicit indentation indicator we do not emit yet.
6402    for line in trimmed.split('\n') {
6403        if line.starts_with(' ') || line.starts_with('\t') {
6404            return false;
6405        }
6406    }
6407    true
6408}
6409
6410/// Format `s` as a literal block scalar (`|` or `|-`) at
6411/// `entry_col + 2` indent.
6412pub(super) fn format_block_literal(s: &str, entry_col: usize) -> String {
6413    let trailing_nl = s.ends_with('\n');
6414    let body = if trailing_nl { &s[..s.len() - 1] } else { s };
6415    let indent_str = " ".repeat(entry_col + 2);
6416
6417    let mut out =
6418        String::with_capacity(s.len() + 8 + indent_str.len() * (body.matches('\n').count() + 1));
6419    out.push('|');
6420    if !trailing_nl {
6421        // Strip chomping indicator removes any trailing newlines, so
6422        // we can faithfully encode the no-trailing-newline case.
6423        out.push('-');
6424    }
6425    out.push('\n');
6426    let mut first = true;
6427    for line in body.split('\n') {
6428        if !first {
6429            out.push('\n');
6430        }
6431        first = false;
6432        out.push_str(&indent_str);
6433        out.push_str(line);
6434    }
6435    // `replace_span` pastes the fragment in place of the value
6436    // bytes only — the trailing line break that separates this
6437    // entry from the next is already in the surrounding source.
6438    out
6439}
6440
6441/// Compute the column (zero-based) of the first non-whitespace byte
6442/// on the line that contains `pos` in `source`. For
6443/// `  version: 0.0.1\n` with `pos` at the value scalar's start,
6444/// returns 2.
6445fn entry_indent_column(source: &str, pos: usize) -> usize {
6446    let bytes = source.as_bytes();
6447    let mut line_start = pos.min(bytes.len());
6448    while line_start > 0 && bytes[line_start - 1] != b'\n' {
6449        line_start -= 1;
6450    }
6451    let mut col = line_start;
6452    while col < bytes.len() && (bytes[col] == b' ' || bytes[col] == b'\t') {
6453        col += 1;
6454    }
6455    col - line_start
6456}
6457
6458/// [`is_plain_safe`] for a leaf inside a flow collection, where `,`
6459/// `[` `]` `{` `}` end or nest a collection wherever they appear in a
6460/// plain scalar (YAML 1.2 §7.3.3, `ns-plain-safe(c)`), not only at
6461/// the first byte. `m: {a: x, y}` written for the string `x, y` reads
6462/// back as two entries; `{a: x {y}}` does not parse at all (#332).
6463pub(super) fn is_plain_safe_in_flow(s: &str) -> bool {
6464    is_plain_safe(s) && !crate::ser::has_flow_indicator(s)
6465}
6466
6467/// `true` if `s` can be safely emitted as a YAML plain scalar without
6468/// being misparsed as a different type (bool, null, number) or
6469/// triggering a structural indicator. Conservative — when in doubt,
6470/// the caller falls back to a quoted style.
6471pub(super) fn is_plain_safe(s: &str) -> bool {
6472    if s.is_empty() {
6473        return false;
6474    }
6475    // NEL/LS/PS read back as line breaks; only double-quoted escapes
6476    // carry them (#335). A `\r` is caught by the control-byte loop.
6477    // The rest of the C1 block and the non-characters are outside
6478    // `c-printable` and need a double-quoted hex escape (#379).
6479    if s.chars().any(|c| {
6480        matches!(
6481            c,
6482            '\u{2028}' | '\u{2029}' | '\u{feff}' | '\u{fffe}' | '\u{ffff}'
6483        ) || ('\u{80}'..='\u{9f}').contains(&c)
6484    }) {
6485        return false;
6486    }
6487    // Reserved scalars that resolve to non-string types.
6488    if matches!(
6489        s,
6490        "null"
6491            | "Null"
6492            | "NULL"
6493            | "~"
6494            | "true"
6495            | "True"
6496            | "TRUE"
6497            | "false"
6498            | "False"
6499            | "FALSE"
6500            | "yes"
6501            | "Yes"
6502            | "YES"
6503            | "no"
6504            | "No"
6505            | "NO"
6506            | "on"
6507            | "On"
6508            | "ON"
6509            | "off"
6510            | "Off"
6511            | "OFF"
6512    ) {
6513        return false;
6514    }
6515    if looks_like_number(s) {
6516        return false;
6517    }
6518    // A scalar starting with `...` at column 0 reads as the
6519    // document-end marker (`-`-leading strings are caught by the
6520    // first-byte check below; `...` needs its own).
6521    if s.starts_with("...") {
6522        return false;
6523    }
6524    let bytes = s.as_bytes();
6525    // Cannot start with structural / flow / quote indicators.
6526    let first = bytes[0];
6527    if matches!(
6528        first,
6529        b'-' | b'?'
6530            | b':'
6531            | b','
6532            | b'['
6533            | b']'
6534            | b'{'
6535            | b'}'
6536            | b'#'
6537            | b'&'
6538            | b'*'
6539            | b'!'
6540            | b'|'
6541            | b'>'
6542            | b'\''
6543            | b'"'
6544            | b'%'
6545            | b'@'
6546            | b'`'
6547            | b' '
6548            | b'\t'
6549    ) {
6550        return false;
6551    }
6552    // Cannot end with whitespace.
6553    if matches!(*bytes.last().unwrap(), b' ' | b'\t') {
6554        return false;
6555    }
6556    // Cannot end with `:`. The loop below rejects `": "`, but a colon at
6557    // the very end has no following byte to catch it there — and in
6558    // block context a trailing colon reads as a mapping indicator, so
6559    //
6560    //     a: a:
6561    //
6562    // does not parse. Found by the `set_value` round-trip property
6563    // test, which proptest minimised to `"a:"`.
6564    if *bytes.last().unwrap() == b':' {
6565        return false;
6566    }
6567    // Disallow line breaks and control characters; disallow `: ` and
6568    // ` #` which terminate plain scalars in block context.
6569    let mut prev: u8 = 0;
6570    for &b in bytes {
6571        if b < 0x20 || b == 0x7F {
6572            return false;
6573        }
6574        if b == b' ' && prev == b':' {
6575            return false;
6576        }
6577        if b == b'#' && prev == b' ' {
6578            return false;
6579        }
6580        prev = b;
6581    }
6582    true
6583}
6584
6585fn looks_like_number(s: &str) -> bool {
6586    // Leading sign or digit makes it a number candidate.
6587    let mut chars = s.chars();
6588    let first = match chars.next() {
6589        Some(c) => c,
6590        None => return false,
6591    };
6592    let candidate = matches!(first, '-' | '+' | '.') || first.is_ascii_digit();
6593    if !candidate {
6594        return false;
6595    }
6596    // Defer the actual parse to `Number`'s integer/float resolvers via
6597    // the streaming scalar resolver (which is the source of truth for
6598    // what the parser would treat as a number).
6599    let scalar = crate::streaming::resolve_plain_ext(s, false, false, false, false, false, false);
6600    match scalar {
6601        crate::streaming::Scalar::Int(_) | crate::streaming::Scalar::Float(_) => true,
6602        #[cfg(feature = "lossless-u64")]
6603        crate::streaming::Scalar::Uint(_) => true,
6604        _ => false,
6605    }
6606}
6607
6608pub(super) fn format_single_quoted(s: &str) -> String {
6609    // YAML 1.2 §7.3.3: single quote is the only escape — `''` for `'`.
6610    let mut out = String::with_capacity(s.len() + 2);
6611    out.push('\'');
6612    for ch in s.chars() {
6613        if ch == '\'' {
6614            out.push_str("''");
6615        } else {
6616            out.push(ch);
6617        }
6618    }
6619    out.push('\'');
6620    out
6621}
6622
6623pub(super) fn format_double_quoted(s: &str) -> String {
6624    // YAML 1.2 §5.7 + §7.3.2: standard JSON-like escapes plus the
6625    // YAML extras (`\0`, `\a`, `\v`, `\e`, `\N`, `\_`, `\L`, `\P`).
6626    // For Phase 2B we emit the JSON-compatible subset; the others
6627    // are unnecessary for round-tripping textual content and would
6628    // complicate the diff if we surface them.
6629    let mut out = String::with_capacity(s.len() + 2);
6630    out.push('"');
6631    for ch in s.chars() {
6632        match ch {
6633            '"' => out.push_str("\\\""),
6634            '\\' => out.push_str("\\\\"),
6635            '\n' => out.push_str("\\n"),
6636            '\r' => out.push_str("\\r"),
6637            '\t' => out.push_str("\\t"),
6638            '\u{0085}' => out.push_str("\\N"),
6639            '\u{2028}' => out.push_str("\\L"),
6640            '\u{2029}' => out.push_str("\\P"),
6641            // A raw BOM must never reach the output — the reader
6642            // rejects one inside a document (§5.2).
6643            '\u{feff}' => out.push_str("\\uFEFF"),
6644            '\x08' => out.push_str("\\b"),
6645            '\x0c' => out.push_str("\\f"),
6646            // C0, DEL, the C1 block (NEL is matched above) and the two
6647            // non-characters: none is in `c-printable` (§5.1), and
6648            // this crate's reader takes them raw while libyaml-based
6649            // tools refuse the document (#379).
6650            c if (c as u32) < 0x20
6651                || ('\u{7f}'..='\u{9f}').contains(&c)
6652                || matches!(c, '\u{fffe}' | '\u{ffff}') =>
6653            {
6654                let _ = write!(&mut out, "\\u{:04X}", c as u32);
6655            }
6656            c => out.push(c),
6657        }
6658    }
6659    out.push('"');
6660    out
6661}
6662#[cfg(test)]
6663mod respell_breaks_tests {
6664    //! `respell_breaks` re-spells a *replacement's* breaks;
6665    //! `indent_continuation_lines` re-spells an *insertion's*. They are
6666    //! the same operation, and the second at indent zero is the first.
6667    //!
6668    //! Pinned as an equivalence rather than implemented as one, so the
6669    //! two halves of #261's rule cannot drift on what re-spelling means
6670    //! while still reading as what each call site is doing.
6671
6672    use super::{document_break, indent_continuation_lines, respell_breaks};
6673
6674    #[test]
6675    fn it_is_indent_continuation_lines_at_column_zero() {
6676        for fragment in [
6677            "|-\n  one\n  two",
6678            "one",
6679            "",
6680            "\n",
6681            "a\n\nb",
6682            "|- # note\n  multi\n  line",
6683        ] {
6684            for nl in ["\n", "\r\n"] {
6685                assert_eq!(
6686                    respell_breaks(fragment, nl),
6687                    indent_continuation_lines(fragment, 0, nl),
6688                    "fragment {fragment:?} with break {nl:?}"
6689                );
6690            }
6691        }
6692    }
6693
6694    #[test]
6695    fn an_lf_document_leaves_the_fragment_alone() {
6696        assert_eq!(respell_breaks("a\nb", document_break("x: 1\n")), "a\nb");
6697    }
6698
6699    #[test]
6700    fn a_crlf_document_respells_every_break() {
6701        assert_eq!(
6702            respell_breaks("a\nb\nc", document_break("x: 1\r\n")),
6703            "a\r\nb\r\nc"
6704        );
6705    }
6706
6707    #[test]
6708    fn a_mixed_document_takes_the_default() {
6709        assert_eq!(
6710            respell_breaks("a\nb", document_break("x: 1\r\ny: 2\n")),
6711            "a\nb"
6712        );
6713    }
6714}
6715
6716#[cfg(test)]
6717mod absorb_emptied_line_tests {
6718    //! Direct unit coverage for @zoosky's #294 helper.
6719    //!
6720    //! The integration tests in `tests/cst_remove_wrapped_flow.rs` drive
6721    //! this through `remove`, which is the behaviour that matters. These
6722    //! pin the boundary decisions at the byte level, where an off-by-one
6723    //! is legible — the difference between reclaiming a line and eating
6724    //! the newline that ends the one above it.
6725
6726    use super::{absorb_emptied_line, end_of_line, flow_member_range, start_of_line};
6727
6728    /// Byte range of the first occurrence of `needle` in `hay`.
6729    fn span(hay: &str, needle: &str) -> (usize, usize) {
6730        let s = hay.find(needle).expect("needle present");
6731        (s, s + needle.len())
6732    }
6733
6734    #[test]
6735    fn widens_when_the_member_is_alone_on_its_line() {
6736        let src = "ports: [\n  80,\n  443,\n]\n";
6737        let (s, e) = span(src, "80,");
6738        let (ws, we) = absorb_emptied_line(src, s, e);
6739        assert_eq!(&src[ws..we], "  80,\n", "takes indentation and terminator");
6740    }
6741
6742    #[test]
6743    fn refuses_when_an_opening_indicator_shares_the_line() {
6744        let src = "ports: [80,\n  443,\n]\n";
6745        let (s, e) = span(src, "80,");
6746        assert_eq!(absorb_emptied_line(src, s, e), (s, e), "unchanged");
6747    }
6748
6749    #[test]
6750    fn refuses_when_a_sibling_shares_the_line() {
6751        let src = "ports: [\n  80, 443,\n]\n";
6752        let (s, e) = span(src, "80, ");
6753        assert_eq!(absorb_emptied_line(src, s, e), (s, e), "unchanged");
6754    }
6755
6756    #[test]
6757    fn refuses_when_a_comment_shares_the_line() {
6758        let src = "ports: [\n  80, # why\n  443,\n]\n";
6759        let (s, e) = span(src, "80,");
6760        assert_eq!(absorb_emptied_line(src, s, e), (s, e), "unchanged");
6761    }
6762
6763    #[test]
6764    fn refuses_when_a_closing_indicator_shares_the_line() {
6765        let src = "ports: [\n  80,\n  443]\n";
6766        let (s, e) = span(src, "443");
6767        assert_eq!(absorb_emptied_line(src, s, e), (s, e), "unchanged");
6768    }
6769
6770    #[test]
6771    fn refuses_at_end_of_input_with_no_terminator() {
6772        // Nothing to reclaim: widening here would report a range that
6773        // runs past the last byte of a line that never ended.
6774        let src = "  80";
6775        let (s, e) = span(src, "80");
6776        assert_eq!(absorb_emptied_line(src, s, e), (s, e), "unchanged");
6777    }
6778
6779    #[test]
6780    fn keeps_the_carriage_return_with_the_line() {
6781        let src = "ports: [\r\n  80,\r\n  443,\r\n]\r\n";
6782        let (s, e) = span(src, "80,");
6783        let (ws, we) = absorb_emptied_line(src, s, e);
6784        assert_eq!(&src[ws..we], "  80,\r\n", "CRLF travels with the line");
6785    }
6786
6787    #[test]
6788    fn a_zero_indent_member_still_widens() {
6789        let src = "[\n1,\n2,\n]\n";
6790        let (s, e) = span(src, "1,");
6791        let (ws, we) = absorb_emptied_line(src, s, e);
6792        assert_eq!(&src[ws..we], "1,\n");
6793    }
6794
6795    #[test]
6796    fn flow_member_range_composes_the_widening() {
6797        // The separator is taken first, then the line — the order matters,
6798        // because widening a range that stops short of the comma would
6799        // leave the comma stranded on the reclaimed line.
6800        let src = "ports: [\n  80,\n  443,\n]\n";
6801        let (s, e) = span(src, "80");
6802        let (rs, re) = flow_member_range(src, s, e);
6803        assert_eq!(&src[rs..re], "  80,\n");
6804    }
6805
6806    #[test]
6807    fn flow_member_range_is_unchanged_on_a_single_line() {
6808        let src = "cfg: {a: 1, b: 2}\n";
6809        let (s, e) = span(src, "a: 1");
6810        let (rs, re) = flow_member_range(src, s, e);
6811        assert_eq!(&src[rs..re], "a: 1, ", "member plus one separator, no line");
6812    }
6813
6814    #[test]
6815    fn line_helpers_agree_with_the_widened_range() {
6816        // Guards the assumption the helper is built on: the widened range
6817        // is exactly [start_of_line, end_of_line] of the member.
6818        let src = "ports: [\n  80,\n  443,\n]\n";
6819        let (s, e) = span(src, "80,");
6820        let (ws, we) = absorb_emptied_line(src, s, e);
6821        assert_eq!(ws, start_of_line(src, s));
6822        assert_eq!(we, end_of_line(src, e));
6823    }
6824}
6825
6826#[cfg(test)]
6827mod rollback_invariant_tests {
6828    //! What happens when an edit fails halfway.
6829    //!
6830    //! Every splicing mutator promises the same thing: if the edit
6831    //! cannot be completed, the document is left exactly as it was.
6832    //! That promise is kept by a rollback arm on each of the three
6833    //! fallible steps — the splice, the re-parse, and the comparison
6834    //! against the pre-edit value — and none of those arms can be
6835    //! reached from outside, because reaching one means the mutator's
6836    //! own logic produced something wrong.
6837    //!
6838    //! So the promise was never checked. These tests use the
6839    //! [`super::fault`] switches to fail each step on demand, and then
6840    //! assert the part that matters: not that an error came back, but
6841    //! that the document is byte-for-byte what it was before.
6842
6843    use super::fault;
6844    use super::{Document, parse_document};
6845    use crate::Value;
6846
6847    /// Carries an inline comment so the comment mutators have
6848    /// something to edit; without one they are documented no-ops and
6849    /// would never reach a splice at all.
6850    const DOC: &str = "a: 1  # beside a\nxs:\n  - p\n  - q\nm:\n  k: 1\n";
6851
6852    /// The mutators that consult `validate` before accepting an edit
6853    /// to [`DOC`]. Which ones do is a property of the *shape* being
6854    /// edited as much as the method: `remove` has a documented fast
6855    /// path for an entry that owns its line, and takes it here, so it
6856    /// is covered separately by
6857    /// [`remove_rolls_back_when_it_cannot_take_its_fast_path`]. The
6858    /// fragment-splicing mutators validate through `replace_span`
6859    /// directly instead.
6860    ///
6861    /// Listed rather than inferred so that a mutator quietly dropping
6862    /// its `validate` call fails this test instead of passing it.
6863    const VALIDATE_GUARDED: &[&str] = &[
6864        "rename_key",
6865        "swap_items",
6866        "move_item",
6867        "push_back_value",
6868        "insert_after_value",
6869        "insert_entry_value",
6870        "set_inline_comment",
6871        "set_leading_comment",
6872        "remove_inline_comment",
6873    ];
6874
6875    /// One mutator, pinned to a fixed path and argument.
6876    type Mutator = fn(&mut Document) -> crate::Result<()>;
6877
6878    /// Every mutator that edits through a splice, as a name and a call.
6879    fn mutators() -> Vec<(&'static str, Mutator)> {
6880        vec![
6881            ("set", |d| d.set("a", "2")),
6882            ("set_path", |d| d.set_path("a", &Value::from(2_i64))),
6883            ("set_path new key", |d| {
6884                d.set_path("fresh", &Value::from(1_i64))
6885            }),
6886            ("set_value", |d| d.set_value("a", &Value::from(2_i64))),
6887            ("remove", |d| d.remove("a")),
6888            ("rename_key", |d| d.rename_key("a", "z")),
6889            ("swap_items", |d| d.swap_items("xs", 0, 1)),
6890            ("move_item", |d| d.move_item("xs", 0, 1)),
6891            ("push_back", |d| d.push_back("xs", "r")),
6892            ("push_back_value", |d| {
6893                d.push_back_value("xs", &Value::from("r"))
6894            }),
6895            ("insert_after", |d| d.insert_after("xs[0]", "r")),
6896            ("insert_after_value", |d| {
6897                d.insert_after_value("xs[0]", &Value::from("r"))
6898            }),
6899            ("insert_entry", |d| d.insert_entry("m", "n", "2")),
6900            ("insert_entry_value", |d| {
6901                d.insert_entry_value("m", "n", &Value::from(2_i64))
6902            }),
6903            ("set_inline_comment", |d| d.set_inline_comment("a", "c")),
6904            ("set_leading_comment", |d| d.set_leading_comment("a", "c")),
6905            ("remove_inline_comment", |d| d.remove_inline_comment("a")),
6906        ]
6907    }
6908
6909    /// The control: with nothing injected, every one of them succeeds.
6910    /// Without this, a mutator that fails for an unrelated reason would
6911    /// make the two tests below pass while proving nothing.
6912    #[test]
6913    fn every_mutator_succeeds_when_nothing_is_injected() {
6914        for (name, op) in mutators() {
6915            let mut doc = parse_document(DOC).expect("fixture parses");
6916            op(&mut doc).unwrap_or_else(|e| {
6917                panic!("{name}: fails without any injected fault, so the rollback tests below measure nothing: {e}")
6918            });
6919        }
6920    }
6921
6922    #[test]
6923    fn a_failed_splice_leaves_the_document_byte_for_byte_unchanged() {
6924        let mut refused = 0;
6925        for (name, op) in mutators() {
6926            // Build (and warm) the document outside the injection
6927            // scope, so only the mutator's own splice is affected.
6928            let mut doc = parse_document(DOC).expect("fixture parses");
6929            doc.validate().expect("fixture validates");
6930
6931            let result = fault::while_splicing_fails(|| op(&mut doc));
6932
6933            // The invariant is not "it errors" — a mutator may decline
6934            // to splice at all. It is that a *reported failure* leaves
6935            // nothing behind.
6936            if result.is_err() {
6937                refused += 1;
6938                assert_eq!(
6939                    doc.to_string(),
6940                    DOC,
6941                    "{name}: the splice failed and the document was left edited"
6942                );
6943            }
6944        }
6945        assert!(
6946            refused >= 10,
6947            "only {refused} mutators reported a failed splice; the injection is              probably not reaching them, so this test proves nothing"
6948        );
6949    }
6950
6951    #[test]
6952    fn a_failed_revalidation_leaves_the_document_byte_for_byte_unchanged() {
6953        for (name, op) in mutators() {
6954            let mut doc = parse_document(DOC).expect("fixture parses");
6955            doc.validate().expect("fixture validates");
6956
6957            let result = fault::while_validation_fails(|| op(&mut doc));
6958
6959            if VALIDATE_GUARDED.contains(&name) {
6960                assert!(
6961                    result.is_err(),
6962                    "{name}: re-validation failed and the mutator reported success, \
6963                     so its guard is no longer consulting `validate`"
6964                );
6965            }
6966            if result.is_err() {
6967                assert_eq!(
6968                    doc.to_string(),
6969                    DOC,
6970                    "{name}: re-validation failed and the document was left edited"
6971                );
6972            }
6973        }
6974    }
6975
6976    /// `remove` skips the guard when the entry owns its line — deleting
6977    /// a whole line cannot disturb a neighbour. When it does *not* own
6978    /// its line, as inside a flow mapping, the guard arbitrates, and
6979    /// that is the path with the rollback on it.
6980    #[test]
6981    fn remove_rolls_back_when_it_cannot_take_its_fast_path() {
6982        const FLOW: &str = "f: {x: 1, y: 2}\ng: 3\n";
6983
6984        // Control: it succeeds, and only `f.x` goes.
6985        let mut doc = parse_document(FLOW).expect("parse");
6986        doc.remove("f.x").expect("removing a flow member");
6987        let out = doc.to_string();
6988        assert!(out.contains("y: 2"), "the sibling was removed too: {out}");
6989        assert!(
6990            out.contains("g: 3"),
6991            "the next entry was removed too: {out}"
6992        );
6993
6994        let mut doc = parse_document(FLOW).expect("parse");
6995        doc.validate().expect("fixture validates");
6996        let res = fault::while_validation_fails(|| doc.remove("f.x"));
6997        assert!(
6998            res.is_err(),
6999            "re-validation failed and `remove` reported success"
7000        );
7001        assert_eq!(doc.to_string(), FLOW, "`remove` left the document edited");
7002
7003        let mut doc = parse_document(FLOW).expect("parse");
7004        doc.validate().expect("fixture validates");
7005        let res = fault::while_splicing_fails(|| doc.remove("f.x"));
7006        assert!(
7007            res.is_err(),
7008            "the splice failed and `remove` reported success"
7009        );
7010        assert_eq!(doc.to_string(), FLOW, "`remove` left the document edited");
7011    }
7012
7013    /// After a rolled-back edit the document must still be *usable* —
7014    /// a snapshot restored with a stale cache would report the
7015    /// pre-rollback value and quietly corrupt the next edit.
7016    #[test]
7017    fn a_rolled_back_document_still_edits_correctly_afterwards() {
7018        for (name, op) in mutators() {
7019            let mut doc = parse_document(DOC).expect("fixture parses");
7020            doc.validate().expect("fixture validates");
7021
7022            let _ = fault::while_splicing_fails(|| op(&mut doc));
7023
7024            // The same mutator, now without the injected failure.
7025            op(&mut doc).unwrap_or_else(|e| {
7026                panic!("{name}: the document was unusable after a rollback: {e}")
7027            });
7028            let out = doc.to_string();
7029            let reparsed: Value = crate::from_str(&out).unwrap_or_else(|e| {
7030                panic!("{name}: post-rollback edit broke the document: {e}\n{out}")
7031            });
7032            assert_eq!(
7033                reparsed,
7034                *doc.as_value(),
7035                "{name}: the cached value disagrees with the source after a rollback"
7036            );
7037        }
7038    }
7039
7040    /// The third guard: the splice lands, the document re-parses, but it
7041    /// does not load back as the value the mutator promised. Unreachable
7042    /// from outside by construction — getting there means the mutator
7043    /// itself produced the wrong document — so only injection can prove
7044    /// that the rollback behind it works.
7045    #[test]
7046    fn a_rejected_oracle_leaves_the_document_byte_for_byte_unchanged() {
7047        let mut refused = 0;
7048        for (name, op) in mutators() {
7049            let mut doc = parse_document(DOC).expect("fixture parses");
7050            doc.validate().expect("fixture validates");
7051
7052            let result = fault::while_the_oracle_rejects(|| op(&mut doc));
7053
7054            if result.is_err() {
7055                refused += 1;
7056                assert_eq!(
7057                    doc.to_string(),
7058                    DOC,
7059                    "{name}: the oracle rejected the edit and the document was left edited"
7060                );
7061            }
7062        }
7063        // Seven of the table reach the oracle. The rest either take a
7064        // documented fast path that skips it (`remove` on an entry that
7065        // owns its line) or re-parse the source directly instead
7066        // (`set`'s direct parse guard). The floor is what stops this
7067        // test passing vacuously if the injection stops arriving.
7068        assert!(
7069            refused >= 7,
7070            "only {refused} mutators consulted the oracle; the injection is probably \
7071             not reaching them, so this test proves nothing"
7072        );
7073    }
7074
7075    /// And the document must still be usable afterwards — a snapshot put
7076    /// back with a stale cache would report the pre-rollback value.
7077    #[test]
7078    fn a_document_rolled_back_by_the_oracle_still_edits_correctly() {
7079        for (name, op) in mutators() {
7080            let mut doc = parse_document(DOC).expect("fixture parses");
7081            doc.validate().expect("fixture validates");
7082            if fault::while_the_oracle_rejects(|| op(&mut doc)).is_ok() {
7083                // This mutator does not consult the oracle, so there was
7084                // no rollback to recover from.
7085                continue;
7086            }
7087
7088            op(&mut doc)
7089                .unwrap_or_else(|e| panic!("{name}: unusable after an oracle rollback: {e}"));
7090            let out = doc.to_string();
7091            let reparsed: Value = crate::from_str(&out)
7092                .unwrap_or_else(|e| panic!("{name}: post-rollback edit broke the document: {e}"));
7093            assert_eq!(
7094                reparsed,
7095                *doc.as_value(),
7096                "{name}: the cached value disagrees with the source after an oracle rollback"
7097            );
7098        }
7099    }
7100
7101    /// The switches must not leak between tests: each helper clears its
7102    /// flag on the way out, including on a panic inside the closure.
7103    #[test]
7104    fn an_injected_failure_is_cleared_even_when_the_closure_panics() {
7105        let panicked = std::panic::catch_unwind(|| {
7106            fault::while_splicing_fails(|| panic!("deliberate"));
7107        });
7108        assert!(panicked.is_err(), "the closure was expected to panic");
7109        assert!(
7110            !fault::splice_should_fail(),
7111            "the splice switch stayed on after a panic, which would make every \
7112             later test in this thread fail for the wrong reason"
7113        );
7114
7115        let panicked = std::panic::catch_unwind(|| {
7116            fault::while_validation_fails(|| panic!("deliberate"));
7117        });
7118        assert!(panicked.is_err(), "the closure was expected to panic");
7119        assert!(
7120            !fault::validate_should_fail(),
7121            "the validate switch stayed on after a panic"
7122        );
7123
7124        let panicked = std::panic::catch_unwind(|| {
7125            fault::while_the_oracle_rejects(|| panic!("deliberate"));
7126        });
7127        assert!(panicked.is_err(), "the closure was expected to panic");
7128        assert!(
7129            !fault::oracle_should_mismatch(),
7130            "the oracle switch stayed on after a panic"
7131        );
7132    }
7133}