1use super::{ErrorKind, Response, ServerError};
21use serde::Deserialize;
22use serde::de::DeserializeOwned;
23use std::ops::Range;
24
25pub const SERVICE: &str = "languagetool";
26pub const CHECK_PATH: &str = "/v2/check";
27pub const LANGUAGES_PATH: &str = "/v2/languages";
28
29pub const DEFAULT_MAX_REQUEST_BYTES: usize = 100_000;
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
34pub enum Level {
35 #[default]
36 Default,
37 Picky,
39}
40
41#[derive(Debug, Clone, Default, PartialEq)]
44pub struct CheckRequest {
45 pub text: String,
46 pub language: String,
48 pub mother_tongue: Option<String>,
50 pub preferred_variants: Vec<String>,
52 pub enabled_rules: Vec<String>,
53 pub disabled_rules: Vec<String>,
54 pub enabled_categories: Vec<String>,
55 pub disabled_categories: Vec<String>,
56 pub enabled_only: bool,
58 pub level: Level,
59}
60
61impl CheckRequest {
62 pub fn new(text: impl Into<String>, language: impl Into<String>) -> CheckRequest {
63 CheckRequest { text: text.into(), language: language.into(), ..CheckRequest::default() }
64 }
65
66 pub fn form(&self) -> String {
68 let language = match self.language.trim() {
69 "" => "auto",
70 language => language,
71 };
72 let lists = [
73 ("preferredVariants", &self.preferred_variants),
74 ("enabledRules", &self.enabled_rules),
75 ("disabledRules", &self.disabled_rules),
76 ("enabledCategories", &self.enabled_categories),
77 ("disabledCategories", &self.disabled_categories),
78 ]
79 .map(|(name, values)| (name, values.join(",")));
80 let mut fields = vec![("text", self.text.as_str()), ("language", language)];
81 if let Some(mother_tongue) = &self.mother_tongue {
82 fields.push(("motherTongue", mother_tongue));
83 }
84 fields.extend(lists.iter().filter(|(_, value)| !value.is_empty()).map(|(name, value)| (*name, value.as_str())));
85 if self.enabled_only {
86 fields.push(("enabledOnly", "true"));
87 }
88 if self.level == Level::Picky {
89 fields.push(("level", "picky"));
90 }
91 crate::http::form(&fields)
92 }
93}
94
95#[derive(Debug, Clone, Default, Deserialize)]
96#[serde(rename_all = "camelCase")]
97pub struct CheckResponse {
98 #[serde(default)]
99 pub language: Option<CheckedLanguage>,
100 #[serde(default)]
101 pub matches: Vec<Match>,
102 #[serde(default)]
103 pub warnings: Warnings,
104}
105
106#[derive(Debug, Clone, Default, Deserialize)]
107#[serde(rename_all = "camelCase")]
108pub struct Warnings {
109 #[serde(default)]
111 pub incomplete_results: bool,
112}
113
114#[derive(Debug, Clone, Default, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct CheckedLanguage {
118 #[serde(default)]
119 pub name: String,
120 #[serde(default)]
121 pub code: String,
122 #[serde(default)]
123 pub detected_language: Option<DetectedLanguage>,
124}
125
126#[derive(Debug, Clone, Default, Deserialize)]
127pub struct DetectedLanguage {
128 #[serde(default)]
129 pub name: String,
130 #[serde(default)]
131 pub code: String,
132 #[serde(default)]
133 pub confidence: Option<f64>,
134}
135
136#[derive(Debug, Clone, Default, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct Match {
141 #[serde(default)]
142 pub message: String,
143 #[serde(default)]
144 pub short_message: String,
145 pub offset: usize,
146 pub length: usize,
147 #[serde(default)]
148 pub replacements: Vec<Replacement>,
149 #[serde(default)]
150 pub context: Context,
151 #[serde(default)]
152 pub sentence: String,
153 #[serde(default)]
154 pub rule: Rule,
155}
156
157impl Match {
158 pub fn byte_range(&self, text: &str) -> Option<Range<usize>> {
161 let (start, end) = (self.offset, self.offset.checked_add(self.length)?);
162 let mut units = 0;
163 let (mut start_byte, mut end_byte) = (None, None);
164 for (at, char) in text.char_indices().chain(std::iter::once((text.len(), '\0'))) {
165 if units == start {
166 start_byte = Some(at);
167 }
168 if units == end {
169 end_byte = Some(at);
170 break;
171 }
172 if units > end {
173 break;
174 }
175 units += char.len_utf16();
176 }
177 Some(start_byte?..end_byte?)
178 }
179}
180
181#[derive(Debug, Clone, Default, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct Replacement {
184 pub value: String,
185 #[serde(default)]
186 pub short_description: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, Deserialize)]
191pub struct Context {
192 #[serde(default)]
193 pub text: String,
194 #[serde(default)]
195 pub offset: usize,
196 #[serde(default)]
197 pub length: usize,
198}
199
200#[derive(Debug, Clone, Default, Deserialize)]
201#[serde(rename_all = "camelCase")]
202pub struct Rule {
203 #[serde(default)]
204 pub id: String,
205 #[serde(default)]
206 pub sub_id: Option<String>,
207 #[serde(default)]
208 pub description: String,
209 #[serde(default)]
211 pub issue_type: String,
212 #[serde(default)]
213 pub category: Category,
214 #[serde(default)]
215 pub urls: Vec<RuleUrl>,
216}
217
218#[derive(Debug, Clone, Default, Deserialize)]
219pub struct Category {
220 #[serde(default)]
222 pub id: String,
223 #[serde(default)]
224 pub name: String,
225}
226
227#[derive(Debug, Clone, Default, Deserialize)]
229pub struct RuleUrl {
230 pub value: String,
231}
232
233#[derive(Debug, Clone, Default, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct Language {
237 pub name: String,
238 pub code: String,
239 pub long_code: String,
241}
242
243pub fn check(request: &CheckRequest) -> Result<CheckResponse, ServerError> {
245 let headers = [("Content-Type", "application/x-www-form-urlencoded"), ("Accept", "application/json")];
246 parse(super::request(SERVICE, "POST", CHECK_PATH, &headers, Some(request.form().as_bytes()))?)
247}
248
249pub fn languages() -> Result<Vec<Language>, ServerError> {
251 parse(super::request(SERVICE, "GET", LANGUAGES_PATH, &[("Accept", "application/json")], None)?)
252}
253
254fn parse<T: DeserializeOwned>(response: Response) -> Result<T, ServerError> {
255 if let Some(error) = response.error() {
256 return Err(error);
257 }
258 serde_json::from_str(&response.body).map_err(|e| ServerError {
259 kind: ErrorKind::Invalid,
260 message: format!("LanguageTool's reply couldn't be read: {e}"),
261 })
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn a_plain_request_sends_only_text_and_language() {
270 assert_eq!(CheckRequest::new("It are fine.", "en-US").form(), "text=It+are+fine.&language=en-US");
271 assert_eq!(CheckRequest::new("Hi", " ").form(), "text=Hi&language=auto");
272 }
273
274 #[test]
275 fn options_join_their_lists_and_skip_the_empty_ones() {
276 let request = CheckRequest {
277 mother_tongue: Some("de".into()),
278 preferred_variants: vec!["en-GB".into(), "de-AT".into()],
279 disabled_rules: vec!["WHITESPACE_RULE".into()],
280 enabled_only: true,
281 level: Level::Picky,
282 ..CheckRequest::new("Hi", "auto")
283 };
284 assert_eq!(
285 request.form(),
286 "text=Hi&language=auto&motherTongue=de&preferredVariants=en-GB%2Cde-AT&disabledRules=WHITESPACE_RULE&enabledOnly=true&level=picky"
287 );
288 }
289
290 #[test]
291 fn a_reply_reads_into_matches() {
292 let body = r#"{
293 "software": {"name": "LanguageTool", "version": "6.8"},
294 "warnings": {"incompleteResults": false},
295 "language": {"name": "English (US)", "code": "en-US",
296 "detectedLanguage": {"name": "English (US)", "code": "en-US", "confidence": 0.98}},
297 "matches": [{
298 "message": "Did you mean \"is\"?", "shortMessage": "Agreement",
299 "replacements": [{"value": "is"}, {"value": "was", "shortDescription": "past"}],
300 "offset": 3, "length": 3,
301 "context": {"text": "It are fine.", "offset": 3, "length": 3},
302 "sentence": "It are fine.",
303 "type": {"typeName": "Other"},
304 "rule": {"id": "IT_VBZ", "subId": "1", "description": "Agreement", "issueType": "grammar",
305 "category": {"id": "GRAMMAR", "name": "Grammar"},
306 "urls": [{"value": "https://example.com/rule"}]},
307 "ignoreForIncompleteSentence": false, "contextForSureMatch": 0
308 }]
309 }"#;
310 let checked: CheckResponse = parse(Response { status: 200, body: body.into() }).unwrap();
311 assert_eq!(checked.language.as_ref().unwrap().detected_language.as_ref().unwrap().confidence, Some(0.98));
312 let found = &checked.matches[0];
313 assert_eq!((found.offset, found.length, found.short_message.as_str()), (3, 3, "Agreement"));
314 assert_eq!(found.replacements[1].short_description.as_deref(), Some("past"));
315 assert_eq!((found.rule.id.as_str(), found.rule.issue_type.as_str(), found.rule.category.id.as_str()), ("IT_VBZ", "grammar", "GRAMMAR"));
316 assert_eq!(found.rule.urls[0].value, "https://example.com/rule");
317 assert!(!checked.warnings.incomplete_results);
318 }
319
320 #[test]
321 fn a_refusal_or_an_unreadable_reply_is_an_error() {
322 let limited = parse::<CheckResponse>(Response { status: 429, body: String::new() }).unwrap_err();
323 assert_eq!(limited.kind, ErrorKind::RateLimited);
324 let garbled = parse::<CheckResponse>(Response { status: 200, body: "<html>".into() }).unwrap_err();
325 assert_eq!(garbled.kind, ErrorKind::Invalid);
326 }
327
328 #[test]
329 fn languages_read_their_long_codes() {
330 let body = r#"[{"name": "German (Austria)", "code": "de", "longCode": "de-AT"}]"#;
331 let languages: Vec<Language> = parse(Response { status: 200, body: body.into() }).unwrap();
332 assert_eq!(languages[0].long_code, "de-AT");
333 }
334
335 #[test]
336 fn utf16_offsets_become_byte_ranges() {
337 let text = "😀 It are.";
338 let found = Match { offset: 6, length: 3, ..Match::default() };
339 assert_eq!(&text[found.byte_range(text).unwrap()], "are");
340 let to_the_end = Match { offset: 10, length: 0, ..Match::default() };
341 assert_eq!(to_the_end.byte_range(text), Some(text.len()..text.len()));
342 assert_eq!(Match { offset: 1, length: 1, ..Match::default() }.byte_range(text), None, "inside the emoji");
343 assert_eq!(Match { offset: 9, length: 5, ..Match::default() }.byte_range(text), None, "past the end");
344 }
345}