Skip to main content

diffler_core/syntax/
scope.rs

1//! "What are we inside of": the chain of enclosing definitions (function,
2//! class, method, …) for a line, derived from a grammar's tags query. The
3//! result is plain data so the diff worker can compute it once and share it.
4
5use tree_sitter::{QueryCursor, StreamingIterator};
6
7use crate::syntax::registry::LanguageRegistry;
8use crate::syntax::{MAX_PARSE_BYTES, parse};
9
10/// Definition spans for a file, queried per line for the enclosing-definition
11/// breadcrumb.
12#[derive(Debug, Clone, Default)]
13pub struct ScopeIndex {
14    defs: Vec<Def>,
15}
16
17#[derive(Debug, Clone)]
18struct Def {
19    start_row: usize,
20    end_row: usize,
21    name: String,
22}
23
24impl ScopeIndex {
25    pub fn is_empty(&self) -> bool {
26        self.defs.is_empty()
27    }
28
29    /// 0-based start rows of every definition, sorted and deduped: the jump
30    /// targets for function/definition motions.
31    pub fn def_starts(&self) -> Vec<usize> {
32        let mut rows: Vec<usize> = self.defs.iter().map(|d| d.start_row).collect();
33        rows.sort_unstable();
34        rows.dedup();
35        rows
36    }
37
38    /// 0-based row span of the definition called `name`, start and end
39    /// inclusive. What lets a reference open on a symbol and show its whole
40    /// extent rather than seating a cursor on its first line.
41    pub fn def_span(&self, name: &str) -> Option<(usize, usize)> {
42        self.defs
43            .iter()
44            .find(|def| def.name == name)
45            .map(|def| (def.start_row, def.end_row))
46    }
47
48    /// Names of the definitions enclosing `line` (0-based), outermost first. A
49    /// line inside `class A` → `method` → body returns `["A", "method"]`.
50    pub fn crumbs(&self, line: usize) -> Vec<String> {
51        let mut hits: Vec<&Def> = self
52            .defs
53            .iter()
54            .filter(|d| d.start_row <= line && line <= d.end_row)
55            .collect();
56        hits.sort_by(|a, b| {
57            a.start_row
58                .cmp(&b.start_row)
59                .then(b.end_row.cmp(&a.end_row))
60        });
61        hits.into_iter().map(|d| d.name.clone()).collect()
62    }
63}
64
65impl LanguageRegistry {
66    /// Parse `content` and index its definition spans for scope lookup. Returns
67    /// an empty index when the language is unsupported, has no tags query, the
68    /// file is too large, or parsing fails, so callers show no breadcrumb.
69    pub fn scope_index(&self, path: &str, content: &str) -> ScopeIndex {
70        if content.len() > MAX_PARSE_BYTES {
71            return ScopeIndex::default();
72        }
73        let Some(entry) = self.for_path(path) else {
74            return ScopeIndex::default();
75        };
76        let Some(query) = entry.tags() else {
77            return ScopeIndex::default();
78        };
79        let Some(tree) = parse(entry, content) else {
80            return ScopeIndex::default();
81        };
82
83        let names = query.capture_names();
84        let bytes = content.as_bytes();
85        let mut cursor = QueryCursor::new();
86        let mut matches = cursor.matches(query, tree.root_node(), bytes);
87        let mut defs = Vec::new();
88        while let Some(m) = matches.next() {
89            let mut span: Option<(usize, usize)> = None;
90            let mut name: Option<String> = None;
91            for cap in m.captures {
92                let cname = names.get(cap.index as usize).copied().unwrap_or("");
93                if cname.starts_with("definition.") {
94                    span = Some((cap.node.start_position().row, cap.node.end_position().row));
95                } else if cname == "name" {
96                    name = cap.node.utf8_text(bytes).ok().map(str::to_owned);
97                }
98            }
99            if let (Some((start_row, end_row)), Some(name)) = (span, name) {
100                defs.push(Def {
101                    start_row,
102                    end_row,
103                    name,
104                });
105            }
106        }
107        ScopeIndex { defs }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn nested_python_scope_reads_class_then_method() {
117        let reg = LanguageRegistry::build();
118        let src = "class A:\n    def method(self):\n        x = 1\n        return x\n";
119        let crumbs = reg.scope_index("a.py", src).crumbs(2);
120        let names: Vec<&str> = crumbs.iter().map(String::as_str).collect();
121        assert_eq!(names, ["A", "method"]);
122    }
123
124    #[test]
125    fn rust_function_scope() {
126        let reg = LanguageRegistry::build();
127        let src = "fn outer() {\n    let y = 2;\n}\n";
128        let crumbs = reg.scope_index("a.rs", src).crumbs(1);
129        let names: Vec<&str> = crumbs.iter().map(String::as_str).collect();
130        assert_eq!(names, ["outer"]);
131    }
132
133    #[test]
134    fn scope_index_empty_for_unsupported_language() {
135        let reg = LanguageRegistry::build();
136        assert!(reg.scope_index("a.zzz-unknown", "whatever\n").is_empty());
137    }
138
139    #[test]
140    fn line_outside_any_definition_has_no_crumbs() {
141        let reg = LanguageRegistry::build();
142        let src = "import os\n\ndef f():\n    pass\n";
143        assert!(reg.scope_index("a.py", src).crumbs(0).is_empty());
144    }
145}