Skip to main content

diffler_core/syntax/
symbols.rs

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