1use tracing::debug;
16
17use crate::checker::Diagnostic;
18use crate::dictionary::Dictionary;
19use crate::hashing::{DiagnosticFingerprint, IgnoreStore};
20use crate::ignore_rules::{IgnoreParser, ResolvedDirectives};
21use crate::morphology::AffixAnalyzer;
22use crate::names::{NameFilter, NameQuery, NameVerdict};
23use crate::prose::is_spelling_category;
24use crate::text_util::{min_suggestion_distance, safe_slice};
25
26#[derive(Default, Clone, Copy)]
31pub struct SuppressionContext<'a> {
32 pub ignore: Option<&'a IgnoreStore>,
33 pub dictionary: Option<&'a Dictionary>,
34 pub morphology: Option<&'a AffixAnalyzer>,
35 pub names: Option<&'a NameFilter>,
36 pub directives: Option<&'a InlineDirectives>,
37}
38
39#[derive(Debug, Clone, Default)]
47pub struct InlineDirectives {
48 resolved: ResolvedDirectives,
49}
50
51impl InlineDirectives {
52 #[must_use]
53 pub fn parse(text: &str) -> Self {
54 let directives = IgnoreParser::parse_directives(text);
55 Self {
56 resolved: IgnoreParser::resolve_all(text, &directives),
57 }
58 }
59
60 #[must_use]
62 pub const fn is_empty(&self) -> bool {
63 self.resolved.ignore_ranges.is_empty() && self.resolved.regions.is_empty()
64 }
65
66 #[must_use]
71 pub fn suppresses(&self, diagnostic: &Diagnostic, text: &str) -> bool {
72 IgnoreParser::should_ignore(diagnostic, &self.resolved.ignore_ranges)
73 || IgnoreParser::should_ignore_by_region(diagnostic, text, &self.resolved.regions)
74 }
75}
76
77impl<'a> SuppressionContext<'a> {
78 #[must_use]
79 pub const fn new() -> Self {
80 Self {
81 ignore: None,
82 dictionary: None,
83 morphology: None,
84 names: None,
85 directives: None,
86 }
87 }
88
89 #[must_use]
90 pub const fn with_ignore(mut self, ignore: &'a IgnoreStore) -> Self {
91 self.ignore = Some(ignore);
92 self
93 }
94
95 #[must_use]
96 pub const fn with_dictionary(mut self, dictionary: &'a Dictionary) -> Self {
97 self.dictionary = Some(dictionary);
98 self
99 }
100
101 #[must_use]
102 pub const fn with_morphology(mut self, morphology: &'a AffixAnalyzer) -> Self {
103 self.morphology = Some(morphology);
104 self
105 }
106
107 #[must_use]
108 pub const fn with_names(mut self, names: &'a NameFilter) -> Self {
109 self.names = Some(names);
110 self
111 }
112
113 #[must_use]
114 pub const fn with_directives(mut self, directives: &'a InlineDirectives) -> Self {
115 self.directives = Some(directives);
116 self
117 }
118}
119
120enum Outcome {
122 Keep,
124 Drop,
126 DropAsName(NameVerdict),
128}
129
130fn classify(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> Outcome {
133 if let Some(directives) = ctx.directives
134 && !directives.is_empty()
135 && directives.suppresses(diagnostic, text)
136 {
137 return Outcome::Drop;
138 }
139
140 if let Some(ignore) = ctx.ignore {
141 let fingerprint = DiagnosticFingerprint::new(
142 &diagnostic.message,
143 text,
144 diagnostic.start_byte as usize,
145 diagnostic.end_byte as usize,
146 );
147 if ignore.is_ignored(&fingerprint) {
148 return Outcome::Drop;
149 }
150 }
151
152 if !is_spelling_category(&diagnostic.unified_id) {
153 return Outcome::Keep;
154 }
155
156 let word = safe_slice(
157 text,
158 diagnostic.start_byte as usize,
159 diagnostic.end_byte as usize,
160 );
161
162 if let Some(dictionary) = ctx.dictionary
163 && dictionary.contains(word)
164 {
165 return Outcome::Drop;
166 }
167
168 if is_known_derivation(word, diagnostic, ctx) {
169 return Outcome::Drop;
170 }
171
172 let Some(filter) = ctx.names else {
173 return Outcome::Keep;
174 };
175 let verdict = filter.evaluate(&NameQuery {
176 token: word,
177 text,
178 start_byte: diagnostic.start_byte as usize,
179 end_byte: diagnostic.end_byte as usize,
180 suggestions: &diagnostic.suggestions,
181 });
182 if verdict.is_name {
183 Outcome::DropAsName(verdict)
184 } else {
185 Outcome::Keep
186 }
187}
188
189fn is_known_derivation(word: &str, diagnostic: &Diagnostic, ctx: &SuppressionContext<'_>) -> bool {
200 let Some(analyzer) = ctx.morphology else {
201 return false;
202 };
203 if min_suggestion_distance(word, &diagnostic.suggestions).is_some_and(|d| d <= 1) {
204 return false;
205 }
206 let Some(analysis) = analyzer.analyze(word, ctx.dictionary) else {
207 return false;
208 };
209 debug!(word, decomposition = %analysis.describe(), "Suppressed as an affixed form");
212 true
213}
214
215#[must_use]
219pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
220 !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
221}
222
223#[derive(Debug, Clone)]
225pub struct DetectedName {
226 pub start_byte: u32,
227 pub end_byte: u32,
228 pub confidence: f32,
229 pub signals: String,
230}
231
232pub fn retain_visible(
238 diagnostics: &mut Vec<Diagnostic>,
239 text: &str,
240 ctx: &SuppressionContext<'_>,
241) -> Vec<DetectedName> {
242 let mut detected = Vec::new();
243 diagnostics.retain(|d| match classify(d, text, ctx) {
244 Outcome::Keep => true,
245 Outcome::Drop => false,
246 Outcome::DropAsName(verdict) => {
247 detected.push(DetectedName {
248 start_byte: d.start_byte,
249 end_byte: d.end_byte,
250 confidence: verdict.score,
251 signals: verdict.signal_tags(),
252 });
253 false
254 }
255 });
256 detected
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn spelling_diagnostic(start: u32, end: u32) -> Diagnostic {
264 Diagnostic {
265 start_byte: start,
266 end_byte: end,
267 message: "Possible spelling mistake found.".to_string(),
268 suggestions: vec![],
269 rule_id: "languagetool.MORFOLOGIK_RULE_EN_US".to_string(),
270 severity: 2,
271 unified_id: "spelling.typo".to_string(),
272 confidence: 1.0,
273 language: String::new(),
274 pack_installable: false,
275 }
276 }
277
278 fn with_suggestions(start: u32, end: u32, suggestions: &[&str]) -> Diagnostic {
280 Diagnostic {
281 suggestions: suggestions.iter().map(|s| (*s).to_string()).collect(),
282 ..spelling_diagnostic(start, end)
283 }
284 }
285
286 #[test]
287 fn an_affixed_form_of_a_known_word_is_suppressed() {
288 let text = "every subalgebra here";
289 let analyzer = AffixAnalyzer::new("en-US");
290 let ctx = SuppressionContext::new().with_morphology(&analyzer);
291 assert!(should_suppress(&spelling_diagnostic(6, 16), text, &ctx));
292 }
293
294 #[test]
295 fn morphology_is_inert_until_it_is_supplied() {
296 let text = "every subalgebra here";
297 assert!(!should_suppress(
298 &spelling_diagnostic(6, 16),
299 text,
300 &SuppressionContext::new()
301 ));
302 }
303
304 #[test]
305 fn a_close_suggestion_vetoes_the_decomposition() {
306 let text = "wait untill then";
309 let analyzer = AffixAnalyzer::new("en-US");
310 let ctx = SuppressionContext::new().with_morphology(&analyzer);
311 assert!(!should_suppress(
312 &with_suggestions(5, 11, &["until"]),
313 text,
314 &ctx
315 ));
316 assert!(should_suppress(&spelling_diagnostic(5, 11), text, &ctx));
319 }
320
321 #[test]
322 fn a_distant_suggestion_does_not_veto() {
323 let text = "the subadditivity holds";
325 let analyzer = AffixAnalyzer::new("en-US");
326 let ctx = SuppressionContext::new().with_morphology(&analyzer);
327 assert!(should_suppress(
328 &with_suggestions(4, 17, &["subjectivity"]),
329 text,
330 &ctx
331 ));
332 }
333
334 #[test]
335 fn morphology_does_not_touch_non_spelling_diagnostics() {
336 let text = "every subalgebra here";
337 let analyzer = AffixAnalyzer::new("en-US");
338 let ctx = SuppressionContext::new().with_morphology(&analyzer);
339 let mut d = spelling_diagnostic(6, 16);
340 d.unified_id = "grammar.agreement".to_string();
341 assert!(!should_suppress(&d, text, &ctx));
342 }
343
344 #[test]
345 fn empty_context_suppresses_nothing() {
346 let text = "Ackermann wrote this.";
347 let d = spelling_diagnostic(0, 9);
348 assert!(!should_suppress(&d, text, &SuppressionContext::new()));
349 }
350
351 #[test]
352 fn dictionary_word_is_suppressed() {
353 let text = "Ackermann wrote this.";
354 let mut dict = Dictionary::new();
355 dict.add_word("ackermann").unwrap();
356 let ctx = SuppressionContext::new().with_dictionary(&dict);
357 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
358 }
359
360 #[test]
361 fn dictionary_lookup_is_case_insensitive() {
362 let text = "ACKERMANN wrote this.";
363 let mut dict = Dictionary::new();
364 dict.add_word("Ackermann").unwrap();
365 let ctx = SuppressionContext::new().with_dictionary(&dict);
366 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
367 }
368
369 #[test]
370 fn dictionary_does_not_suppress_non_spelling_diagnostics() {
371 let text = "Ackermann wrote this.";
372 let mut dict = Dictionary::new();
373 dict.add_word("ackermann").unwrap();
374 let mut d = spelling_diagnostic(0, 9);
375 d.unified_id = "grammar.agreement".to_string();
376 let ctx = SuppressionContext::new().with_dictionary(&dict);
377 assert!(!should_suppress(&d, text, &ctx));
378 }
379
380 #[test]
381 fn multibyte_spans_do_not_panic() {
382 let text = "Grüße von Müller.";
383 let mut dict = Dictionary::new();
384 dict.add_word("müller").unwrap();
385 let start = text.find("Müller").unwrap() as u32;
386 let ctx = SuppressionContext::new().with_dictionary(&dict);
387 assert!(should_suppress(
389 &spelling_diagnostic(start, start + 7),
390 text,
391 &ctx
392 ));
393 }
394
395 #[test]
396 fn detected_names_are_reported_for_the_inspector() {
397 use crate::names::{Aggressiveness, NameFilter};
398
399 let text = "The logic of Hoare is central.";
400 let filter = NameFilter::new(Aggressiveness::Balanced, "en-US");
401 let ctx = SuppressionContext::new().with_names(&filter);
402
403 let start = text.find("Hoare").unwrap() as u32;
404 let mut d = spelling_diagnostic(start, start + 5);
405 d.suggestions = vec!["Hare".to_string(), "Hoar".to_string()];
406 let mut diagnostics = vec![d];
407
408 let detected = retain_visible(&mut diagnostics, text, &ctx);
409
410 assert!(diagnostics.is_empty(), "the name should have been dropped");
411 assert_eq!(detected.len(), 1);
412 assert_eq!(detected[0].start_byte, start);
413 assert_eq!(detected[0].end_byte, start + 5);
414 assert!(detected[0].confidence > 0.0);
415 assert!(
416 detected[0].signals.contains("gazetteer"),
417 "signals were {}",
418 detected[0].signals
419 );
420 }
421
422 #[test]
423 fn nothing_is_reported_without_a_name_filter() {
424 let text = "The logic of Hoare is central.";
425 let start = text.find("Hoare").unwrap() as u32;
426 let mut diagnostics = vec![spelling_diagnostic(start, start + 5)];
427 let detected = retain_visible(&mut diagnostics, text, &SuppressionContext::new());
428 assert_eq!(diagnostics.len(), 1, "opt-in: must stay flagged");
429 assert!(detected.is_empty());
430 }
431
432 #[test]
433 fn retain_visible_drops_only_suppressed() {
434 let text = "Ackermann met Hoare.";
435 let mut dict = Dictionary::new();
436 dict.add_word("ackermann").unwrap();
437 let ctx = SuppressionContext::new().with_dictionary(&dict);
438
439 let hoare_start = text.find("Hoare").unwrap() as u32;
440 let mut diagnostics = vec![
441 spelling_diagnostic(0, 9),
442 spelling_diagnostic(hoare_start, hoare_start + 5),
443 ];
444 retain_visible(&mut diagnostics, text, &ctx);
445
446 assert_eq!(diagnostics.len(), 1);
447 assert_eq!(diagnostics[0].start_byte, hoare_start);
448 }
449}