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 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/// The suppression sources available at a given call site.
23///
24/// Each is optional because the entry points differ in what they have: the CLI has no
25/// ignore store, and the name filter is only present when the user opts in.
26#[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
62/// What the suppression pass decided about one diagnostic.
63enum Outcome {
64    /// Show it.
65    Keep,
66    /// Drop it (ignore store or user dictionary).
67    Drop,
68    /// Drop it because the token is a human name.
69    DropAsName(NameVerdict),
70}
71
72/// The single decision point. Both public entry points route through this so the four
73/// call sites cannot diverge again.
74fn 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/// Whether `diagnostic` should be dropped before reaching the user.
121///
122/// `text` is the full document; `diagnostic`'s offsets must index into it.
123#[must_use]
124pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
125    !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
126}
127
128/// A span the name filter recognised, for the inspector.
129#[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
137/// Drop suppressed diagnostics, reporting any dropped because they were names.
138///
139/// The report exists so the inspector can show exactly what the filter silenced and on
140/// what evidence — without it the feature is invisible and undebuggable. Call sites that
141/// don't surface it simply discard the return value.
142pub 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        // "Müller" is 7 bytes because of the umlaut.
225        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}