Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 9 additions & 44 deletions src/javascript.rs
Original file line number Diff line number Diff line change
@@ -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<Node> {
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::<FlatItem>::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<usize>,
items: &mut Vec<FlatItem>,
) {
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<Node> {
javascript_node_from_ast(ast_node, source, preview_len)
}
}

Expand All @@ -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(),
})
}
Expand All @@ -89,12 +63,3 @@ fn javascript_ast_name(ast_node: AstNode, source: &str) -> Option<String> {
.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<String> {
source
.lines()
.nth(ast_node.start_position().row)
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| truncate_preview(line, preview_len))
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ mod markdown;
mod model;
mod render;
mod rust;
mod traverse;
mod tree;
mod util;

Expand Down
88 changes: 28 additions & 60 deletions src/rust.rs
Original file line number Diff line number Diff line change
@@ -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<Node> {
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::<FlatItem>::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<usize>,
has_test_attribute: bool,
items: &mut Vec<FlatItem>,
) {
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<Node> {
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
}
}
}

Expand Down Expand Up @@ -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(),
})
}
Expand Down Expand Up @@ -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<String> {
source
.lines()
.nth(ast_node.start_position().row)
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| truncate_preview(line, preview_len))
}
120 changes: 120 additions & 0 deletions src/traverse.rs
Original file line number Diff line number Diff line change
@@ -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<Node>;

/// 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<Node> {
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::<FlatItem>::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<usize>,
classifier: &mut impl NodeClassifier,
items: &mut Vec<FlatItem>,
) {
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<String> {
source
.lines()
.nth(ast_node.start_position().row)
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| truncate_preview(line, preview_len))
}
Loading