mod backup;
mod pool;
mod util;
use std::fmt;
use std::sync::{Arc, RwLock};
use std::time::SystemTime;
use fst::{IntoStreamer as _, Streamer as _};
use hashbrown::HashSet;
use regex_syntax::escape as regex_escape;
use crate::lexer::ranges::LexerRegexRange;
use super::generic::*;
pub use self::pool::{FstStoreId, FstStorePool};
use self::util::*;
pub struct FstStore {
graph: fst::Set,
target: FstStoreId,
pending: FstStorePending,
last_used: Arc<RwLock<SystemTime>>,
last_consolidated: Arc<RwLock<SystemTime>>,
graph_consolidate: Arc<RwLock<HashSet<FstStoreId>>>,
action_config: FstStoreActionConfig,
}
#[derive(Default)]
pub struct FstStorePending {
pop: Arc<RwLock<HashSet<Vec<u8>>>>,
push: Arc<RwLock<HashSet<Vec<u8>>>>,
}
pub struct FstStoreActionBuilder<'build> {
pub fst_store_config: &'build crate::config::FstStoreConfig,
}
type FstStoreAtom = u32;
pub struct FstStoreMisc;
#[derive(Copy, Clone)]
enum FstStorePathMode {
Permanent,
Temporary,
Backup,
}
impl FstStorePathMode {
fn extension(&self) -> &'static str {
match self {
FstStorePathMode::Permanent => ".fst",
FstStorePathMode::Temporary => ".fst.tmp",
FstStorePathMode::Backup => ".fst.bck",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct FstStoreActionConfig {
pub prefix_matching_enabled: bool,
pub fuzzy_matching_enabled: bool,
}
impl Default for FstStoreActionConfig {
fn default() -> Self {
Self {
prefix_matching_enabled: true,
fuzzy_matching_enabled: true,
}
}
}
const WORD_LIMIT_LENGTH: usize = 40;
impl FstStore {
pub fn cardinality(&self) -> usize {
self.graph.len()
}
pub fn as_stream(&self) -> fst::set::Stream<'_> {
self.graph.into_stream()
}
pub fn lookup_begins_(&self, word: &str) -> Result<fst::set::Stream<'_, fst_regex::Regex>, ()> {
let mut regex_str = regex_escape(word);
regex_str.push('(');
LexerRegexRange::from(word)
.unwrap_or_default()
.write_to(&mut regex_str)
.map_err(|error| tracing::error!(
"Could not lookup word in fst via 'begins': {word:?} because regex write failed: {error:?}"
))?;
regex_str.push_str("*)");
tracing::debug!("Looking-up word in fst via 'begins': {word:?} with regex: {regex_str:?}");
let regex = fst_regex::Regex::new(®ex_str).map_err(|_error| ())?;
Ok(self.graph.search(regex).into_stream())
}
pub fn lookup_typos_(
&self,
word: &str,
typo_factor: u32,
) -> Result<fst::set::Stream<'_, fst_levenshtein::Levenshtein>, ()> {
tracing::debug!(
"Looking-up word in fst via 'typos': {word:?} with typo factor: {typo_factor:?}"
);
let fuzzy = fst_levenshtein::Levenshtein::new(word, typo_factor).map_err(|_error| ())?;
Ok(self.graph.search(fuzzy).into_stream())
}
pub fn should_consolidate(&self) {
let id = self.target;
if self.graph_consolidate.read().unwrap().contains(&id) {
tracing::debug!("Graph consolidation already scheduled on pool: {id}");
return;
};
self.graph_consolidate.write().unwrap().insert(id);
let mut last_consolidated_value = self.last_consolidated.write().unwrap();
*last_consolidated_value = SystemTime::now();
drop(last_consolidated_value);
tracing::info!("Graph consolidation scheduled on pool: {id}");
}
}
impl StoreGeneric for FstStore {
fn ref_last_used(&self) -> &RwLock<SystemTime> {
&self.last_used
}
}
impl FstStore {
pub fn push_word(&self, word: &str, fst_store_config: &crate::config::FstStoreConfig) -> bool {
if Self::word_over_limit(word) {
return false;
}
let word_bytes = word.as_bytes();
if self.pending.pop.read().unwrap().contains(word_bytes) {
self.pending.pop.write().unwrap().remove(word_bytes);
}
let graph_fst = self.graph.as_fst();
if self.graph.contains(&word) {
return false;
}
if check_over_limits(graph_fst.size(), graph_fst.len(), &fst_store_config.graph) {
return false;
}
{
let pending_push_guard = self.pending.push.read().unwrap();
if pending_push_guard.contains(word_bytes)
|| pending_push_guard.len() >= fst_store_config.graph.max_words
{
return false;
}
}
(self.pending.push.write().unwrap()).insert(word_bytes.to_vec());
self.should_consolidate();
true
}
pub fn pop_word(&self, word: &str) -> bool {
if Self::word_over_limit(word) {
return false;
}
let word_bytes = word.as_bytes();
if self.pending.push.read().unwrap().contains(word_bytes) {
self.pending.push.write().unwrap().remove(word_bytes);
}
if !self.graph.contains(word_bytes) {
return false;
}
if self.pending.pop.read().unwrap().contains(word_bytes) {
return false;
}
(self.pending.pop.write().unwrap()).insert(word_bytes.to_vec());
self.should_consolidate();
true
}
pub fn suggest_words(
&self,
from_word: &str,
original_word_len: usize,
limit: usize,
max_typo_factor: Option<u32>,
) -> Option<impl ExactSizeIterator<Item = (String, u16)> + DoubleEndedIterator + use<>> {
use indexmap::IndexMap;
if Self::word_over_limit(from_word) {
return None;
}
let mut found_words: IndexMap<String, u16> = IndexMap::with_capacity(limit);
if self.action_config.prefix_matching_enabled {
if let Some(stream) = self.lookup_begins(from_word, original_word_len) {
for (word, score) in stream {
if found_words.contains_key(&word) {
continue;
}
found_words.insert(word, score);
if found_words.len() >= limit {
break;
}
}
}
}
if self.action_config.fuzzy_matching_enabled && found_words.len() < limit {
let max_typo_factor = max_typo_factor.unwrap_or(typo_factor(original_word_len));
let mut typo_factor = 1u32;
while found_words.len() < limit && typo_factor <= max_typo_factor {
let Some(stream) = self.lookup_typos(from_word, typo_factor) else {
break;
};
for (word, score) in stream {
if found_words.contains_key(&word) {
continue;
}
found_words.insert(word, score);
if found_words.len() >= limit {
break;
}
}
typo_factor += 1;
}
}
if !found_words.is_empty() {
Some(found_words.into_iter())
} else {
None
}
}
pub fn lookup_begins(
&self,
word: &str,
original_word_len: usize,
) -> Option<impl Iterator<Item = (String, u16)>> {
if Self::word_over_limit(word) {
return None;
}
if !self.action_config.prefix_matching_enabled {
return None;
}
let Ok(stream) = self.lookup_begins_(word) else {
return None;
};
tracing::debug!(?word, "looking up for word in 'begins' fst stream");
Some(FstStreamIterator(stream).map(move |word| {
let distance: usize = original_word_len.abs_diff(word.len());
let score = u16::try_from(distance).unwrap_or(u16::MAX);
(word, score)
}))
}
pub fn lookup_typos(
&self,
word: &str,
typo_factor: u32,
) -> Option<impl Iterator<Item = (String, u16)>> {
if !self.action_config.fuzzy_matching_enabled {
return None;
}
let Ok(stream) = self.lookup_typos_(word, typo_factor) else {
return None;
};
tracing::debug!(
?word,
typo_factor,
"looking up for word in 'typos' fst stream"
);
let score = u16::try_from(typo_factor).unwrap_or(u16::MAX);
Some(FstStreamIterator(stream).map(move |word| (word, score)))
}
pub fn list_words(&self, limit: usize, offset: usize) -> Result<Vec<String>, ()> {
let stream = self.as_stream();
match stream
.into_strs()
.map(|words| words.into_iter().skip(offset).take(limit).collect())
{
Err(err) => {
tracing::debug!("conversion of stream failed: {err:?}");
Err(())
}
Ok(words) => Ok(words),
}
}
pub fn count_words(&self) -> usize {
self.cardinality()
}
fn word_over_limit(word: &str) -> bool {
if word.len() > WORD_LIMIT_LENGTH {
tracing::debug!("got over-limit fst word: {word:?}");
true
} else {
false
}
}
}
pub(crate) fn typo_factor(word_len: usize) -> u32 {
match word_len {
1..=3 => 0,
4..=6 => 1,
7..=9 => 2,
_ => 3,
}
}
#[repr(transparent)]
struct FstStreamIterator<'a, A: fst::Automaton>(fst::set::Stream<'a, A>);
impl<'a, A: fst::Automaton> Iterator for FstStreamIterator<'a, A> {
type Item = String;
fn next(&mut self) -> Option<Self::Item> {
match self.0.next() {
Some(bytes) => match str::from_utf8(bytes) {
Ok(str) => Some(str.to_owned()),
Err(_) => None,
},
None => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(in crate::store::fst) fn test_fst_pool() -> FstStorePool {
let fst_store_config = test_fst_store_config();
FstStorePool::new(fst_store_config, Default::default())
}
pub(in crate::store::fst) fn test_fst_store_config() -> Arc<crate::config::FstStoreConfig> {
Arc::new(
config::Config::builder()
.add_source(config::File::from_str(
crate::config::tests::defaults_toml(),
config::FileFormat::Toml,
))
.build()
.unwrap()
.get::<crate::config::FstStoreConfig>("store.fst")
.unwrap(),
)
}
}
impl fmt::Debug for FstStore {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use crate::util::fmt::AsPrettyRwLock;
let Self {
graph,
target,
pending,
last_used,
last_consolidated,
graph_consolidate,
action_config,
} = self;
f.debug_struct("FstStore")
.field("graph", graph)
.field("target", target)
.field("pending", pending)
.field("last_used", &AsPrettyRwLock(last_used))
.field("last_consolidated", &AsPrettyRwLock(last_consolidated))
.field("graph_consolidate", &AsPrettyRwLock(graph_consolidate))
.field("action_config", action_config)
.finish()
}
}
impl fmt::Debug for FstStorePending {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use crate::util::fmt::AsPrettyRwLock;
let Self { pop, push } = self;
f.debug_struct("FstStorePending")
.field("pop", &AsPrettyRwLock(pop))
.field("push", &AsPrettyRwLock(push))
.finish()
}
}