1use 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
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 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 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 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 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 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 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 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 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 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 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 "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}