hangman_solver_lib/solver/
pattern.rs

1// SPDX-License-Identifier: EUPL-1.2
2use std::char;
3use std::iter::zip;
4
5use crate::language::Language;
6use crate::solver::char_collection::CharCollection;
7use crate::solver::char_trait::ControlChars;
8use crate::solver::char_utils::CharUtils;
9use crate::solver::hangman_result::HangmanResult;
10#[cfg(feature = "wasm-bindgen")]
11use crate::solver::hangman_result::WasmHangmanResult;
12use crate::solver::infallible_char_collection::InfallibleCharCollection;
13
14use counter::Counter;
15use itertools::Itertools;
16
17#[cfg(feature = "wasm-bindgen")]
18use js_sys::JsString;
19
20#[allow(clippy::struct_field_names)]
21pub struct Pattern {
22    invalid_letters: Vec<char>,
23    pattern: Vec<char>,
24    first_letter: char,
25    /// true for normal hangman mode
26    letters_in_pattern_have_no_other_occurrences: bool,
27    known_letters_count: usize,
28    invalid_letters_all_ascii: bool,
29    invalid_ascii_letters: [bool; 128],
30}
31
32#[allow(dead_code)]
33impl Pattern {
34    #[must_use]
35    pub const fn invalid_letters(&self) -> &[char] {
36        self.invalid_letters.as_slice()
37    }
38
39    #[must_use]
40    pub const fn pattern(&self) -> &[char] {
41        self.pattern.as_slice()
42    }
43}
44
45#[expect(clippy::used_underscore_items)]
46impl Pattern {
47    #[inline]
48    pub fn new<E1, E2, Err: From<E1> + From<E2>>(
49        pattern: &(impl CharCollection<Error = E1> + ?Sized),
50        invalid_letters: &(impl CharCollection<Error = E2> + ?Sized),
51        letters_in_pattern_have_no_other_occurrences: bool,
52    ) -> Result<Self, Err> {
53        let mut known_letters_count = 0;
54        let mut pattern_as_chars: Vec<char> =
55            Vec::with_capacity(pattern.try_count_chars()?);
56
57        for ch in pattern.try_iter_chars()? {
58            let ch = ch?;
59            if ch.is_whitespace() {
60                continue;
61            }
62            if ch.is_wildcard() {
63                pattern_as_chars.push(char::WILDCARD);
64                continue;
65            }
66            known_letters_count += 1;
67            pattern_as_chars.extend(ch.to_lowercase());
68        }
69
70        let mut invalid_letters_vec: Vec<char> = invalid_letters
71            .try_iter_chars()?
72            .filter(|ch| {
73                !ch.as_ref()
74                    .is_ok_and(|ch| ch.is_whitespace() || ch.is_wildcard())
75            })
76            .collect::<Result<_, _>>()?;
77
78        if letters_in_pattern_have_no_other_occurrences {
79            for ch in &pattern_as_chars {
80                if ch.is_normalised_wildcard() {
81                    continue;
82                }
83                if invalid_letters_vec.contains(ch) {
84                    continue;
85                }
86                invalid_letters_vec.push(*ch);
87            }
88        }
89
90        let first_letter = *pattern_as_chars.first().unwrap_or(&char::WILDCARD);
91
92        let mut invalid_ascii_letters = [false; 128];
93        let mut invalid_letters_all_ascii: bool = true;
94
95        for ch in &invalid_letters_vec {
96            if let Some(b) = ch
97                .to_ascii_char()
98                .map(usize::from)
99                .and_then(|ch| invalid_ascii_letters.get_mut(ch))
100            {
101                *b = true;
102            } else {
103                invalid_letters_all_ascii = false;
104            }
105        }
106
107        Ok(Self {
108            invalid_letters: invalid_letters_vec,
109            pattern: pattern_as_chars,
110            first_letter,
111            letters_in_pattern_have_no_other_occurrences,
112            known_letters_count,
113            invalid_ascii_letters,
114            invalid_letters_all_ascii,
115        })
116    }
117
118    #[inline]
119    #[must_use]
120    fn first_letter_is_wildcard(&self) -> bool {
121        debug_assert_eq!(
122            self.first_letter.is_wildcard(),
123            self.first_letter.is_normalised_wildcard()
124        );
125        self.first_letter.is_normalised_wildcard()
126    }
127
128    #[must_use]
129    #[inline]
130    fn first_letter_matches<CC: InfallibleCharCollection + ?Sized>(
131        &self,
132        word: &&CC,
133    ) -> bool {
134        // This only makes sense if first_letter_is_wildcard is false
135        debug_assert!(!self.first_letter_is_wildcard());
136        word.first_char() == Some(self.first_letter)
137    }
138
139    #[inline]
140    pub(super) fn letter_is_valid(&self, letter: char) -> bool {
141        !letter
142            .to_ascii_char()
143            .map(usize::from)
144            .and_then(|ch| self.invalid_ascii_letters.get(ch))
145            .copied()
146            .unwrap_or(false)
147            && (self.invalid_letters_all_ascii
148                || !self.invalid_letters.contains(&letter))
149    }
150
151    #[must_use]
152    #[inline]
153    fn matches<CC: InfallibleCharCollection + ?Sized>(
154        &self,
155        word: &&CC,
156    ) -> bool {
157        // This only makes sense if word has the same length as the pattern
158        debug_assert_eq!(word.char_count(), self.pattern.len());
159        // none of the reserved chars shall be in the word
160        debug_assert_eq!(
161            char::RESERVED
162                .iter()
163                .filter(|ch| word.iter_chars().contains(ch))
164                .count(),
165            0
166        );
167        for (p, w) in zip(self.pattern.iter(), word.iter_chars()) {
168            if *p == char::WILDCARD {
169                if !self.letter_is_valid(w) {
170                    return false;
171                }
172            } else if *p != w {
173                return false;
174            }
175        }
176        true
177    }
178
179    #[inline]
180    #[must_use]
181    fn known_letters_count(&self) -> usize {
182        debug_assert_eq!(
183            self.known_letters_count,
184            self.pattern
185                .iter()
186                .filter(|ch| !ch.is_normalised_wildcard())
187                .count()
188        );
189
190        self.known_letters_count
191    }
192
193    #[must_use]
194    #[inline]
195    fn _collect_count_and_create_letter_frequency<
196        'a,
197        'b,
198        CC: InfallibleCharCollection + ?Sized + 'a,
199        T: Iterator<Item = &'a CC>,
200    >(
201        &self,
202        words: &'b mut T,
203        max_words_to_collect: Option<usize>,
204    ) -> (u32, Counter<char, u32>, Vec<&'a CC>) {
205        let mut letter_counter: Counter<char, u32> = Counter::new();
206
207        let update_counter: fn(&mut Counter<char, u32>, &CC) =
208            if self.letters_in_pattern_have_no_other_occurrences {
209                |counter, word| counter.update(word.iter_chars().unique())
210            } else {
211                |counter, word| counter.update(word.iter_chars())
212            };
213
214        let mut words =
215            words.inspect(|word| update_counter(&mut letter_counter, word));
216
217        let (words_vec, additional_count): (Vec<&'a CC>, usize) =
218            if let Some(n) = max_words_to_collect {
219                (words.by_ref().take(n).collect(), words.count())
220            } else {
221                (words.collect(), 0)
222            };
223
224        let words_count = u32::try_from(additional_count + words_vec.len())
225            .unwrap_or(u32::MAX);
226
227        if self.letters_in_pattern_have_no_other_occurrences {
228            for letter in &self.pattern {
229                if let Some(count) = letter_counter.remove(letter) {
230                    debug_assert_eq!(count, words_count);
231                }
232            }
233        } else {
234            for letter in self
235                .pattern
236                .iter()
237                .filter(|char| !char.is_normalised_wildcard())
238            {
239                if let Some(new_count) = letter_counter
240                    .get(letter)
241                    .and_then(|c| c.checked_sub(words_count))
242                    .and_then(|c| if c == 0 { None } else { Some(c) })
243                {
244                    letter_counter.insert(*letter, new_count);
245                } else {
246                    letter_counter.remove(letter);
247                }
248            }
249        }
250
251        (words_count, letter_counter, words_vec)
252    }
253
254    #[must_use]
255    #[inline]
256    pub fn solve(
257        &self,
258        language: Language,
259        max_words_to_collect: Option<usize>,
260    ) -> HangmanResult {
261        let mut all_words = language.read_words(self.pattern.len()).into_iter();
262        let (possible_words, letter_frequency, matching_words_count) =
263            self._solve_internal(&mut all_words, max_words_to_collect);
264
265        let mut invalid: Vec<char> = self
266            .invalid_letters
267            .iter()
268            .filter(|ch| !self.pattern.contains(*ch))
269            .copied()
270            .collect();
271
272        invalid.sort_unstable();
273        HangmanResult {
274            input: self.pattern.iter().collect(),
275            invalid,
276            possible_words,
277            language,
278            letter_frequency,
279            matching_words_count,
280        }
281    }
282
283    #[must_use]
284    #[inline]
285    fn _solve_internal<
286        'a,
287        'b,
288        CC: InfallibleCharCollection + ?Sized + 'a,
289        T: Iterator<Item = &'a CC>,
290    >(
291        &self,
292        all_words: &'b mut T,
293        max_words_to_collect: Option<usize>,
294    ) -> (Vec<&'a CC>, Vec<(char, u32)>, u32) {
295        let (word_count, letter_frequency, words) =
296            if self.invalid_letters.is_empty()
297                && self.known_letters_count() == 0
298            {
299                self._collect_count_and_create_letter_frequency(
300                    all_words,
301                    max_words_to_collect,
302                )
303            } else if self.first_letter_is_wildcard() {
304                let mut filtered_words =
305                    all_words.filter(|word| self.matches(word));
306                self._collect_count_and_create_letter_frequency(
307                    &mut filtered_words,
308                    max_words_to_collect,
309                )
310            } else {
311                let mut filtered_words = all_words
312                    .skip_while(|word| !self.first_letter_matches(word))
313                    .take_while(|word| self.first_letter_matches(word))
314                    .filter(|word| self.matches(word));
315                self._collect_count_and_create_letter_frequency(
316                    &mut filtered_words,
317                    max_words_to_collect,
318                )
319            };
320
321        (words, letter_frequency.most_common_ordered(), word_count)
322    }
323}
324
325#[cfg(feature = "wasm-bindgen")]
326#[expect(clippy::used_underscore_items)]
327impl Pattern {
328    #[must_use]
329    #[allow(dead_code)]
330    pub fn solve_with_words<'a, 'b, T: Iterator<Item = &'a JsString>>(
331        &self,
332        all_words: &'b mut T,
333        max_words_to_collect: Option<usize>,
334    ) -> WasmHangmanResult {
335        let (possible_words, letter_frequency, matching_words_count) =
336            self._solve_internal(all_words, max_words_to_collect);
337
338        let mut invalid: Vec<char> = self
339            .invalid_letters
340            .iter()
341            .filter(|ch| !self.pattern.contains(*ch))
342            .copied()
343            .collect();
344
345        let mut letter_frequency_string: String = String::new();
346
347        for (char, count) in letter_frequency {
348            if !letter_frequency_string.is_empty() {
349                letter_frequency_string.push_str(", ");
350            }
351            letter_frequency_string.push(char);
352            letter_frequency_string.push_str(": ");
353            letter_frequency_string.push_str(&count.to_string());
354        }
355
356        invalid.sort_unstable();
357        WasmHangmanResult {
358            input: JsString::from(self.pattern.iter().collect::<String>()),
359            invalid: JsString::from(invalid.iter().collect::<String>()),
360            possible_words: possible_words
361                .into_iter()
362                .map(JsString::to_string)
363                .collect(),
364            letter_frequency: JsString::from(letter_frequency_string),
365            matching_words_count,
366        }
367    }
368}