Skip to main content

hyprcorrect_core/
languagetool.rs

1//! LanguageTool HTTP correction provider (M5).
2//!
3//! POSTs the text to a self-hosted LanguageTool server's
4//! `/v2/check` endpoint and turns the JSON `matches` array into
5//! [`crate::providers::Correction`]s.
6//!
7//! Off until the user enables it in Preferences → LanguageTool.
8//! Bring-your-own server — the project does not bundle LanguageTool
9//! itself (it's Java + dictionaries; would dwarf the crate).
10
11use std::time::Duration;
12
13use crate::LanguageToolConfig;
14use crate::providers::Correction;
15
16const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17
18/// Errors from a LanguageTool check.
19#[derive(Debug, thiserror::Error)]
20pub enum LanguageToolError {
21    /// The user hasn't enabled LanguageTool in the config.
22    #[error("LanguageTool is disabled in the config")]
23    Disabled,
24    /// `url` field is empty.
25    #[error("LanguageTool URL is empty")]
26    NoUrl,
27    /// Network or HTTP error reaching the server.
28    #[error("LanguageTool request failed: {0}")]
29    Request(String),
30    /// Couldn't make sense of the response body.
31    #[error("LanguageTool response was unparseable: {0}")]
32    Response(String),
33}
34
35/// The LanguageTool HTTP correction provider.
36#[derive(Debug, Clone)]
37pub struct LanguageToolProvider {
38    endpoint: String,
39}
40
41impl LanguageToolProvider {
42    /// Build the provider from the user's [`LanguageToolConfig`].
43    /// Returns `Err` cleanly when the config is disabled or empty —
44    /// the daemon treats either as "fall back to spellbook".
45    ///
46    /// # Errors
47    ///
48    /// See [`LanguageToolError`].
49    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    /// Check `text` against the LanguageTool server. Returns one
63    /// [`Correction`] per match (deduplicated implicitly by
64    /// LanguageTool's own ranking).
65    ///
66    /// # Errors
67    ///
68    /// See [`LanguageToolError`].
69    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(&lt),
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(&lt),
196            Err(LanguageToolError::NoUrl)
197        ));
198    }
199}