use crate::as_yaml::YamlNode;
use crate::lex::SyntaxKind;
use crate::nodes::has_child_token;
use crate::yaml::{Document, Mapping, Scalar, Sequence, SyntaxNode};
use std::fmt;
pub fn print_tree(node: &SyntaxNode) {
print_tree_indent(node, 0);
}
pub fn print_tree_indent(node: &SyntaxNode, indent: usize) {
let mut out = String::new();
tree_to_string_indent(node, indent, &mut out, false);
print!("{out}");
}
pub fn tree_to_string(node: &SyntaxNode) -> String {
let mut result = String::new();
tree_to_string_indent(node, 0, &mut result, false);
result
}
pub fn shape_to_string(node: &SyntaxNode) -> String {
let mut result = String::new();
tree_to_string_indent(node, 0, &mut result, true);
result
}
fn is_pending_block_marker(token: &rowan::SyntaxToken<crate::yaml::Lang>) -> bool {
token.kind() == SyntaxKind::WHITESPACE
&& token.text().is_empty()
&& token
.parent()
.is_some_and(|p| matches!(p.kind(), SyntaxKind::MAPPING | SyntaxKind::SEQUENCE))
}
fn tree_to_string_indent(node: &SyntaxNode, indent: usize, result: &mut String, shape: bool) {
let prefix = " ".repeat(indent);
for child in node.children_with_tokens() {
match child {
rowan::NodeOrToken::Node(n) => {
result.push_str(&format!("{}{:?}\n", prefix, n.kind()));
tree_to_string_indent(&n, indent + 1, result, shape);
}
rowan::NodeOrToken::Token(t) if shape && is_pending_block_marker(&t) => {}
rowan::NodeOrToken::Token(t) => {
let text = t.text().replace('\n', "\\n").replace('\r', "\\r");
result.push_str(&format!("{}{:?}: {:?}\n", prefix, t.kind(), text));
}
}
}
}
pub fn print_stats(node: &SyntaxNode) {
let mut node_counts = std::collections::HashMap::new();
let mut token_counts = std::collections::HashMap::new();
count_nodes(node, &mut node_counts, &mut token_counts);
println!("=== Node Counts ===");
let mut node_vec: Vec<_> = node_counts.iter().collect();
node_vec.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
for (kind, count) in node_vec {
println!(" {kind:?}: {count}");
}
println!("\n=== Token Counts ===");
let mut token_vec: Vec<_> = token_counts.iter().collect();
token_vec.sort_by_key(|(_, count)| std::cmp::Reverse(**count));
for (kind, count) in token_vec {
println!(" {kind:?}: {count}");
}
}
fn count_nodes(
node: &SyntaxNode,
node_counts: &mut std::collections::HashMap<SyntaxKind, usize>,
token_counts: &mut std::collections::HashMap<SyntaxKind, usize>,
) {
*node_counts.entry(node.kind()).or_insert(0) += 1;
for child in node.children_with_tokens() {
match child {
rowan::NodeOrToken::Node(n) => {
count_nodes(&n, node_counts, token_counts);
}
rowan::NodeOrToken::Token(t) => {
*token_counts.entry(t.kind()).or_insert(0) += 1;
}
}
}
}
pub fn validate_tree(node: &SyntaxNode) -> Result<(), String> {
let node = &crate::nodes::immutable_copy(node);
let mut in_flow = vec![crate::nodes::in_flow_collection(node)];
for event in node.preorder() {
match event {
rowan::WalkEvent::Enter(n) => {
let outer = *in_flow.last().expect("the walk's own entry stays");
validate_node(&n, outer)?;
let opens_flow = matches!(n.kind(), SyntaxKind::MAPPING | SyntaxKind::SEQUENCE)
&& crate::nodes::starts_a_flow_collection(&n);
in_flow.push(outer || opens_flow);
}
rowan::WalkEvent::Leave(_) => {
in_flow.pop();
}
}
}
node.descendants()
.try_for_each(|n| validate_no_stacked_indents(&n))?;
node.descendants()
.try_for_each(|n| validate_no_double_trailing_newline(&n))
}
pub fn roundtrip_ok(node: &SyntaxNode) -> Result<(), String> {
let text = node.to_string();
let parse = crate::Parse::parse_yaml(&text);
if !parse.positioned_errors().is_empty() {
return Err(format!(
"re-parse produced {} error(s); first: {}",
parse.positioned_errors().len(),
parse.positioned_errors()[0].message,
));
}
let round = parse.tree().to_string();
if round != text {
return Err(format!(
"roundtrip mismatch:\n original: {text:?}\n reparsed: {round:?}",
));
}
Ok(())
}
pub fn key_string(key: &YamlNode) -> Option<String> {
let identity = crate::as_yaml::node_identity(key.syntax(), None).ok()??;
Some(identity.key_string())
}
#[cfg(test)]
#[track_caller]
pub(crate) fn assert_cst_ok(node: &impl rowan::ast::AstNode<Language = crate::yaml::Lang>) {
let syntax = node.syntax();
if let Err(e) = validate_tree(syntax) {
panic!("CST invariant violated: {e}\n---\n{syntax}\n---");
}
if let Err(e) = roundtrip_ok(syntax) {
panic!("roundtrip failed: {e}\n---\n{syntax}\n---");
}
}
#[cfg(test)]
#[track_caller]
pub(crate) fn assert_edited(yaml: &crate::yaml::YamlFile, expected: &str) {
use rowan::ast::AstNode;
use std::str::FromStr;
assert_eq!(yaml.to_string(), expected);
let reparsed = crate::yaml::YamlFile::from_str(expected).unwrap();
assert_eq!(
shape_to_string(yaml.syntax()),
shape_to_string(reparsed.syntax())
);
let violations: Vec<String> = yaml
.documents()
.flat_map(|doc| crate::validator::Validator::new().validate(&doc))
.map(|v| v.to_string())
.collect();
assert_eq!(violations, Vec::<String>::new());
}
fn validate_node(node: &SyntaxNode, in_flow: bool) -> Result<(), String> {
if matches!(
node.kind(),
SyntaxKind::MAPPING_ENTRY | SyntaxKind::SEQUENCE_ENTRY
) && in_flow
{
validate_flow_entry_end(node)?;
}
validate_children(node)?;
match node.kind() {
SyntaxKind::MAPPING_ENTRY => {
let keys: Vec<_> = node
.children()
.filter(|c| c.kind() == SyntaxKind::KEY)
.collect();
if keys.len() != 1 {
return Err(format!(
"MAPPING_ENTRY should have exactly 1 KEY, found {}",
keys.len()
));
}
let colons: Vec<_> = node
.children_with_tokens()
.filter(|c| c.as_token().is_some_and(|t| t.kind() == SyntaxKind::COLON))
.collect();
let is_explicit_key = has_child_token(node, |k| k == SyntaxKind::QUESTION);
let expected_colons = if is_explicit_key || in_flow {
0..=1
} else {
1..=1
};
if !expected_colons.contains(&colons.len()) {
return Err(format!(
"MAPPING_ENTRY should have {} COLON(s), found {}",
if is_explicit_key || in_flow {
"0 or 1"
} else {
"exactly 1"
},
colons.len()
));
}
let values: Vec<_> = node
.children()
.filter(|c| c.kind() == SyntaxKind::VALUE)
.collect();
if values.len() != 1 {
return Err(format!(
"MAPPING_ENTRY should have exactly 1 VALUE, found {}",
values.len()
));
}
if !in_flow && !is_last_entry_in_parent(node) && !entry_is_terminated(node) {
return Err(format!(
"Block-style MAPPING_ENTRY not terminated by a NEWLINE (last token: {:?})",
node.last_token().map(|t| t.kind())
));
}
}
SyntaxKind::MAPPING | SyntaxKind::SEQUENCE
if !crate::nodes::starts_a_flow_collection(node) =>
{
let stray = node
.children_with_tokens()
.filter_map(|c| c.into_token())
.filter(|t| t.kind() == SyntaxKind::COMMENT)
.find(|t| {
std::iter::successors(t.prev_token(), |p| p.prev_token())
.find(|p| !p.text().is_empty())
.is_some_and(|p| {
!matches!(p.kind(), SyntaxKind::NEWLINE | SyntaxKind::INDENT)
})
});
if let Some(comment) = stray {
return Err(format!(
"{:?} holds the comment {:?} ending an entry's line",
node.kind(),
comment.text()
));
}
let nested = node
.ancestors()
.skip(1)
.any(|n| matches!(n.kind(), SyntaxKind::MAPPING | SyntaxKind::SEQUENCE));
let trailing_line = node
.last_child_or_token()
.and_then(|c| c.into_token())
.filter(|t| !t.text().is_empty());
if let Some(token) = trailing_line.filter(|_| nested) {
return Err(format!(
"nested {:?} ends with {:?} {:?} after its last entry",
node.kind(),
token.kind(),
token.text()
));
}
let leading_line = node
.children_with_tokens()
.find(|c| !c.as_token().is_some_and(|t| t.text().is_empty()))
.and_then(|c| c.into_token());
if let Some(token) = leading_line {
return Err(format!(
"{:?} starts with {:?} {:?} before its first entry",
node.kind(),
token.kind(),
token.text()
));
}
}
SyntaxKind::SEQUENCE_ENTRY
if !in_flow && !is_last_entry_in_parent(node) && !entry_is_terminated(node) =>
{
return Err(format!(
"Block-style SEQUENCE_ENTRY not terminated by a NEWLINE (last token: {:?})",
node.last_token().map(|t| t.kind())
));
}
_ => {}
}
Ok(())
}
fn validate_children(node: &SyntaxNode) -> Result<(), String> {
match node.kind() {
SyntaxKind::KEY | SyntaxKind::VALUE | SyntaxKind::SEQUENCE_ENTRY
if node.first_child().is_none() =>
{
Err(format!("{:?} holds no node", node.kind()))
}
SyntaxKind::SCALAR if node.first_token().is_none() => Err("SCALAR holds no token".into()),
SyntaxKind::MAPPING | SyntaxKind::SEQUENCE => {
let entry = if node.kind() == SyntaxKind::MAPPING {
SyntaxKind::MAPPING_ENTRY
} else {
SyntaxKind::SEQUENCE_ENTRY
};
let flow = crate::nodes::starts_a_flow_collection(node);
let stray = node.children_with_tokens().find(|c| match c {
rowan::NodeOrToken::Node(n) => n.kind() != entry && n.kind() != SyntaxKind::ERROR,
rowan::NodeOrToken::Token(t) => match t.kind() {
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::INDENT
| SyntaxKind::COMMENT => false,
SyntaxKind::LEFT_BRACE
| SyntaxKind::RIGHT_BRACE
| SyntaxKind::LEFT_BRACKET
| SyntaxKind::RIGHT_BRACKET => !flow,
_ => true,
},
});
match stray {
Some(c) => Err(format!(
"{:?} holds {:?} outside its entries",
node.kind(),
c.kind()
)),
None => Ok(()),
}
}
_ => Ok(()),
}
}
fn validate_flow_entry_end(entry: &SyntaxNode) -> Result<(), String> {
let children: Vec<_> = entry.children_with_tokens().collect();
let end = children
.iter()
.rposition(|c| c.as_node().is_some() || c.kind() == SyntaxKind::COMMA)
.map_or(0, |i| i + 1);
let newline = children[end..]
.iter()
.position(|c| c.kind() == SyntaxKind::NEWLINE);
match newline {
Some(i) if end + i + 1 < children.len() => Err(format!(
"flow {:?} holds {:?} after the end of its line",
entry.kind(),
children[end + i + 1].kind()
)),
_ => Ok(()),
}
}
fn entry_is_terminated(entry: &SyntaxNode) -> bool {
crate::nodes::trivia::ends_line(entry)
}
fn is_last_entry_in_parent(entry: &SyntaxNode) -> bool {
let kind = entry.kind();
let mut sib = entry.next_sibling();
while let Some(s) = sib {
if s.kind() == kind {
return false;
}
sib = s.next_sibling();
}
true
}
fn validate_no_stacked_indents(node: &SyntaxNode) -> Result<(), String> {
let mut prev_was_indent = false;
for child in node.children_with_tokens() {
match &child {
rowan::NodeOrToken::Token(t) if t.kind() == SyntaxKind::INDENT => {
if prev_was_indent {
return Err(format!(
"adjacent INDENT tokens inside {:?} -- would render as stacked indentation",
node.kind()
));
}
prev_was_indent = true;
}
rowan::NodeOrToken::Token(_) => prev_was_indent = false,
rowan::NodeOrToken::Node(_) => prev_was_indent = false,
}
}
Ok(())
}
fn validate_no_double_trailing_newline(node: &SyntaxNode) -> Result<(), String> {
let checks_apply = matches!(
node.kind(),
SyntaxKind::MAPPING_ENTRY | SyntaxKind::SEQUENCE_ENTRY
);
if checks_apply {
let kinds: Vec<_> = node.children_with_tokens().map(|c| c.kind()).collect();
if kinds.ends_with(&[SyntaxKind::NEWLINE, SyntaxKind::NEWLINE]) {
return Err(format!(
"{:?} ends with two NEWLINE tokens -- would render as a stray blank line: {:?}",
node.kind(),
node.text().to_string(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::yaml::YamlFile;
use rowan::ast::AstNode;
use std::str::FromStr;
#[test]
fn test_validate_tree_fits_a_small_stack() {
let text = format!("{}{}", "[{".repeat(127), "}]".repeat(127));
let green = YamlFile::from_str(&text)
.unwrap()
.syntax()
.green()
.into_owned();
let tree = green.clone();
std::thread::Builder::new()
.stack_size(64 * 1024)
.spawn(move || validate_tree(&SyntaxNode::new_root_mut(tree)))
.unwrap()
.join()
.unwrap()
.unwrap();
drop(green);
}
#[test]
fn test_validate_tree_line_end_comment_outside_entry() {
let file = YamlFile::from_str("- a # c\n- b\n").unwrap();
assert_eq!(validate_tree(file.syntax()), Ok(()));
let entry = file
.syntax()
.descendants()
.find(|n| n.kind() == SyntaxKind::SEQUENCE_ENTRY)
.unwrap();
let line_end: Vec<_> = entry
.children_with_tokens()
.skip_while(|c| c.kind() != SyntaxKind::COMMENT)
.collect();
for c in &line_end {
c.detach();
}
let sequence = entry.parent().unwrap();
crate::nodes::insert_children(&sequence, entry.index() + 1, line_end);
assert_eq!(file.syntax().to_string(), "- a # c\n- b\n");
assert_eq!(
validate_tree(file.syntax()),
Err("SEQUENCE holds the comment \"# c\" ending an entry's line".to_string())
);
}
#[test]
fn test_validate_tree_comment_line_after_explicit_block_key() {
for yaml in ["? - a\n# c\n", "- ? - a\n# c\n"] {
let file = YamlFile::from_str(yaml).unwrap();
assert_eq!(validate_tree(file.syntax()), Ok(()), "{yaml:?}");
}
}
#[test]
fn test_validate_tree_nested_collection_ending_in_comment() {
let file = YamlFile::from_str("a:\n b: 1\n # c\n").unwrap();
assert_eq!(validate_tree(file.syntax()), Ok(()));
let root = file.syntax().first_child().unwrap().first_child().unwrap();
let nested = root
.descendants()
.find(|n| n.kind() == SyntaxKind::MAPPING && n != &root)
.unwrap();
let lines: Vec<_> = root
.children_with_tokens()
.skip_while(|c| c.kind() == SyntaxKind::MAPPING_ENTRY)
.collect();
for c in &lines {
match c {
rowan::NodeOrToken::Node(n) => n.detach(),
rowan::NodeOrToken::Token(t) => t.detach(),
}
}
crate::nodes::append_children(&nested, lines);
assert_eq!(file.syntax().to_string(), "a:\n b: 1\n # c\n");
assert_eq!(
validate_tree(file.syntax()),
Err("nested MAPPING ends with NEWLINE \"\\n\" after its last entry".to_string())
);
}
#[test]
fn test_validate_tree_implicit_null_ending_sequence_entry() {
for yaml in [
"- a:\n- c\n",
"-\n a: &b\n- c\n",
"- &c : &a\n- c\n",
"- &a\n- a\n-\n &a : a\n b: &b\n-\n &c : &a\n",
] {
let file = YamlFile::from_str(yaml).unwrap();
assert_eq!(validate_tree(file.syntax()), Ok(()), "{yaml:?}");
}
let file = YamlFile::from_str("- a: 1\n- c\n").unwrap();
let entry = file
.syntax()
.descendants()
.find(|n| n.kind() == SyntaxKind::SEQUENCE_ENTRY)
.unwrap();
entry.last_token().unwrap().detach();
assert_eq!(
validate_tree(file.syntax()),
Err(
"Block-style SEQUENCE_ENTRY not terminated by a NEWLINE (last token: Some(INT))"
.to_string()
)
);
}
#[test]
fn test_validate_tree_newlines_around_value() {
for yaml in ["{:\nv\n}\n", "{a:\n\nv}\n", "-\n\n a\n"] {
let file = YamlFile::from_str(yaml).unwrap();
assert_eq!(validate_tree(file.syntax()), Ok(()), "{yaml:?}");
}
let file = YamlFile::from_str("- a\n- b\n").unwrap();
let entry = file
.syntax()
.descendants()
.find(|n| n.kind() == SyntaxKind::SEQUENCE_ENTRY)
.unwrap();
let blank = YamlFile::from_str("- a\n").unwrap();
let newline = blank
.syntax()
.descendants_with_tokens()
.find(|c| c.kind() == SyntaxKind::NEWLINE)
.unwrap();
newline.detach();
crate::nodes::append_children(&entry, vec![newline]);
assert_eq!(
validate_tree(file.syntax()),
Err(
"SEQUENCE_ENTRY ends with two NEWLINE tokens -- would render as a stray blank line: \"- a\\n\\n\""
.to_string()
)
);
}
#[test]
fn test_validate_tree_entry_without_value() {
let file = YamlFile::parse("a:\n<<\n").tree();
assert_eq!(validate_tree(file.syntax()), Ok(()));
for entry in file
.syntax()
.descendants()
.filter(|n| n.kind() == SyntaxKind::MAPPING_ENTRY)
.collect::<Vec<_>>()
{
let value = entry
.children()
.find(|c| c.kind() == SyntaxKind::VALUE)
.unwrap();
let index = value.index();
value.detach();
assert_eq!(
validate_tree(file.syntax()),
Err("MAPPING_ENTRY should have exactly 1 VALUE, found 0".to_string())
);
crate::nodes::insert_children(&entry, index, vec![value.into()]);
assert_eq!(file.syntax().to_string(), "a:\n<<\n");
}
}
#[test]
fn test_validate_tree_empty_slots() {
let file = YamlFile::from_str("a: [b]\n").unwrap();
let find = |kind| {
file.syntax()
.descendants()
.find(|n| n.kind() == kind)
.unwrap()
};
for kind in [
SyntaxKind::KEY,
SyntaxKind::VALUE,
SyntaxKind::SEQUENCE_ENTRY,
] {
let slot = find(kind);
let node = slot.first_child().unwrap();
let index = node.index();
node.detach();
assert_eq!(
validate_tree(file.syntax()),
Err(format!("{kind:?} holds no node"))
);
crate::nodes::insert_children(&slot, index, vec![node.into()]);
}
let token = find(SyntaxKind::SCALAR).first_token().unwrap();
token.detach();
assert_eq!(
validate_tree(file.syntax()),
Err("SCALAR holds no token".to_string())
);
}
#[test]
fn test_validate_tree_stray_token_in_collection() {
let file = YamlFile::parse("[]:\n]:").tree();
assert_eq!(validate_tree(file.syntax()), Ok(()));
let error = file
.syntax()
.descendants()
.find(|n| n.kind() == SyntaxKind::ERROR)
.unwrap();
let mapping = error.parent().unwrap();
let token = error.first_token().unwrap();
let index = error.index();
error.detach();
token.detach();
crate::nodes::insert_children(&mapping, index, vec![token.into()]);
assert_eq!(file.syntax().to_string(), "[]:\n]:");
assert_eq!(
validate_tree(file.syntax()),
Err("MAPPING holds RIGHT_BRACKET outside its entries".to_string())
);
}
#[test]
fn test_print_tree() {
let yaml = YamlFile::from_str("name: Alice").unwrap();
print_tree(yaml.syntax());
}
#[test]
fn test_tree_to_string() {
let yaml = YamlFile::from_str("name: Alice").unwrap();
let s = tree_to_string(yaml.syntax());
let expected = "DOCUMENT\n MAPPING\n MAPPING_ENTRY\n KEY\n SCALAR\n STRING: \"name\"\n COLON: \":\"\n WHITESPACE: \" \"\n VALUE\n SCALAR\n STRING: \"Alice\"\n";
assert_eq!(s, expected);
}
#[test]
fn test_tree_to_string_sequence() {
let yaml = YamlFile::from_str("- item1\n- item2\n").unwrap();
let s = tree_to_string(yaml.syntax());
assert_eq!(
s,
concat!(
"DOCUMENT\n",
" SEQUENCE\n",
" SEQUENCE_ENTRY\n",
" DASH: \"-\"\n",
" WHITESPACE: \" \"\n",
" SCALAR\n",
" STRING: \"item1\"\n",
" NEWLINE: \"\\\\n\"\n",
" SEQUENCE_ENTRY\n",
" DASH: \"-\"\n",
" WHITESPACE: \" \"\n",
" SCALAR\n",
" STRING: \"item2\"\n",
" NEWLINE: \"\\\\n\"\n",
)
);
}
#[test]
fn test_print_stats() {
let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
print_stats(yaml.syntax());
}
#[test]
fn test_validate_tree() {
let yaml = YamlFile::from_str("name: Alice\nage: 30\n").unwrap();
validate_tree(yaml.syntax()).expect("Tree should be valid");
}
fn build(kind: SyntaxKind, children: &[(SyntaxKind, Option<&str>)]) -> SyntaxNode {
let mut builder = rowan::GreenNodeBuilder::new();
builder.start_node(kind.into());
for (kind, text) in children {
match (kind, text) {
(SyntaxKind::ROOT, None) => builder.finish_node(),
(kind, None) => builder.start_node((*kind).into()),
(kind, Some(text)) => builder.token((*kind).into(), text),
}
}
builder.finish_node();
SyntaxNode::new_root(builder.finish())
}
const END: (SyntaxKind, Option<&str>) = (SyntaxKind::ROOT, None);
fn entry(key: &'static str, value: &'static str) -> Vec<(SyntaxKind, Option<&'static str>)> {
use SyntaxKind::*;
vec![
(MAPPING_ENTRY, None),
(KEY, None),
(SCALAR, None),
(STRING, Some(key)),
END,
END,
(COLON, Some(":")),
(WHITESPACE, Some(" ")),
(VALUE, None),
(SCALAR, None),
(STRING, Some(value)),
END,
END,
]
}
#[test]
fn test_validate_tree_mapping_entry_errors() {
use SyntaxKind::*;
let mut two_keys = entry("a", "1");
two_keys.insert(1, (KEY, None));
two_keys.insert(2, END);
two_keys.extend([(NEWLINE, Some("\n")), END]);
assert_eq!(
validate_tree(&build(MAPPING, &two_keys)),
Err("MAPPING_ENTRY should have exactly 1 KEY, found 2".to_string())
);
let mut no_colon = entry("a", "1");
no_colon.remove(6);
no_colon.extend([(NEWLINE, Some("\n")), END]);
assert_eq!(
validate_tree(&build(MAPPING, &no_colon)),
Err("MAPPING_ENTRY should have exactly 1 COLON(s), found 0".to_string())
);
let mut two_values = entry("a", "1");
two_values.extend([(VALUE, None), END, (NEWLINE, Some("\n")), END]);
assert_eq!(
validate_tree(&build(MAPPING, &two_values)),
Err("MAPPING_ENTRY should have exactly 1 VALUE, found 2".to_string())
);
let mut unterminated = entry("a", "1");
unterminated.push(END);
unterminated.extend(entry("b", "2"));
unterminated.extend([(NEWLINE, Some("\n")), END]);
assert_eq!(
validate_tree(&build(MAPPING, &unterminated)),
Err(
"Block-style MAPPING_ENTRY not terminated by a NEWLINE (last token: Some(STRING))"
.to_string()
)
);
}
#[test]
fn test_validate_tree_flow_entry_may_lack_colon() {
use SyntaxKind::*;
let mut children = vec![(LEFT_BRACE, Some("{"))];
let mut flow_entry = entry("a", "1");
flow_entry.remove(6);
children.extend(flow_entry);
children.extend([END, (RIGHT_BRACE, Some("}"))]);
assert_eq!(validate_tree(&build(MAPPING, &children)), Ok(()));
}
#[test]
fn test_validate_tree_stacked_indents_and_double_newline() {
use SyntaxKind::*;
assert_eq!(
validate_tree(&build(
SEQUENCE,
&[
(SEQUENCE_ENTRY, None),
(DASH, Some("-")),
(WHITESPACE, Some(" ")),
(SCALAR, None),
(STRING, Some("a")),
END,
(NEWLINE, Some("\n")),
END,
(INDENT, Some(" ")),
(INDENT, Some(" ")),
]
)),
Err(
"adjacent INDENT tokens inside SEQUENCE -- would render as stacked indentation"
.to_string()
)
);
let mut doubled = entry("a", "1");
doubled.extend([(NEWLINE, Some("\n")), (NEWLINE, Some("\n")), END]);
assert_eq!(
validate_tree(&build(MAPPING, &doubled)),
Err(
"MAPPING_ENTRY ends with two NEWLINE tokens -- would render as a stray blank line: \"a: 1\\n\\n\""
.to_string()
)
);
}
#[test]
fn test_roundtrip_ok_reports_parse_errors() {
let node = build(SyntaxKind::ROOT, &[(SyntaxKind::STRING, Some("[a"))]);
assert_eq!(
roundtrip_ok(&node),
Err(format!(
"re-parse produced 1 error(s); first: {}",
crate::Parse::parse_yaml("[a").positioned_errors()[0].message
))
);
let file = YamlFile::from_str("a: 1\n").unwrap();
assert_eq!(roundtrip_ok(file.syntax()), Ok(()));
}
}
pub struct PrettyDebug<'a, T>(pub &'a T);
impl<'a> fmt::Display for PrettyDebug<'a, Document> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Document {{")?;
if let Some(mapping) = self.0.as_mapping() {
write_indented(f, &format!("{}", PrettyDebug(&mapping)), 1)?;
} else if let Some(sequence) = self.0.as_sequence() {
write_indented(f, &format!("{}", PrettyDebug(&sequence)), 1)?;
} else if let Some(scalar) = self.0.as_scalar() {
writeln!(f, " {}", PrettyDebug(&scalar))?;
}
writeln!(f, "}}")
}
}
impl<'a> fmt::Display for PrettyDebug<'a, Mapping> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Mapping [")?;
for (key, value) in self.0.iter() {
write!(f, " ")?;
format_node(f, &key, 0)?;
write!(f, ": ")?;
format_node(f, &value, 1)?;
writeln!(f)?;
}
write!(f, "]")
}
}
impl<'a> fmt::Display for PrettyDebug<'a, Sequence> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Sequence [")?;
for (i, value) in self.0.values().enumerate() {
write!(f, " [{i}]: ")?;
format_node(f, &value, 1)?;
writeln!(f)?;
}
write!(f, "]")
}
}
impl<'a> fmt::Display for PrettyDebug<'a, Scalar> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Scalar({:?})", self.0.value())
}
}
fn write_indented(f: &mut fmt::Formatter<'_>, text: &str, indent: usize) -> fmt::Result {
let indent_str = " ".repeat(indent);
for line in text.lines() {
writeln!(f, "{indent_str}{line}")?;
}
Ok(())
}
fn format_node(f: &mut fmt::Formatter<'_>, node: &YamlNode, indent: usize) -> fmt::Result {
let indent_str = " ".repeat(indent);
let write_scalar = |f: &mut fmt::Formatter<'_>, sv: crate::scalar::ScalarValue| {
write!(f, "{:?} (type: {:?})", sv.value(), sv.scalar_type())
};
match node {
YamlNode::Scalar(s) => write_scalar(f, crate::scalar::ScalarValue::from_scalar(s))?,
YamlNode::Mapping(m) => {
writeln!(f, "Mapping {{")?;
for (k, v) in m.iter() {
write!(f, "{indent_str} ")?;
format_node(f, &k, 0)?;
write!(f, ": ")?;
format_node(f, &v, indent + 1)?;
writeln!(f)?;
}
write!(f, "{indent_str}}}")?;
}
YamlNode::Sequence(s) => {
writeln!(f, "Sequence [")?;
for (i, v) in s.values().enumerate() {
write!(f, "{indent_str} [{i}]: ")?;
format_node(f, &v, indent + 1)?;
writeln!(f)?;
}
write!(f, "{indent_str}]")?;
}
YamlNode::Alias(a) => {
write!(f, "Alias(*{})", a.name())?;
}
YamlNode::TaggedNode(t) => {
write!(f, "Tagged({:?}, ", t.tag().unwrap_or_default())?;
let content = rowan::ast::AstNode::syntax(t)
.children()
.find_map(YamlNode::from_syntax);
match content {
Some(YamlNode::Scalar(s)) => {
let sv = crate::scalar::ScalarValue::from_tagged(t);
write_scalar(
f,
sv.unwrap_or_else(|| crate::scalar::ScalarValue::from_scalar(&s)),
)?;
}
Some(content) => format_node(f, &content, indent)?,
None => {}
}
write!(f, ")")?;
}
}
Ok(())
}
pub struct ValueInspector<'a>(pub &'a crate::as_yaml::YamlNode);
impl<'a> fmt::Display for ValueInspector<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Value Inspector:")?;
writeln!(f, "===============")?;
match self.0 {
crate::as_yaml::YamlNode::Scalar(s) => {
writeln!(f, "Type: Scalar")?;
writeln!(f, " Value: {:?}", s.as_string())?;
writeln!(f, "\nCoercion Capabilities:")?;
writeln!(f, " to_i64: {:?}", self.0.to_i64())?;
writeln!(f, " to_f64: {:?}", self.0.to_f64())?;
writeln!(f, " to_bool: {:?}", self.0.to_bool())?;
}
crate::as_yaml::YamlNode::Mapping(m) => {
writeln!(f, "Type: Mapping")?;
writeln!(f, " Size: {} entries", m.len())?;
}
crate::as_yaml::YamlNode::Sequence(s) => {
writeln!(f, "Type: Sequence")?;
writeln!(f, " Length: {} items", s.len())?;
}
crate::as_yaml::YamlNode::Alias(a) => {
writeln!(f, "Type: Alias")?;
writeln!(f, " Anchor name: {}", a.name())?;
}
crate::as_yaml::YamlNode::TaggedNode(_) => {
writeln!(f, "Type: TaggedNode")?;
}
}
Ok(())
}
}
pub struct VisualDiff<'a> {
pub before: &'a Document,
pub after: &'a Document,
}
impl<'a> fmt::Display for VisualDiff<'a> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "Visual Diff:")?;
writeln!(f, "============")?;
writeln!(f, "\nBefore:")?;
writeln!(f, "-------")?;
for line in self.before.to_string().lines() {
writeln!(f, " {line}")?;
}
writeln!(f, "\nAfter:")?;
writeln!(f, "------")?;
for line in self.after.to_string().lines() {
writeln!(f, " {line}")?;
}
writeln!(f, "\nChanges:")?;
writeln!(f, "--------")?;
let before_str = self.before.to_string();
let after_str = self.after.to_string();
let before_lines: Vec<_> = before_str.lines().collect();
let after_lines: Vec<_> = after_str.lines().collect();
for (i, (b, a)) in before_lines.iter().zip(after_lines.iter()).enumerate() {
if b != a {
writeln!(f, "Line {}: {:?} -> {:?}", i + 1, b, a)?;
}
}
if before_lines.len() > after_lines.len() {
writeln!(
f,
"Removed {} lines",
before_lines.len() - after_lines.len()
)?;
} else if after_lines.len() > before_lines.len() {
writeln!(f, "Added {} lines", after_lines.len() - before_lines.len())?;
}
Ok(())
}
}
pub fn graphviz_dot(node: &SyntaxNode) -> String {
let mut result = String::from("digraph CST {\n");
result.push_str(" node [shape=box, fontname=\"Courier\"];\n");
result.push_str(" edge [fontsize=10];\n\n");
let mut counter = 0;
graphviz_node(&mut result, node, None, &mut counter);
result.push_str("}\n");
result
}
fn graphviz_node(
result: &mut String,
node: &SyntaxNode,
parent_id: Option<usize>,
counter: &mut usize,
) {
let node_id = *counter;
*counter += 1;
let label = format!("{:?}", node.kind());
result.push_str(&format!(" n{node_id} [label=\"{label}\"];\n"));
if let Some(pid) = parent_id {
result.push_str(&format!(" n{pid} -> n{node_id};\n"));
}
for child in node.children_with_tokens() {
match child {
rowan::NodeOrToken::Node(n) => {
graphviz_node(result, &n, Some(node_id), counter);
}
rowan::NodeOrToken::Token(t) => {
let token_id = *counter;
*counter += 1;
let text = t
.text()
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\\\n")
.replace('\r', "\\\\r");
let label = format!("{:?}\\n\\\"{}\\\"", t.kind(), text);
result.push_str(&format!(
" n{token_id} [label=\"{label}\", shape=ellipse, style=filled, fillcolor=lightgray];\n"
));
result.push_str(&format!(" n{node_id} -> n{token_id};\n"));
}
}
}
}
#[cfg(test)]
mod new_debug_tests {
use super::*;
use crate::yaml::YamlFile;
use rowan::ast::AstNode;
use std::str::FromStr;
#[test]
fn test_pretty_debug_document() {
let doc = Document::from_str("name: Alice\nage: 30").unwrap();
let output = format!("{}", PrettyDebug(&doc));
assert_eq!(output.lines().next(), Some("Document {"));
assert_eq!(output.lines().last(), Some("}"));
}
#[test]
fn test_pretty_debug_shows_mapping_keys() {
let doc = Document::from_str("a: {b: {c: 1}}\nl: [x, [y]]\n").unwrap();
assert_eq!(
PrettyDebug(&doc).to_string(),
concat!(
"Document {\n",
" Mapping [\n",
" \"a\" (type: String): Mapping {\n",
" \"b\" (type: String): Mapping {\n",
" \"c\" (type: String): \"1\" (type: Integer)\n",
" }\n",
" }\n",
" \"l\" (type: String): Sequence [\n",
" [0]: \"x\" (type: String)\n",
" [1]: Sequence [\n",
" [0]: \"y\" (type: String)\n",
" ]\n",
" ]\n",
" ]\n",
"}\n",
)
);
}
#[test]
fn test_pretty_debug_shows_tagged_content() {
let doc = Document::from_str("a: !!int 5\nb: !t [x, *y]\n").unwrap();
assert_eq!(
PrettyDebug(&doc).to_string(),
concat!(
"Document {\n",
" Mapping [\n",
" \"a\" (type: String): Tagged(\"!!int\", \"5\" (type: Integer))\n",
" \"b\" (type: String): Tagged(\"!t\", Sequence [\n",
" [0]: \"x\" (type: String)\n",
" [1]: Alias(*y)\n",
" ])\n",
" ]\n",
"}\n",
)
);
}
#[test]
fn test_value_inspector_scalar() {
let yaml = YamlFile::from_str("port: 8080").unwrap();
let doc = yaml.document().unwrap();
let mapping = doc.as_mapping().unwrap();
let value = mapping.get("port").unwrap();
let output = format!("{}", ValueInspector(&value));
assert_eq!(output.lines().next(), Some("Value Inspector:"));
assert_eq!(output.lines().nth(1), Some("==============="));
assert_eq!(output.lines().nth(2), Some("Type: Scalar"));
}
#[test]
fn test_visual_diff_simple() {
let before = Document::from_str("name: Alice").unwrap();
let after = Document::from_str("name: Bob").unwrap();
let diff = VisualDiff {
before: &before,
after: &after,
};
let output = format!("{}", diff);
assert_eq!(output.lines().next(), Some("Visual Diff:"));
assert_eq!(output.lines().nth(1), Some("============"));
}
#[test]
fn test_graphviz_dot_structure() {
let yaml = YamlFile::from_str("name: Alice").unwrap();
let dot = graphviz_dot(yaml.syntax());
assert_eq!(dot.lines().next(), Some("digraph CST {"));
assert_eq!(dot.lines().last(), Some("}"));
}
#[test]
fn test_graphviz_dot_escapes_token_text() {
let yaml = YamlFile::from_str("k: \"a\\\"b\\\\c\"\n").unwrap();
let dot = graphviz_dot(yaml.syntax());
let token_labels: Vec<&str> = dot
.lines()
.filter(|l| l.contains("shape=ellipse"))
.map(|l| l.split(", shape=").next().unwrap().trim())
.collect();
assert_eq!(
token_labels,
vec![
r#"n6 [label="STRING\n\"k\"""#,
r#"n7 [label="COLON\n\":\"""#,
r#"n8 [label="WHITESPACE\n\" \"""#,
r#"n11 [label="STRING\n\"\"a\\\"b\\\\c\"\"""#,
r#"n12 [label="NEWLINE\n\"\\n\"""#,
]
);
}
#[test]
fn test_graphviz_dot_contains_nodes() {
let yaml = YamlFile::from_str("key: value").unwrap();
let dot = graphviz_dot(yaml.syntax());
let node_count = dot.lines().filter(|l| l.trim().starts_with("node")).count();
assert!(
node_count >= 1,
"expected at least one node declaration in dot output"
);
}
#[test]
fn test_pretty_debug_sequence_and_scalar_roots() {
let doc = Document::from_str("- 1\n- [2]\n").unwrap();
assert_eq!(
PrettyDebug(&doc).to_string(),
concat!(
"Document {\n",
" Sequence [\n",
" [0]: \"1\" (type: Integer)\n",
" [1]: Sequence [\n",
" [0]: \"2\" (type: Integer)\n",
" ]\n",
" ]\n",
"}\n",
)
);
let doc = Document::from_str("5\n").unwrap();
assert_eq!(
PrettyDebug(&doc).to_string(),
"Document {\n Scalar(\"5\")\n}\n"
);
}
#[test]
fn test_value_inspector_kinds() {
let doc =
Document::from_str("s: 'yes'\nm: {a: 1, b: 2}\nl: [1]\nx: *a\nt: !t 1\n").unwrap();
let mapping = doc.as_mapping().unwrap();
let inspect = |key: &str| ValueInspector(&mapping.get(key).unwrap()).to_string();
let header = "Value Inspector:\n===============\n";
assert_eq!(
inspect("s"),
format!("{header}Type: Scalar\n Value: \"yes\"\n\nCoercion Capabilities:\n to_i64: None\n to_f64: None\n to_bool: None\n")
);
assert_eq!(
inspect("m"),
format!("{header}Type: Mapping\n Size: 2 entries\n")
);
assert_eq!(
inspect("l"),
format!("{header}Type: Sequence\n Length: 1 items\n")
);
assert_eq!(
inspect("x"),
format!("{header}Type: Alias\n Anchor name: a\n")
);
assert_eq!(inspect("t"), format!("{header}Type: TaggedNode\n"));
}
#[test]
fn test_visual_diff_output() {
let before = Document::from_str("name: Alice\nage: 30\n").unwrap();
let after = Document::from_str("name: Bob\nage: 30\nx: 1\n").unwrap();
let header = |a: &Document, b: &Document| {
let mut out = "Visual Diff:\n============\n\nBefore:\n-------\n".to_string();
for line in a.to_string().lines() {
out.push_str(&format!(" {line}\n"));
}
out.push_str("\nAfter:\n------\n");
for line in b.to_string().lines() {
out.push_str(&format!(" {line}\n"));
}
out + "\nChanges:\n--------\n"
};
assert_eq!(
VisualDiff {
before: &before,
after: &after
}
.to_string(),
header(&before, &after) + "Line 1: \"name: Alice\" -> \"name: Bob\"\nAdded 1 lines\n"
);
assert_eq!(
VisualDiff {
before: &after,
after: &before
}
.to_string(),
header(&after, &before) + "Line 1: \"name: Bob\" -> \"name: Alice\"\nRemoved 1 lines\n"
);
}
#[test]
fn test_visual_diff_same_length() {
let before = Document::from_str("a: 1\n").unwrap();
let after = Document::from_str("a: 2\n").unwrap();
let diff = VisualDiff {
before: &before,
after: &after,
}
.to_string();
assert!(
diff.ends_with("Changes:\n--------\nLine 1: \"a: 1\" -> \"a: 2\"\n"),
"{diff}"
);
}
#[test]
fn test_graphviz_dot_full_output() {
let yaml = YamlFile::from_str("a").unwrap();
assert_eq!(
graphviz_dot(yaml.syntax()),
concat!(
"digraph CST {\n",
" node [shape=box, fontname=\"Courier\"];\n",
" edge [fontsize=10];\n\n",
" n0 [label=\"ROOT\"];\n",
" n1 [label=\"DOCUMENT\"];\n",
" n0 -> n1;\n",
" n2 [label=\"SCALAR\"];\n",
" n1 -> n2;\n",
" n3 [label=\"STRING\\n\\\"a\\\"\", shape=ellipse, style=filled, fillcolor=lightgray];\n",
" n2 -> n3;\n",
"}\n",
)
);
}
#[test]
fn test_shape_to_string_with_leading_comments() {
let file = YamlFile::from_str("a: 1\n# c\nb: 2\n").unwrap();
file.document().unwrap().as_mapping().unwrap().remove("a");
assert_eq!(file.to_string(), "# c\nb: 2\n");
let expected = "DOCUMENT\n COMMENT: \"# c\"\n NEWLINE: \"\\\\n\"\n MAPPING\n MAPPING_ENTRY\n KEY\n SCALAR\n STRING: \"b\"\n COLON: \":\"\n WHITESPACE: \" \"\n VALUE\n SCALAR\n INT: \"2\"\n NEWLINE: \"\\\\n\"\n";
assert_eq!(tree_to_string(file.syntax()), expected);
assert_eq!(shape_to_string(file.syntax()), expected);
let reparsed = YamlFile::from_str(&file.to_string()).unwrap();
assert_eq!(shape_to_string(reparsed.syntax()), expected);
}
#[test]
fn test_shape_to_string_leaves_out_pending_block_marker() {
let mapping = crate::Mapping::new_pending_block();
assert_eq!(
tree_to_string(mapping.syntax()),
"LEFT_BRACE: \"{\"\nWHITESPACE: \"\"\nRIGHT_BRACE: \"}\"\n"
);
assert_eq!(
shape_to_string(mapping.syntax()),
"LEFT_BRACE: \"{\"\nRIGHT_BRACE: \"}\"\n"
);
}
#[test]
fn test_key_string() {
let doc = Document::from_str("0x10: a\n'x': b\n*a : c\n").unwrap();
let keys: Vec<_> = doc.as_mapping().unwrap().keys().collect();
assert_eq!(key_string(&keys[0]), Some("16".to_string()));
assert_eq!(key_string(&keys[1]), Some("x".to_string()));
assert_eq!(key_string(&keys[2]), None);
}
}