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}