1use tracing::debug;
16
17use crate::checker::Diagnostic;
18use crate::dictionary::Dictionary;
19use crate::hashing::{DiagnosticFingerprint, IgnoreStore};
20use crate::morphology::AffixAnalyzer;
21use crate::names::{NameFilter, NameQuery, NameVerdict};
22use crate::prose::is_spelling_category;
23use crate::text_util::{min_suggestion_distance, safe_slice};
24
25#[derive(Default, Clone, Copy)]
30pub struct SuppressionContext<'a> {
31 pub ignore: Option<&'a IgnoreStore>,
32 pub dictionary: Option<&'a Dictionary>,
33 pub morphology: Option<&'a AffixAnalyzer>,
34 pub names: Option<&'a NameFilter>,
35}
36
37impl<'a> SuppressionContext<'a> {
38 #[must_use]
39 pub const fn new() -> Self {
40 Self {
41 ignore: None,
42 dictionary: None,
43 morphology: None,
44 names: None,
45 }
46 }
47
48 #[must_use]
49 pub const fn with_ignore(mut self, ignore: &'a IgnoreStore) -> Self {
50 self.ignore = Some(ignore);
51 self
52 }
53
54 #[must_use]
55 pub const fn with_dictionary(mut self, dictionary: &'a Dictionary) -> Self {
56 self.dictionary = Some(dictionary);
57 self
58 }
59
60 #[must_use]
61 pub const fn with_morphology(mut self, morphology: &'a AffixAnalyzer) -> Self {
62 self.morphology = Some(morphology);
63 self
64 }
65
66 #[must_use]
67 pub const fn with_names(mut self, names: &'a NameFilter) -> Self {
68 self.names = Some(names);
69 self
70 }
71}
72
73enum Outcome {
75 Keep,
77 Drop,
79 DropAsName(NameVerdict),
81}
82
83fn classify(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> Outcome {
86 if let Some(ignore) = ctx.ignore {
87 let fingerprint = DiagnosticFingerprint::new(
88 &diagnostic.message,
89 text,
90 diagnostic.start_byte as usize,
91 diagnostic.end_byte as usize,
92 );
93 if ignore.is_ignored(&fingerprint) {
94 return Outcome::Drop;
95 }
96 }
97
98 if !is_spelling_category(&diagnostic.unified_id) {
99 return Outcome::Keep;
100 }
101
102 let word = safe_slice(
103 text,
104 diagnostic.start_byte as usize,
105 diagnostic.end_byte as usize,
106 );
107
108 if let Some(dictionary) = ctx.dictionary
109 && dictionary.contains(word)
110 {
111 return Outcome::Drop;
112 }
113
114 if is_known_derivation(word, diagnostic, ctx) {
115 return Outcome::Drop;
116 }
117
118 let Some(filter) = ctx.names else {
119 return Outcome::Keep;
120 };
121 let verdict = filter.evaluate(&NameQuery {
122 token: word,
123 text,
124 start_byte: diagnostic.start_byte as usize,
125 end_byte: diagnostic.end_byte as usize,
126 suggestions: &diagnostic.suggestions,
127 });
128 if verdict.is_name {
129 Outcome::DropAsName(verdict)
130 } else {
131 Outcome::Keep
132 }
133}
134
135fn is_known_derivation(word: &str, diagnostic: &Diagnostic, ctx: &SuppressionContext<'_>) -> bool {
146 let Some(analyzer) = ctx.morphology else {
147 return false;
148 };
149 if min_suggestion_distance(word, &diagnostic.suggestions).is_some_and(|d| d <= 1) {
150 return false;
151 }
152 let Some(analysis) = analyzer.analyze(word, ctx.dictionary) else {
153 return false;
154 };
155 debug!(word, decomposition = %analysis.describe(), "Suppressed as an affixed form");
158 true
159}
160
161#[must_use]
165pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
166 !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
167}
168
169#[derive(Debug, Clone)]
171pub struct DetectedName {
172 pub start_byte: u32,
173 pub end_byte: u32,
174 pub confidence: f32,
175 pub signals: String,
176}
177
178pub fn retain_visible(
184 diagnostics: &mut Vec<Diagnostic>,
185 text: &str,
186 ctx: &SuppressionContext<'_>,
187) -> Vec<DetectedName> {
188 let mut detected = Vec::new();
189 diagnostics.retain(|d| match classify(d, text, ctx) {
190 Outcome::Keep => true,
191 Outcome::Drop => false,
192 Outcome::DropAsName(verdict) => {
193 detected.push(DetectedName {
194 start_byte: d.start_byte,
195 end_byte: d.end_byte,
196 confidence: verdict.score,
197 signals: verdict.signal_tags(),
198 });
199 false
200 }
201 });
202 detected
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn spelling_diagnostic(start: u32, end: u32) -> Diagnostic {
210 Diagnostic {
211 start_byte: start,
212 end_byte: end,
213 message: "Possible spelling mistake found.".to_string(),
214 suggestions: vec![],
215 rule_id: "languagetool.MORFOLOGIK_RULE_EN_US".to_string(),
216 severity: 2,
217 unified_id: "spelling.typo".to_string(),
218 confidence: 1.0,
219 }
220 }
221
222 fn with_suggestions(start: u32, end: u32, suggestions: &[&str]) -> Diagnostic {
224 Diagnostic {
225 suggestions: suggestions.iter().map(|s| (*s).to_string()).collect(),
226 ..spelling_diagnostic(start, end)
227 }
228 }
229
230 #[test]
231 fn an_affixed_form_of_a_known_word_is_suppressed() {
232 let text = "every subalgebra here";
233 let analyzer = AffixAnalyzer::new("en-US");
234 let ctx = SuppressionContext::new().with_morphology(&analyzer);
235 assert!(should_suppress(&spelling_diagnostic(6, 16), text, &ctx));
236 }
237
238 #[test]
239 fn morphology_is_inert_until_it_is_supplied() {
240 let text = "every subalgebra here";
241 assert!(!should_suppress(
242 &spelling_diagnostic(6, 16),
243 text,
244 &SuppressionContext::new()
245 ));
246 }
247
248 #[test]
249 fn a_close_suggestion_vetoes_the_decomposition() {
250 let text = "wait untill then";
253 let analyzer = AffixAnalyzer::new("en-US");
254 let ctx = SuppressionContext::new().with_morphology(&analyzer);
255 assert!(!should_suppress(
256 &with_suggestions(5, 11, &["until"]),
257 text,
258 &ctx
259 ));
260 assert!(should_suppress(&spelling_diagnostic(5, 11), text, &ctx));
263 }
264
265 #[test]
266 fn a_distant_suggestion_does_not_veto() {
267 let text = "the subadditivity holds";
269 let analyzer = AffixAnalyzer::new("en-US");
270 let ctx = SuppressionContext::new().with_morphology(&analyzer);
271 assert!(should_suppress(
272 &with_suggestions(4, 17, &["subjectivity"]),
273 text,
274 &ctx
275 ));
276 }
277
278 #[test]
279 fn morphology_does_not_touch_non_spelling_diagnostics() {
280 let text = "every subalgebra here";
281 let analyzer = AffixAnalyzer::new("en-US");
282 let ctx = SuppressionContext::new().with_morphology(&analyzer);
283 let mut d = spelling_diagnostic(6, 16);
284 d.unified_id = "grammar.agreement".to_string();
285 assert!(!should_suppress(&d, text, &ctx));
286 }
287
288 #[test]
289 fn empty_context_suppresses_nothing() {
290 let text = "Ackermann wrote this.";
291 let d = spelling_diagnostic(0, 9);
292 assert!(!should_suppress(&d, text, &SuppressionContext::new()));
293 }
294
295 #[test]
296 fn dictionary_word_is_suppressed() {
297 let text = "Ackermann wrote this.";
298 let mut dict = Dictionary::new();
299 dict.add_word("ackermann").unwrap();
300 let ctx = SuppressionContext::new().with_dictionary(&dict);
301 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
302 }
303
304 #[test]
305 fn dictionary_lookup_is_case_insensitive() {
306 let text = "ACKERMANN wrote this.";
307 let mut dict = Dictionary::new();
308 dict.add_word("Ackermann").unwrap();
309 let ctx = SuppressionContext::new().with_dictionary(&dict);
310 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
311 }
312
313 #[test]
314 fn dictionary_does_not_suppress_non_spelling_diagnostics() {
315 let text = "Ackermann wrote this.";
316 let mut dict = Dictionary::new();
317 dict.add_word("ackermann").unwrap();
318 let mut d = spelling_diagnostic(0, 9);
319 d.unified_id = "grammar.agreement".to_string();
320 let ctx = SuppressionContext::new().with_dictionary(&dict);
321 assert!(!should_suppress(&d, text, &ctx));
322 }
323
324 #[test]
325 fn multibyte_spans_do_not_panic() {
326 let text = "Grüße von Müller.";
327 let mut dict = Dictionary::new();
328 dict.add_word("müller").unwrap();
329 let start = text.find("Müller").unwrap() as u32;
330 let ctx = SuppressionContext::new().with_dictionary(&dict);
331 assert!(should_suppress(
333 &spelling_diagnostic(start, start + 7),
334 text,
335 &ctx
336 ));
337 }
338
339 #[test]
340 fn detected_names_are_reported_for_the_inspector() {
341 use crate::names::{Aggressiveness, NameFilter};
342
343 let text = "The logic of Hoare is central.";
344 let filter = NameFilter::new(Aggressiveness::Balanced, "en-US");
345 let ctx = SuppressionContext::new().with_names(&filter);
346
347 let start = text.find("Hoare").unwrap() as u32;
348 let mut d = spelling_diagnostic(start, start + 5);
349 d.suggestions = vec!["Hare".to_string(), "Hoar".to_string()];
350 let mut diagnostics = vec![d];
351
352 let detected = retain_visible(&mut diagnostics, text, &ctx);
353
354 assert!(diagnostics.is_empty(), "the name should have been dropped");
355 assert_eq!(detected.len(), 1);
356 assert_eq!(detected[0].start_byte, start);
357 assert_eq!(detected[0].end_byte, start + 5);
358 assert!(detected[0].confidence > 0.0);
359 assert!(
360 detected[0].signals.contains("gazetteer"),
361 "signals were {}",
362 detected[0].signals
363 );
364 }
365
366 #[test]
367 fn nothing_is_reported_without_a_name_filter() {
368 let text = "The logic of Hoare is central.";
369 let start = text.find("Hoare").unwrap() as u32;
370 let mut diagnostics = vec![spelling_diagnostic(start, start + 5)];
371 let detected = retain_visible(&mut diagnostics, text, &SuppressionContext::new());
372 assert_eq!(diagnostics.len(), 1, "opt-in: must stay flagged");
373 assert!(detected.is_empty());
374 }
375
376 #[test]
377 fn retain_visible_drops_only_suppressed() {
378 let text = "Ackermann met Hoare.";
379 let mut dict = Dictionary::new();
380 dict.add_word("ackermann").unwrap();
381 let ctx = SuppressionContext::new().with_dictionary(&dict);
382
383 let hoare_start = text.find("Hoare").unwrap() as u32;
384 let mut diagnostics = vec![
385 spelling_diagnostic(0, 9),
386 spelling_diagnostic(hoare_start, hoare_start + 5),
387 ];
388 retain_visible(&mut diagnostics, text, &ctx);
389
390 assert_eq!(diagnostics.len(), 1);
391 assert_eq!(diagnostics[0].start_byte, hoare_start);
392 }
393}