use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use super::file_table::FileId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct WordHit {
pub file: FileId,
pub line: u32,
}
impl super::postings::FilePosting for WordHit {
fn file(&self) -> FileId {
self.file
}
}
#[derive(Debug, Default, Clone)]
pub struct WordIndex {
pub(super) postings: super::postings::FilePostings<Arc<str>, WordHit>,
}
impl WordIndex {
pub fn new() -> Self {
Self::default()
}
pub fn index_file(&mut self, id: FileId, content: &str) {
let mut contributed: HashMap<&str, Vec<WordHit>> = HashMap::new();
for (line_idx, line) in content.split('\n').enumerate() {
let line_no = (line_idx as u32) + 1;
tokenize(line, |word| {
if word.len() < MIN_WORD_LEN {
return;
}
contributed.entry(word).or_default().push(WordHit {
file: id,
line: line_no,
});
});
}
let rows: Vec<_> = contributed
.into_iter()
.map(|(word, hits)| {
let key = self
.postings
.key(word)
.cloned()
.unwrap_or_else(|| Arc::from(word));
(key, hits)
})
.collect();
self.postings.replace_file(id, rows);
}
pub fn remove_file(&mut self, id: FileId) {
self.postings.remove_file(id);
}
pub fn get(&self, word: &str) -> Cow<'_, [WordHit]> {
self.postings.get(word)
}
pub fn distinct_words(&self) -> usize {
self.postings.len()
}
pub fn snapshot_postings(&self) -> Vec<super::snapshot::WordPosting> {
self.posting_rows()
.into_iter()
.map(|(word, hits)| super::snapshot::WordPosting {
word: word.to_string(),
hits: hits.iter().map(|h| (h.file, h.line)).collect(),
})
.collect()
}
pub(super) fn posting_rows(&self) -> Vec<(&str, Cow<'_, [WordHit]>)> {
let mut rows: Vec<(&str, Cow<'_, [WordHit]>)> = self
.postings
.rows()
.into_iter()
.map(|(word, hits)| (&**word, hits))
.collect();
rows.sort_unstable_by(|a, b| a.0.cmp(b.0));
rows
}
pub fn from_postings(postings: Vec<super::snapshot::WordPosting>) -> Self {
Self {
postings: super::postings::FilePostings::from_rows(postings.into_iter().map(|p| {
(
Arc::from(p.word),
p.hits
.into_iter()
.map(|(file, line)| WordHit { file, line })
.collect(),
)
})),
}
}
pub fn estimated_bytes(&self) -> usize {
self.postings.estimated_bytes()
+ self
.postings
.keys()
.map(|key| key.len() + 16)
.sum::<usize>()
}
pub(super) fn compact_if_heavy(&mut self) {
self.postings.compact_if_heavy();
}
}
const MIN_WORD_LEN: usize = 2;
pub fn records(word: &str) -> bool {
let mut tokens = 0;
let mut whole = false;
tokenize(word, |token| {
tokens += 1;
whole = token == word;
});
tokens == 1 && whole && word.len() >= MIN_WORD_LEN
}
pub fn tokenize<'a>(line: &'a str, mut yield_token: impl FnMut(&'a str)) {
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if !is_ident_start(bytes[i]) {
i += 1;
continue;
}
let start = i;
i += 1;
while i < bytes.len() && is_ident_cont(bytes[i]) {
i += 1;
}
let token = std::str::from_utf8(&bytes[start..i]).expect("ASCII run");
yield_token(token);
}
}
#[inline(always)]
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
#[inline(always)]
fn is_ident_cont(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn restored_occurrences_preserve_multiplicity_and_survive_replacement() {
let mut idx = WordIndex::from_postings(vec![
super::super::snapshot::WordPosting {
word: "alpha".into(),
hits: vec![(2, 3), (9, 1), (2, 3)],
},
super::super::snapshot::WordPosting {
word: "alpha".into(),
hits: vec![(4, 2)],
},
]);
assert_eq!(
&*idx.get("alpha"),
&[
WordHit { file: 2, line: 3 },
WordHit { file: 9, line: 1 },
WordHit { file: 2, line: 3 },
WordHit { file: 4, line: 2 },
]
);
idx.remove_file(2);
idx.index_file(4, "beta beta\nalpha alpha");
assert_eq!(
&*idx.get("alpha"),
&[
WordHit { file: 9, line: 1 },
WordHit { file: 4, line: 2 },
WordHit { file: 4, line: 2 },
]
);
let mut restored = WordIndex::from_postings(idx.snapshot_postings());
assert_eq!(restored.get("alpha"), idx.get("alpha"));
restored.remove_file(4);
assert!(restored.get("beta").is_empty());
assert_eq!(&*restored.get("alpha"), &[WordHit { file: 9, line: 1 }]);
}
#[test]
fn tokenize_skips_punctuation_and_numbers() {
let mut tokens: Vec<String> = Vec::new();
tokenize("let foo_bar = baz(1, 2.0); // 42_things", |t| {
tokens.push(t.to_string());
});
assert_eq!(tokens, vec!["let", "foo_bar", "baz", "_things"]);
}
#[test]
fn index_records_line_numbers() {
let mut idx = WordIndex::new();
idx.index_file(7, "alpha\n beta gamma\nalpha");
let alpha_hits = idx.get("alpha");
assert_eq!(alpha_hits.len(), 2);
assert_eq!(alpha_hits[0], WordHit { file: 7, line: 1 });
assert_eq!(alpha_hits[1], WordHit { file: 7, line: 3 });
let gamma_hits = idx.get("gamma");
assert_eq!(&*gamma_hits, &[WordHit { file: 7, line: 2 }]);
}
#[test]
fn remove_and_reindex_replace_entries() {
let mut idx = WordIndex::new();
idx.index_file(1, "foo bar baz");
idx.remove_file(1);
assert!(idx.get("foo").is_empty());
idx.index_file(1, "qux");
assert!(idx.get("foo").is_empty());
assert_eq!(&*idx.get("qux"), &[WordHit { file: 1, line: 1 }]);
}
#[test]
fn single_character_tokens_are_skipped() {
let mut idx = WordIndex::new();
idx.index_file(1, "a foo b bar c");
assert!(idx.get("a").is_empty());
assert_eq!(idx.get("foo").len(), 1);
}
}