Skip to main content

sinter_store/
search.rs

1//! Symbol search: exact name index plus lowercased-trigram fuzzy index.
2//! Both are maintained incrementally by `update.rs`.
3
4use std::collections::{BTreeSet, HashMap};
5
6use redb::ReadableDatabase;
7use sinter_core::{Node, NodeId};
8
9use crate::error::StoreError;
10use crate::store::{INTERN, NAME_NODES, Store, TOKENS_WORDS};
11
12/// Lowercased character trigrams of a name; names shorter than 3 chars
13/// index as one whole-name gram.
14pub(crate) fn trigrams(name: &str) -> Vec<String> {
15    let lower: Vec<char> = name.to_lowercase().chars().collect();
16    if lower.len() < 3 {
17        return vec![lower.iter().collect()];
18    }
19    let mut grams: Vec<String> = lower.windows(3).map(|w| w.iter().collect()).collect();
20    grams.sort();
21    grams.dedup();
22    grams
23}
24
25/// Distinct lowercase words a node is findable by: name, signature, doc
26/// text, and file-path segments. Identifiers split on non-alphanumerics
27/// (snake_case, path separators) and camelCase boundaries, including
28/// acronym-word ("HTTPServer" -> http, server); each full identifier is
29/// also indexed whole ("PlayerCharacterV2" -> "playercharacterv2") so
30/// exact-name lookup stays one keyed read. Subwords shorter than 2 chars
31/// are dropped. This is a RECALL filter, not the scorer: the consumer
32/// re-scores candidates with its own substring logic, so over-inclusion is
33/// fine. Recall is subword-boundary based — substrings crossing subword
34/// boundaries ("rchar") are not indexed (accepted limitation, design §4).
35pub(crate) fn node_tokens(node: &Node) -> BTreeSet<String> {
36    let mut words = BTreeSet::new();
37    for text in [
38        node.name.as_str(),
39        node.signature.as_str(),
40        node.file.as_str(),
41        node.doc.as_deref().unwrap_or(""),
42    ] {
43        for ident in text
44            .split(|c: char| !c.is_alphanumeric())
45            .filter(|s| !s.is_empty())
46        {
47            for sub in camel_split(ident) {
48                if sub.chars().count() >= 2 {
49                    words.insert(sub);
50                }
51            }
52            let whole = ident.to_lowercase();
53            if whole.chars().count() >= 2 {
54                words.insert(whole);
55            }
56        }
57    }
58    words
59}
60
61/// Lowercased camelCase subwords: a boundary before an uppercase char that
62/// follows a non-uppercase one (aB, 2B) or that starts a lowercase run
63/// after an acronym (the S in "HTTPServer").
64fn camel_split(ident: &str) -> Vec<String> {
65    let chars: Vec<char> = ident.chars().collect();
66    let mut words = Vec::new();
67    let mut cur = String::new();
68    for (i, &c) in chars.iter().enumerate() {
69        let boundary = i > 0
70            && c.is_uppercase()
71            && (!chars[i - 1].is_uppercase() || chars.get(i + 1).is_some_and(|n| n.is_lowercase()));
72        if boundary && !cur.is_empty() {
73            words.push(std::mem::take(&mut cur));
74        }
75        cur.extend(c.to_lowercase());
76    }
77    if !cur.is_empty() {
78        words.push(cur);
79    }
80    words
81}
82
83impl Store {
84    /// Nodes whose name matches exactly (case-sensitive).
85    pub fn nodes_named(&self, name: &str) -> Result<Vec<Node>, StoreError> {
86        let txn = self.db.begin_read()?;
87        let table = txn.open_multimap_table(NAME_NODES)?;
88        let mut interned = BTreeSet::new();
89        for guard in table.get(name)? {
90            interned.insert(guard?.value());
91        }
92        drop(table);
93        drop(txn);
94        self.decode_ids(interned)
95    }
96
97    /// Nodes indexed under this exact lowercase token (see `node_tokens`).
98    pub fn nodes_with_token(&self, word: &str) -> Result<Vec<Node>, StoreError> {
99        self.decode_ids(self.token_ids([word].into_iter())?)
100    }
101
102    /// Recall candidates for query terms: union over each term as an exact
103    /// token plus its trailing-`s` singular variant. Deduped by node id,
104    /// sorted by id — deterministic. The consumer re-scores.
105    pub fn candidates_for_terms(&self, terms: &[String]) -> Result<Vec<Node>, StoreError> {
106        let words = terms.iter().flat_map(|term| {
107            let singular = term
108                .strip_suffix('s')
109                .filter(|singular| !singular.is_empty());
110            [Some(term.as_str()), singular].into_iter().flatten()
111        });
112        self.decode_ids(self.token_ids(words)?)
113    }
114
115    fn token_ids<'a>(
116        &self,
117        words: impl Iterator<Item = &'a str>,
118    ) -> Result<BTreeSet<u32>, StoreError> {
119        let txn = self.db.begin_read()?;
120        let table = txn.open_multimap_table(TOKENS_WORDS)?;
121        let mut ids = BTreeSet::new();
122        for word in words {
123            for guard in table.get(word)? {
124                ids.insert(guard?.value());
125            }
126        }
127        Ok(ids)
128    }
129
130    /// Interned ids -> nodes, in id order (deterministic).
131    fn decode_ids(&self, interned: BTreeSet<u32>) -> Result<Vec<Node>, StoreError> {
132        let txn = self.db.begin_read()?;
133        let table = txn.open_table(INTERN)?;
134        let mut ids = Vec::new();
135        for i in interned {
136            if let Some(guard) = table.get(i)? {
137                ids.push(guard.value().to_string());
138            }
139        }
140        drop(table);
141        drop(txn);
142        ids.sort();
143        let mut nodes = Vec::new();
144        for id in ids {
145            if let Some(node) = self.node(&NodeId::new(id))? {
146                nodes.push(node);
147            }
148        }
149        Ok(nodes)
150    }
151
152    /// Fuzzy candidates: nodes sharing the most trigrams with the query,
153    /// best first, capped at `limit`.
154    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Node>, StoreError> {
155        let txn = self.db.begin_read()?;
156        let table = txn.open_multimap_table(crate::store::TRIGRAMS)?;
157        let mut hits: HashMap<u32, usize> = HashMap::new();
158        let query_grams = trigrams(query);
159        for gram in &query_grams {
160            for guard in table.get(gram.as_str())? {
161                *hits.entry(guard?.value()).or_default() += 1;
162            }
163        }
164        drop(table);
165        // Rank by shared grams, tie-broken by resolved id string for
166        // deterministic output.
167        let intern = txn.open_table(INTERN)?;
168        let mut ranked: Vec<(String, usize)> = Vec::new();
169        for (interned, shared) in hits {
170            if let Some(guard) = intern.get(interned)? {
171                ranked.push((guard.value().to_string(), shared));
172            }
173        }
174        drop(intern);
175        drop(txn);
176        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
177        let mut nodes = Vec::new();
178        for (id, shared) in ranked.into_iter().take(limit.max(1)) {
179            // Require a majority of query trigrams to appear in the name.
180            if shared * 2 >= query_grams.len()
181                && let Some(node) = self.node(&NodeId::new(id))?
182            {
183                nodes.push(node);
184            }
185        }
186        Ok(nodes)
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use sinter_core::{Node, NodeId, Span, SymbolKind};
193
194    use super::node_tokens;
195
196    fn node(name: &str, file: &str, signature: &str, doc: Option<&str>) -> Node {
197        Node {
198            id: NodeId::new(format!("{file}#{name}@0")),
199            kind: SymbolKind::Function,
200            name: name.to_string(),
201            file: file.to_string(),
202            span: Span { start: 0, end: 1 },
203            signature: signature.to_string(),
204            doc: doc.map(str::to_string),
205        }
206    }
207
208    fn has(words: &std::collections::BTreeSet<String>, expect: &[&str]) {
209        for w in expect {
210            assert!(words.contains(*w), "missing {w:?} in {words:?}");
211        }
212    }
213
214    #[test]
215    fn camel_case_splits_and_keeps_whole_identifier() {
216        let words = node_tokens(&node("PlayerCharacterV2", "a.rs", "", None));
217        has(&words, &["player", "character", "v2", "playercharacterv2"]);
218    }
219
220    #[test]
221    fn acronym_word_boundary() {
222        let words = node_tokens(&node("HTTPServer", "a.rs", "", None));
223        has(&words, &["http", "server", "httpserver"]);
224    }
225
226    #[test]
227    fn snake_case_and_signature_and_doc() {
228        let words = node_tokens(&node(
229            "climb_state",
230            "a.rs",
231            "fn climb_state(input: MoveInput)",
232            Some("Main traversal controller."),
233        ));
234        has(
235            &words,
236            &[
237                // No "climb_state" whole token: query terms are split on
238                // non-alphanumerics too, so an underscore token is unreachable.
239                "climb",
240                "state",
241                "move",
242                "input",
243                "traversal",
244                "controller",
245                "fn",
246            ],
247        );
248    }
249
250    #[test]
251    fn path_segments_indexed() {
252        let words = node_tokens(&node("f", "src/player/ClimbComponent.test.ts", "", None));
253        has(
254            &words,
255            &[
256                "src",
257                "player",
258                "climb",
259                "component",
260                "climbcomponent",
261                "test",
262                "ts",
263            ],
264        );
265    }
266
267    #[test]
268    fn short_subwords_dropped_but_short_wholes_kept_at_two_chars() {
269        let words = node_tokens(&node("aB", "x.rs", "", None));
270        assert!(words.contains("ab"), "{words:?}");
271        assert!(!words.contains("a") && !words.contains("b"), "{words:?}");
272    }
273}