//! Grammar and spelling checking via a LanguageTool HTTP server. //! //! Talks to the `/v2/check` endpoint of a LanguageTool instance (typically a //! local server, e.g. `http://localhost:8010`). The request is a plain //! `application/x-www-form-urlencoded` POST; both `http` and `https` endpoints //! are supported (the `ureq` dependency links a static rustls backend). //! //! LanguageTool reports match positions as UTF-16 offsets (it is a Java //! program). We convert those to byte offsets against the exact text we sent so //! callers can splice replacements and underline ranges directly in the buffer. use serde::Deserialize; use std::time::Duration; /// A single grammar/spelling issue, positioned as a byte range in the checked /// text. #[derive(Debug, Clone)] pub struct Match { /// Byte offset of the issue in the checked text. pub start: usize, /// Byte offset one past the end of the issue. pub end: usize, /// Human-readable explanation of the problem. pub message: String, /// Suggested replacements, best first (may be empty). pub replacements: Vec, /// True for spelling mistakes (LanguageTool category `TYPOS`), as opposed to /// grammar/style issues. Used to colour the underline differently. pub spelling: bool, } // ---- Wire format (subset of the LanguageTool JSON response) ---------------- #[derive(Deserialize)] struct Response { #[serde(default)] matches: Vec, } #[derive(Deserialize)] struct WireMatch { message: String, offset: usize, length: usize, #[serde(default)] replacements: Vec, rule: WireRule, } #[derive(Deserialize)] struct WireReplacement { value: String, } #[derive(Deserialize)] struct WireRule { category: WireCategory, } #[derive(Deserialize)] struct WireCategory { #[serde(default)] id: String, } /// Run a check against the LanguageTool server. /// /// `server` is the base URL (e.g. `http://localhost:8010`); `language` is a /// LanguageTool code such as `en-US`, or `auto` to detect. A non-empty `token` /// is sent as an `Authorization: Bearer` header (for a server behind an auth /// proxy). Returned matches use byte offsets into `text`. pub fn check(server: &str, language: &str, token: &str, text: &str) -> Result, String> { let base = server.trim().trim_end_matches('/'); if base.is_empty() || base.ends_with("//") { return Err("No LanguageTool host set (open Settings ▸ LanguageTool)".to_string()); } let url = format!("{base}/v2/check"); let agent = ureq::AgentBuilder::new() .timeout(Duration::from_secs(30)) .build(); let mut request = agent.post(&url); let token = token.trim(); if !token.is_empty() { request = request.set("Authorization", &format!("Bearer {token}")); } let resp = request .send_form(&[("language", language), ("text", text)]) .map_err(|e| friendly_error(base, e))?; let body = resp .into_string() .map_err(|e| format!("Could not read LanguageTool reply: {e}"))?; parse_response(text, &body) } /// Parse a LanguageTool `/v2/check` JSON reply, converting each match to a byte /// range in the `text` that was sent. fn parse_response(text: &str, body: &str) -> Result, String> { let parsed: Response = serde_json::from_str(body) .map_err(|e| format!("Unexpected reply from LanguageTool: {e}"))?; let map = utf16_to_byte_map(text); let byte_at = |u16: usize| -> usize { map.get(u16).copied().unwrap_or(text.len()) }; let matches = parsed .matches .into_iter() .map(|m| { let start = byte_at(m.offset); let end = byte_at(m.offset + m.length).max(start); Match { start, end, message: m.message, replacements: m.replacements.into_iter().map(|r| r.value).collect(), spelling: m.rule.category.id.eq_ignore_ascii_case("TYPOS"), } }) .collect(); Ok(matches) } /// Turn a `ureq` error into a message aimed at someone running a local server. fn friendly_error(base: &str, err: ureq::Error) -> String { match err { ureq::Error::Status(code, _) => { format!("LanguageTool returned HTTP {code} (is {base} a LanguageTool server?)") } ureq::Error::Transport(t) => { format!("Could not reach LanguageTool at {base} — is the server running? ({t})") } } } /// Build a lookup from UTF-16 code-unit index to byte offset in `text`. /// /// `map[i]` is the byte offset of the character boundary at UTF-16 unit `i`. /// Indices that land inside a surrogate pair are clamped to the next boundary. /// The final entry maps the end-of-string. fn utf16_to_byte_map(text: &str) -> Vec { let mut map: Vec = Vec::with_capacity(text.len() + 1); let mut units = 0usize; for (byte, ch) in text.char_indices() { while map.len() <= units { map.push(byte); } units += ch.len_utf16(); } while map.len() <= units { map.push(text.len()); } map } #[cfg(test)] mod tests { use super::*; #[test] fn maps_ascii_offsets_one_to_one() { let text = "hello world"; let map = utf16_to_byte_map(text); assert_eq!(map[0], 0); assert_eq!(map[6], 6); // 'w' assert_eq!(map[11], 11); // end } #[test] fn maps_multibyte_utf8_within_bmp() { // "café" — 'é' is 2 bytes in UTF-8 but 1 UTF-16 unit. let text = "café!"; let map = utf16_to_byte_map(text); assert_eq!(map[0], 0); // c assert_eq!(map[3], 3); // é starts at byte 3 assert_eq!(map[4], 5); // '!' is after the 2-byte é assert_eq!(map[5], 6); // end } #[test] fn maps_astral_char_as_two_units() { // "a😀b": 😀 is 4 UTF-8 bytes and 2 UTF-16 units. let text = "a😀b"; let map = utf16_to_byte_map(text); assert_eq!(map[0], 0); // a assert_eq!(map[1], 1); // 😀 starts at byte 1 assert_eq!(map[3], 5); // b, after the 4-byte emoji assert_eq!(map[4], 6); // end } #[test] fn parses_a_realistic_response() { // Shape mirrors a real LanguageTool /v2/check reply. let text = "I has a apple."; let body = r#"{ "matches": [ { "message": "Did you mean 'have'?", "shortMessage": "Grammar", "offset": 2, "length": 3, "replacements": [{"value": "have"}], "rule": {"id": "HE_VERB_AGR", "category": {"id": "GRAMMAR", "name": "Grammar"}} }, { "message": "Possible spelling mistake.", "offset": 8, "length": 5, "replacements": [{"value": "apple"}, {"value": "apples"}], "rule": {"id": "MORFOLOGIK_RULE_EN_US", "category": {"id": "TYPOS", "name": "Possible Typo"}} } ] }"#; let matches = parse_response(text, body).unwrap(); assert_eq!(matches.len(), 2); assert_eq!(&text[matches[0].start..matches[0].end], "has"); assert_eq!(matches[0].replacements, vec!["have"]); assert!(!matches[0].spelling); assert_eq!(&text[matches[1].start..matches[1].end], "apple"); assert_eq!(matches[1].replacements, vec!["apple", "apples"]); assert!(matches[1].spelling); } #[test] fn empty_matches_is_ok() { let matches = parse_response("clean text", r#"{"matches": []}"#).unwrap(); assert!(matches.is_empty()); } }