Skip to main content

graphyn_core/
ast.rs

1//! Shared tree-sitter traversal primitives.
2//!
3//! Behind the `ast` feature so that consumers who only read the graph — the
4//! store, the query layer — do not pull a parser they never call.
5//!
6//! Every adapter previously carried its own copy of a recursive `walk_tree`.
7//! Recursion is the wrong shape here: node depth is attacker- and
8//! generator-controlled (a machine-generated initializer nests one level per
9//! element), adapters parse files on `rayon` workers whose stacks are smaller
10//! than the main thread's, and a stack overflow aborts the process rather than
11//! unwinding, so one pathological file would take down an entire `analyze` run.
12//! [`walk`] is iterative and depth-bounded instead, and reports what it skipped
13//! so callers can turn truncation into a diagnostic rather than a silent gap.
14
15use tree_sitter::Node;
16
17/// Maximum node depth visited by [`walk`].
18///
19/// Hand-written code rarely exceeds ~100; the limit exists to bound generated
20/// and adversarial input, not to constrain real source.
21pub const MAX_TREE_DEPTH: usize = 512;
22
23/// What a [`walk`] actually covered.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct WalkStats {
26    /// Nodes handed to the visitor.
27    pub visited: usize,
28    /// Subtrees left unvisited because they sat below [`MAX_TREE_DEPTH`].
29    pub skipped_subtrees: usize,
30}
31
32impl WalkStats {
33    /// True if any subtree was skipped, meaning extraction for this file is
34    /// incomplete and the caller should say so.
35    pub fn truncated(&self) -> bool {
36        self.skipped_subtrees > 0
37    }
38}
39
40/// Visit `root` and all its descendants in pre-order, depth-first.
41///
42/// Iterative and allocation-free: it drives a single [`tree_sitter::TreeCursor`]
43/// rather than recursing or buffering children. Sibling order is preserved, so
44/// the visit sequence matches source order and results are reproducible.
45///
46/// The walk never escapes the subtree rooted at `root`, even when `root` has
47/// siblings in the wider tree.
48pub fn walk<'t, F>(root: Node<'t>, visit: &mut F) -> WalkStats
49where
50    F: FnMut(Node<'t>),
51{
52    let mut stats = WalkStats::default();
53    let mut cursor = root.walk();
54    let mut depth = 0usize;
55
56    loop {
57        visit(cursor.node());
58        stats.visited += 1;
59
60        if depth < MAX_TREE_DEPTH {
61            if cursor.goto_first_child() {
62                depth += 1;
63                continue;
64            }
65        } else if cursor.node().child_count() > 0 {
66            stats.skipped_subtrees += 1;
67        }
68
69        // Walk up until there is a sibling to move to. Stopping at depth 0
70        // keeps us inside `root`'s subtree.
71        loop {
72            if depth == 0 {
73                return stats;
74            }
75            if cursor.goto_next_sibling() {
76                break;
77            }
78            cursor.goto_parent();
79            depth -= 1;
80        }
81    }
82}
83
84/// The source text a node spans, or `None` if it is not valid UTF-8.
85pub fn node_text<'a>(node: Node<'_>, source: &'a [u8]) -> Option<&'a str> {
86    node.utf8_text(source).ok()
87}
88
89/// The text of a named field of `node`.
90pub fn field_text<'a>(node: Node<'_>, field: &str, source: &'a [u8]) -> Option<&'a str> {
91    node_text(node.child_by_field_name(field)?, source)
92}
93
94/// The 1-based line a node starts on, for `Symbol` and `Relationship` records.
95pub fn start_line(node: Node<'_>) -> u32 {
96    node.start_position().row as u32 + 1
97}
98
99/// The 1-based line a node ends on.
100pub fn end_line(node: Node<'_>) -> u32 {
101    node.end_position().row as u32 + 1
102}
103
104/// The first line of a node's text, for use as a symbol signature.
105///
106/// Trailing whitespace is trimmed; the body of a multi-line definition is
107/// dropped, so the result stays a readable one-line summary.
108pub fn first_line_of(node: Node<'_>, source: &[u8]) -> Option<String> {
109    let text = node_text(node, source)?;
110    Some(text.lines().next().unwrap_or("").trim_end().to_string())
111}
112
113/// True if the subtree contains a node tree-sitter could not parse.
114///
115/// tree-sitter always returns a tree; syntax it cannot handle becomes `ERROR`
116/// or `MISSING` nodes. Adapters use this to emit a parse diagnostic instead of
117/// silently extracting from a partial tree.
118pub fn has_parse_error(root: Node<'_>) -> bool {
119    // `has_error` covers the whole subtree and is maintained by the parser, so
120    // this is O(1) rather than a traversal.
121    root.has_error()
122}
123
124/// The first `ERROR` or `MISSING` node in the subtree, for locating a
125/// parse diagnostic on a line.
126pub fn first_error_line(root: Node<'_>) -> Option<u32> {
127    if !root.has_error() {
128        return None;
129    }
130    let mut line = None;
131    walk(root, &mut |node| {
132        if line.is_none() && (node.is_error() || node.is_missing()) {
133            line = Some(start_line(node));
134        }
135    });
136    line
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn parse_rust(source: &str) -> tree_sitter::Tree {
144        let mut parser = tree_sitter::Parser::new();
145        parser
146            .set_language(&tree_sitter_rust::language())
147            .expect("rust grammar loads");
148        parser.parse(source, None).expect("parser returns a tree")
149    }
150
151    #[test]
152    fn walk_visits_in_source_order() {
153        let tree = parse_rust("struct A; struct B; struct C;");
154        let src = "struct A; struct B; struct C;".as_bytes();
155        let mut names = Vec::new();
156        walk(tree.root_node(), &mut |node| {
157            if node.kind() == "type_identifier" {
158                if let Some(t) = node_text(node, src) {
159                    names.push(t.to_string());
160                }
161            }
162        });
163        assert_eq!(names, vec!["A", "B", "C"], "sibling order must be preserved");
164    }
165
166    #[test]
167    fn walk_covers_the_whole_subtree() {
168        let tree = parse_rust("fn f() { let x = Foo { a: 1 }; }");
169        let mut count = 0usize;
170        let stats = walk(tree.root_node(), &mut |_| count += 1);
171        assert_eq!(stats.visited, count);
172        assert!(count > 10, "a non-trivial function has many nodes");
173        assert!(!stats.truncated());
174    }
175
176    #[test]
177    fn walk_bounds_pathological_nesting_instead_of_overflowing() {
178        // Deeply nested parentheses: one AST level per pair. A recursive walker
179        // overflows here on a worker thread; this must return normally.
180        let depth = MAX_TREE_DEPTH * 4;
181        let source = format!("fn f() {{ let x = {}1{}; }}", "(".repeat(depth), ")".repeat(depth));
182        let tree = parse_rust(&source);
183        let stats = walk(tree.root_node(), &mut |_| {});
184        assert!(
185            stats.truncated(),
186            "input nests deeper than the limit, so truncation must be reported"
187        );
188        assert!(stats.visited >= MAX_TREE_DEPTH);
189    }
190
191    #[test]
192    fn walk_stays_inside_the_requested_subtree() {
193        let source = "struct A; struct B;";
194        let tree = parse_rust(source);
195        let first = tree.root_node().child(0).expect("first item exists");
196
197        let mut seen = Vec::new();
198        walk(first, &mut |node| {
199            if node.kind() == "type_identifier" {
200                if let Some(t) = node_text(node, source.as_bytes()) {
201                    seen.push(t.to_string());
202                }
203            }
204        });
205        assert_eq!(seen, vec!["A"], "walking one item must not reach its sibling");
206    }
207
208    #[test]
209    fn parse_errors_are_detected_and_located() {
210        let tree = parse_rust("fn broken( {");
211        assert!(has_parse_error(tree.root_node()));
212        assert!(first_error_line(tree.root_node()).is_some());
213
214        let clean = parse_rust("fn ok() {}");
215        assert!(!has_parse_error(clean.root_node()));
216        assert_eq!(first_error_line(clean.root_node()), None);
217    }
218}