hyprcorrect_core/
providers.rs1use std::ops::Range;
10
11use async_trait::async_trait;
12
13#[async_trait]
15pub trait CorrectionProvider: Send + Sync {
16 async fn check(&self, text: &str, ctx: &Context) -> Result<Vec<Correction>, Error>;
21}
22
23#[derive(Debug, Clone)]
25pub struct Correction {
26 pub span: Range<usize>,
28 pub original: String,
30 pub suggestions: Vec<String>,
32}
33
34#[derive(Debug, Clone, Default)]
36pub struct Context {
37 pub app_id: Option<String>,
40 pub locale: Option<String>,
42}
43
44#[derive(Debug, thiserror::Error)]
46pub enum Error {
47 #[error("could not initialize correction provider: {0}")]
49 Init(String),
50 #[error("correction request failed: {0}")]
52 Request(String),
53 #[error("malformed correction response: {0}")]
55 Response(String),
56}
57
58pub struct OfflineProvider {
64 dictionary: spellbook::Dictionary,
65}
66
67impl OfflineProvider {
68 pub fn from_hunspell(aff: &str, dic: &str) -> Result<Self, Error> {
74 let dictionary =
75 spellbook::Dictionary::new(aff, dic).map_err(|e| Error::Init(format!("{e:?}")))?;
76 Ok(Self { dictionary })
77 }
78
79 pub fn en_us() -> Result<Self, Error> {
90 Self::from_hunspell(
91 include_str!("../dictionaries/en_US/en_US.aff"),
92 include_str!("../dictionaries/en_US/en_US.dic"),
93 )
94 }
95
96 pub fn check_text(&self, text: &str) -> Vec<Correction> {
99 let mut corrections = Vec::new();
100 for (offset, word) in words(text) {
101 if self.dictionary.check(word) {
102 continue;
103 }
104 let mut suggestions = Vec::new();
105 self.dictionary.suggest(word, &mut suggestions);
106 corrections.push(Correction {
107 span: offset..offset + word.len(),
108 original: word.to_string(),
109 suggestions,
110 });
111 }
112 corrections
113 }
114}
115
116#[async_trait]
117impl CorrectionProvider for OfflineProvider {
118 async fn check(&self, text: &str, _ctx: &Context) -> Result<Vec<Correction>, Error> {
119 Ok(self.check_text(text))
120 }
121}
122
123fn words(text: &str) -> Vec<(usize, &str)> {
126 let mut out = Vec::new();
127 let mut start: Option<usize> = None;
128 for (i, c) in text.char_indices() {
129 if c.is_whitespace() {
130 if let Some(s) = start.take() {
131 out.push((s, &text[s..i]));
132 }
133 } else if start.is_none() {
134 start = Some(i);
135 }
136 }
137 if let Some(s) = start {
138 out.push((s, &text[s..]));
139 }
140 out
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 const TEST_AFF: &str = "";
150 const TEST_DIC: &str = "5\nhello\nworld\nthe\nquick\nveneer\n";
151
152 fn provider() -> OfflineProvider {
153 OfflineProvider::from_hunspell(TEST_AFF, TEST_DIC).unwrap()
154 }
155
156 #[test]
157 fn correct_words_produce_no_corrections() {
158 assert!(provider().check_text("hello world").is_empty());
159 }
160
161 #[test]
162 fn a_misspelling_is_flagged_with_suggestions() {
163 let corrections = provider().check_text("helo");
164 assert_eq!(corrections.len(), 1);
165 assert_eq!(corrections[0].original, "helo");
166 assert!(
167 corrections[0].suggestions.iter().any(|s| s == "hello"),
168 "expected 'hello' among suggestions, got {:?}",
169 corrections[0].suggestions,
170 );
171 }
172
173 #[test]
174 fn correction_span_locates_the_word() {
175 let corrections = provider().check_text("the helo");
176 assert_eq!(corrections.len(), 1);
177 assert_eq!(corrections[0].span, 4..8);
179 }
180
181 #[test]
182 fn only_misspelled_words_are_reported() {
183 let corrections = provider().check_text("the quick fakeword");
184 assert_eq!(corrections.len(), 1);
185 assert_eq!(corrections[0].original, "fakeword");
186 }
187
188 static EN_US: std::sync::LazyLock<OfflineProvider> =
190 std::sync::LazyLock::new(|| OfflineProvider::en_us().expect("bundled en_US parses"));
191
192 #[test]
193 fn en_us_accepts_common_words() {
194 assert!(EN_US.check_text("the quick brown fox").is_empty());
195 }
196
197 #[test]
198 fn en_us_flags_a_misspelling_with_the_right_fix() {
199 let corrections = EN_US.check_text("teh");
200 assert_eq!(corrections.len(), 1);
201 assert!(
202 corrections[0].suggestions.iter().any(|s| s == "the"),
203 "expected 'the' among suggestions, got {:?}",
204 corrections[0].suggestions,
205 );
206 }
207
208 #[test]
209 fn en_us_suggests_for_the_motivating_typo() {
210 let corrections = EN_US.check_text("vernuer");
212 assert_eq!(corrections.len(), 1);
213 assert!(
214 !corrections[0].suggestions.is_empty(),
215 "expected suggestions for 'vernuer'",
216 );
217 }
218}