use cyrs_syntax::{
SyntaxKind, SyntaxNode, TextEdit, TextRange, TextSize, incremental_reparse, parse,
};
fn shape(node: &SyntaxNode) -> Vec<SyntaxKind> {
node.preorder_with_tokens()
.filter_map(|ev| match ev {
rowan::WalkEvent::Enter(el) => {
let k = match &el {
rowan::NodeOrToken::Node(n) => n.kind(),
rowan::NodeOrToken::Token(t) => t.kind(),
};
if k.is_trivia() { None } else { Some(k) }
}
rowan::WalkEvent::Leave(_) => None,
})
.collect()
}
fn tokens(node: &SyntaxNode) -> Vec<(SyntaxKind, String)> {
node.preorder_with_tokens()
.filter_map(|ev| match ev {
rowan::WalkEvent::Enter(rowan::NodeOrToken::Token(t)) if !t.kind().is_trivia() => {
Some((t.kind(), t.text().to_string()))
}
_ => None,
})
.collect()
}
#[test]
fn edit_preserves_tree_shape() {
let src = r#"MATCH (n) WHERE n.name = "alice" RETURN n"#;
let p = parse(src);
assert!(p.errors().is_empty(), "fixture must parse clean");
let before = shape(&p.syntax());
let quote_start = src.find('"').expect("fixture has a string literal");
let a_offset = u32::try_from(quote_start + 1).expect("fixture fits in u32");
let range = TextRange::new(TextSize::new(a_offset), TextSize::new(a_offset + 1));
let edit = TextEdit::replace(range, "A");
let np = incremental_reparse(&p.syntax(), &edit);
assert!(np.errors().is_empty(), "edited tree must still parse clean");
let after = shape(&np.syntax());
assert_eq!(
before, after,
"structural shape must be unchanged by an edit confined to a string literal"
);
assert_eq!(
np.syntax().to_string(),
r#"MATCH (n) WHERE n.name = "Alice" RETURN n"#
);
}
#[test]
fn edit_across_clause_boundary() {
let src = "MATCH (n) RETURN n";
let p = parse(src);
assert!(p.errors().is_empty(), "fixture must parse clean");
assert!(
shape(&p.syntax()).contains(&SyntaxKind::MATCH_CLAUSE),
"sanity: fixture has MATCH_CLAUSE"
);
let range = TextRange::new(TextSize::new(0), TextSize::new(5));
let edit = TextEdit::replace(range, "OPTIONAL MATCH");
let np = incremental_reparse(&p.syntax(), &edit);
assert!(
np.errors().is_empty(),
"edited tree must parse clean; errors = {:?}",
np.errors()
);
let after = shape(&np.syntax());
assert!(
after.contains(&SyntaxKind::OPTIONAL_MATCH_CLAUSE),
"edited tree must contain OPTIONAL_MATCH_CLAUSE; shape = {after:?}"
);
assert!(
!after.contains(&SyntaxKind::MATCH_CLAUSE),
"edited tree must not still contain a plain MATCH_CLAUSE"
);
assert_eq!(np.syntax().to_string(), "OPTIONAL MATCH (n) RETURN n");
}
#[test]
fn edit_noop_rename_ident() {
let src = "MATCH (n) RETURN n";
let p = parse(src);
assert!(p.errors().is_empty(), "fixture must parse clean");
let before_shape = shape(&p.syntax());
let before_tokens = tokens(&p.syntax());
let range = TextRange::new(TextSize::new(7), TextSize::new(8));
let edit1 = TextEdit::replace(range, "m");
let p1 = incremental_reparse(&p.syntax(), &edit1);
let range2 = TextRange::new(TextSize::new(17), TextSize::new(18));
let edit2 = TextEdit::replace(range2, "m");
let p2 = incremental_reparse(&p1.syntax(), &edit2);
assert!(p2.errors().is_empty(), "renamed tree must parse clean");
assert_eq!(p2.syntax().to_string(), "MATCH (m) RETURN m");
let after_shape = shape(&p2.syntax());
let after_tokens = tokens(&p2.syntax());
assert_eq!(
before_shape, after_shape,
"ident rename must not change tree shape"
);
assert_eq!(before_tokens.len(), after_tokens.len());
for (i, ((bk, bt), (ak, at))) in before_tokens.iter().zip(after_tokens.iter()).enumerate() {
assert_eq!(bk, ak, "token {i} kind must be unchanged");
if *bk == SyntaxKind::IDENT && bt == "n" {
assert_eq!(at, "m", "token {i} renamed n → m");
} else {
assert_eq!(bt, at, "token {i} text unchanged");
}
}
}