5ecbf3ffbb
Check the current file against a LanguageTool server (typically a local instance) from a background thread. Issues are underlined in the editor (red spelling, blue grammar) and listed in a bottom panel with one-click fixes that re-align the remaining matches. A menu bar (File / View / Settings) exposes a LanguageTool settings dialog with discrete scheme / host / port / token / language fields, an endpoint preview, and a Test connection button. Config stores the parts and assembles the base URL. The token is sent as a bearer header. Uses ureq with a statically-linked rustls backend, so both http and https endpoints work with no new runtime library dependencies. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
234 lines
7.7 KiB
Rust
234 lines
7.7 KiB
Rust
//! 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<String>,
|
|
/// 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<WireMatch>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct WireMatch {
|
|
message: String,
|
|
offset: usize,
|
|
length: usize,
|
|
#[serde(default)]
|
|
replacements: Vec<WireReplacement>,
|
|
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<Vec<Match>, 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<Vec<Match>, 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<usize> {
|
|
let mut map: Vec<usize> = 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());
|
|
}
|
|
}
|