1use crate::checker::Diagnostic;
16use crate::dictionary::Dictionary;
17use crate::hashing::{DiagnosticFingerprint, IgnoreStore};
18use crate::names::{NameFilter, NameQuery, NameVerdict};
19use crate::prose::is_spelling_category;
20use crate::text_util::safe_slice;
21
22#[derive(Default, Clone, Copy)]
27pub struct SuppressionContext<'a> {
28 pub ignore: Option<&'a IgnoreStore>,
29 pub dictionary: Option<&'a Dictionary>,
30 pub names: Option<&'a NameFilter>,
31}
32
33impl<'a> SuppressionContext<'a> {
34 #[must_use]
35 pub const fn new() -> Self {
36 Self {
37 ignore: None,
38 dictionary: None,
39 names: None,
40 }
41 }
42
43 #[must_use]
44 pub const fn with_ignore(mut self, ignore: &'a IgnoreStore) -> Self {
45 self.ignore = Some(ignore);
46 self
47 }
48
49 #[must_use]
50 pub const fn with_dictionary(mut self, dictionary: &'a Dictionary) -> Self {
51 self.dictionary = Some(dictionary);
52 self
53 }
54
55 #[must_use]
56 pub const fn with_names(mut self, names: &'a NameFilter) -> Self {
57 self.names = Some(names);
58 self
59 }
60}
61
62enum Outcome {
64 Keep,
66 Drop,
68 DropAsName(NameVerdict),
70}
71
72fn classify(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> Outcome {
75 if let Some(ignore) = ctx.ignore {
76 let fingerprint = DiagnosticFingerprint::new(
77 &diagnostic.message,
78 text,
79 diagnostic.start_byte as usize,
80 diagnostic.end_byte as usize,
81 );
82 if ignore.is_ignored(&fingerprint) {
83 return Outcome::Drop;
84 }
85 }
86
87 if !is_spelling_category(&diagnostic.unified_id) {
88 return Outcome::Keep;
89 }
90
91 let word = safe_slice(
92 text,
93 diagnostic.start_byte as usize,
94 diagnostic.end_byte as usize,
95 );
96
97 if let Some(dictionary) = ctx.dictionary
98 && dictionary.contains(word)
99 {
100 return Outcome::Drop;
101 }
102
103 let Some(filter) = ctx.names else {
104 return Outcome::Keep;
105 };
106 let verdict = filter.evaluate(&NameQuery {
107 token: word,
108 text,
109 start_byte: diagnostic.start_byte as usize,
110 end_byte: diagnostic.end_byte as usize,
111 suggestions: &diagnostic.suggestions,
112 });
113 if verdict.is_name {
114 Outcome::DropAsName(verdict)
115 } else {
116 Outcome::Keep
117 }
118}
119
120#[must_use]
124pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
125 !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
126}
127
128#[derive(Debug, Clone)]
130pub struct DetectedName {
131 pub start_byte: u32,
132 pub end_byte: u32,
133 pub confidence: f32,
134 pub signals: String,
135}
136
137pub fn retain_visible(
143 diagnostics: &mut Vec<Diagnostic>,
144 text: &str,
145 ctx: &SuppressionContext<'_>,
146) -> Vec<DetectedName> {
147 let mut detected = Vec::new();
148 diagnostics.retain(|d| match classify(d, text, ctx) {
149 Outcome::Keep => true,
150 Outcome::Drop => false,
151 Outcome::DropAsName(verdict) => {
152 detected.push(DetectedName {
153 start_byte: d.start_byte,
154 end_byte: d.end_byte,
155 confidence: verdict.score,
156 signals: verdict.signal_tags(),
157 });
158 false
159 }
160 });
161 detected
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn spelling_diagnostic(start: u32, end: u32) -> Diagnostic {
169 Diagnostic {
170 start_byte: start,
171 end_byte: end,
172 message: "Possible spelling mistake found.".to_string(),
173 suggestions: vec![],
174 rule_id: "languagetool.MORFOLOGIK_RULE_EN_US".to_string(),
175 severity: 2,
176 unified_id: "spelling.typo".to_string(),
177 confidence: 1.0,
178 }
179 }
180
181 #[test]
182 fn empty_context_suppresses_nothing() {
183 let text = "Ackermann wrote this.";
184 let d = spelling_diagnostic(0, 9);
185 assert!(!should_suppress(&d, text, &SuppressionContext::new()));
186 }
187
188 #[test]
189 fn dictionary_word_is_suppressed() {
190 let text = "Ackermann wrote this.";
191 let mut dict = Dictionary::new();
192 dict.add_word("ackermann").unwrap();
193 let ctx = SuppressionContext::new().with_dictionary(&dict);
194 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
195 }
196
197 #[test]
198 fn dictionary_lookup_is_case_insensitive() {
199 let text = "ACKERMANN wrote this.";
200 let mut dict = Dictionary::new();
201 dict.add_word("Ackermann").unwrap();
202 let ctx = SuppressionContext::new().with_dictionary(&dict);
203 assert!(should_suppress(&spelling_diagnostic(0, 9), text, &ctx));
204 }
205
206 #[test]
207 fn dictionary_does_not_suppress_non_spelling_diagnostics() {
208 let text = "Ackermann wrote this.";
209 let mut dict = Dictionary::new();
210 dict.add_word("ackermann").unwrap();
211 let mut d = spelling_diagnostic(0, 9);
212 d.unified_id = "grammar.agreement".to_string();
213 let ctx = SuppressionContext::new().with_dictionary(&dict);
214 assert!(!should_suppress(&d, text, &ctx));
215 }
216
217 #[test]
218 fn multibyte_spans_do_not_panic() {
219 let text = "Grüße von Müller.";
220 let mut dict = Dictionary::new();
221 dict.add_word("müller").unwrap();
222 let start = text.find("Müller").unwrap() as u32;
223 let ctx = SuppressionContext::new().with_dictionary(&dict);
224 assert!(should_suppress(
226 &spelling_diagnostic(start, start + 7),
227 text,
228 &ctx
229 ));
230 }
231
232 #[test]
233 fn detected_names_are_reported_for_the_inspector() {
234 use crate::names::{Aggressiveness, NameFilter};
235
236 let text = "The logic of Hoare is central.";
237 let filter = NameFilter::new(Aggressiveness::Balanced, "en-US");
238 let ctx = SuppressionContext::new().with_names(&filter);
239
240 let start = text.find("Hoare").unwrap() as u32;
241 let mut d = spelling_diagnostic(start, start + 5);
242 d.suggestions = vec!["Hare".to_string(), "Hoar".to_string()];
243 let mut diagnostics = vec![d];
244
245 let detected = retain_visible(&mut diagnostics, text, &ctx);
246
247 assert!(diagnostics.is_empty(), "the name should have been dropped");
248 assert_eq!(detected.len(), 1);
249 assert_eq!(detected[0].start_byte, start);
250 assert_eq!(detected[0].end_byte, start + 5);
251 assert!(detected[0].confidence > 0.0);
252 assert!(
253 detected[0].signals.contains("gazetteer"),
254 "signals were {}",
255 detected[0].signals
256 );
257 }
258
259 #[test]
260 fn nothing_is_reported_without_a_name_filter() {
261 let text = "The logic of Hoare is central.";
262 let start = text.find("Hoare").unwrap() as u32;
263 let mut diagnostics = vec![spelling_diagnostic(start, start + 5)];
264 let detected = retain_visible(&mut diagnostics, text, &SuppressionContext::new());
265 assert_eq!(diagnostics.len(), 1, "opt-in: must stay flagged");
266 assert!(detected.is_empty());
267 }
268
269 #[test]
270 fn retain_visible_drops_only_suppressed() {
271 let text = "Ackermann met Hoare.";
272 let mut dict = Dictionary::new();
273 dict.add_word("ackermann").unwrap();
274 let ctx = SuppressionContext::new().with_dictionary(&dict);
275
276 let hoare_start = text.find("Hoare").unwrap() as u32;
277 let mut diagnostics = vec![
278 spelling_diagnostic(0, 9),
279 spelling_diagnostic(hoare_start, hoare_start + 5),
280 ];
281 retain_visible(&mut diagnostics, text, &ctx);
282
283 assert_eq!(diagnostics.len(), 1);
284 assert_eq!(diagnostics[0].start_byte, hoare_start);
285 }
286}