diff --git a/src/javascript.rs b/src/javascript.rs index 3460f1a..4376ece 100644 --- a/src/javascript.rs +++ b/src/javascript.rs @@ -1,54 +1,28 @@ use crate::{ - model::Language, - model::Node, - tree::{build_tree, FlatItem}, - util::truncate_preview, + model::{Language, Node}, + traverse::{analyze_tree_sitter, ast_preview, NodeClassifier}, }; -use tree_sitter::{Node as AstNode, Parser}; +use tree_sitter::Node as AstNode; pub(crate) fn analyze_javascript( language: Language, source: &str, preview_len: usize, ) -> Vec { - let mut parser = Parser::new(); let grammar = match language { Language::JavaScript => tree_sitter_javascript::LANGUAGE.into(), Language::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), Language::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), _ => unreachable!("analyze_javascript only accepts JS/TS languages"), }; - parser - .set_language(&grammar) - .expect("tree-sitter JavaScript/TypeScript grammar is valid"); - let Some(tree) = parser.parse(source, None) else { - return Vec::new(); - }; - - let mut items = Vec::::new(); - collect_javascript_items(tree.root_node(), source, preview_len, None, &mut items); - build_tree(&items, None) + analyze_tree_sitter(grammar, source, preview_len, JsClassifier) } -fn collect_javascript_items( - ast_node: AstNode, - source: &str, - preview_len: usize, - parent: Option, - items: &mut Vec, -) { - let current_parent = if let Some(node) = javascript_node_from_ast(ast_node, source, preview_len) - { - let index = items.len(); - items.push(FlatItem { parent, node }); - Some(index) - } else { - parent - }; +struct JsClassifier; - let mut cursor = ast_node.walk(); - for child in ast_node.named_children(&mut cursor) { - collect_javascript_items(child, source, preview_len, current_parent, items); +impl NodeClassifier for JsClassifier { + fn classify(&mut self, ast_node: AstNode, source: &str, preview_len: usize) -> Option { + javascript_node_from_ast(ast_node, source, preview_len) } } @@ -71,7 +45,7 @@ fn javascript_node_from_ast(ast_node: AstNode, source: &str, preview_len: usize) name: Some(name), start_line: ast_node.start_position().row + 1, end_line: ast_node.end_position().row + 1, - preview: javascript_ast_preview(ast_node, source, preview_len), + preview: ast_preview(ast_node, source, preview_len), children: Vec::new(), }) } @@ -89,12 +63,3 @@ fn javascript_ast_name(ast_node: AstNode, source: &str) -> Option { .and_then(|node| node.utf8_text(source.as_bytes()).ok()) .map(ToString::to_string) } - -fn javascript_ast_preview(ast_node: AstNode, source: &str, preview_len: usize) -> Option { - source - .lines() - .nth(ast_node.start_position().row) - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| truncate_preview(line, preview_len)) -} diff --git a/src/lib.rs b/src/lib.rs index 0197ec4..da7218f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ mod markdown; mod model; mod render; mod rust; +mod traverse; mod tree; mod util; diff --git a/src/rust.rs b/src/rust.rs index bfe5f4b..17788d1 100644 --- a/src/rust.rs +++ b/src/rust.rs @@ -1,65 +1,42 @@ use crate::{ model::Node, - tree::{build_tree, FlatItem}, + traverse::{analyze_tree_sitter, ast_preview, NodeClassifier}, util::truncate_preview, }; -use tree_sitter::{Node as AstNode, Parser}; +use tree_sitter::Node as AstNode; pub(crate) fn analyze_rust(source: &str, preview_len: usize) -> Vec { - let mut parser = Parser::new(); - parser - .set_language(&tree_sitter_rust::LANGUAGE.into()) - .expect("tree-sitter Rust grammar is valid"); - let Some(tree) = parser.parse(source, None) else { - return Vec::new(); - }; - - let mut items = Vec::::new(); - collect_rust_items( - tree.root_node(), + analyze_tree_sitter( + tree_sitter_rust::LANGUAGE.into(), source, preview_len, - None, - false, - &mut items, - ); - build_tree(&items, None) + RustClassifier::default(), + ) } -fn collect_rust_items( - ast_node: AstNode, - source: &str, - preview_len: usize, - parent: Option, - has_test_attribute: bool, - items: &mut Vec, -) { - let current_parent = - if let Some(node) = rust_node_from_ast(ast_node, source, preview_len, has_test_attribute) { - let index = items.len(); - items.push(FlatItem { parent, node }); - Some(index) - } else { - parent - }; +#[derive(Default)] +struct RustClassifier { + /// Set when the preceding sibling was a `#[test]` attribute. + /// Consumed by the next `classify` call. + pending_test_attribute: bool, +} - let mut cursor = ast_node.walk(); - let mut pending_test_attribute = false; - for child in ast_node.named_children(&mut cursor) { - if child.kind() == "attribute_item" { - pending_test_attribute = rust_is_test_attribute(child, source); - continue; - } +impl NodeClassifier for RustClassifier { + fn classify(&mut self, ast_node: AstNode, source: &str, preview_len: usize) -> Option { + let has_test_attribute = self.pending_test_attribute; + self.pending_test_attribute = false; + rust_node_from_ast(ast_node, source, preview_len, has_test_attribute) + } - collect_rust_items( - child, - source, - preview_len, - current_parent, - pending_test_attribute, - items, - ); - pending_test_attribute = false; + fn observe_sibling(&mut self, ast_node: AstNode, source: &str) -> bool { + if ast_node.kind() == "attribute_item" { + // Attribute nodes are not structural items; skip them but + // record test-attribute status for the next sibling. + self.pending_test_attribute = rust_is_test_attribute(ast_node, source); + true + } else { + false + } } } @@ -92,7 +69,7 @@ fn rust_node_from_ast( name: Some(name), start_line: ast_node.start_position().row + 1, end_line: ast_node.end_position().row + 1, - preview: rust_ast_preview(ast_node, source, preview_len), + preview: ast_preview(ast_node, source, preview_len), children: Vec::new(), }) } @@ -121,12 +98,3 @@ fn rust_is_test_attribute(ast_node: AstNode, source: &str) -> bool { .utf8_text(source.as_bytes()) .is_ok_and(|text| text.starts_with("#[test") || text.starts_with("#[tokio::test")) } - -fn rust_ast_preview(ast_node: AstNode, source: &str, preview_len: usize) -> Option { - source - .lines() - .nth(ast_node.start_position().row) - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(|line| truncate_preview(line, preview_len)) -} diff --git a/src/traverse.rs b/src/traverse.rs new file mode 100644 index 0000000..5b41002 --- /dev/null +++ b/src/traverse.rs @@ -0,0 +1,120 @@ +use crate::{ + model::Node, + tree::{build_tree, FlatItem}, + util::truncate_preview, +}; +use tree_sitter::{Language as Grammar, Node as AstNode, Parser}; + +/// Language-specific classification of a tree-sitter AST node. +/// +/// Each language implements this trait to decide which AST nodes are +/// structural items worth surfacing, what `kind` label they receive, and +/// how their display name is derived. The shared traversal machinery in +/// [`collect_items`] / [`analyze_tree_sitter`] handles the recursive walk +/// and flat-list-to-tree reconstruction. +/// +/// For languages that need to propagate sibling-level state (e.g. Rust's +/// `#[test]` attribute), the classifier can maintain internal mutable +/// state: [`observe_sibling`](NodeClassifier::observe_sibling) is called +/// for each named child before recursion, and the classifier decides +/// whether to skip the node and/or stash context for the next sibling's +/// [`classify`](NodeClassifier::classify) call. +pub(crate) trait NodeClassifier { + /// Classify `ast_node` into an output [`Node`]. + /// + /// Returns `None` when the node is not a structural item. + fn classify(&mut self, ast_node: AstNode, source: &str, preview_len: usize) -> Option; + + /// Inspect a named child before the traversal recurses into it. + /// + /// Returns `true` to skip the node entirely (do not recurse or + /// classify). This is where languages detect attribute/decorator + /// nodes and stash state that the next [`classify`] call reads. + /// + /// [`classify`]: NodeClassifier::classify + fn observe_sibling(&mut self, _ast_node: AstNode, _source: &str) -> bool { + false + } +} + +/// Parse `source` with the given tree-sitter `grammar`, traverse the AST +/// using `classifier`, and return the hierarchical node tree. +/// +/// This is the shared entry point for all tree-sitter-based analyzers. +/// Language-specific behaviour is supplied via the [`NodeClassifier`] +/// implementation. +pub(crate) fn analyze_tree_sitter( + grammar: Grammar, + source: &str, + preview_len: usize, + classifier: impl NodeClassifier, +) -> Vec { + let mut parser = Parser::new(); + parser + .set_language(&grammar) + .expect("tree-sitter grammar is valid"); + let Some(tree) = parser.parse(source, None) else { + return Vec::new(); + }; + + let mut items = Vec::::new(); + let mut classifier = classifier; + collect_items( + tree.root_node(), + source, + preview_len, + None, + &mut classifier, + &mut items, + ); + build_tree(&items, None) +} + +/// Recursively walk `ast_node`'s named children, classifying each node +/// via `classifier` and accumulating [`FlatItem`]s into `items`. +/// +/// Nodes that produce an output [`Node`] become parents for their +/// descendants; nodes that do not are transparent (children attach to the +/// current parent). +fn collect_items( + ast_node: AstNode, + source: &str, + preview_len: usize, + parent: Option, + classifier: &mut impl NodeClassifier, + items: &mut Vec, +) { + let current_parent = if let Some(node) = classifier.classify(ast_node, source, preview_len) { + let index = items.len(); + items.push(FlatItem { parent, node }); + Some(index) + } else { + parent + }; + + let mut cursor = ast_node.walk(); + for child in ast_node.named_children(&mut cursor) { + if classifier.observe_sibling(child, source) { + continue; + } + collect_items( + child, + source, + preview_len, + current_parent, + classifier, + items, + ); + } +} + +/// Extract the first non-empty source line at the node's start position, +/// trimmed and truncated to `preview_len` characters. +pub(crate) fn ast_preview(ast_node: AstNode, source: &str, preview_len: usize) -> Option { + source + .lines() + .nth(ast_node.start_position().row) + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| truncate_preview(line, preview_len)) +}