use cfg_if::cfg_if;
use std::fmt;
use std::marker::PhantomData;
use std::ops::Range;
use std::path::PathBuf;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Error {
InvalidLocale,
UnsupportedLocale { locale: String },
DictionaryNotFound {
locale: String,
searched: Vec<PathBuf>,
},
InitializationFailed {
locale: Option<String>,
message: String,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::InvalidLocale => write!(f, "invalid locale"),
Error::UnsupportedLocale { locale } => write!(f, "unsupported locale: {locale}"),
Error::DictionaryNotFound { locale, searched } => write!(
f,
"dictionary not found for locale: {locale}, searched: {searched:?}"
),
Error::InitializationFailed { locale, message } => write!(
f,
"initialization failed for locale: {locale:?}, message: {message}"
),
}
}
}
impl std::error::Error for Error {}
fn normalize_locale(locale: &str) -> (String, String) {
let mut parts = locale
.trim()
.split(['_', '-'])
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect::<Vec<_>>();
if let Some(lang) = parts.first_mut() {
*lang = lang.to_lowercase();
}
if let Some(region) = parts.get_mut(1) {
*region = region.to_uppercase();
}
let hunspell = parts.join("_");
let bcp47 = parts.join("-");
(hunspell, bcp47)
}
cfg_if! {
if #[cfg(target_os = "macos")] {
mod mac;
use crate::mac as imp;
} else if #[cfg(windows)] {
mod win;
use crate::win as imp;
} else if #[cfg(unix)] {
mod unix;
use crate::unix as imp;
} else {
compile_error!("target platform is not supported");
}
}
#[derive(Debug)]
pub struct Checker(imp::Checker, PhantomData<*const ()>);
impl Checker {
pub fn new() -> Result<Self, Error> {
Ok(Checker(imp::Checker::new()?, PhantomData))
}
pub fn with_locale(locale: &str) -> Result<Self, Error> {
if locale.trim().is_empty() {
return Err(Error::InvalidLocale);
}
let (hunspell, bcp47) = normalize_locale(locale);
if hunspell.is_empty() {
return Err(Error::InvalidLocale);
}
Ok(Checker(
imp::Checker::with_locale(&hunspell, &bcp47)?,
PhantomData,
))
}
pub fn suggest(&self, word: &str) -> Vec<String> {
self.0.suggest(word)
}
pub fn check<'a>(&self, text: &'a str) -> impl Iterator<Item = SpellingError> + 'a + use<'a> {
self.0.check(text).map(SpellingError)
}
pub fn is_correct(&self, word: &str) -> bool {
self.check(word).next().is_none()
}
pub fn ignore(&mut self, word: &str) {
self.0.ignore(word)
}
pub fn locale(&self) -> &str {
self.0.locale()
}
pub fn available_locales() -> Vec<String> {
imp::Checker::available_locales()
}
}
pub struct SpellingError(imp::SpellingError);
impl SpellingError {
pub fn text(&self) -> &str {
self.0.text()
}
pub fn start(&self) -> usize {
self.0.start()
}
pub fn end(&self) -> usize {
self.0.end()
}
pub fn range(&self) -> Range<usize> {
self.start()..self.end()
}
}
impl fmt::Display for SpellingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} @ {}..{}", self.text(), self.start(), self.end())
}
}
#[cfg(test)]
mod tests {
use super::{Checker, Error};
#[test]
fn no_errors() {
let text = "I'm happy that this sentence has no errors.";
let checker = Checker::with_locale("en_US").unwrap();
assert_eq!(checker.check(text).count(), 0);
}
#[test]
fn single_error() {
let text = "beleeve";
let checker = Checker::with_locale("en_US").unwrap();
let errors = checker.check(text).collect::<Vec<_>>();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].text(), "beleeve");
assert_eq!(&text[errors[0].start()..errors[0].end()], "beleeve");
}
#[test]
fn multiple_errors() {
let text = "asdf hjkl qwer uiop";
let checker = Checker::with_locale("en_US").unwrap();
let errors = checker.check(text).collect::<Vec<_>>();
assert_eq!(errors.len(), 4);
assert_eq!(errors[0].text(), "asdf");
assert_eq!(errors[1].text(), "hjkl");
assert_eq!(errors[2].text(), "qwer");
assert_eq!(errors[3].text(), "uiop");
}
#[test]
fn error_ranges() {
let text = "one asdf two";
let checker = Checker::with_locale("en_US").unwrap();
let errors: Vec<_> = checker.check(text).collect();
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].text(), "asdf");
assert_eq!(errors[0].start(), 4);
assert_eq!(errors[0].end(), 8);
assert_eq!(&text[errors[0].start()..errors[0].end()], "asdf");
}
#[test]
fn empty() {
let checker = Checker::with_locale("en_US").unwrap();
assert_eq!(checker.check("").count(), 0);
}
#[test]
fn ignore() {
let mut checker = Checker::with_locale("en_US").unwrap();
assert_eq!(checker.check("foobarbaz").count(), 1);
checker.ignore("foobarbaz");
assert_eq!(checker.check("foobarbaz").count(), 0);
}
#[test]
fn ignore_not_permanent() {
let mut checker = Checker::with_locale("en_US").unwrap();
checker.ignore("foobarbaz");
drop(checker);
let checker = Checker::with_locale("en_US").unwrap();
assert_eq!(checker.check("foobarbaz").count(), 1);
}
#[test]
fn with_locale_en_us() {
assert!(Checker::with_locale("en_US").is_ok());
assert!(Checker::with_locale("en-US").is_ok());
}
#[test]
fn with_locale_empty() {
assert!(matches!(
Checker::with_locale(""),
Err(Error::InvalidLocale)
));
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn with_locale_unknown() {
match Checker::with_locale("zz_ZZ") {
Err(Error::DictionaryNotFound { locale, searched }) => {
assert!(locale.contains("zz"));
assert!(!searched.is_empty());
}
other => panic!("expected DictionaryNotFound, got {other:?}"),
}
}
#[test]
#[cfg(any(windows, target_os = "macos"))]
fn with_locale_unknown() {
match Checker::with_locale("zz_ZZ") {
Err(Error::UnsupportedLocale { locale }) => {
assert!(locale.to_lowercase().contains("zz"));
}
other => panic!("expected UnsupportedLocale, got {other:?}"),
}
}
#[cfg(all(unix, not(target_os = "macos")))]
fn unix_locale_or_skip(locales: &[&str]) -> Option<Checker> {
for tag in locales {
match Checker::with_locale(tag) {
Ok(c) => return Some(c),
Err(Error::DictionaryNotFound { .. }) => continue,
Err(e) => panic!("{e}"),
}
}
if std::env::var_os("CI").is_some() {
panic!("missing Hunspell dicts for {locales:?} (CI must install them)");
}
None
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn hunspell_de_de() {
let Some(checker) = unix_locale_or_skip(&["de_DE", "de"]) else {
return;
};
assert!(checker.is_correct("Haus"));
assert!(!checker.is_correct("Hauzz"));
assert!(!checker.suggest("Hauzz").is_empty());
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn hunspell_fr() {
let Some(checker) = unix_locale_or_skip(&["fr_FR", "fr"]) else {
return;
};
assert!(checker.is_correct("bonjour"));
assert!(!checker.is_correct("bonjoour"));
assert!(!checker.suggest("bonjoour").is_empty());
}
#[test]
fn utf8_range() {
let text = "café beleeve";
let checker = Checker::with_locale("en_US").unwrap();
let errors: Vec<_> = checker.check(text).collect();
let e = errors
.iter()
.find(|e| e.text() == "beleeve")
.unwrap_or_else(|| {
panic!(
"expected beleeve, got {:?}",
errors.iter().map(|e| e.text()).collect::<Vec<_>>()
)
});
assert_eq!(&text[e.start()..e.end()], "beleeve");
assert_eq!(e.range(), e.start()..e.end());
assert!(e.start() > 0);
}
#[test]
fn locale_en() {
let checker = Checker::with_locale("en_US").unwrap();
assert!(checker.locale().to_lowercase().contains("en"));
}
#[test]
fn available_locales_nonempty() {
assert!(!Checker::available_locales().is_empty());
}
#[test]
fn suggest_misspelling() {
let checker = Checker::with_locale("en_US").unwrap();
let suggestions = checker.suggest("beleeve");
assert!(!suggestions.is_empty());
}
#[test]
fn is_correct() {
let checker = Checker::with_locale("en_US").unwrap();
assert!(checker.is_correct("believe"));
assert!(!checker.is_correct("beleeve"));
}
#[test]
fn ignore_interior_nul() {
let mut checker = Checker::with_locale("en_US").unwrap();
checker.ignore("foo\0bar");
}
#[test]
fn new_succeeds() {
assert!(Checker::with_locale("en_US").is_ok());
}
}