aboutsummaryrefslogtreecommitdiffstats
path: root/src/suggest.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/suggest.rs')
-rw-r--r--src/suggest.rs221
1 files changed, 221 insertions, 0 deletions
diff --git a/src/suggest.rs b/src/suggest.rs
new file mode 100644
index 0000000..377259f
--- /dev/null
+++ b/src/suggest.rs
@@ -0,0 +1,221 @@
+//! Offline suggestions: «¿quiso decir…?» ("did you mean…?").
+//!
+//! Uses the hunspell dictionary that already ships with the system
+//! (`/usr/share/hunspell/es_*.dic`): about 57,000 stems, one per line, with
+//! the inflection flags after a slash (`anhelar/REDÀA`).
+//!
+//! Matching ignores accents, so «cafe» finds «café» and «arbol» finds
+//! «árbol», the most common typing mistake in Spanish.
+
+use std::path::{Path, PathBuf};
+
+use anyhow::Result;
+
+/// Where the dictionary usually lives, in order of preference.
+const CANDIDATES: &[&str] = &[
+ "/usr/share/hunspell",
+ "/usr/share/myspell/dicts",
+ "/usr/local/share/hunspell",
+];
+
+/// Finds an installed Spanish `.dic`. Prefers the user's regional variant
+/// (`$LANG`) and falls back to any `es_*`.
+pub fn find_dictionary() -> Option<PathBuf> {
+ let preferred = std::env::var("LANG")
+ .ok()
+ .and_then(|l| l.split('.').next().map(str::to_string))
+ .filter(|l| l.starts_with("es"));
+
+ let mut found: Vec<PathBuf> = Vec::new();
+ for dir in CANDIDATES {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ continue;
+ };
+ for e in entries.flatten() {
+ let p = e.path();
+ let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
+ if name.starts_with("es") && name.ends_with(".dic") {
+ found.push(p);
+ }
+ }
+ }
+ found.sort();
+ if let Some(pref) = preferred {
+ if let Some(hit) = found
+ .iter()
+ .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(pref.as_str()))
+ {
+ return Some(hit.clone());
+ }
+ }
+ found.into_iter().next()
+}
+
+/// Loads the stems from the `.dic`, without the inflection flags.
+pub fn load_words(path: &Path) -> Result<Vec<String>> {
+ let raw = std::fs::read_to_string(path)?;
+ let mut out = Vec::with_capacity(60_000);
+ // The first line is the entry count, not a word.
+ for line in raw.lines().skip(1) {
+ let word = line.split('/').next().unwrap_or("").trim();
+ // Skip entries that contain digits.
+ if word.is_empty() || word.chars().any(|c| c.is_ascii_digit()) {
+ continue;
+ }
+ out.push(word.to_string());
+ }
+ Ok(out)
+}
+
+/// The `limit` words closest to `query`.
+pub fn closest(query: &str, words: &[String], limit: usize) -> Vec<String> {
+ let needle = fold(query);
+ // Past this distance the suggestions stop looking alike.
+ let max = match needle.chars().count() {
+ 0..=4 => 1,
+ 5..=7 => 2,
+ _ => 3,
+ };
+
+ // Someone typing in lowercase wants a common word, not a proper noun:
+ // the .dic includes Abel, Argos or Perseo, which are just noise.
+ let lowercase_query = !query.chars().any(char::is_uppercase);
+
+ let mut scored: Vec<(usize, usize, bool, &String)> = Vec::new();
+ for w in words {
+ if lowercase_query && w.chars().next().is_some_and(char::is_uppercase) {
+ continue;
+ }
+ let cand = fold(w);
+ // A cheap filter before the expensive computation.
+ if cand.len().abs_diff(needle.len()) > max + 2 {
+ continue;
+ }
+ let d = distance(&needle, &cand, max);
+ if d <= max {
+ // On equal distance, the closest length wins, then the one that
+ // starts with the same letter.
+ let len_gap = cand.chars().count().abs_diff(needle.chars().count());
+ let same_start = cand.chars().next() == needle.chars().next();
+ scored.push((d, len_gap, !same_start, w));
+ }
+ }
+ scored.sort_by(|a, b| (a.0, a.1, a.2, a.3).cmp(&(b.0, b.1, b.2, b.3)));
+ scored.dedup_by(|a, b| a.3 == b.3);
+ scored
+ .into_iter()
+ .take(limit)
+ .map(|(_, _, _, w)| w.clone())
+ .collect()
+}
+
+/// Lowercase and without accents, so the comparison does not penalize them.
+pub fn fold(s: &str) -> String {
+ s.trim()
+ .to_lowercase()
+ .chars()
+ .map(|c| match c {
+ 'á' => 'a',
+ 'é' => 'e',
+ 'í' => 'i',
+ 'ó' => 'o',
+ 'ú' | 'ü' => 'u',
+ other => other,
+ })
+ .collect()
+}
+
+/// Levenshtein distance, giving up as soon as it exceeds `max`.
+fn distance(a: &str, b: &str, max: usize) -> usize {
+ let a: Vec<char> = a.chars().collect();
+ let b: Vec<char> = b.chars().collect();
+ if a.is_empty() {
+ return b.len();
+ }
+ if b.is_empty() {
+ return a.len();
+ }
+ let mut prev: Vec<usize> = (0..=b.len()).collect();
+ let mut cur = vec![0usize; b.len() + 1];
+ for i in 1..=a.len() {
+ cur[0] = i;
+ let mut row_min = cur[0];
+ for j in 1..=b.len() {
+ let cost = usize::from(a[i - 1] != b[j - 1]);
+ cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
+ row_min = row_min.min(cur[j]);
+ }
+ if row_min > max {
+ return max + 1;
+ }
+ std::mem::swap(&mut prev, &mut cur);
+ }
+ prev[b.len()]
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn words(list: &[&str]) -> Vec<String> {
+ list.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn folds_accents_and_case() {
+ assert_eq!(fold("Café"), "cafe");
+ assert_eq!(fold("ÁRBOL"), "arbol");
+ // ñ is a letter of its own, not an accented n: it is kept.
+ assert_eq!(fold("Año"), "año");
+ }
+
+ #[test]
+ fn suggests_the_accented_spelling() {
+ let w = words(&["café", "cafetera", "calle", "casa"]);
+ assert_eq!(
+ closest("cafe", &w, 3).first().map(String::as_str),
+ Some("café")
+ );
+ }
+
+ #[test]
+ fn suggests_for_a_typo() {
+ let w = words(&["perro", "pero", "puerro", "casa"]);
+ let s = closest("perrro", &w, 3);
+ assert!(s.contains(&"perro".to_string()), "suggestions: {s:?}");
+ }
+
+ #[test]
+ fn skips_proper_nouns_for_a_lowercase_query() {
+ let w = words(&["Perseo", "perro", "perrero"]);
+ let s = closest("perrro", &w, 5);
+ assert!(!s.contains(&"Perseo".to_string()), "suggestions: {s:?}");
+ assert!(s.contains(&"perro".to_string()));
+ }
+
+ #[test]
+ fn keeps_proper_nouns_when_the_query_is_capitalised() {
+ let w = words(&["Perseo", "perro"]);
+ assert!(closest("Perseu", &w, 5).contains(&"Perseo".to_string()));
+ }
+
+ #[test]
+ fn returns_nothing_for_gibberish() {
+ let w = words(&["casa", "perro", "hablar"]);
+ assert!(closest("xkcdqwzy", &w, 5).is_empty());
+ }
+
+ #[test]
+ fn respects_the_limit() {
+ let w = words(&["casa", "cara", "caza", "cosa", "capa", "cava"]);
+ assert!(closest("casa", &w, 3).len() <= 3);
+ }
+
+ #[test]
+ fn distance_gives_up_past_the_ceiling() {
+ // Very different: returns max+1 without computing the exact value.
+ assert_eq!(distance("abcdefgh", "zzzzzzzz", 2), 3);
+ assert_eq!(distance("casa", "cosa", 2), 1);
+ assert_eq!(distance("casa", "casa", 2), 0);
+ }
+}