1use 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
12pub(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
25pub(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
61fn 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 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 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 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 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 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 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 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 "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}