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    /// Names of the definitions enclosing `line` (0-based), outermost first. A
39    /// line inside `class A` → `method` → body returns `["A", "method"]`.
40    pub fn crumbs(&self, line: usize) -> Vec<String> {
41        let mut hits: Vec<&Def> = self
42            .defs
43            .iter()
44            .filter(|d| d.start_row <= line && line <= d.end_row)
45            .collect();
46        hits.sort_by(|a, b| {
47            a.start_row
48                .cmp(&b.start_row)
49                .then(b.end_row.cmp(&a.end_row))
50        });
51        hits.into_iter().map(|d| d.name.clone()).collect()
52    }
53}
54
55impl LanguageRegistry {
56    /// Parse `content` and index its definition spans for scope lookup. Returns
57    /// an empty index when the language is unsupported, has no tags query, the
58    /// file is too large, or parsing fails — callers then show no breadcrumb.
59    pub fn scope_index(&self, path: &str, content: &str) -> ScopeIndex {
60        if content.len() > MAX_PARSE_BYTES {
61            return ScopeIndex::default();
62        }
63        let Some(entry) = self.for_path(path) else {
64            return ScopeIndex::default();
65        };
66        let Some(query) = entry.tags() else {
67            return ScopeIndex::default();
68        };
69        let Some(tree) = parse(entry, content) else {
70            return ScopeIndex::default();
71        };
72
73        let names = query.capture_names();
74        let bytes = content.as_bytes();
75        let mut cursor = QueryCursor::new();
76        let mut matches = cursor.matches(query, tree.root_node(), bytes);
77        let mut defs = Vec::new();
78        while let Some(m) = matches.next() {
79            let mut span: Option<(usize, usize)> = None;
80            let mut name: Option<String> = None;
81            for cap in m.captures {
82                let cname = names.get(cap.index as usize).copied().unwrap_or("");
83                if cname.starts_with("definition.") {
84                    span = Some((cap.node.start_position().row, cap.node.end_position().row));
85                } else if cname == "name" {
86                    name = cap.node.utf8_text(bytes).ok().map(str::to_owned);
87                }
88            }
89            if let (Some((start_row, end_row)), Some(name)) = (span, name) {
90                defs.push(Def {
91                    start_row,
92                    end_row,
93                    name,
94                });
95            }
96        }
97        ScopeIndex { defs }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn nested_python_scope_reads_class_then_method() {
107        let reg = LanguageRegistry::build();
108        let src = "class A:\n    def method(self):\n        x = 1\n        return x\n";
109        let crumbs = reg.scope_index("a.py", src).crumbs(2);
110        let names: Vec<&str> = crumbs.iter().map(String::as_str).collect();
111        assert_eq!(names, ["A", "method"]);
112    }
113
114    #[test]
115    fn rust_function_scope() {
116        let reg = LanguageRegistry::build();
117        let src = "fn outer() {\n    let y = 2;\n}\n";
118        let crumbs = reg.scope_index("a.rs", src).crumbs(1);
119        let names: Vec<&str> = crumbs.iter().map(String::as_str).collect();
120        assert_eq!(names, ["outer"]);
121    }
122
123    #[test]
124    fn scope_index_empty_for_unsupported_language() {
125        let reg = LanguageRegistry::build();
126        assert!(reg.scope_index("a.zzz-unknown", "whatever\n").is_empty());
127    }
128
129    #[test]
130    fn line_outside_any_definition_has_no_crumbs() {
131        let reg = LanguageRegistry::build();
132        let src = "import os\n\ndef f():\n    pass\n";
133        assert!(reg.scope_index("a.py", src).crumbs(0).is_empty());
134    }
135}