Yeast: Add AST dumper for human-readable tree output

Add yeast::dump::dump_ast() which produces indented text output:

    program
      assignment
        left:
          left_assignment_list
            identifier "x"
            identifier "y"
        right:
          call
            method: identifier "foo"

Named fields are shown with "field:" labels, unnamed children are
indented under their parent. Leaf nodes show their text content.
Locations are optional via DumpOptions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Taus
2026-05-01 13:50:55 +00:00
parent 83739f6eaf
commit 2113ba7f61
3 changed files with 175 additions and 3 deletions

155
shared/yeast/src/dump.rs Normal file
View File

@@ -0,0 +1,155 @@
use std::fmt::Write;
use crate::{Ast, Node, NodeContent, CHILD_FIELD};
/// Options for controlling AST dump output.
pub struct DumpOptions {
/// Whether to include source locations in the output.
pub show_locations: bool,
/// Whether to include source text for leaf nodes.
pub show_content: bool,
}
impl Default for DumpOptions {
fn default() -> Self {
Self {
show_locations: false,
show_content: true,
}
}
}
/// Dump a yeast AST as a human-readable indented text format.
///
/// Output format:
/// ```text
/// program
/// assignment
/// left:
/// left_assignment_list
/// identifier "x"
/// identifier "y"
/// right:
/// call
/// method:
/// identifier "foo"
/// ```
pub fn dump_ast(ast: &Ast, root: usize, source: &str) -> String {
dump_ast_with_options(ast, root, source, &DumpOptions::default())
}
pub fn dump_ast_with_options(ast: &Ast, root: usize, source: &str, options: &DumpOptions) -> String {
let mut out = String::new();
dump_node(ast, root, source, options, 0, &mut out);
out
}
fn dump_node(ast: &Ast, id: usize, source: &str, options: &DumpOptions, indent: usize, out: &mut String) {
let node = match ast.get_node(id) {
Some(n) => n,
None => return,
};
let prefix = " ".repeat(indent);
// Node kind
write!(out, "{}{}", prefix, node.kind_name()).unwrap();
// Location
if options.show_locations {
let start = node.start_position();
let end = node.end_position();
write!(out, " [{},{}]-[{},{}]",
start.row + 1, start.column + 1,
end.row + 1, end.column + 1
).unwrap();
}
// Content for leaf nodes
if options.show_content && node.is_named() && is_leaf(node) {
let content = node_content(node, source);
if !content.is_empty() {
write!(out, " {:?}", content).unwrap();
}
}
writeln!(out).unwrap();
// Named fields first
for (&field_id, children) in &node.fields {
if field_id == CHILD_FIELD {
continue; // Handle unnamed children last
}
let field_name = ast.field_name_for_id(field_id).unwrap_or("?");
if children.len() == 1 {
write!(out, "{} {}:", prefix, field_name).unwrap();
// Inline single child
let child = ast.get_node(children[0]);
if child.map_or(false, |c| is_leaf(c)) {
write!(out, " ").unwrap();
dump_node_inline(ast, children[0], source, options, out);
} else {
writeln!(out).unwrap();
dump_node(ast, children[0], source, options, indent + 2, out);
}
} else {
writeln!(out, "{} {}:", prefix, field_name).unwrap();
for &child_id in children {
dump_node(ast, child_id, source, options, indent + 2, out);
}
}
}
// Unnamed children
if let Some(children) = node.fields.get(&CHILD_FIELD) {
for &child_id in children {
dump_node(ast, child_id, source, options, indent + 1, out);
}
}
}
/// Dump a leaf node inline (no newline prefix, caller provides context).
fn dump_node_inline(ast: &Ast, id: usize, source: &str, options: &DumpOptions, out: &mut String) {
let node = match ast.get_node(id) {
Some(n) => n,
None => return,
};
write!(out, "{}", node.kind_name()).unwrap();
if options.show_locations {
let start = node.start_position();
let end = node.end_position();
write!(out, " [{},{}]-[{},{}]",
start.row + 1, start.column + 1,
end.row + 1, end.column + 1
).unwrap();
}
if options.show_content && node.is_named() {
let content = node_content(node, source);
if !content.is_empty() {
write!(out, " {:?}", content).unwrap();
}
}
writeln!(out).unwrap();
}
fn is_leaf(node: &Node) -> bool {
node.fields.is_empty()
}
fn node_content(node: &Node, source: &str) -> String {
match &node.content {
NodeContent::DynamicString(s) if !s.is_empty() => s.clone(),
_ => {
let range = node.byte_range();
if range.start < source.len() && range.end <= source.len() {
source[range.start..range.end].to_string()
} else {
String::new()
}
}
}
}

View File

@@ -8,6 +8,7 @@ use serde_json::{json, Value};
pub mod build;
pub mod captures;
pub mod cursor;
pub mod dump;
pub mod node_types_yaml;
pub mod print;
pub mod query;
@@ -238,7 +239,7 @@ impl Ast {
id
}
fn field_name_for_id(&self, id: FieldId) -> Option<&'static str> {
pub fn field_name_for_id(&self, id: FieldId) -> Option<&'static str> {
if id == CHILD_FIELD {
Some(CHILD_FIELD_NAME)
} else {
@@ -386,8 +387,8 @@ pub struct Node {
id: Id,
kind: KindId,
kind_name: &'static str,
fields: BTreeMap<FieldId, Vec<Id>>,
content: NodeContent,
pub(crate) fields: BTreeMap<FieldId, Vec<Id>>,
pub(crate) content: NodeContent,
is_named: bool,
is_missing: bool,
is_extra: bool,
@@ -403,6 +404,10 @@ impl Node {
self.kind_name
}
pub fn kind_name(&self) -> &'static str {
self.kind_name
}
pub fn is_named(&self) -> bool {
self.is_named
}

View File

@@ -182,3 +182,15 @@ fn test_shorthand_rule() {
assert!(output.contains("\"method\""));
assert!(output.contains("\"receiver\""));
}
#[test]
fn test_dump_ast() {
let input = "x, y = foo()";
let runner = Runner::new(tree_sitter_ruby::LANGUAGE.into(), vec![]);
let ast = runner.run(input);
let output = yeast::dump::dump_ast(&ast, ast.get_root(), input);
println!("{}", output);
assert!(output.contains("program"));
assert!(output.contains("assignment"));
assert!(output.contains("identifier"));
}