diff --git a/shared/yeast/src/dump.rs b/shared/yeast/src/dump.rs new file mode 100644 index 00000000000..35212dcff03 --- /dev/null +++ b/shared/yeast/src/dump.rs @@ -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() + } + } + } +} diff --git a/shared/yeast/src/lib.rs b/shared/yeast/src/lib.rs index a57aadf1b4f..0298a29e157 100644 --- a/shared/yeast/src/lib.rs +++ b/shared/yeast/src/lib.rs @@ -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>, - content: NodeContent, + pub(crate) fields: BTreeMap>, + 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 } diff --git a/shared/yeast/tests/test.rs b/shared/yeast/tests/test.rs index f7199b9f6ab..e4d49b38f7b 100644 --- a/shared/yeast/tests/test.rs +++ b/shared/yeast/tests/test.rs @@ -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")); +}