mod arabic;
mod detect;
mod english;
mod french;
mod german;
mod spanish;
pub use detect::{LanguageHint, detect_language};
use detect::synonyms_for;
use english::CURATED_SYNONYMS;
use phf::{Set, phf_set};
use rand::{Rng, RngExt, rng as thread_rng};
use std::collections::HashMap;
#[cfg(not(target_arch = "wasm32"))]
use std::fs;
const DEFAULT_REPLACEMENT_PROBABILITY: f64 = 0.5;
#[cfg(not(target_arch = "wasm32"))]
const WORDLIST_MIN_WORD_LEN: usize = 5;
#[cfg(not(target_arch = "wasm32"))]
const WORDLIST_MAX_WORD_LEN: usize = 14;
#[cfg(not(target_arch = "wasm32"))]
static SYSTEM_DICT_PATHS: &[&str] = &[
"/usr/share/dict/american-english",
"/usr/share/dict/words",
"/usr/dict/words",
];
static STOP_WORDS: Set<&'static str> = phf_set! {
"a", "an", "the", "and", "or", "but", "is", "are", "was", "were",
"be", "been", "being", "have", "has", "had", "do", "does", "did",
"will", "would", "could", "should", "may", "might", "shall", "can",
"to", "of", "in", "on", "at", "by", "for", "with", "about", "as",
"into", "through", "during", "before", "after", "above", "below",
"from", "up", "down", "out", "off", "over", "under", "again", "then",
"once", "here", "there", "when", "where", "why", "how", "all", "both",
"each", "few", "more", "most", "other", "some", "such", "no", "not",
"only", "own", "same", "than", "too", "very", "just", "because",
"if", "while", "although", "though", "so", "yet", "nor", "either",
"neither", "i", "me", "my", "myself", "we", "our", "ours", "ourselves",
"you", "your", "yours", "yourself", "yourselves", "he", "him", "his",
"himself", "she", "her", "hers", "herself", "it", "its", "itself",
"they", "them", "their", "theirs", "themselves", "what", "which", "who",
"whom", "this", "that", "these", "those", "am", "every",
};
#[cfg(not(target_arch = "wasm32"))]
fn load_wordlist_by_length() -> HashMap<usize, Vec<String>> {
let mut by_length: HashMap<usize, Vec<String>> = HashMap::new();
for path in SYSTEM_DICT_PATHS {
if let Ok(content) = fs::read_to_string(path) {
for word in content.lines() {
let w = word.trim().to_lowercase();
if w.len() >= WORDLIST_MIN_WORD_LEN
&& w.len() <= WORDLIST_MAX_WORD_LEN
&& w.chars().all(|c| c.is_ascii_alphabetic())
{
by_length.entry(w.len()).or_default().push(w);
}
}
break;
}
}
by_length
}
#[cfg(target_arch = "wasm32")]
fn load_wordlist_by_length() -> HashMap<usize, Vec<String>> {
HashMap::new()
}
pub struct SynonymBank {
wordlist: HashMap<usize, Vec<String>>,
pub(super) language: LanguageHint,
}
impl SynonymBank {
pub fn new() -> Self {
Self {
wordlist: load_wordlist_by_length(),
language: LanguageHint::English,
}
}
pub fn with_language(lang: LanguageHint) -> Self {
Self {
wordlist: load_wordlist_by_length(),
language: lang,
}
}
pub fn candidate<R: Rng>(&self, word: &str, rng: &mut R) -> Option<&str> {
let table = synonyms_for(self.language);
if let Some(synonyms) = table.get(word) {
let idx = rng.random_range(0..synonyms.len());
return Some(synonyms[idx]);
}
if let Some(bucket) = self.wordlist.get(&word.len()).filter(|b| !b.is_empty()) {
let idx = rng.random_range(0..bucket.len());
return Some(&bucket[idx]);
}
None
}
pub fn curated_count(&self) -> usize {
CURATED_SYNONYMS.len()
}
pub fn wordlist_len(&self) -> usize {
self.wordlist.values().map(Vec::len).sum()
}
}
impl Default for SynonymBank {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct EnhanceOutput {
pub text: String,
pub probability: f64,
pub words_substituted: usize,
pub language: LanguageHint,
}
pub struct StochasticEnhancer {
bank: SynonymBank,
probability: f64,
}
impl StochasticEnhancer {
pub fn new(probability: f64) -> Self {
Self {
bank: SynonymBank::new(),
probability: probability.clamp(0.0, 1.0),
}
}
pub fn with_default_probability() -> Self {
Self::new(DEFAULT_REPLACEMENT_PROBABILITY)
}
pub fn with_language(lang: LanguageHint) -> Self {
Self {
bank: SynonymBank::with_language(lang),
probability: DEFAULT_REPLACEMENT_PROBABILITY,
}
}
pub fn with_language_and_probability(lang: LanguageHint, probability: f64) -> Self {
Self {
bank: SynonymBank::with_language(lang),
probability: probability.clamp(0.0, 1.0),
}
}
pub fn probability(&self) -> f64 {
self.probability
}
pub fn enhance(&self, text: &str) -> EnhanceOutput {
let mut rng = thread_rng();
let mut total_substituted: usize = 0;
let enhanced = text
.lines()
.map(|line| {
let (enhanced_line, count) = self.enhance_line(line, &mut rng);
total_substituted += count;
enhanced_line
})
.collect::<Vec<_>>()
.join("\n");
EnhanceOutput {
text: enhanced,
probability: self.probability,
words_substituted: total_substituted,
language: self.bank.language,
}
}
fn enhance_line<R: Rng>(&self, line: &str, rng: &mut R) -> (String, usize) {
let mut result = String::with_capacity(line.len());
let mut substituted: usize = 0;
for (i, raw_token) in line.split_whitespace().enumerate() {
if i > 0 {
result.push(' ');
}
let (prefix, word, suffix) = split_token(raw_token);
let lower = word.to_lowercase();
if is_stop_word(&lower) || rng.random::<f64>() >= self.probability {
result.push_str(raw_token);
continue;
}
if let Some(candidate) = self.bank.candidate(&lower, rng) {
let styled = apply_case_style(word, candidate);
result.push_str(prefix);
result.push_str(&styled);
result.push_str(suffix);
substituted += 1;
} else {
result.push_str(raw_token);
}
}
(result, substituted)
}
}
pub fn split_token(raw: &str) -> (&str, &str, &str) {
let start = raw
.char_indices()
.find(|(_, c)| c.is_alphabetic())
.map(|(i, _)| i)
.unwrap_or(raw.len());
let end = raw
.char_indices()
.rev()
.find(|(_, c)| c.is_alphabetic())
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
if start >= end {
return ("", raw, "");
}
(&raw[..start], &raw[start..end], &raw[end..])
}
fn apply_case_style(original: &str, candidate: &str) -> String {
if original.chars().all(|c| c.is_uppercase()) {
candidate.to_uppercase()
} else if original.chars().next().is_some_and(|c| c.is_uppercase()) {
capitalize(candidate)
} else {
candidate.to_lowercase()
}
}
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + chars.as_str()
}
}
}
pub fn is_stop_word(word: &str) -> bool {
STOP_WORDS.contains(word)
}