use super::{ErrorKind, Response, ServerError};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::ops::Range;
pub const SERVICE: &str = "languagetool";
pub const CHECK_PATH: &str = "/v2/check";
pub const LANGUAGES_PATH: &str = "/v2/languages";
pub const DEFAULT_MAX_REQUEST_BYTES: usize = 100_000;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Level {
#[default]
Default,
Picky,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct CheckRequest {
pub text: String,
pub language: String,
pub mother_tongue: Option<String>,
pub preferred_variants: Vec<String>,
pub enabled_rules: Vec<String>,
pub disabled_rules: Vec<String>,
pub enabled_categories: Vec<String>,
pub disabled_categories: Vec<String>,
pub enabled_only: bool,
pub level: Level,
}
impl CheckRequest {
pub fn new(text: impl Into<String>, language: impl Into<String>) -> CheckRequest {
CheckRequest { text: text.into(), language: language.into(), ..CheckRequest::default() }
}
pub fn form(&self) -> String {
let language = match self.language.trim() {
"" => "auto",
language => language,
};
let lists = [
("preferredVariants", &self.preferred_variants),
("enabledRules", &self.enabled_rules),
("disabledRules", &self.disabled_rules),
("enabledCategories", &self.enabled_categories),
("disabledCategories", &self.disabled_categories),
]
.map(|(name, values)| (name, values.join(",")));
let mut fields = vec![("text", self.text.as_str()), ("language", language)];
if let Some(mother_tongue) = &self.mother_tongue {
fields.push(("motherTongue", mother_tongue));
}
fields.extend(lists.iter().filter(|(_, value)| !value.is_empty()).map(|(name, value)| (*name, value.as_str())));
if self.enabled_only {
fields.push(("enabledOnly", "true"));
}
if self.level == Level::Picky {
fields.push(("level", "picky"));
}
crate::http::form(&fields)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckResponse {
#[serde(default)]
pub language: Option<CheckedLanguage>,
#[serde(default)]
pub matches: Vec<Match>,
#[serde(default)]
pub warnings: Warnings,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Warnings {
#[serde(default)]
pub incomplete_results: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckedLanguage {
#[serde(default)]
pub name: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub detected_language: Option<DetectedLanguage>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DetectedLanguage {
#[serde(default)]
pub name: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Match {
#[serde(default)]
pub message: String,
#[serde(default)]
pub short_message: String,
pub offset: usize,
pub length: usize,
#[serde(default)]
pub replacements: Vec<Replacement>,
#[serde(default)]
pub context: Context,
#[serde(default)]
pub sentence: String,
#[serde(default)]
pub rule: Rule,
}
impl Match {
pub fn byte_range(&self, text: &str) -> Option<Range<usize>> {
let (start, end) = (self.offset, self.offset.checked_add(self.length)?);
let mut units = 0;
let (mut start_byte, mut end_byte) = (None, None);
for (at, char) in text.char_indices().chain(std::iter::once((text.len(), '\0'))) {
if units == start {
start_byte = Some(at);
}
if units == end {
end_byte = Some(at);
break;
}
if units > end {
break;
}
units += char.len_utf16();
}
Some(start_byte?..end_byte?)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Replacement {
pub value: String,
#[serde(default)]
pub short_description: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Context {
#[serde(default)]
pub text: String,
#[serde(default)]
pub offset: usize,
#[serde(default)]
pub length: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
#[serde(default)]
pub id: String,
#[serde(default)]
pub sub_id: Option<String>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub issue_type: String,
#[serde(default)]
pub category: Category,
#[serde(default)]
pub urls: Vec<RuleUrl>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Category {
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RuleUrl {
pub value: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Language {
pub name: String,
pub code: String,
pub long_code: String,
}
pub fn check(request: &CheckRequest) -> Result<CheckResponse, ServerError> {
let headers = [("Content-Type", "application/x-www-form-urlencoded"), ("Accept", "application/json")];
parse(super::request(SERVICE, "POST", CHECK_PATH, &headers, Some(request.form().as_bytes()))?)
}
pub fn languages() -> Result<Vec<Language>, ServerError> {
parse(super::request(SERVICE, "GET", LANGUAGES_PATH, &[("Accept", "application/json")], None)?)
}
fn parse<T: DeserializeOwned>(response: Response) -> Result<T, ServerError> {
if let Some(error) = response.error() {
return Err(error);
}
serde_json::from_str(&response.body).map_err(|e| ServerError {
kind: ErrorKind::Invalid,
message: format!("LanguageTool's reply couldn't be read: {e}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_request_sends_only_text_and_language() {
assert_eq!(CheckRequest::new("It are fine.", "en-US").form(), "text=It+are+fine.&language=en-US");
assert_eq!(CheckRequest::new("Hi", " ").form(), "text=Hi&language=auto");
}
#[test]
fn options_join_their_lists_and_skip_the_empty_ones() {
let request = CheckRequest {
mother_tongue: Some("de".into()),
preferred_variants: vec!["en-GB".into(), "de-AT".into()],
disabled_rules: vec!["WHITESPACE_RULE".into()],
enabled_only: true,
level: Level::Picky,
..CheckRequest::new("Hi", "auto")
};
assert_eq!(
request.form(),
"text=Hi&language=auto&motherTongue=de&preferredVariants=en-GB%2Cde-AT&disabledRules=WHITESPACE_RULE&enabledOnly=true&level=picky"
);
}
#[test]
fn a_reply_reads_into_matches() {
let body = r#"{
"software": {"name": "LanguageTool", "version": "6.8"},
"warnings": {"incompleteResults": false},
"language": {"name": "English (US)", "code": "en-US",
"detectedLanguage": {"name": "English (US)", "code": "en-US", "confidence": 0.98}},
"matches": [{
"message": "Did you mean \"is\"?", "shortMessage": "Agreement",
"replacements": [{"value": "is"}, {"value": "was", "shortDescription": "past"}],
"offset": 3, "length": 3,
"context": {"text": "It are fine.", "offset": 3, "length": 3},
"sentence": "It are fine.",
"type": {"typeName": "Other"},
"rule": {"id": "IT_VBZ", "subId": "1", "description": "Agreement", "issueType": "grammar",
"category": {"id": "GRAMMAR", "name": "Grammar"},
"urls": [{"value": "https://example.com/rule"}]},
"ignoreForIncompleteSentence": false, "contextForSureMatch": 0
}]
}"#;
let checked: CheckResponse = parse(Response { status: 200, body: body.into() }).unwrap();
assert_eq!(checked.language.as_ref().unwrap().detected_language.as_ref().unwrap().confidence, Some(0.98));
let found = &checked.matches[0];
assert_eq!((found.offset, found.length, found.short_message.as_str()), (3, 3, "Agreement"));
assert_eq!(found.replacements[1].short_description.as_deref(), Some("past"));
assert_eq!((found.rule.id.as_str(), found.rule.issue_type.as_str(), found.rule.category.id.as_str()), ("IT_VBZ", "grammar", "GRAMMAR"));
assert_eq!(found.rule.urls[0].value, "https://example.com/rule");
assert!(!checked.warnings.incomplete_results);
}
#[test]
fn a_refusal_or_an_unreadable_reply_is_an_error() {
let limited = parse::<CheckResponse>(Response { status: 429, body: String::new() }).unwrap_err();
assert_eq!(limited.kind, ErrorKind::RateLimited);
let garbled = parse::<CheckResponse>(Response { status: 200, body: "<html>".into() }).unwrap_err();
assert_eq!(garbled.kind, ErrorKind::Invalid);
}
#[test]
fn languages_read_their_long_codes() {
let body = r#"[{"name": "German (Austria)", "code": "de", "longCode": "de-AT"}]"#;
let languages: Vec<Language> = parse(Response { status: 200, body: body.into() }).unwrap();
assert_eq!(languages[0].long_code, "de-AT");
}
#[test]
fn utf16_offsets_become_byte_ranges() {
let text = "😀 It are.";
let found = Match { offset: 6, length: 3, ..Match::default() };
assert_eq!(&text[found.byte_range(text).unwrap()], "are");
let to_the_end = Match { offset: 10, length: 0, ..Match::default() };
assert_eq!(to_the_end.byte_range(text), Some(text.len()..text.len()));
assert_eq!(Match { offset: 1, length: 1, ..Match::default() }.byte_range(text), None, "inside the emoji");
assert_eq!(Match { offset: 9, length: 5, ..Match::default() }.byte_range(text), None, "past the end");
}
}