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