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}