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::ReadableTable;
7use sinter_core::Node;
8
9use crate::error::StoreError;
10use crate::store::{BODY_TERMS, INTERN, NAME_NODES, 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    /// Functions whose body (not header) uses this lowercase word, capped
103    /// at `limit` in id order.
104    pub fn nodes_with_body_term(&self, word: &str, limit: usize) -> Result<Vec<Node>, StoreError> {
105        let txn = self.db.begin_read()?;
106        let table = txn.open_multimap_table(BODY_TERMS)?;
107        let mut ids = BTreeSet::new();
108        for guard in table.get(word)?.take(limit) {
109            ids.insert(guard?.value());
110        }
111        drop(table);
112        drop(txn);
113        self.decode_ids(ids)
114    }
115
116    /// Every node id carrying `word` as a body term, in interned order.
117    /// Cheap (no node decode): membership evidence for ranking.
118    pub fn body_term_ids(&self, word: &str) -> Result<Vec<String>, StoreError> {
119        let txn = self.db.begin_read()?;
120        let table = txn.open_multimap_table(BODY_TERMS)?;
121        let intern = txn.open_table(INTERN)?;
122        let mut ids = Vec::new();
123        for guard in table.get(word)? {
124            if let Some(id) = intern.get(guard?.value())? {
125                ids.push(id.value().to_string());
126            }
127        }
128        Ok(ids)
129    }
130
131    /// Document frequency: how many nodes carry `word` as a body term.
132    pub fn body_term_df(&self, word: &str) -> Result<u64, StoreError> {
133        let txn = self.db.begin_read()?;
134        Ok(txn.open_multimap_table(BODY_TERMS)?.get(word)?.len())
135    }
136
137    /// Recall candidates for query terms: union over each term as an exact
138    /// token plus its trailing-`s` singular variant. Deduped by node id,
139    /// sorted by id — deterministic. The consumer re-scores.
140    pub fn candidates_for_terms(&self, terms: &[String]) -> Result<Vec<Node>, StoreError> {
141        let variants = terms
142            .iter()
143            .map(|term| {
144                let mut words = vec![term.clone()];
145                if let Some(singular) = term
146                    .strip_suffix('s')
147                    .filter(|singular| !singular.is_empty())
148                {
149                    words.push(singular.to_owned());
150                }
151                words
152            })
153            .collect::<Vec<_>>();
154        self.candidates_for_term_variants(&variants)
155    }
156
157    /// Recall candidates for already-normalized query-term variants.
158    /// Each inner vector represents one semantic term (for example,
159    /// `["parsed", "parse"]`). All variants are unioned by node id.
160    pub fn candidates_for_term_variants(
161        &self,
162        variants: &[Vec<String>],
163    ) -> Result<Vec<Node>, StoreError> {
164        let words = variants.iter().flatten().map(String::as_str);
165        self.decode_ids(self.token_ids(words)?)
166    }
167
168    fn token_ids<'a>(
169        &self,
170        words: impl Iterator<Item = &'a str>,
171    ) -> Result<BTreeSet<u32>, StoreError> {
172        let txn = self.db.begin_read()?;
173        let table = txn.open_multimap_table(TOKENS_WORDS)?;
174        let mut ids = BTreeSet::new();
175        for word in words {
176            for guard in table.get(word)? {
177                ids.insert(guard?.value());
178            }
179        }
180        Ok(ids)
181    }
182
183    /// Interned ids -> nodes, in id order (deterministic).
184    fn decode_ids(&self, interned: BTreeSet<u32>) -> Result<Vec<Node>, StoreError> {
185        let txn = self.db.begin_read()?;
186        let intern = txn.open_table(INTERN)?;
187        let mut ids = Vec::new();
188        for i in interned {
189            if let Some(guard) = intern.get(i)? {
190                ids.push(guard.value().to_string());
191            }
192        }
193        drop(intern);
194        ids.sort();
195        let table = txn.open_table(NODES)?;
196        let mut nodes = Vec::new();
197        for id in ids {
198            if let Some(guard) = table.get(id.as_str())? {
199                nodes.push(postcard::from_bytes(guard.value())?);
200            }
201        }
202        Ok(nodes)
203    }
204
205    /// Nodes matching the glob `{head}*{tail}` over the qualified name
206    /// (the `Type::method` part of the id). `Type::*` lists members,
207    /// `*::m` finds every `m` across types; without `::` the bare name is
208    /// matched (`pre*`, `*fix`). `*::m` uses the exact-name index; the
209    /// other shapes walk NODES once (no qualified-name index exists; ids
210    /// are file-ordered).
211    pub fn nodes_glob(&self, head: &str, tail: &str) -> Result<Vec<Node>, StoreError> {
212        let qualified = |id: &str| -> String {
213            match id.split_once('#') {
214                Some((_, rest)) => rest.rsplit_once('@').map_or(rest, |(q, _)| q).to_string(),
215                None => id.to_string(),
216            }
217        };
218        if let Some(name) = tail.strip_prefix("::").filter(|_| head.is_empty()) {
219            let mut nodes = self.nodes_named(name)?;
220            nodes.retain(|n| qualified(n.id.as_str()).ends_with(tail));
221            return Ok(nodes);
222        }
223        let txn = self.db.begin_read()?;
224        let table = txn.open_table(NODES)?;
225        let mut nodes = Vec::new();
226        for entry in table.iter()? {
227            let (id, bytes) = entry?;
228            let q = qualified(id.value());
229            let target = if head.contains("::") || tail.contains("::") {
230                q.as_str()
231            } else {
232                q.rsplit("::").next().unwrap_or(&q)
233            };
234            if target.len() >= head.len() + tail.len()
235                && target.starts_with(head)
236                && target.ends_with(tail)
237            {
238                nodes.push(postcard::from_bytes(bytes.value())?);
239            }
240        }
241        Ok(nodes)
242    }
243
244    /// Fuzzy candidates: nodes sharing the most trigrams with the query,
245    /// best first, capped at `limit`.
246    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Node>, StoreError> {
247        let txn = self.db.begin_read()?;
248        let table = txn.open_multimap_table(crate::store::TRIGRAMS)?;
249        let mut hits: HashMap<u32, usize> = HashMap::new();
250        let query_grams = trigrams(query);
251        for gram in &query_grams {
252            for guard in table.get(gram.as_str())? {
253                *hits.entry(guard?.value()).or_default() += 1;
254            }
255        }
256        drop(table);
257        // Rank by shared grams, tie-broken by resolved id string for
258        // deterministic output.
259        let intern = txn.open_table(INTERN)?;
260        let mut ranked: Vec<(String, usize)> = Vec::new();
261        for (interned, shared) in hits {
262            if let Some(guard) = intern.get(interned)? {
263                ranked.push((guard.value().to_string(), shared));
264            }
265        }
266        drop(intern);
267        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
268        let table = txn.open_table(NODES)?;
269        let mut nodes = Vec::new();
270        // Require a majority of query trigrams to appear in the name; filter
271        // before capping so weak hits cannot crowd out qualifying ones.
272        for (id, _) in ranked
273            .into_iter()
274            .filter(|(_, shared)| shared * 2 >= query_grams.len())
275            .take(limit.max(1))
276        {
277            if let Some(guard) = table.get(id.as_str())? {
278                nodes.push(postcard::from_bytes(guard.value())?);
279            }
280        }
281        Ok(nodes)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use sinter_core::{Node, NodeId, Span, SymbolKind};
288
289    use super::node_tokens;
290
291    fn node(name: &str, file: &str, signature: &str, doc: Option<&str>) -> Node {
292        Node {
293            id: NodeId::new(format!("{file}#{name}@0")),
294            kind: SymbolKind::Function,
295            name: name.to_string(),
296            file: file.to_string(),
297            span: Span { start: 0, end: 1 },
298            signature: signature.to_string(),
299            doc: doc.map(str::to_string),
300        }
301    }
302
303    fn has(words: &std::collections::BTreeSet<String>, expect: &[&str]) {
304        for w in expect {
305            assert!(words.contains(*w), "missing {w:?} in {words:?}");
306        }
307    }
308
309    #[test]
310    fn camel_case_splits_and_keeps_whole_identifier() {
311        let words = node_tokens(&node("PlayerCharacterV2", "a.rs", "", None));
312        has(&words, &["player", "character", "v2", "playercharacterv2"]);
313    }
314
315    #[test]
316    fn acronym_word_boundary() {
317        let words = node_tokens(&node("HTTPServer", "a.rs", "", None));
318        has(&words, &["http", "server", "httpserver"]);
319    }
320
321    #[test]
322    fn snake_case_and_signature_and_doc() {
323        let words = node_tokens(&node(
324            "climb_state",
325            "a.rs",
326            "fn climb_state(input: MoveInput)",
327            Some("Main traversal controller."),
328        ));
329        has(
330            &words,
331            &[
332                // No "climb_state" whole token: query terms are split on
333                // non-alphanumerics too, so an underscore token is unreachable.
334                "climb",
335                "state",
336                "move",
337                "input",
338                "traversal",
339                "controller",
340                "fn",
341            ],
342        );
343    }
344
345    #[test]
346    fn path_segments_indexed() {
347        let words = node_tokens(&node("f", "src/player/ClimbComponent.test.ts", "", None));
348        has(
349            &words,
350            &[
351                "src",
352                "player",
353                "climb",
354                "component",
355                "climbcomponent",
356                "test",
357                "ts",
358            ],
359        );
360    }
361
362    #[test]
363    fn short_subwords_dropped_but_short_wholes_kept_at_two_chars() {
364        let words = node_tokens(&node("aB", "x.rs", "", None));
365        assert!(words.contains("ab"), "{words:?}");
366        assert!(!words.contains("a") && !words.contains("b"), "{words:?}");
367    }
368}