use crate::engine::filter::LetterFilter;
use crate::engine::generator::SimpleRng;
const RAW_WORDLIST: &str = include_str!("../../data/wordlist-en.txt");
pub struct Dictionary {
all_words: Vec<&'static str>,
by_letter: [Vec<u32>; 26],
}
impl Default for Dictionary {
fn default() -> Self {
Self::from_embedded()
}
}
impl Dictionary {
pub fn from_embedded() -> Self {
let mut by_letter: [Vec<u32>; 26] = Default::default();
let mut all_words: Vec<&'static str> = Vec::with_capacity(10_000);
for raw in RAW_WORDLIST.lines() {
let word = raw.trim();
if word.is_empty() {
continue;
}
if !word.bytes().all(|b| b.is_ascii_lowercase()) {
continue;
}
let idx = all_words.len() as u32;
all_words.push(word);
let mut seen = [false; 26];
for b in word.bytes() {
let li = (b - b'a') as usize;
if !seen[li] {
seen[li] = true;
by_letter[li].push(idx);
}
}
}
Self {
all_words,
by_letter,
}
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.all_words.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.all_words.is_empty()
}
pub fn next_word(&self, filter: &LetterFilter, rng: &mut SimpleRng) -> Option<&'static str> {
let pool: &[u32] = match filter.focused {
Some(c) if c.is_ascii_lowercase() => &self.by_letter[(c as u8 - b'a') as usize],
_ => {
if self.all_words.is_empty() {
return None;
}
return self.sample_filtered(filter, rng, None);
}
};
if pool.is_empty() {
return None;
}
self.sample_filtered(filter, rng, Some(pool))
}
fn sample_filtered(
&self,
filter: &LetterFilter,
rng: &mut SimpleRng,
pool: Option<&[u32]>,
) -> Option<&'static str> {
let pool_len = match pool {
Some(p) => p.len() as u32,
None => self.all_words.len() as u32,
};
if pool_len == 0 {
return None;
}
const RANDOM_TRIES: u32 = 16;
for _ in 0..RANDOM_TRIES {
let pick = rng.next_bounded(pool_len);
let word_idx = match pool {
Some(p) => p[pick as usize] as usize,
None => pick as usize,
};
let word = self.all_words[word_idx];
if word_chars_allowed(word, filter) {
return Some(word);
}
}
let mut valid: Vec<&'static str> = Vec::new();
match pool {
Some(p) => {
for &i in p {
let w = self.all_words[i as usize];
if word_chars_allowed(w, filter) {
valid.push(w);
}
}
}
None => {
for &w in &self.all_words {
if word_chars_allowed(w, filter) {
valid.push(w);
}
}
}
}
if valid.is_empty() {
None
} else {
let pick = rng.next_bounded(valid.len() as u32) as usize;
Some(valid[pick])
}
}
}
#[inline]
fn word_chars_allowed(word: &str, filter: &LetterFilter) -> bool {
word.chars().all(|c| filter.is_allowed(c))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_embedded_wordlist() {
let dict = Dictionary::from_embedded();
assert!(
dict.len() >= 1000,
"expected dictionary to contain >= 1000 words, got {}",
dict.len()
);
}
#[test]
fn all_words_are_ascii_lowercase() {
let dict = Dictionary::from_embedded();
for w in &dict.all_words {
assert!(w.bytes().all(|b| b.is_ascii_lowercase()), "bad word: {w}");
assert!(!w.is_empty());
}
}
#[test]
fn next_word_respects_filter() {
let dict = Dictionary::from_embedded();
let allowed: Vec<char> = vec!['e', 't', 'a', 'o', 'i', 'n', 'r', 's', 'h', 'l'];
let filter = LetterFilter::new(&allowed, Some('e'));
let mut rng = SimpleRng::with_seed(42);
for _ in 0..100 {
let word = dict
.next_word(&filter, &mut rng)
.expect("filter should have at least one matching word");
assert!(
word.contains('e'),
"focused key 'e' missing from word '{word}'",
);
for c in word.chars() {
assert!(
allowed.contains(&c),
"word '{word}' contains disallowed char '{c}'",
);
}
}
}
#[test]
fn returns_none_when_no_match() {
let dict = Dictionary::from_embedded();
let filter = LetterFilter::new(&['q', 'z'], None);
let mut rng = SimpleRng::with_seed(42);
assert!(dict.next_word(&filter, &mut rng).is_none());
}
#[test]
fn returns_none_with_focus_outside_alphabet() {
let dict = Dictionary::from_embedded();
let filter = LetterFilter::new(&['a', 'b'], Some('q'));
let mut rng = SimpleRng::with_seed(42);
assert!(dict.next_word(&filter, &mut rng).is_none());
}
#[test]
fn unfocused_filter_returns_word() {
let dict = Dictionary::from_embedded();
let allowed: Vec<char> = ('a'..='z').collect();
let filter = LetterFilter::new(&allowed, None);
let mut rng = SimpleRng::with_seed(7);
let word = dict.next_word(&filter, &mut rng);
assert!(word.is_some());
}
}