Skip to main content

noyalib/cst/
anchor.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Anchor and alias management.
5//!
6//! YAML's `&name` (anchor) declares a node by name; `*name` (alias)
7//! references it. At parse time the loader resolves every alias to
8//! the value of the matching anchor, so the typed [`crate::Value`]
9//! tree contains independent copies — but in the *source*, the
10//! `&name` site and every `*name` site stay distinct. This module
11//! gives callers the visibility and primitives needed to manage
12//! both halves of that contract.
13//!
14//! # The propagation contract
15//!
16//! [`crate::cst::Document::set`] (and every other lossless mutation)
17//! edits the source. When the target byte range happens to be inside
18//! an anchored value (i.e. the bytes covered by `&name`'s decorated
19//! node), the change *propagates* to every `*name` site automatically
20//! on the next load — because aliases are pointers, and re-parsing
21//! the new source yields the new value at every site that referenced
22//! the anchor.
23//!
24//! Concretely:
25//!
26//! ```rust
27//! use noyalib::cst::parse_document;
28//!
29//! let src = "\
30//! defaults: &cfg
31//!   port: 8080
32//! server:
33//!   <<: *cfg
34//!   host: localhost
35//! ";
36//! let mut doc = parse_document(src).unwrap();
37//! doc.set("defaults.port", "9090").unwrap();
38//!
39//! // The source still has one `&cfg` and one `*cfg` — but the
40//! // anchored value is now 9090, so the alias resolves to 9090 too.
41//! let v = doc.as_value();
42//! assert_eq!(v["server"]["port"].as_i64(), Some(9090));
43//! ```
44//!
45//! # Breaking aliases
46//!
47//! Sometimes the user wants the *opposite* — independent copies.
48//! [`crate::cst::Document::materialise_alias_at`] replaces a `*name`
49//! token with the source text of its anchored value, leaving the
50//! result independent from any future edits to the anchor. The
51//! current scope handles scalar-valued anchors only; multi-line
52//! block-valued anchors return a clear "follow-up" error so callers
53//! know to fall back to a manual splice.
54//!
55//! [`crate::cst::Document::materialise_aliases_of`] is the bulk
56//! convenience: materialise every alias for one anchor in one call.
57//!
58//! # Discovery
59//!
60//! [`crate::cst::Document::anchors`] and
61//! [`crate::cst::Document::aliases`] enumerate every `&name` /
62//! `*name` lexeme in source order, returning the byte span of each
63//! mark and the name. [`crate::cst::Document::aliases_of`] filters
64//! aliases by anchor name — useful before deciding propagate vs
65//! break.
66
67use crate::cst::document::Document;
68use crate::cst::green::{GreenChild, GreenNode};
69use crate::cst::syntax::SyntaxKind;
70use crate::error::{Error, Result};
71use crate::prelude::*;
72
73/// An `&name` anchor declaration discovered in the document source.
74///
75/// # Examples
76///
77/// ```
78/// use noyalib::cst::parse_document;
79///
80/// let doc = parse_document("foo: &id1 1\nbar: 2\n").unwrap();
81/// let anchors = doc.anchors();
82/// assert_eq!(anchors.len(), 1);
83/// assert_eq!(anchors[0].name, "id1");
84/// ```
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct AnchorInfo {
87    /// The anchor name, without the leading `&`.
88    pub name: String,
89    /// Byte range of the `&name` lexeme itself in the document source.
90    pub mark_span: (usize, usize),
91}
92
93/// A `*name` alias reference discovered in the document source.
94///
95/// # Examples
96///
97/// ```
98/// use noyalib::cst::parse_document;
99///
100/// let doc = parse_document("foo: &id1 1\nbar: *id1\n").unwrap();
101/// let aliases = doc.aliases();
102/// assert_eq!(aliases.len(), 1);
103/// assert_eq!(aliases[0].name, "id1");
104/// ```
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct AliasInfo {
107    /// The alias name, without the leading `*`.
108    pub name: String,
109    /// Byte range of the `*name` lexeme itself in the document source.
110    pub mark_span: (usize, usize),
111}
112
113impl Document {
114    /// Every `&name` declaration in this document, in source order.
115    ///
116    /// # Examples
117    ///
118    /// ```
119    /// use noyalib::cst::parse_document;
120    ///
121    /// let doc = parse_document(
122    ///     "defaults: &cfg\n  port: 8080\nserver:\n  <<: *cfg\n",
123    /// ).unwrap();
124    /// let anchors = doc.anchors();
125    /// assert_eq!(anchors.len(), 1);
126    /// assert_eq!(anchors[0].name, "cfg");
127    /// ```
128    #[must_use]
129    pub fn anchors(&self) -> Vec<AnchorInfo> {
130        let mut out = Vec::new();
131        walk_marks(self.syntax(), self.source(), 0, |kind, span, name| {
132            if kind == SyntaxKind::AnchorMark {
133                out.push(AnchorInfo {
134                    name: name.to_owned(),
135                    mark_span: span,
136                });
137            }
138        });
139        out
140    }
141
142    /// Every `*name` reference in this document, in source order.
143    ///
144    /// # Examples
145    ///
146    /// ```
147    /// use noyalib::cst::parse_document;
148    ///
149    /// let doc = parse_document("a: &x 1\nb: *x\nc: *x\n").unwrap();
150    /// let aliases = doc.aliases();
151    /// assert_eq!(aliases.len(), 2);
152    /// ```
153    #[must_use]
154    pub fn aliases(&self) -> Vec<AliasInfo> {
155        let mut out = Vec::new();
156        walk_marks(self.syntax(), self.source(), 0, |kind, span, name| {
157            if kind == SyntaxKind::AliasMark {
158                out.push(AliasInfo {
159                    name: name.to_owned(),
160                    mark_span: span,
161                });
162            }
163        });
164        out
165    }
166
167    /// Aliases whose name matches `name`, in source order.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use noyalib::cst::parse_document;
173    ///
174    /// let doc = parse_document("a: &x 1\nb: &y 2\nc: *x\nd: *y\n").unwrap();
175    /// let xs = doc.aliases_of("x");
176    /// assert_eq!(xs.len(), 1);
177    /// assert_eq!(xs[0].name, "x");
178    /// ```
179    #[must_use]
180    pub fn aliases_of(&self, name: &str) -> Vec<AliasInfo> {
181        self.aliases()
182            .into_iter()
183            .filter(|a| a.name == name)
184            .collect()
185    }
186
187    /// Replace the `*name` alias whose mark begins at byte
188    /// `position` with the source text of the matching `&name`'s
189    /// scalar value.
190    ///
191    /// After the splice, the alias's site holds an independent copy
192    /// of the anchored scalar — subsequent edits to the anchored
193    /// value do not propagate to it.
194    ///
195    /// # Errors
196    ///
197    /// - `position` does not start an `*name` token.
198    /// - The named anchor is not declared earlier in source order.
199    /// - The anchored value is not a scalar (multi-line block
200    ///   collections require manual handling — read the anchor's
201    ///   span via [`Self::anchors`] and splice with
202    ///   [`Self::replace_span`]).
203    /// - The same parse-after-edit errors as
204    ///   [`Self::replace_span`].
205    ///
206    /// # Examples
207    ///
208    /// ```
209    /// use noyalib::cst::parse_document;
210    ///
211    /// let mut doc = parse_document("a: &x 7\nb: *x\n").unwrap();
212    /// let alias = doc.aliases()[0].clone();
213    /// doc.materialise_alias_at(alias.mark_span.0).unwrap();
214    /// assert!(!doc.to_string().contains("*x"));
215    /// assert!(doc.to_string().contains("b: 7"));
216    /// ```
217    pub fn materialise_alias_at(&mut self, position: usize) -> Result<()> {
218        let aliases = self.aliases();
219        let alias = aliases
220            .iter()
221            .find(|a| a.mark_span.0 == position)
222            .ok_or_else(|| {
223                Error::Parse(format!(
224                    "materialise_alias_at: no alias mark begins at byte {position}"
225                ))
226            })?
227            .clone();
228
229        // Find the matching anchor declared earlier in source order.
230        // YAML 1.2.2 §7.1 says aliases reference the *most recent*
231        // matching anchor — we resolve to the closest preceding one.
232        let anchor_value_text = self
233            .anchored_scalar_text(&alias.name, alias.mark_span.0)?
234            .to_owned();
235
236        self.replace_span(alias.mark_span.0, alias.mark_span.1, &anchor_value_text)
237    }
238
239    /// Materialise every alias whose name matches `name`. Returns
240    /// the count of aliases replaced.
241    ///
242    /// Aliases are processed in *reverse* source order so each
243    /// splice's offsets stay valid for later (earlier in source)
244    /// aliases.
245    ///
246    /// # Errors
247    ///
248    /// As [`Self::materialise_alias_at`]. The first failing alias
249    /// aborts the batch — already-materialised aliases stay
250    /// materialised, the rest are unchanged.
251    ///
252    /// # Examples
253    ///
254    /// ```
255    /// use noyalib::cst::parse_document;
256    ///
257    /// let mut doc = parse_document("a: &x 7\nb: *x\nc: *x\n").unwrap();
258    /// let n = doc.materialise_aliases_of("x").unwrap();
259    /// assert_eq!(n, 2);
260    /// assert!(!doc.to_string().contains('*'));
261    /// ```
262    pub fn materialise_aliases_of(&mut self, name: &str) -> Result<usize> {
263        let mut targets: Vec<usize> = self
264            .aliases_of(name)
265            .iter()
266            .map(|a| a.mark_span.0)
267            .collect();
268        targets.sort_unstable();
269        targets.reverse();
270        let total = targets.len();
271        for pos in targets {
272            self.materialise_alias_at(pos)?;
273        }
274        Ok(total)
275    }
276
277    /// Rename every `&old` anchor declaration and every `*old`
278    /// alias reference to `new` in one atomic pass. Returns the
279    /// total number of touched sites (anchors + aliases).
280    ///
281    /// Splices run in *reverse source order* so each successive
282    /// splice's offsets stay valid for earlier sites. The whole
283    /// rename is byte-faithful outside the touched marks —
284    /// comments, blank lines, and sibling formatting survive
285    /// verbatim.
286    ///
287    /// # Errors
288    ///
289    /// - `new` is empty or contains characters that would not be
290    ///   accepted as a YAML anchor name (any of the flow
291    ///   indicators `,[]{}` or whitespace per YAML 1.2 §6.9.2).
292    /// - `old` does not match any anchor or alias in the document
293    ///   (so the call is a no-op the user probably did not
294    ///   intend) — surfaced as an error rather than a silent
295    ///   zero-count.
296    /// - `new` already names a *different* anchor in the document
297    ///   (unless `new == old`): merging the two would make every
298    ///   `*new` alias resolve to the last declaration, silently
299    ///   changing the document's meaning, so the rename is refused.
300    /// - The same parse-after-edit errors as
301    ///   [`crate::cst::Document::replace_span`]. The rename is a
302    ///   single atomic splice over the whole document, so it is
303    ///   all-or-nothing: on any error the document is left
304    ///   byte-for-byte unchanged.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// use noyalib::cst::parse_document;
310    ///
311    /// let mut doc = parse_document(
312    ///     "defaults: &cfg\n  port: 8080\nservice:\n  <<: *cfg\nbackup: *cfg\n",
313    /// ).unwrap();
314    ///
315    /// // Rename `cfg` → `defaults`. The single `&cfg` declaration
316    /// // and both `*cfg` references are updated in one call.
317    /// let n = doc.rename_anchor("cfg", "defaults").unwrap();
318    /// assert_eq!(n, 3); // 1 anchor + 2 aliases
319    /// let out = doc.to_string();
320    /// assert!(!out.contains("&cfg"));
321    /// assert!(!out.contains("*cfg"));
322    /// assert!(out.contains("&defaults"));
323    /// assert!(out.contains("*defaults"));
324    /// ```
325    pub fn rename_anchor(&mut self, old: &str, new: &str) -> Result<usize> {
326        if !is_valid_anchor_name(new) {
327            return Err(Error::Parse(format!(
328                "rename_anchor: `{new}` is not a valid YAML anchor name \
329                 (must be non-empty and free of flow indicators / whitespace)"
330            )));
331        }
332
333        // Collect every site (anchor or alias) in source order.
334        let anchors = self.anchors();
335        // Refuse renaming onto a name already used by a *different*
336        // anchor. Two `&new` declarations would make every `*new`
337        // alias resolve to the last one (YAML 1.2.2 §7.1), silently
338        // changing what the document means — the opposite of a safe
339        // refactor. A no-op rename (`old == new`) is exempt.
340        if new != old && anchors.iter().any(|a| a.name == new) {
341            return Err(Error::Parse(format!(
342                "rename_anchor: `&{new}` already declares a different anchor; \
343                 renaming `{old}` onto it would change alias resolution"
344            )));
345        }
346        let aliases = self.aliases();
347        let mut sites: Vec<(char, (usize, usize))> = anchors
348            .iter()
349            .filter(|a| a.name == old)
350            .map(|a| ('&', a.mark_span))
351            .chain(
352                aliases
353                    .iter()
354                    .filter(|a| a.name == old)
355                    .map(|a| ('*', a.mark_span)),
356            )
357            .collect();
358        if sites.is_empty() {
359            return Err(Error::Parse(format!(
360                "rename_anchor: no `&{old}` declaration or `*{old}` reference \
361                 found in the document"
362            )));
363        }
364        sites.sort_unstable_by_key(|(_, span)| span.0);
365
366        // Build the new source by stitching together the original
367        // bytes between sites and the renamed marker text at each
368        // site. A single `replace_span` over the whole document
369        // commits the atomic edit — intermediate states that would
370        // otherwise have a mismatched anchor / alias name pair
371        // (and fail re-parse) are never observed.
372        let total = sites.len();
373        let original = self.source().to_owned();
374        let mut new_source = String::with_capacity(original.len());
375        let mut cursor = 0;
376        for (marker, (start, end)) in sites {
377            new_source.push_str(&original[cursor..start]);
378            new_source.push(marker);
379            new_source.push_str(new);
380            cursor = end;
381        }
382        new_source.push_str(&original[cursor..]);
383
384        self.replace_span(0, original.len(), &new_source)?;
385        Ok(total)
386    }
387
388    /// Look up `name` as the closest `&name` anchor declared at byte
389    /// position `<= before` in source order, and return the source
390    /// text of its decorated scalar value. Returns an error if the
391    /// name is unknown or the decorated value is not a scalar.
392    ///
393    /// The CST scanner sometimes emits scalar tokens whose text
394    /// includes the trailing line break (a plain scalar at the end
395    /// of a line is captured as `"7\n"` rather than `"7"`); we trim
396    /// trailing whitespace before classifying so a `7\n`-bearing
397    /// scalar is correctly recognised as scalar, not multi-line.
398    fn anchored_scalar_text(&self, name: &str, before: usize) -> Result<&str> {
399        let source = self.source();
400        let mut chosen: Option<(usize, usize)> = None;
401        walk_anchor_value_spans(
402            self.syntax(),
403            source,
404            0,
405            |anchor_name, mark_span, value_span| {
406                if anchor_name == name && mark_span.0 < before {
407                    // Last writer in source order wins — YAML 1.2.2 §7.1
408                    // resolves to the most recent matching anchor.
409                    chosen = Some(value_span);
410                }
411            },
412        );
413        let (vs, ve) = chosen.ok_or_else(|| {
414            Error::Parse(format!(
415                "materialise_alias_at: no `&{name}` anchor declared before byte {before}"
416            ))
417        })?;
418        let raw = &source[vs..ve];
419        let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
420        if trimmed.contains('\n') {
421            return Err(Error::Parse(format!(
422                "materialise_alias_at: anchor `&{name}` decorates a multi-line block value — \
423                 only scalar-valued anchors are materialisable in this scope. \
424                 Use `Document::anchors()` + `Document::replace_span()` for manual block splicing."
425            )));
426        }
427        if trimmed.is_empty() {
428            return Err(Error::Parse(format!(
429                "materialise_alias_at: anchor `&{name}` decorates an empty value"
430            )));
431        }
432        Ok(trimmed)
433    }
434}
435
436/// Callback signature for [`walk_marks`]: `(kind, mark_span, name)`.
437type MarkVisitor<'a> = dyn FnMut(SyntaxKind, (usize, usize), &str) + 'a;
438
439/// Walk every `&name` / `*name` token in `node`, calling `visit`
440/// with `(kind, mark_span, name)` for each.
441fn walk_marks(
442    node: &GreenNode,
443    source: &str,
444    base: usize,
445    mut visit: impl FnMut(SyntaxKind, (usize, usize), &str),
446) {
447    walk_marks_inner(node, source, base, &mut visit);
448}
449
450fn walk_marks_inner(node: &GreenNode, source: &str, base: usize, visit: &mut MarkVisitor<'_>) {
451    let mut pos = base;
452    for child in node.children() {
453        let len = child.text_len();
454        match child {
455            GreenChild::Token { kind, .. } => {
456                if matches!(kind, SyntaxKind::AnchorMark | SyntaxKind::AliasMark) {
457                    let span = (pos, pos + len);
458                    // Lexeme is `&name` or `*name` — name skips the
459                    // marker byte. Both `&` and `*` are single-byte
460                    // ASCII, so `pos + 1` is always a char boundary.
461                    let name = &source[pos + 1..pos + len];
462                    visit(*kind, span, name);
463                }
464            }
465            GreenChild::Node(inner) => walk_marks_inner(inner, source, pos, visit),
466        }
467        pos += len;
468    }
469}
470
471/// Callback signature for [`walk_anchor_value_spans`]:
472/// `(name, mark_span, value_span)`.
473type AnchorValueVisitor<'a> = dyn FnMut(&str, (usize, usize), (usize, usize)) + 'a;
474
475/// Walk every `&name` token and call `visit` with the anchor's name,
476/// the mark span, and the byte span of the decorated value (the
477/// first non-trivia, non-property sibling that follows the anchor in
478/// its parent node). For anchors at the end of their parent with no
479/// content sibling, the value span is collapsed to the mark's end
480/// byte (an empty slice) — handled as "not a scalar" by callers.
481fn walk_anchor_value_spans(
482    root: &GreenNode,
483    source: &str,
484    base: usize,
485    mut visit: impl FnMut(&str, (usize, usize), (usize, usize)),
486) {
487    walk_anchor_value_spans_inner(root, source, base, &mut visit);
488}
489
490fn walk_anchor_value_spans_inner(
491    node: &GreenNode,
492    source: &str,
493    base: usize,
494    visit: &mut AnchorValueVisitor<'_>,
495) {
496    let children: Vec<&GreenChild> = node.children().collect();
497    let mut pos = base;
498    let mut child_starts: Vec<usize> = Vec::with_capacity(children.len());
499    for c in &children {
500        child_starts.push(pos);
501        pos += c.text_len();
502    }
503
504    for (i, child) in children.iter().enumerate() {
505        let child_start = child_starts[i];
506        if let GreenChild::Token { kind, len } = child {
507            if *kind == SyntaxKind::AnchorMark {
508                let len_u = *len as usize;
509                let mark_span = (child_start, child_start + len_u);
510                let name = &source[child_start + 1..child_start + len_u];
511                let value_span = decorated_value_span(&children, &child_starts, i);
512                visit(name, mark_span, value_span);
513            }
514        }
515        if let GreenChild::Node(inner) = child {
516            walk_anchor_value_spans_inner(inner, source, child_start, visit);
517        }
518    }
519}
520
521/// Given a parent's `children` and their absolute starting byte
522/// positions, plus the index of an `AnchorMark` within them, return
523/// the byte span of the value the anchor decorates: the first
524/// non-trivia, non-property sibling that follows.
525fn decorated_value_span(
526    children: &[&GreenChild],
527    starts: &[usize],
528    anchor_idx: usize,
529) -> (usize, usize) {
530    let anchor_end = starts[anchor_idx] + children[anchor_idx].text_len();
531    for j in (anchor_idx + 1)..children.len() {
532        let kind = match children[j] {
533            GreenChild::Token { kind, .. } => Some(*kind),
534            GreenChild::Node(inner) => Some(inner.kind()),
535        };
536        let Some(kind) = kind else { continue };
537        if is_trivia_or_property(kind) {
538            continue;
539        }
540        let start = starts[j];
541        let len = children[j].text_len();
542        return (start, start + len);
543    }
544    // Anchor at end of parent with no content sibling — produce an
545    // empty span anchored at the mark's end. Callers that read
546    // `source[start..end]` will see "" and treat it as not-a-scalar.
547    (anchor_end, anchor_end)
548}
549
550/// `true` when `name` is a valid YAML anchor name per §6.9.2 —
551/// non-empty, no flow indicators (`,[]{}`), no whitespace.
552fn is_valid_anchor_name(name: &str) -> bool {
553    if name.is_empty() {
554        return false;
555    }
556    name.bytes().all(|b| {
557        !matches!(
558            b,
559            b',' | b'[' | b']' | b'{' | b'}' | b' ' | b'\t' | b'\r' | b'\n'
560        )
561    })
562}
563
564fn is_trivia_or_property(kind: SyntaxKind) -> bool {
565    matches!(
566        kind,
567        SyntaxKind::Whitespace
568            | SyntaxKind::Newline
569            | SyntaxKind::Comment
570            | SyntaxKind::Bom
571            | SyntaxKind::Directive
572            | SyntaxKind::TagMark
573            | SyntaxKind::AnchorMark
574    )
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::cst::parse_document;
581
582    #[test]
583    fn anchors_listed_in_source_order() {
584        let src = "a: &one 1\nb: &two 2\nc: 3\n";
585        let doc = parse_document(src).unwrap();
586        let anchors = doc.anchors();
587        assert_eq!(anchors.len(), 2);
588        assert_eq!(anchors[0].name, "one");
589        assert_eq!(anchors[1].name, "two");
590        // Mark spans must point at the `&name` lexeme.
591        let (s, e) = anchors[0].mark_span;
592        assert_eq!(&src[s..e], "&one");
593    }
594
595    #[test]
596    fn aliases_listed_in_source_order() {
597        let src = "a: &one 1\nb: *one\nc: *one\n";
598        let doc = parse_document(src).unwrap();
599        let aliases = doc.aliases();
600        assert_eq!(aliases.len(), 2);
601        assert_eq!(aliases[0].name, "one");
602        assert_eq!(aliases[1].name, "one");
603        let (s, e) = aliases[0].mark_span;
604        assert_eq!(&src[s..e], "*one");
605    }
606
607    #[test]
608    fn aliases_of_filters_by_name() {
609        let src = "a: &x 1\nb: &y 2\nc: *x\nd: *y\ne: *x\n";
610        let doc = parse_document(src).unwrap();
611        assert_eq!(doc.aliases_of("x").len(), 2);
612        assert_eq!(doc.aliases_of("y").len(), 1);
613        assert_eq!(doc.aliases_of("missing").len(), 0);
614    }
615
616    #[test]
617    fn no_anchors_no_aliases() {
618        let doc = parse_document("a: 1\nb: 2\n").unwrap();
619        assert!(doc.anchors().is_empty());
620        assert!(doc.aliases().is_empty());
621    }
622
623    #[test]
624    fn anchor_on_block_value_is_visible() {
625        let src = "defaults: &cfg\n  port: 8080\n  host: db1\n";
626        let doc = parse_document(src).unwrap();
627        let anchors = doc.anchors();
628        assert_eq!(anchors.len(), 1);
629        assert_eq!(anchors[0].name, "cfg");
630    }
631
632    #[test]
633    fn materialise_replaces_alias_with_anchor_text() {
634        let src = "a: &x 7\nb: *x\n";
635        let mut doc = parse_document(src).unwrap();
636        let pos = doc.aliases()[0].mark_span.0;
637        doc.materialise_alias_at(pos).unwrap();
638        let out = doc.to_string();
639        assert_eq!(out, "a: &x 7\nb: 7\n");
640        assert!(doc.aliases().is_empty(), "alias must be gone, got: {out}");
641    }
642
643    #[test]
644    fn materialise_with_quoted_scalar() {
645        let src = "a: &x \"hello world\"\nb: *x\n";
646        let mut doc = parse_document(src).unwrap();
647        let pos = doc.aliases()[0].mark_span.0;
648        doc.materialise_alias_at(pos).unwrap();
649        assert_eq!(
650            doc.to_string(),
651            "a: &x \"hello world\"\nb: \"hello world\"\n"
652        );
653    }
654
655    #[test]
656    fn materialise_aliases_of_handles_multiple_in_one_call() {
657        let src = "a: &x 7\nb: *x\nc: *x\nd: *x\n";
658        let mut doc = parse_document(src).unwrap();
659        let n = doc.materialise_aliases_of("x").unwrap();
660        assert_eq!(n, 3);
661        assert!(doc.aliases().is_empty());
662        assert_eq!(doc.anchors().len(), 1);
663    }
664
665    #[test]
666    fn materialise_block_anchor_errors_with_actionable_message() {
667        let src = "defaults: &cfg\n  port: 8080\n  host: db1\nserver: *cfg\n";
668        let mut doc = parse_document(src).unwrap();
669        let pos = doc.aliases()[0].mark_span.0;
670        let err = doc.materialise_alias_at(pos).unwrap_err();
671        let msg = err.to_string();
672        assert!(
673            msg.contains("multi-line") && msg.contains("scalar-valued"),
674            "error must point at the limitation, got: {msg}"
675        );
676        // Document is unchanged.
677        assert_eq!(doc.to_string(), src);
678    }
679
680    #[test]
681    fn materialise_unknown_position_errors() {
682        let mut doc = parse_document("a: &x 7\nb: *x\n").unwrap();
683        let err = doc.materialise_alias_at(0).unwrap_err();
684        assert!(err.to_string().contains("no alias mark begins at byte 0"));
685    }
686
687    #[test]
688    fn edits_to_anchored_value_propagate_to_aliases_on_reload() {
689        // The propagation contract documented in this module's
690        // rustdoc — set() on the anchor's value updates every alias
691        // site automatically because aliases are pointers.
692        let src = "\
693defaults: &cfg
694  port: 8080
695server:
696  <<: *cfg
697  host: localhost
698";
699        let mut doc = parse_document(src).unwrap();
700        doc.set("defaults.port", "9090").unwrap();
701        let v = doc.as_value();
702        // The merge-key alias resolved to the new anchored value.
703        assert_eq!(v["server"]["port"].as_i64(), Some(9090));
704        assert_eq!(v["defaults"]["port"].as_i64(), Some(9090));
705        // Source still has exactly one anchor and one alias.
706        assert_eq!(doc.anchors().len(), 1);
707        assert_eq!(doc.aliases().len(), 1);
708    }
709}