1use std::collections::{BTreeMap, BTreeSet};
24
25use crate::file::{compress, decompress, FileError};
26use crate::varint::{read_uvarint, write_uvarint};
27
28const MIN_TOKEN_LEN: usize = 2;
30
31const SUBSTRING_TOKEN_CAP: usize = 512;
34
35pub fn tokenize(text: &str) -> impl Iterator<Item = String> + '_ {
39 text.split(|c: char| !c.is_alphanumeric())
40 .filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
41 .map(|t| t.to_lowercase())
42}
43
44fn shared_prefix(a: &str, b: &str) -> usize {
47 let mut n = a
48 .as_bytes()
49 .iter()
50 .zip(b.as_bytes())
51 .take_while(|(x, y)| x == y)
52 .count();
53 while n > 0 && !b.is_char_boundary(n) {
54 n -= 1;
55 }
56 n
57}
58
59#[derive(Default)]
61pub struct TextIndexBuilder {
62 postings: BTreeMap<String, BTreeSet<u32>>,
63}
64
65impl TextIndexBuilder {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn add(&mut self, token: &str, subject: u32) {
72 self.postings
73 .entry(token.to_string())
74 .or_default()
75 .insert(subject);
76 }
77
78 pub fn add_text(&mut self, text: &str, subject: u32) {
80 for tok in tokenize(text) {
81 self.postings.entry(tok).or_default().insert(subject);
82 }
83 }
84
85 pub fn is_empty(&self) -> bool {
86 self.postings.is_empty()
87 }
88
89 pub fn build(&self, codec: u8) -> Vec<u8> {
91 let mut postings = Vec::new();
93 let mut entries: Vec<(&str, u64, u64)> = Vec::with_capacity(self.postings.len());
94 for (token, subjects) in &self.postings {
95 let off = postings.len() as u64;
96 write_uvarint(&mut postings, subjects.len() as u64);
97 let mut prev = 0u32;
98 for &s in subjects {
99 write_uvarint(&mut postings, (s - prev) as u64);
100 prev = s;
101 }
102 entries.push((token, off, postings.len() as u64 - off));
103 }
104 let mut tt = Vec::new();
106 write_uvarint(&mut tt, entries.len() as u64);
107 let mut prev = "";
108 for (token, off, len) in &entries {
109 let shared = shared_prefix(prev, token);
110 write_uvarint(&mut tt, shared as u64);
111 let suffix = &token.as_bytes()[shared..];
112 write_uvarint(&mut tt, suffix.len() as u64);
113 tt.extend_from_slice(suffix);
114 write_uvarint(&mut tt, *off);
115 write_uvarint(&mut tt, *len);
116 prev = token;
117 }
118 let ctt = compress(codec, &tt);
119
120 let mut out = Vec::with_capacity(10 + ctt.len() + postings.len());
121 write_uvarint(&mut out, ctt.len() as u64);
122 out.extend_from_slice(&ctt);
123 out.extend_from_slice(&postings);
124 out
125 }
126}
127
128enum Postings {
130 Resident(Vec<u8>),
132 Remote(Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>),
134}
135
136pub struct TextIndex {
138 tokens: Vec<(String, u64, u64)>,
140 postings: Postings,
141}
142
143impl TextIndex {
144 pub fn from_section(section: &[u8], codec: u8) -> Result<Self, FileError> {
146 let (tokens, postings_start) = parse_token_table(section, codec)?;
147 let postings = section
148 .get(postings_start..)
149 .ok_or(FileError::Container("text-index postings overrun"))?
150 .to_vec();
151 Ok(TextIndex {
152 tokens,
153 postings: Postings::Resident(postings),
154 })
155 }
156
157 pub fn from_token_table(
160 prefix: &[u8],
161 codec: u8,
162 loader: Box<dyn Fn(u64, u64) -> Option<Vec<u8>> + Send + Sync>,
163 ) -> Result<Self, FileError> {
164 let (tokens, _postings_start) = parse_token_table(prefix, codec)?;
165 Ok(TextIndex {
166 tokens,
167 postings: Postings::Remote(loader),
168 })
169 }
170
171 pub fn postings_base(section_prefix: &[u8]) -> Option<usize> {
174 let (len, n) = read_uvarint(section_prefix)?;
175 Some(n + len as usize)
176 }
177
178 pub fn token_count(&self) -> usize {
179 self.tokens.len()
180 }
181
182 fn posting_for(&self, off: u64, len: u64) -> Option<Vec<u8>> {
183 match &self.postings {
184 Postings::Resident(blob) => blob
185 .get(off as usize..(off + len) as usize)
186 .map(<[u8]>::to_vec),
187 Postings::Remote(load) => load(off, len),
188 }
189 }
190
191 pub fn lookup(&self, token: &str) -> Vec<u32> {
194 let i = self.tokens.partition_point(|(t, _, _)| t.as_str() < token);
195 match self.tokens.get(i) {
196 Some((t, off, len)) if t == token => self
197 .posting_for(*off, *len)
198 .map(|b| decode_postings(&b))
199 .unwrap_or_default(),
200 _ => Vec::new(),
201 }
202 }
203
204 pub fn substring(&self, piece: &str) -> Option<Vec<u32>> {
211 let matching: Vec<&(String, u64, u64)> = self
212 .tokens
213 .iter()
214 .filter(|(t, _, _)| t.contains(piece))
215 .collect();
216 if matching.len() > SUBSTRING_TOKEN_CAP {
217 return None;
218 }
219 let mut out = BTreeSet::new();
220 for (_, off, len) in matching {
221 if let Some(b) = self.posting_for(*off, *len) {
222 out.extend(decode_postings(&b));
223 }
224 }
225 Some(out.into_iter().collect())
226 }
227
228 pub fn prefix(&self, prefix: &str) -> Vec<u32> {
231 let start = self.tokens.partition_point(|(t, _, _)| t.as_str() < prefix);
232 let mut out = BTreeSet::new();
233 for (t, off, len) in &self.tokens[start..] {
234 if !t.starts_with(prefix) {
235 break;
236 }
237 if let Some(b) = self.posting_for(*off, *len) {
238 out.extend(decode_postings(&b));
239 }
240 }
241 out.into_iter().collect()
242 }
243}
244
245#[allow(clippy::type_complexity)]
248fn parse_token_table(
249 bytes: &[u8],
250 codec: u8,
251) -> Result<(Vec<(String, u64, u64)>, usize), FileError> {
252 let (ttlen, n) = read_uvarint(bytes).ok_or(FileError::Container("truncated text-index len"))?;
253 let end = n
254 .checked_add(ttlen as usize)
255 .filter(|&e| e <= bytes.len())
256 .ok_or(FileError::Container("text-index token table overruns"))?;
257 let tt = decompress(codec, &bytes[n..end])?;
258
259 let mut pos = 0usize;
260 let take = |pos: &mut usize| -> Option<u64> {
261 let (v, k) = read_uvarint(tt.get(*pos..)?)?;
262 *pos += k;
263 Some(v)
264 };
265 let count = take(&mut pos).ok_or(FileError::Container("truncated token count"))? as usize;
266 let mut tokens: Vec<(String, u64, u64)> = Vec::with_capacity(count.min(tt.len()));
267 let mut prev = String::new();
268 for _ in 0..count {
269 let shared = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
270 let slen = take(&mut pos).ok_or(FileError::Container("truncated token"))? as usize;
271 let sstart = pos;
272 let send = sstart
273 .checked_add(slen)
274 .filter(|&e| e <= tt.len())
275 .ok_or(FileError::Container("token suffix overruns"))?;
276 let suffix = &tt[sstart..send];
277 pos = send;
278 let off = take(&mut pos).ok_or(FileError::Container("truncated posting off"))?;
279 let len = take(&mut pos).ok_or(FileError::Container("truncated posting len"))?;
280 let base = prev.get(..shared.min(prev.len())).unwrap_or(&prev);
281 let mut token = String::with_capacity(base.len() + suffix.len());
282 token.push_str(base);
283 token.push_str(
284 std::str::from_utf8(suffix).map_err(|_| FileError::Container("bad token utf8"))?,
285 );
286 tokens.push((token.clone(), off, len));
287 prev = token;
288 }
289 Ok((tokens, end))
290}
291
292fn decode_postings(bytes: &[u8]) -> Vec<u32> {
294 let mut pos = 0usize;
295 let take = |pos: &mut usize| -> Option<u64> {
296 let (v, k) = read_uvarint(bytes.get(*pos..)?)?;
297 *pos += k;
298 Some(v)
299 };
300 let Some(count) = take(&mut pos) else {
301 return Vec::new();
302 };
303 let mut out = Vec::with_capacity((count as usize).min(bytes.len()));
304 let mut prev = 0u32;
305 for _ in 0..count {
306 let Some(d) = take(&mut pos) else { break };
307 prev = prev.wrapping_add(d as u32);
308 out.push(prev);
309 }
310 out
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::CODEC_NONE;
317
318 #[test]
319 fn tokenize_splits_lowercases_and_drops_short() {
320 let got: Vec<String> = tokenize("Alpha-D-Glucose, 6-phosphate (β)").collect();
322 assert_eq!(
323 got.iter().map(String::as_str).collect::<Vec<_>>(),
324 ["alpha", "glucose", "phosphate"]
325 );
326 }
327
328 #[test]
329 fn round_trip_lookup_prefix_and_and() {
330 let mut b = TextIndexBuilder::new();
331 b.add_text("alpha-D-glucose", 10);
332 b.add_text("glucose 6-phosphate", 11);
333 b.add_text("benzene ring", 12);
334 let bytes = b.build(CODEC_NONE);
335 let idx = TextIndex::from_section(&bytes, CODEC_NONE).unwrap();
336
337 assert_eq!(idx.lookup("glucose"), vec![10, 11]);
338 assert_eq!(idx.lookup("benzene"), vec![12]);
339 assert!(idx.lookup("missing").is_empty());
340 assert_eq!(idx.prefix("alph"), vec![10]);
342 assert_eq!(idx.prefix("phos"), vec![11]);
344 }
345
346 #[test]
347 fn remote_loader_fetches_only_the_queried_posting() {
348 use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
349 use std::sync::Arc;
350 let mut b = TextIndexBuilder::new();
351 b.add_text("glucose here", 1);
352 b.add_text("benzene there", 2);
353 let bytes = b.build(CODEC_NONE);
354 let base = TextIndex::postings_base(&bytes).unwrap();
355 let postings = bytes[base..].to_vec();
356
357 let calls = Arc::new(AtomicUsize::new(0));
358 let (p, c) = (postings.clone(), calls.clone());
359 let loader = Box::new(move |off: u64, len: u64| {
360 c.fetch_add(1, SeqCst);
361 p.get(off as usize..(off + len) as usize)
362 .map(<[u8]>::to_vec)
363 });
364 let idx = TextIndex::from_token_table(&bytes[..base], CODEC_NONE, loader).unwrap();
366 assert_eq!(idx.lookup("glucose"), vec![1]);
367 assert_eq!(calls.load(SeqCst), 1, "one posting fetched");
368 assert!(idx.lookup("nope").is_empty());
369 assert_eq!(calls.load(SeqCst), 1, "an absent token fetches nothing");
370 }
371}