use ahash::{AHashMap, AHashSet};
use super::{GitHistoryIndex, encoding, keys};
use crate::git::CommitInfo;
pub const FIELD_AUTHOR: u8 = 0;
pub const FIELD_MESSAGE: u8 = 1;
const MAX_TERM_LEN: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FtsScope {
Author,
Message,
All,
}
impl FtsScope {
pub fn parse(field: Option<&str>) -> Self {
match field.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
Some("author") => FtsScope::Author,
Some("message") | Some("summary") | Some("body") => FtsScope::Message,
_ => FtsScope::All,
}
}
fn fields(self) -> &'static [u8] {
match self {
FtsScope::Author => &[FIELD_AUTHOR],
FtsScope::Message => &[FIELD_MESSAGE],
FtsScope::All => &[FIELD_AUTHOR, FIELD_MESSAGE],
}
}
}
pub fn tokenize(text: &str, out: &mut AHashSet<String>) {
let mut current = String::new();
for ch in text.chars() {
if ch.is_alphanumeric() {
current.extend(ch.to_lowercase());
} else if !current.is_empty() {
flush_token(&mut current, out);
}
}
flush_token(&mut current, out);
}
fn flush_token(current: &mut String, out: &mut AHashSet<String>) {
if current.is_empty() {
return;
}
if current.len() <= MAX_TERM_LEN {
out.insert(std::mem::take(current));
} else {
current.clear();
}
}
pub fn index_commit_terms(
postings: &mut AHashMap<Vec<u8>, Vec<u32>>,
ord: u32,
author: &str,
author_email: &str,
summary: &str,
body: &str,
) {
let mut author_terms = AHashSet::new();
tokenize(author, &mut author_terms);
tokenize(author_email, &mut author_terms);
for term in &author_terms {
postings
.entry(keys::term_key(FIELD_AUTHOR, term.as_bytes()))
.or_default()
.push(ord);
}
let mut message_terms = AHashSet::new();
tokenize(summary, &mut message_terms);
tokenize(body, &mut message_terms);
for term in &message_terms {
postings
.entry(keys::term_key(FIELD_MESSAGE, term.as_bytes()))
.or_default()
.push(ord);
}
}
pub fn commit_matches_terms(info: &CommitInfo, query_terms: &AHashSet<String>, scope: FtsScope) -> bool {
if query_terms.is_empty() {
return false;
}
let mut have: AHashSet<String> = AHashSet::new();
if matches!(scope, FtsScope::Author | FtsScope::All) {
tokenize(&info.author, &mut have);
tokenize(&info.author_email, &mut have);
}
if matches!(scope, FtsScope::Message | FtsScope::All) {
tokenize(&info.summary, &mut have);
tokenize(&info.body, &mut have);
}
query_terms.iter().all(|term| have.contains(term))
}
pub fn commit_matches(info: &CommitInfo, query: &str, scope: FtsScope) -> bool {
let mut query_terms: AHashSet<String> = AHashSet::new();
tokenize(query, &mut query_terms);
commit_matches_terms(info, &query_terms, scope)
}
impl GitHistoryIndex {
pub fn search_commits(&self, query: &str, scope: FtsScope, skip: usize, take: usize) -> Vec<CommitInfo> {
let mut query_terms: AHashSet<String> = AHashSet::new();
tokenize(query, &mut query_terms);
if query_terms.is_empty() || take == 0 {
return Vec::new();
}
let mut matching: Option<AHashSet<u32>> = None;
for term in &query_terms {
let ords = self.ords_for_term(term, scope);
matching = Some(match matching {
None => ords,
Some(acc) => {
let mut acc = acc;
acc.retain(|ord| ords.contains(ord));
acc
}
});
if matching.as_ref().is_some_and(|set| set.is_empty()) {
return Vec::new();
}
}
let Some(matching) = matching else {
return Vec::new();
};
let mut ords: Vec<u32> = matching.into_iter().collect();
ords.sort_unstable_by(|a, b| b.cmp(a));
let mut cache = AHashMap::new();
ords.into_iter()
.skip(skip)
.take(take)
.filter_map(|ord| self.commit_meta(ord, false).map(|meta| (ord, meta)))
.map(|(ord, meta)| {
let mut info = self.meta_to_info(meta, &mut cache);
info.body = self.commit_text(ord).unwrap_or_default();
info
})
.collect()
}
fn ords_for_term(&self, term: &str, scope: FtsScope) -> AHashSet<u32> {
let mut out = AHashSet::new();
for &field in scope.fields() {
if let Some(bytes) = self.term_posting_bytes(&keys::term_key(field, term.as_bytes())) {
out.extend(encoding::decode_ords(&bytes));
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn toks(text: &str) -> Vec<String> {
let mut set = AHashSet::new();
tokenize(text, &mut set);
let mut v: Vec<String> = set.into_iter().collect();
v.sort();
v
}
#[test]
fn tokenize_lowercases_and_splits_on_non_alphanumeric() {
assert_eq!(toks("fix(api): Cache"), vec!["api", "cache", "fix"]);
}
#[test]
fn tokenize_email_splits_into_local_and_host_parts() {
assert_eq!(toks("Jane.Doe@Example.com"), vec!["com", "doe", "example", "jane"]);
}
#[test]
fn tokenize_dedups_repeats() {
assert_eq!(toks("fix fix FIX"), vec!["fix"]);
}
#[test]
fn tokenize_drops_oversized_blob_but_keeps_neighbors() {
let blob = "x".repeat(MAX_TERM_LEN + 1);
let text = format!("keep {blob} tail");
assert_eq!(toks(&text), vec!["keep", "tail"]);
}
#[test]
fn empty_and_punctuation_only_tokenize_to_nothing() {
assert!(toks("").is_empty());
assert!(toks(" -- .. //").is_empty());
}
#[test]
fn scope_parse_maps_aliases() {
assert_eq!(FtsScope::parse(Some("author")), FtsScope::Author);
assert_eq!(FtsScope::parse(Some("Message")), FtsScope::Message);
assert_eq!(FtsScope::parse(Some("body")), FtsScope::Message);
assert_eq!(FtsScope::parse(None), FtsScope::All);
assert_eq!(FtsScope::parse(Some("whatever")), FtsScope::All);
}
}