owl_spell/
engine.rs

1use crate::ignorefile;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::vec::Vec;
9
10#[derive(Deserialize, Debug, Serialize)]
11pub struct Config {
12    #[serde(default = "default_lang")]
13    pub lang: String,
14}
15
16fn default_lang() -> String {
17    "en".to_string()
18}
19impl Default for Config {
20    fn default() -> Self {
21        Config {
22            lang: default_lang(),
23        }
24    }
25}
26
27#[derive(PartialEq)]
28pub enum Status {
29    Ignored,
30    GlobalIgnored,
31    Misspelled,
32}
33
34impl Status {
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            Status::Ignored => "ignored",
38            Status::GlobalIgnored => "global_ignored",
39            Status::Misspelled => "misspelled",
40        }
41    }
42}
43
44/// Result of a spell check
45pub struct WordBad {
46    pub word: String,
47    pub file: String,
48    #[allow(dead_code)] // for now
49    pub line_num: usize,
50}
51
52// keep URLs intact & don't spell check them
53const WORD_PATTERN: &str = r"https?://[^\s]+|\b[a-zA-Z]+(?:[''][a-zA-Z]+)*\b";
54
55fn is_url(s: &str) -> bool {
56    s.starts_with("http://") || s.starts_with("https://")
57}
58
59pub fn get_home_dir() -> PathBuf {
60    let owl_dir = env::home_dir()
61        .expect("no home directory?")
62        .join(".config/owl");
63    let dict_dir = owl_dir.join("dicts");
64    // create ~/.config/owl/dicts/ and all parent dirs
65    fs::create_dir_all(&dict_dir).expect("could not create home dir");
66
67    owl_dir
68}
69
70fn load_config() -> Config {
71    let path = get_home_dir().join("config.toml");
72    let maybe_config = fs::read_to_string(path);
73
74    let cfg = match maybe_config {
75        Ok(config_content) => toml::from_str(&config_content).unwrap_or_default(),
76        _ => Config::default(),
77    };
78    cfg
79}
80
81pub struct Engine {
82    dict: spellbook::Dictionary,               // star of the show
83    pub word_regex: Regex,                     // for splitting words
84    pub local_ignore_file: PathBuf,            // .spellignore
85    pub local_ignored_words: HashSet<String>,  //
86    pub global_ignore_file: PathBuf,           // ~/.config/owl/owlignore
87    pub global_ignored_words: HashSet<String>, //
88}
89
90impl Engine {
91    /// Instantiate owl engine for checking words with ignores.
92    pub fn new(path: &Path) -> Engine {
93        const DICT_MSG: &str = "\nRun with `--init en` to download a dictionary.";
94        let home_dir = get_home_dir();
95
96        let local_ignore_file = ignorefile::get_ignorefile(path);
97        let local_ignored_words = ignorefile::load_words(&local_ignore_file);
98        let global_ignore_file = home_dir.join("spellignore").to_path_buf();
99        let global_ignored_words = ignorefile::load_words(&global_ignore_file);
100
101        let dict_dir = home_dir.join("dicts");
102
103        let cfg = load_config();
104
105        let aff_path = dict_dir.join(format!("{}.aff", cfg.lang));
106        let aff = std::fs::read_to_string(&aff_path);
107        let dic_path = dict_dir.join(format!("{}.dic", cfg.lang));
108        let dic = std::fs::read_to_string(&dic_path);
109        let dict = match (aff, dic) {
110            (Err(err), _) => {
111                panic!("Could not load {:?}: {}\n{}", aff_path, err, DICT_MSG);
112            }
113            (_, Err(err)) => {
114                panic!("Could not load {:?}: {}\n{}", dic_path, err, DICT_MSG);
115            }
116            (Ok(aff_txt), Ok(dic_txt)) => match spellbook::Dictionary::new(&aff_txt, &dic_txt) {
117                Ok(dict) => dict,
118                Err(err) => {
119                    panic!(
120                        "Could not load dictionary {:?} {:?}: {}\n{}",
121                        aff_path, dic_path, err, DICT_MSG
122                    );
123                }
124            },
125        };
126
127        Self {
128            word_regex: Regex::new(WORD_PATTERN).expect("Invalid hard-coded regex!"),
129            dict,
130            global_ignore_file,
131            global_ignored_words,
132            local_ignore_file,
133            local_ignored_words,
134        }
135    }
136
137    /// Run spellcheck on file, returning WordBad per match.
138    ///
139    /// Return values are not deduped yet.
140    pub fn get_misspelled(&self, path: &str) -> Vec<WordBad> {
141        let content = fs::read_to_string(path).expect("Cannot read file");
142        let mut bad = vec![];
143
144        let lines: Vec<_> = content.lines().map(|line| line.to_string()).collect();
145
146        for (line_num, line) in lines.iter().enumerate() {
147            for word in self.word_regex.find_iter(line) {
148                if !is_url(word.as_str()) && !self.dict.check(word.as_str()) {
149                    bad.push(WordBad {
150                        word: word.as_str().to_string(),
151                        line_num,
152                        file: path.to_string(),
153                    });
154                }
155            }
156        }
157        bad
158    }
159
160    /// Check if a word is ignored.
161    ///
162    pub fn get_ignore_status(&self, word: &str) -> Status {
163        if self.local_ignored_words.contains(word) {
164            Status::Ignored
165        } else if self.global_ignored_words.contains(word) {
166            Status::GlobalIgnored
167        } else {
168            Status::Misspelled
169        }
170    }
171
172    /// Make suggestions using underlying library.
173    ///
174    /// TODO: incorporate ignores?
175    pub fn suggest(&self, word: &str, suggestions: &mut Vec<String>) {
176        self.dict.suggest(word, suggestions);
177    }
178
179    pub fn check(&self, word: &str) -> bool {
180        self.dict.check(word)
181    }
182}