Files
md-manuscript/src/langtool.rs
T
landon 5ecbf3ffbb Add LanguageTool grammar/spell checking with a settings dialog
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>
2026-07-27 08:33:00 -05:00

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());
}
}