spellkit 0.4.0

Cross-platform native spell checking for Rust (NSSpellChecker, Windows Spell Checker, Hunspell).
Documentation
use crate::Error;

use std::ffi::{CStr, CString};
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::ptr;

use hunspell_sys::{
    Hunhandle, Hunspell_add, Hunspell_create, Hunspell_destroy, Hunspell_free_list, Hunspell_spell,
    Hunspell_suggest,
};

const DICT_DIRS: &[&str] = &[
    "/usr/share/hunspell",
    "/usr/share/myspell/dicts",
    "/usr/share/myspell",
    "/usr/local/share/hunspell",
];

const DEFAULT_LOCALES: &[&str] = &["en_US", "en_GB"];

fn dict_search_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();
    if let Ok(dicpath) = std::env::var("DICPATH") {
        for part in dicpath.split(':') {
            if !part.is_empty() {
                dirs.push(PathBuf::from(part));
            }
        }
    }
    for dir in DICT_DIRS {
        dirs.push(PathBuf::from(dir));
    }
    dirs
}

fn find_dictionary(dirs: &[PathBuf], locales: &[&str]) -> Option<(PathBuf, PathBuf, String)> {
    for dir in dirs {
        for locale in locales {
            let aff = Path::new(dir).join(format!("{locale}.aff"));
            let dic = Path::new(dir).join(format!("{locale}.dic"));
            if aff.is_file() && dic.is_file() {
                return Some((aff, dic, (*locale).to_owned()));
            }
        }
    }
    None
}

fn open_dictionary(locales: &[&str]) -> Result<(*mut Hunhandle, String), Error> {
    let dirs = dict_search_dirs();
    let Some((aff, dic, locale)) = find_dictionary(&dirs, locales) else {
        return Err(Error::DictionaryNotFound {
            locale: locales.join(","),
            searched: dirs,
        });
    };
    let label = Some(locale.clone());
    let aff_c =
        CString::new(aff.as_os_str().as_bytes()).map_err(|_| Error::InitializationFailed {
            locale: label.clone(),
            message: "dictionary path contains NUL".into(),
        })?;
    let dic_c =
        CString::new(dic.as_os_str().as_bytes()).map_err(|_| Error::InitializationFailed {
            locale: label.clone(),
            message: "dictionary path contains NUL".into(),
        })?;
    let hunspell = unsafe { Hunspell_create(aff_c.as_ptr(), dic_c.as_ptr()) };
    if hunspell.is_null() {
        return Err(Error::InitializationFailed {
            locale: label.clone(),
            message: "Hunspell_create returned null".into(),
        });
    }
    Ok((hunspell, locale))
}

#[derive(Debug)]
pub struct Checker {
    hunspell: *mut Hunhandle,
    locale: String,
}

fn env_locale() -> Option<String> {
    for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
        let Ok(val) = std::env::var(key) else {
            continue;
        };
        let val = val.trim();
        if val.is_empty() || val == "C" || val == "POSIX" {
            continue;
        }
        let base = val.split('.').next()?.split('@').next()?.trim();
        if base.is_empty() {
            continue;
        }
        return Some(base.replace('-', "_"));
    }
    None
}

impl Checker {
    pub fn new() -> Result<Self, Error> {
        if let Some(loc) = env_locale() {
            if let Ok((hunspell, locale)) = open_dictionary(&[&loc]) {
                return Ok(Checker { hunspell, locale });
            }
            if let Some((lang, _)) = loc.split_once('_') {
                if let Ok((hunspell, locale)) = open_dictionary(&[lang]) {
                    return Ok(Checker { hunspell, locale });
                }
            }
        }
        let (hunspell, locale) = open_dictionary(DEFAULT_LOCALES)?;
        Ok(Checker { hunspell, locale })
    }

    pub fn with_locale(hunspell_locale: &str, _bcp47: &str) -> Result<Self, Error> {
        let (hunspell, locale) = open_dictionary(&[hunspell_locale])?;
        Ok(Checker { hunspell, locale })
    }

    pub fn locale(&self) -> &str {
        &self.locale
    }

    pub fn available_locales() -> Vec<String> {
        let mut out = Vec::new();
        for dir in dict_search_dirs() {
            let Ok(entries) = std::fs::read_dir(&dir) else {
                continue;
            };
            for ent in entries.flatten() {
                let path = ent.path();
                if path.extension().and_then(|e| e.to_str()) != Some("dic") {
                    continue;
                }
                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                    out.push(stem.to_owned());
                }
            }
        }
        out.sort();
        out.dedup();
        out
    }

    pub fn suggest(&self, word: &str) -> Vec<String> {
        const MAX: usize = 10;

        let Ok(cstr) = CString::new(word) else {
            return Vec::new();
        };

        unsafe {
            let mut list: *mut *mut i8 = ptr::null_mut();
            let n = Hunspell_suggest(
                self.hunspell,
                &mut list,
                cstr.as_bytes_with_nul().as_ptr() as *const i8,
            );
            if n <= 0 || list.is_null() {
                return Vec::new();
            }

            let mut out = Vec::new();
            let take = (n as usize).min(MAX);
            for i in 0..take {
                let p = *list.add(i);
                if p.is_null() {
                    continue;
                }
                if let Ok(s) = CStr::from_ptr(p).to_str() {
                    out.push(s.to_owned());
                }
            }
            Hunspell_free_list(self.hunspell, &mut list, n);
            out
        }
    }

    pub fn check<'a>(&self, text: &'a str) -> impl Iterator<Item = SpellingError> + 'a + use<'a> {
        let hunspell = self.hunspell;

        words(text).filter_map(move |(start, end, word)| {
            let cstr = CString::new(word).ok()?;
            let ok =
                unsafe { Hunspell_spell(hunspell, cstr.as_bytes_with_nul().as_ptr() as *const i8) }
                    != 0;
            if ok {
                None
            } else {
                Some(SpellingError {
                    text: word.to_owned(),
                    start,
                    end,
                })
            }
        })
    }

    pub fn ignore(&mut self, word: &str) {
        let Ok(cstr) = CString::new(word) else {
            return;
        };

        unsafe {
            Hunspell_add(
                self.hunspell,
                cstr.as_bytes_with_nul().as_ptr() as *const i8,
            )
        };
    }
}

impl Drop for Checker {
    fn drop(&mut self) {
        unsafe {
            Hunspell_destroy(self.hunspell);
        }
    }
}

pub struct SpellingError {
    text: String,
    start: usize,
    end: usize,
}

impl SpellingError {
    pub fn text(&self) -> &str {
        &self.text
    }
    pub fn start(&self) -> usize {
        self.start
    }
    pub fn end(&self) -> usize {
        self.end
    }
}

fn is_word_char(c: char) -> bool {
    c.is_alphanumeric() || c == '\''
}

fn words(text: &str) -> impl Iterator<Item = (usize, usize, &str)> {
    let mut words = Vec::new();
    let mut chars = text.char_indices().peekable();

    while let Some((start, c)) = chars.next() {
        if !is_word_char(c) {
            continue;
        }

        let mut end = start + c.len_utf8();
        while let Some(&(i, next)) = chars.peek() {
            if !is_word_char(next) {
                break;
            }
            end = i + next.len_utf8();
            chars.next();
        }
        words.push((start, end, &text[start..end]));
    }
    words.into_iter()
}