use crate::cst::document::Document;
use crate::cst::green::{GreenChild, GreenNode};
use crate::cst::syntax::SyntaxKind;
use crate::error::{Error, Result};
use crate::prelude::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnchorInfo {
pub name: String,
pub mark_span: (usize, usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AliasInfo {
pub name: String,
pub mark_span: (usize, usize),
}
impl Document {
#[must_use]
pub fn anchors(&self) -> Vec<AnchorInfo> {
let mut out = Vec::new();
walk_marks(self.syntax(), self.source(), 0, |kind, span, name| {
if kind == SyntaxKind::AnchorMark {
out.push(AnchorInfo {
name: name.to_owned(),
mark_span: span,
});
}
});
out
}
#[must_use]
pub fn aliases(&self) -> Vec<AliasInfo> {
let mut out = Vec::new();
walk_marks(self.syntax(), self.source(), 0, |kind, span, name| {
if kind == SyntaxKind::AliasMark {
out.push(AliasInfo {
name: name.to_owned(),
mark_span: span,
});
}
});
out
}
#[must_use]
pub fn aliases_of(&self, name: &str) -> Vec<AliasInfo> {
self.aliases()
.into_iter()
.filter(|a| a.name == name)
.collect()
}
pub fn materialise_alias_at(&mut self, position: usize) -> Result<()> {
let aliases = self.aliases();
let alias = aliases
.iter()
.find(|a| a.mark_span.0 == position)
.ok_or_else(|| {
Error::Parse(format!(
"materialise_alias_at: no alias mark begins at byte {position}"
))
})?
.clone();
let anchor_value_text = self
.anchored_scalar_text(&alias.name, alias.mark_span.0)?
.to_owned();
self.replace_span(alias.mark_span.0, alias.mark_span.1, &anchor_value_text)
}
pub fn materialise_aliases_of(&mut self, name: &str) -> Result<usize> {
let mut targets: Vec<usize> = self
.aliases_of(name)
.iter()
.map(|a| a.mark_span.0)
.collect();
targets.sort_unstable();
targets.reverse();
let total = targets.len();
for pos in targets {
self.materialise_alias_at(pos)?;
}
Ok(total)
}
pub fn rename_anchor(&mut self, old: &str, new: &str) -> Result<usize> {
if !is_valid_anchor_name(new) {
return Err(Error::Parse(format!(
"rename_anchor: `{new}` is not a valid YAML anchor name \
(must be non-empty and free of flow indicators / whitespace)"
)));
}
let anchors = self.anchors();
let aliases = self.aliases();
let mut sites: Vec<(char, (usize, usize))> = anchors
.iter()
.filter(|a| a.name == old)
.map(|a| ('&', a.mark_span))
.chain(
aliases
.iter()
.filter(|a| a.name == old)
.map(|a| ('*', a.mark_span)),
)
.collect();
if sites.is_empty() {
return Err(Error::Parse(format!(
"rename_anchor: no `&{old}` declaration or `*{old}` reference \
found in the document"
)));
}
sites.sort_unstable_by_key(|(_, span)| span.0);
let total = sites.len();
let original = self.source().to_owned();
let mut new_source = String::with_capacity(original.len());
let mut cursor = 0;
for (marker, (start, end)) in sites {
new_source.push_str(&original[cursor..start]);
new_source.push(marker);
new_source.push_str(new);
cursor = end;
}
new_source.push_str(&original[cursor..]);
self.replace_span(0, original.len(), &new_source)?;
Ok(total)
}
fn anchored_scalar_text(&self, name: &str, before: usize) -> Result<&str> {
let source = self.source();
let mut chosen: Option<(usize, usize)> = None;
walk_anchor_value_spans(
self.syntax(),
source,
0,
|anchor_name, mark_span, value_span| {
if anchor_name == name && mark_span.0 < before {
chosen = Some(value_span);
}
},
);
let (vs, ve) = chosen.ok_or_else(|| {
Error::Parse(format!(
"materialise_alias_at: no `&{name}` anchor declared before byte {before}"
))
})?;
let raw = &source[vs..ve];
let trimmed = raw.trim_end_matches(['\n', '\r', ' ', '\t']);
if trimmed.contains('\n') {
return Err(Error::Parse(format!(
"materialise_alias_at: anchor `&{name}` decorates a multi-line block value — \
only scalar-valued anchors are materialisable in this scope. \
Use `Document::anchors()` + `Document::replace_span()` for manual block splicing."
)));
}
if trimmed.is_empty() {
return Err(Error::Parse(format!(
"materialise_alias_at: anchor `&{name}` decorates an empty value"
)));
}
Ok(trimmed)
}
}
type MarkVisitor<'a> = dyn FnMut(SyntaxKind, (usize, usize), &str) + 'a;
fn walk_marks(
node: &GreenNode,
source: &str,
base: usize,
mut visit: impl FnMut(SyntaxKind, (usize, usize), &str),
) {
walk_marks_inner(node, source, base, &mut visit);
}
fn walk_marks_inner(node: &GreenNode, source: &str, base: usize, visit: &mut MarkVisitor<'_>) {
let mut pos = base;
for child in node.children() {
let len = child.text_len();
match child {
GreenChild::Token { kind, .. } => {
if matches!(kind, SyntaxKind::AnchorMark | SyntaxKind::AliasMark) {
let span = (pos, pos + len);
let name = &source[pos + 1..pos + len];
visit(*kind, span, name);
}
}
GreenChild::Node(inner) => walk_marks_inner(inner, source, pos, visit),
}
pos += len;
}
}
type AnchorValueVisitor<'a> = dyn FnMut(&str, (usize, usize), (usize, usize)) + 'a;
fn walk_anchor_value_spans(
root: &GreenNode,
source: &str,
base: usize,
mut visit: impl FnMut(&str, (usize, usize), (usize, usize)),
) {
walk_anchor_value_spans_inner(root, source, base, &mut visit);
}
fn walk_anchor_value_spans_inner(
node: &GreenNode,
source: &str,
base: usize,
visit: &mut AnchorValueVisitor<'_>,
) {
let children: Vec<&GreenChild> = node.children().collect();
let mut pos = base;
let mut child_starts: Vec<usize> = Vec::with_capacity(children.len());
for c in &children {
child_starts.push(pos);
pos += c.text_len();
}
for (i, child) in children.iter().enumerate() {
let child_start = child_starts[i];
if let GreenChild::Token { kind, len } = child {
if *kind == SyntaxKind::AnchorMark {
let len_u = *len as usize;
let mark_span = (child_start, child_start + len_u);
let name = &source[child_start + 1..child_start + len_u];
let value_span = decorated_value_span(&children, &child_starts, i);
visit(name, mark_span, value_span);
}
}
if let GreenChild::Node(inner) = child {
walk_anchor_value_spans_inner(inner, source, child_start, visit);
}
}
}
fn decorated_value_span(
children: &[&GreenChild],
starts: &[usize],
anchor_idx: usize,
) -> (usize, usize) {
let anchor_end = starts[anchor_idx] + children[anchor_idx].text_len();
for j in (anchor_idx + 1)..children.len() {
let kind = match children[j] {
GreenChild::Token { kind, .. } => Some(*kind),
GreenChild::Node(inner) => Some(inner.kind()),
};
let Some(kind) = kind else { continue };
if is_trivia_or_property(kind) {
continue;
}
let start = starts[j];
let len = children[j].text_len();
return (start, start + len);
}
(anchor_end, anchor_end)
}
fn is_valid_anchor_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
name.bytes().all(|b| {
!matches!(
b,
b',' | b'[' | b']' | b'{' | b'}' | b' ' | b'\t' | b'\r' | b'\n'
)
})
}
fn is_trivia_or_property(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::Whitespace
| SyntaxKind::Newline
| SyntaxKind::Comment
| SyntaxKind::Bom
| SyntaxKind::Directive
| SyntaxKind::TagMark
| SyntaxKind::AnchorMark
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cst::parse_document;
#[test]
fn anchors_listed_in_source_order() {
let src = "a: &one 1\nb: &two 2\nc: 3\n";
let doc = parse_document(src).unwrap();
let anchors = doc.anchors();
assert_eq!(anchors.len(), 2);
assert_eq!(anchors[0].name, "one");
assert_eq!(anchors[1].name, "two");
let (s, e) = anchors[0].mark_span;
assert_eq!(&src[s..e], "&one");
}
#[test]
fn aliases_listed_in_source_order() {
let src = "a: &one 1\nb: *one\nc: *one\n";
let doc = parse_document(src).unwrap();
let aliases = doc.aliases();
assert_eq!(aliases.len(), 2);
assert_eq!(aliases[0].name, "one");
assert_eq!(aliases[1].name, "one");
let (s, e) = aliases[0].mark_span;
assert_eq!(&src[s..e], "*one");
}
#[test]
fn aliases_of_filters_by_name() {
let src = "a: &x 1\nb: &y 2\nc: *x\nd: *y\ne: *x\n";
let doc = parse_document(src).unwrap();
assert_eq!(doc.aliases_of("x").len(), 2);
assert_eq!(doc.aliases_of("y").len(), 1);
assert_eq!(doc.aliases_of("missing").len(), 0);
}
#[test]
fn no_anchors_no_aliases() {
let doc = parse_document("a: 1\nb: 2\n").unwrap();
assert!(doc.anchors().is_empty());
assert!(doc.aliases().is_empty());
}
#[test]
fn anchor_on_block_value_is_visible() {
let src = "defaults: &cfg\n port: 8080\n host: db1\n";
let doc = parse_document(src).unwrap();
let anchors = doc.anchors();
assert_eq!(anchors.len(), 1);
assert_eq!(anchors[0].name, "cfg");
}
#[test]
fn materialise_replaces_alias_with_anchor_text() {
let src = "a: &x 7\nb: *x\n";
let mut doc = parse_document(src).unwrap();
let pos = doc.aliases()[0].mark_span.0;
doc.materialise_alias_at(pos).unwrap();
let out = doc.to_string();
assert_eq!(out, "a: &x 7\nb: 7\n");
assert!(doc.aliases().is_empty(), "alias must be gone, got: {out}");
}
#[test]
fn materialise_with_quoted_scalar() {
let src = "a: &x \"hello world\"\nb: *x\n";
let mut doc = parse_document(src).unwrap();
let pos = doc.aliases()[0].mark_span.0;
doc.materialise_alias_at(pos).unwrap();
assert_eq!(
doc.to_string(),
"a: &x \"hello world\"\nb: \"hello world\"\n"
);
}
#[test]
fn materialise_aliases_of_handles_multiple_in_one_call() {
let src = "a: &x 7\nb: *x\nc: *x\nd: *x\n";
let mut doc = parse_document(src).unwrap();
let n = doc.materialise_aliases_of("x").unwrap();
assert_eq!(n, 3);
assert!(doc.aliases().is_empty());
assert_eq!(doc.anchors().len(), 1);
}
#[test]
fn materialise_block_anchor_errors_with_actionable_message() {
let src = "defaults: &cfg\n port: 8080\n host: db1\nserver: *cfg\n";
let mut doc = parse_document(src).unwrap();
let pos = doc.aliases()[0].mark_span.0;
let err = doc.materialise_alias_at(pos).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("multi-line") && msg.contains("scalar-valued"),
"error must point at the limitation, got: {msg}"
);
assert_eq!(doc.to_string(), src);
}
#[test]
fn materialise_unknown_position_errors() {
let mut doc = parse_document("a: &x 7\nb: *x\n").unwrap();
let err = doc.materialise_alias_at(0).unwrap_err();
assert!(err.to_string().contains("no alias mark begins at byte 0"));
}
#[test]
fn edits_to_anchored_value_propagate_to_aliases_on_reload() {
let src = "\
defaults: &cfg
port: 8080
server:
<<: *cfg
host: localhost
";
let mut doc = parse_document(src).unwrap();
doc.set("defaults.port", "9090").unwrap();
let v = doc.as_value();
assert_eq!(v["server"]["port"].as_i64(), Some(9090));
assert_eq!(v["defaults"]["port"].as_i64(), Some(9090));
assert_eq!(doc.anchors().len(), 1);
assert_eq!(doc.aliases().len(), 1);
}
}