hyprcorrect_core/
languagetool.rs1use std::time::Duration;
12
13use crate::LanguageToolConfig;
14use crate::providers::Correction;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17
18#[derive(Debug, thiserror::Error)]
20pub enum LanguageToolError {
21 #[error("LanguageTool is disabled in the config")]
23 Disabled,
24 #[error("LanguageTool URL is empty")]
26 NoUrl,
27 #[error("LanguageTool request failed: {0}")]
29 Request(String),
30 #[error("LanguageTool response was unparseable: {0}")]
32 Response(String),
33}
34
35#[derive(Debug, Clone)]
37pub struct LanguageToolProvider {
38 endpoint: String,
39}
40
41impl LanguageToolProvider {
42 pub fn from_config(lt: &LanguageToolConfig) -> Result<Self, LanguageToolError> {
50 if !lt.enabled {
51 return Err(LanguageToolError::Disabled);
52 }
53 let url = lt.url.trim().trim_end_matches('/');
54 if url.is_empty() {
55 return Err(LanguageToolError::NoUrl);
56 }
57 Ok(Self {
58 endpoint: format!("{url}/v2/check"),
59 })
60 }
61
62 pub fn check_text(&self, text: &str) -> Result<Vec<Correction>, LanguageToolError> {
70 if text.trim().is_empty() {
71 return Ok(Vec::new());
72 }
73 let agent = ureq::AgentBuilder::new().timeout(REQUEST_TIMEOUT).build();
74 let response = agent
75 .post(&self.endpoint)
76 .send_form(&[("text", text), ("language", "en-US")])
77 .map_err(|e| LanguageToolError::Request(e.to_string()))?;
78 let json: serde_json::Value = response
79 .into_json()
80 .map_err(|e| LanguageToolError::Response(e.to_string()))?;
81 Ok(parse_matches(&json, text))
82 }
83}
84
85fn parse_matches(json: &serde_json::Value, text: &str) -> Vec<Correction> {
86 let Some(matches) = json["matches"].as_array() else {
87 return Vec::new();
88 };
89 let mut out = Vec::with_capacity(matches.len());
90 for m in matches {
91 let offset = match m["offset"].as_u64() {
92 Some(n) => n as usize,
93 None => continue,
94 };
95 let length = match m["length"].as_u64() {
96 Some(n) => n as usize,
97 None => continue,
98 };
99 if length == 0 {
100 continue;
101 }
102 let end = offset.saturating_add(length);
103 if end > text.len() || !text.is_char_boundary(offset) || !text.is_char_boundary(end) {
104 continue;
105 }
106 let suggestions: Vec<String> = m["replacements"]
107 .as_array()
108 .into_iter()
109 .flat_map(|a| a.iter())
110 .filter_map(|r| r["value"].as_str().map(str::to_string))
111 .collect();
112 if suggestions.is_empty() {
113 continue;
114 }
115 out.push(Correction {
116 span: offset..end,
117 original: text[offset..end].to_string(),
118 suggestions,
119 });
120 }
121 out
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 const SAMPLE: &str = r#"{
129 "matches": [
130 {
131 "offset": 4,
132 "length": 4,
133 "replacements": [{"value": "hello"}, {"value": "helot"}]
134 },
135 {
136 "offset": 9,
137 "length": 5,
138 "replacements": [{"value": "world"}]
139 }
140 ]
141 }"#;
142
143 #[test]
144 fn parses_matches_into_corrections() {
145 let json: serde_json::Value = serde_json::from_str(SAMPLE).unwrap();
146 let text = "the helo wrold";
147 let cs = parse_matches(&json, text);
148 assert_eq!(cs.len(), 2);
149 assert_eq!(cs[0].span, 4..8);
150 assert_eq!(cs[0].original, "helo");
151 assert_eq!(cs[0].suggestions, vec!["hello", "helot"]);
152 assert_eq!(cs[1].span, 9..14);
153 assert_eq!(cs[1].original, "wrold");
154 assert_eq!(cs[1].suggestions, vec!["world"]);
155 }
156
157 #[test]
158 fn ignores_matches_with_no_replacements() {
159 let json: serde_json::Value =
160 serde_json::from_str(r#"{"matches":[{"offset":0,"length":3,"replacements":[]}]}"#)
161 .unwrap();
162 let cs = parse_matches(&json, "the");
163 assert!(cs.is_empty());
164 }
165
166 #[test]
167 fn ignores_matches_with_out_of_range_spans() {
168 let json: serde_json::Value = serde_json::from_str(
169 r#"{"matches":[{"offset":10,"length":5,"replacements":[{"value":"x"}]}]}"#,
170 )
171 .unwrap();
172 let cs = parse_matches(&json, "short");
173 assert!(cs.is_empty());
174 }
175
176 #[test]
177 fn disabled_config_errors_cleanly() {
178 let lt = LanguageToolConfig {
179 enabled: false,
180 url: "http://localhost:8081".into(),
181 };
182 assert!(matches!(
183 LanguageToolProvider::from_config(<),
184 Err(LanguageToolError::Disabled)
185 ));
186 }
187
188 #[test]
189 fn empty_url_errors_cleanly() {
190 let lt = LanguageToolConfig {
191 enabled: true,
192 url: " ".into(),
193 };
194 assert!(matches!(
195 LanguageToolProvider::from_config(<),
196 Err(LanguageToolError::NoUrl)
197 ));
198 }
199}