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
43pub struct WordBad {
45 pub word: String,
46 pub file: String,
47 #[allow(dead_code)] pub line_num: usize,
49}
50
51const 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 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, pub extensions: Vec<String>, pub word_regex: Regex, pub local_ignore_file: PathBuf, pub local_ignored_words: HashSet<String>, pub global_ignore_file: PathBuf, pub global_ignored_words: HashSet<String>, }
89
90impl Engine {
91 #[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 #[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 #[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 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}