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::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/// The suppression sources available at a given call site.
27///
28/// Each is optional because the entry points differ in what they have: the CLI has no
29/// ignore store, and the name filter is only present when the user opts in.
30#[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/// Inline `lang-check-*` directives resolved from a whole document.
40///
41/// Resolved once per document and shared by every diagnostic, because the
42/// directives live in comments that the prose extractors strip: a range of
43/// extracted prose never contains the `lang-check-begin` that governs it, and
44/// its offsets are range-local. Both only line up at the call sites that hold
45/// the document, which is where [`retain_visible`] runs.
46#[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    /// Whether anything was found, so callers can skip the per-diagnostic work.
61    #[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    /// Whether `diagnostic` falls inside a suppressing directive.
67    ///
68    /// `text` is the document the directives were parsed from, and the
69    /// diagnostic's offsets must index into it.
70    #[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
120/// What the suppression pass decided about one diagnostic.
121enum Outcome {
122    /// Show it.
123    Keep,
124    /// Drop it (ignore store or user dictionary).
125    Drop,
126    /// Drop it because the token is a human name.
127    DropAsName(NameVerdict),
128}
129
130/// The single decision point. Both public entry points route through this so the four
131/// call sites cannot diverge again.
132fn 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
189/// Whether the token is an affixed form of known material, and not a typo.
190///
191/// Decomposition alone is not enough. Measured over single-edit misspellings of common
192/// English words it accepts about 1% of them — `untill` as `un` + `till`, `intergrate`
193/// as `inter` + `grate` — and every one of those has its correction sitting in the
194/// engine's own suggestion list one edit away. A token that close to a real word is a
195/// slip, whatever else it also parses as, so the suggestions veto the analysis.
196///
197/// No suggestions at all is the opposite evidence: the engine could think of nothing
198/// this might have been, which is what a genuinely novel coinage looks like.
199fn 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    // Suppression is otherwise silent, and a feature that hides squiggles without
210    // saying why cannot be debugged from a bug report.
211    debug!(word, decomposition = %analysis.describe(), "Suppressed as an affixed form");
212    true
213}
214
215/// Whether `diagnostic` should be dropped before reaching the user.
216///
217/// `text` is the full document; `diagnostic`'s offsets must index into it.
218#[must_use]
219pub fn should_suppress(diagnostic: &Diagnostic, text: &str, ctx: &SuppressionContext<'_>) -> bool {
220    !matches!(classify(diagnostic, text, ctx), Outcome::Keep)
221}
222
223/// A span the name filter recognised, for the inspector.
224#[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
232/// Drop suppressed diagnostics, reporting any dropped because they were names.
233///
234/// The report exists so the inspector can show exactly what the filter silenced and on
235/// what evidence — without it the feature is invisible and undebuggable. Call sites that
236/// don't surface it simply discard the return value.
237pub 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    /// A spelling diagnostic carrying the corrections an engine would actually offer.
279    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        // `untill` parses as un + till, and `till` really is a word. The engine's own
307        // correction, one edit away, is what says otherwise.
308        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        // Without that evidence the same token is accepted, which is exactly why the
317        // guard is not optional.
318        assert!(should_suppress(&spelling_diagnostic(5, 11), text, &ctx));
319    }
320
321    #[test]
322    fn a_distant_suggestion_does_not_veto() {
323        // Engines answer novel coinages with something, but not something close.
324        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        // "Müller" is 7 bytes because of the umlaut.
388        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}