spellkit 0.3.0

Bindings to your friendly neighborhood spellchecker.
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 find_dictionary(locales: &[&str]) -> Option<(PathBuf, PathBuf)> {
    for dir in DICT_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));
            }
        }
    }
    None
}

fn open_dictionary(locales: &[&str]) -> Result<*mut Hunhandle, Error> {
    let (aff, dic) = find_dictionary(locales).ok_or(Error::Unavailable)?;
    let aff_c = CString::new(aff.as_os_str().as_bytes()).map_err(|_| Error::Unavailable)?;
    let dic_c = CString::new(dic.as_os_str().as_bytes()).map_err(|_| Error::Unavailable)?;
    let hunspell = unsafe { Hunspell_create(aff_c.as_ptr(), dic_c.as_ptr()) };
    if hunspell.is_null() {
        return Err(Error::Unavailable);
    }
    Ok(hunspell)
}

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

impl Checker {
    pub fn new() -> Result<Self, Error> {
        Ok(Checker {
            hunspell: open_dictionary(DEFAULT_LOCALES)?,
        })
    }

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

    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 cstr = CString::new(word).unwrap();

        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()
}