Skip to main content

lang_check/
suppression.rs

1//! Shared post-check diagnostic suppression.
2//!
3//! Every entry point (protobuf server, LSP, CLI, background indexer) runs the same
4//! suppression pass once a diagnostic has been produced. Keeping it here means the four
5//! paths cannot drift apart — previously the user-dictionary check existed only in the
6//! server and LSP paths, so `language-check check` reported spelling errors for words the
7//! user had explicitly whitelisted.
8//!
9//! Ordering requirements for callers:
10//! - diagnostic offsets must already be rebased to **document** coordinates, so `text` is
11//!   the whole document and surrounding context is visible;
12//! - `unified_id` must already be populated (only [`crate::orchestrator::Orchestrator`]
13//!   does that), since the spelling check keys on it.
14
15use 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/// The suppression sources available at a given call site.
26///
27/// Each is optional because the entry points differ in what they have: the CLI has no
28/// ignore store, and the name filter is only present when the user opts in.
29#[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
73/// What the suppression pass decided about one diagnostic.
74enum Outcome {
75    /// Show it.
76    Keep,
77    /// Drop it (ignore store or user dictionary).
78    Drop,
79    /// Drop it because the token is a human name.
80    DropAsName(NameVerdict),
81}
82
83/// The single decision point. Both public entry points route through this so the four
84/// call sites cannot diverge again.
85fn 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
135/// Whether the token is an affixed form of known material, and not a typo.
136///
137/// Decomposition alone is not enough. Measured over single-edit misspellings of common
138/// English words it accepts about 1% of them — `untill` as `un` + `till`, `intergrate`
139/// as `inter` + `grate` — and every one of those has its correction sitting in the
140/// engine's own suggestion list one edit away. A token that close to a real word is a
141/// slip, whatever else it also parses as, so the suggestions veto the analysis.
142///
143/// No suggestions at all is the opposite evidence: the engine could think of nothing
144/// this might have been, which is what a genuinely novel coinage looks like.
145fn 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    // Suppression is otherwise silent, and a feature that hides squiggles without
156    // saying why cannot be debugged from a bug report.
157    debug!(word, decomposition = %analysis.describe(), "Suppressed as an affixed form");
158    true
159}
160
161/// Whether `diagnostic` should be dropped before reaching the user.
162///
163/// `text` is the full document; `diagnostic`'s offsets must index into it.
164#[must_use]
165pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
166    !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
167}
168
169/// A span the name filter recognised, for the inspector.
170#[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
178/// Drop suppressed diagnostics, reporting any dropped because they were names.
179///
180/// The report exists so the inspector can show exactly what the filter silenced and on
181/// what evidence — without it the feature is invisible and undebuggable. Call sites that
182/// don't surface it simply discard the return value.
183pub 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    /// A spelling diagnostic carrying the corrections an engine would actually offer.
223    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        // `untill` parses as un + till, and `till` really is a word. The engine's own
251        // correction, one edit away, is what says otherwise.
252        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        // Without that evidence the same token is accepted, which is exactly why the
261        // guard is not optional.
262        assert!(should_suppress(&spelling_diagnostic(5, 11), text, &ctx));
263    }
264
265    #[test]
266    fn a_distant_suggestion_does_not_veto() {
267        // Engines answer novel coinages with something, but not something close.
268        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        // "Müller" is 7 bytes because of the umlaut.
332        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}