1use std::collections::HashMap;
2
3use semtree_core::Chunk;
4
5const K1: f32 = 1.2;
7const B: f32 = 0.75;
8const NAME_BOOST: usize = 3;
10
11pub struct LexicalIndex {
19 ids: Vec<String>,
20 doc_len: Vec<f32>,
21 avgdl: f32,
22 postings: HashMap<String, Vec<(usize, u32)>>,
24}
25
26impl LexicalIndex {
27 pub fn from_chunks<'a>(chunks: impl Iterator<Item = &'a Chunk>) -> Self {
29 let mut ids = Vec::new();
30 let mut doc_len = Vec::new();
31 let mut postings: HashMap<String, Vec<(usize, u32)>> = HashMap::new();
32
33 for chunk in chunks {
34 let doc_idx = ids.len();
35 ids.push(chunk.id.clone());
36
37 let mut text = String::new();
38 if let Some(name) = &chunk.name {
39 for _ in 0..NAME_BOOST {
40 text.push_str(name);
41 text.push(' ');
42 }
43 }
44 text.push_str(&chunk.content);
45
46 let tokens = tokenize(&text);
47 doc_len.push(tokens.len() as f32);
48
49 let mut tf: HashMap<String, u32> = HashMap::new();
50 for t in tokens {
51 *tf.entry(t).or_insert(0) += 1;
52 }
53 for (term, count) in tf {
54 postings.entry(term).or_default().push((doc_idx, count));
55 }
56 }
57
58 let n = ids.len().max(1) as f32;
59 let avgdl = doc_len.iter().sum::<f32>() / n;
60 let avgdl = if avgdl == 0.0 { 1.0 } else { avgdl };
61
62 Self {
63 ids,
64 doc_len,
65 avgdl,
66 postings,
67 }
68 }
69
70 pub fn len(&self) -> usize {
71 self.ids.len()
72 }
73
74 pub fn is_empty(&self) -> bool {
75 self.ids.is_empty()
76 }
77
78 pub fn search(&self, query: &str, limit: usize) -> Vec<(String, f32)> {
80 let n = self.ids.len() as f32;
81 let mut q_terms = tokenize(query);
82 q_terms.sort();
83 q_terms.dedup();
84
85 let mut scores: HashMap<usize, f32> = HashMap::new();
86 for term in q_terms {
87 let Some(postings) = self.postings.get(&term) else {
88 continue;
89 };
90 let df = postings.len() as f32;
91 let idf = (1.0 + (n - df + 0.5) / (df + 0.5)).ln();
92 for &(doc, tf) in postings {
93 let tf = tf as f32;
94 let dl = self.doc_len[doc];
95 let denom = tf + K1 * (1.0 - B + B * dl / self.avgdl);
96 *scores.entry(doc).or_insert(0.0) += idf * (tf * (K1 + 1.0)) / denom;
97 }
98 }
99
100 let mut ranked: Vec<(usize, f32)> = scores.into_iter().collect();
101 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
102 ranked.truncate(limit);
103 ranked
104 .into_iter()
105 .map(|(doc, score)| (self.ids[doc].clone(), score))
106 .collect()
107 }
108}
109
110fn tokenize(text: &str) -> Vec<String> {
113 let mut tokens = Vec::new();
114 let mut buf = String::new();
115 for ch in text.chars() {
116 if ch.is_alphanumeric() {
117 buf.push(ch);
118 } else if !buf.is_empty() {
119 split_camel(&buf, &mut tokens);
120 buf.clear();
121 }
122 }
123 if !buf.is_empty() {
124 split_camel(&buf, &mut tokens);
125 }
126 tokens
127}
128
129fn split_camel(word: &str, out: &mut Vec<String>) {
130 let chars: Vec<char> = word.chars().collect();
131 let mut start = 0;
132 for i in 1..chars.len() {
133 if chars[i - 1].is_lowercase() && chars[i].is_uppercase() {
135 push_token(&chars[start..i], out);
136 start = i;
137 }
138 }
139 push_token(&chars[start..], out);
140}
141
142fn push_token(chars: &[char], out: &mut Vec<String>) {
143 if chars.len() >= 2 {
144 out.push(chars.iter().collect::<String>().to_lowercase());
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use semtree_core::{ChunkKind, Language, Span};
152 use std::path::PathBuf;
153
154 fn chunk(id: &str, name: &str, content: &str) -> Chunk {
155 Chunk {
156 id: id.to_string(),
157 path: PathBuf::from(format!("{id}.rs")),
158 language: Language::Rust,
159 kind: ChunkKind::Function,
160 name: Some(name.to_string()),
161 content: content.to_string(),
162 span: Span::new(0, content.len(), 0, 0),
163 doc: None,
164 }
165 }
166
167 #[test]
168 fn tokenizer_splits_camel_and_snake() {
169 let toks = tokenize("TokenBucket rate_limit HTTPServer");
170 assert!(toks.contains(&"token".to_string()));
171 assert!(toks.contains(&"bucket".to_string()));
172 assert!(toks.contains(&"rate".to_string()));
173 assert!(toks.contains(&"limit".to_string()));
174 }
175
176 #[test]
177 fn bm25_ranks_identifier_match_first() {
178 let chunks = [
179 chunk(
180 "a",
181 "TokenBucket",
182 "fn refill(&mut self) { self.tokens += 1; }",
183 ),
184 chunk("b", "parse_config", "fn parse_config() { read_file(); }"),
185 chunk(
186 "c",
187 "log_message",
188 "fn log_message(msg: &str) { println!(); }",
189 ),
190 ];
191 let idx = LexicalIndex::from_chunks(chunks.iter());
192
193 let hits = idx.search("token bucket throttling", 3);
195 assert_eq!(hits.first().map(|(id, _)| id.as_str()), Some("a"));
196 }
197
198 #[test]
199 fn empty_query_returns_nothing() {
200 let chunks = [chunk("a", "foo", "bar")];
201 let idx = LexicalIndex::from_chunks(chunks.iter());
202 assert!(idx.search("", 5).is_empty());
203 }
204}