Skip to main content

diffler_core/syntax/
symbols.rs

1//! The names a file uses, as its parse tree sees them: every identifier by
2//! line and byte range, so a reader can follow one name through a change.
3
4use std::ops::Range;
5
6use crate::syntax::registry::LanguageRegistry;
7use crate::syntax::scope::tag_pass;
8use crate::syntax::{MAX_PARSE_BYTES, ScopeIndex, parse};
9
10/// One identifier: its 0-based line, its byte range within that line, and the
11/// name itself.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Ident {
14    pub line: usize,
15    pub range: Range<usize>,
16    pub name: String,
17    /// The parse names a function, method or type here: a definition or a
18    /// call the grammar's tags query marks, or a type position.
19    pub item: bool,
20}
21
22impl LanguageRegistry {
23    /// Every identifier in `content`, top to bottom, and its definition
24    /// spans, from one parse. A file with no grammar, or too large to parse,
25    /// falls back to plain words and no definitions; those words include the
26    /// ones inside strings and comments.
27    pub fn symbols(&self, path: &str, content: &str) -> (Vec<Ident>, ScopeIndex) {
28        let entry = (content.len() <= MAX_PARSE_BYTES)
29            .then(|| self.for_path(path))
30            .flatten();
31        let Some((entry, tree)) = entry.and_then(|entry| Some((entry, parse(entry, content)?)))
32        else {
33            return (words(content), ScopeIndex::default());
34        };
35        let (scope, tagged) = entry
36            .tags()
37            .map(|query| tag_pass(query, &tree, content))
38            .unwrap_or_default();
39        let mut idents = Vec::new();
40        let mut cursor = tree.walk();
41        loop {
42            let node = cursor.node();
43            if node.child_count() == 0 && !node.is_missing() && is_identifier(node.kind()) {
44                let start = node.start_position();
45                let len = node.end_byte() - node.start_byte();
46                if let Some(name) = content
47                    .get(node.start_byte()..node.end_byte())
48                    .filter(|name| !name.is_empty())
49                {
50                    idents.push(Ident {
51                        line: start.row,
52                        range: start.column..start.column + len,
53                        name: name.to_owned(),
54                        item: node.kind().contains("type") || tagged.contains(&node.start_byte()),
55                    });
56                }
57            }
58            if cursor.goto_first_child() || cursor.goto_next_sibling() {
59                continue;
60            }
61            loop {
62                if !cursor.goto_parent() {
63                    return (idents, scope);
64                }
65                if cursor.goto_next_sibling() {
66                    break;
67                }
68            }
69        }
70    }
71}
72
73/// Node kinds that name something. Grammars spell it many ways
74/// (`field_identifier`, `type_identifier`, `simple_identifier`, PHP's
75/// `name`, Ruby's `constant`), and only leaves are names.
76fn is_identifier(kind: &str) -> bool {
77    kind.ends_with("identifier") || kind == "name" || kind == "constant"
78}
79
80/// Every run of `[A-Za-z0-9_]` that does not start with a digit.
81fn words(content: &str) -> Vec<Ident> {
82    let mut out = Vec::new();
83    for (line, text) in content.lines().enumerate() {
84        let mut start = None;
85        for (at, ch) in text
86            .char_indices()
87            .chain(std::iter::once((text.len(), ' ')))
88        {
89            let word_char = ch.is_ascii_alphanumeric() || ch == '_';
90            match (start, word_char) {
91                (None, true) => start = Some(at),
92                (Some(from), false) => {
93                    start = None;
94                    let Some(token) = text.get(from..at) else {
95                        continue;
96                    };
97                    if !token.starts_with(|first: char| first.is_ascii_digit()) {
98                        out.push(Ident {
99                            line,
100                            range: from..at,
101                            name: token.to_owned(),
102                            item: false,
103                        });
104                    }
105                }
106                _ => {}
107            }
108        }
109    }
110    out
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn names_on(idents: &[Ident], line: usize) -> Vec<&str> {
118        idents
119            .iter()
120            .filter(|ident| ident.line == line)
121            .map(|ident| ident.name.as_str())
122            .collect()
123    }
124
125    #[test]
126    fn a_parsed_file_yields_its_names_and_skips_strings_and_comments() {
127        let reg = LanguageRegistry::build();
128        let src = "fn total(price: u32, qty: u32) -> u32 {\n    // price times qty\n    let label = \"qty\";\n    price * qty\n}\n";
129        let idents = reg.symbols("a.rs", src).0;
130        assert_eq!(
131            names_on(&idents, 0),
132            ["total", "price", "qty"],
133            "the grammar parses a primitive type as its own node"
134        );
135        assert!(names_on(&idents, 1).is_empty(), "a comment names nothing");
136        assert_eq!(names_on(&idents, 2), ["label"], "a string names nothing");
137        assert_eq!(names_on(&idents, 3), ["price", "qty"]);
138        let price = idents.iter().find(|i| i.line == 3).expect("price");
139        assert_eq!(
140            &src.lines().nth(3).expect("line")[price.range.clone()],
141            "price"
142        );
143    }
144
145    #[test]
146    fn a_call_or_a_definition_names_an_item_and_a_value_does_not() {
147        let reg = LanguageRegistry::build();
148        let src = "fn apply(lens: Lens) -> u32 {\n    lens.marks(total)\n}\n";
149        let idents = reg.symbols("a.rs", src).0;
150        let item = |line: usize, name: &str| {
151            idents
152                .iter()
153                .find(|ident| ident.line == line && ident.name == name)
154                .is_some_and(|ident| ident.item)
155        };
156        assert!(item(0, "apply"), "a definition");
157        assert!(item(0, "Lens"), "a type position");
158        assert!(item(1, "marks"), "a method call");
159        assert!(!item(1, "lens"), "the value it is called on");
160        assert!(!item(1, "total"), "an argument");
161    }
162
163    #[test]
164    fn a_file_without_a_grammar_falls_back_to_words() {
165        let reg = LanguageRegistry::build();
166        let idents = reg.symbols("notes.zzz", "total = price * 2qty\n").0;
167        assert_eq!(names_on(&idents, 0), ["total", "price"]);
168    }
169}