Skip to main content

nvrim/diagnostics/filters/lsps/
harper_ls.rs

1//! "Harper" LSP custom filter.
2//!
3//! Suppresses noisy diagnostics that cannot be filtered directly with "Harper".
4
5use std::collections::HashMap;
6use std::collections::HashSet;
7use std::convert::identity;
8use std::sync::LazyLock;
9
10use lit2::map;
11use lit2::set;
12use nvim_oxi::Dictionary;
13use ytil_noxi::buffer::TextBoundary;
14
15use crate::diagnostics::filters::BufferWithPath;
16use crate::diagnostics::filters::DiagnosticLocation;
17use crate::diagnostics::filters::DiagnosticsFilter;
18use crate::diagnostics::filters::lsps::GetDiagMsgOutput;
19use crate::diagnostics::filters::lsps::LspFilter;
20
21/// Static blacklist initialized once on first access.
22/// Maps diagnostic text to sets of message substrings to suppress.
23static HARPER_BLACKLIST: LazyLock<HashMap<&'static str, HashSet<&'static str>>> = LazyLock::new(|| {
24    map! {
25        "has ": set!["You may be missing a preposition here"],
26        "stderr": set!["instead of"],
27        "stdout": set!["instead of"],
28        "stdin": set!["instead of"],
29        "deduper": set!["Did you mean to spell"],
30        "TODO": set!["Hyphenate"],
31        "FIXME": set!["Did you mean `IME`"],
32        "Resolve": set!["Insert `to` to complete the infinitive"],
33        "foreground": set!["This sentence does not start with a capital letter"],
34        "build": set!["This sentence does not start with a capital letter"],
35        "args": set!["Use `argument` instead of `arg`"],
36        "stack overflow": set!["Ensure proper capitalization of companies"],
37        "over all": set!["closed compound `overall`"],
38        "checkout": set!["not a compound noun"]
39    }
40});
41
42pub struct HarperLsFilter<'a> {
43    /// LSP diagnostic source name; only diagnostics from this source are eligible for blacklist matching.
44    pub source: &'a str,
45    /// Blacklist of messages per source. References the static blacklist for one-time initialization.
46    pub blacklist: &'a HashMap<&'static str, HashSet<&'static str>>,
47    /// Optional buffer path substring that must be contained within the buffer path for filtering to apply.
48    pub path_substring: Option<&'a str>,
49}
50
51impl HarperLsFilter<'_> {
52    /// Build Harper LSP diagnostic filters.
53    ///
54    /// Returns a vector of boxed [`DiagnosticsFilter`] configured for the Harper language server. Includes a single
55    /// [`HarperLsFilter`] suppressing channel-related noise ("stderr", "stdout", "stdin").
56    pub fn filters() -> Vec<Box<dyn DiagnosticsFilter>> {
57        vec![Box::new(HarperLsFilter {
58            source: "Harper",
59            path_substring: None,
60            blacklist: &HARPER_BLACKLIST,
61        })]
62    }
63}
64
65impl LspFilter for HarperLsFilter<'_> {
66    fn path_substring(&self) -> Option<&str> {
67        self.path_substring
68    }
69
70    fn source(&self) -> &str {
71        self.source
72    }
73}
74
75impl DiagnosticsFilter for HarperLsFilter<'_> {
76    fn skip_diagnostic(&self, buf: &BufferWithPath, lsp_diag: &Dictionary) -> rootcause::Result<bool> {
77        let diag_msg = match self.get_diag_msg_or_skip(&buf.path, lsp_diag)? {
78            GetDiagMsgOutput::Msg(diag_msg) => diag_msg,
79            GetDiagMsgOutput::Skip => return Ok(false),
80        };
81
82        let diag_location = DiagnosticLocation::try_from(lsp_diag)?;
83
84        let diag_text = buf
85            .buffer
86            .get_text_between(diag_location.start(), diag_location.end(), TextBoundary::Exact)?;
87
88        Ok(self
89            .blacklist
90            .get(diag_text.as_str())
91            .map(|blacklisted_msgs| {
92                blacklisted_msgs
93                    .iter()
94                    .any(|blacklisted_msg| diag_msg.contains(blacklisted_msg))
95            })
96            .is_some_and(identity))
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use ytil_noxi::buffer::mock::MockBuffer;
103
104    use super::*;
105    use crate::diagnostics::filters::BufferWithPath;
106
107    #[test]
108    fn skip_diagnostic_when_path_substring_pattern_not_matched_returns_false() {
109        let test_blacklist = map! {"stderr": set!["instead of"]};
110        let filter = HarperLsFilter {
111            source: "Harper",
112            blacklist: &test_blacklist,
113            path_substring: Some("src/"),
114        };
115        let buf = create_buffer_with_path_and_content("tests/main.rs", vec!["stderr"]);
116        let diag = dict! {
117            source: "Harper",
118            message: "instead of something",
119            lnum: 0,
120            col: 0,
121            end_lnum: 0,
122            end_col: 6,
123        };
124        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
125        assert!(!res);
126    }
127
128    #[test]
129    fn skip_diagnostic_when_source_mismatch_returns_false() {
130        let test_blacklist = map! {"stderr": set!["instead of"]};
131        let filter = HarperLsFilter {
132            source: "Harper",
133            blacklist: &test_blacklist,
134            path_substring: None,
135        };
136        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
137        let diag = dict! {
138            source: "Other",
139            message: "instead of something",
140            lnum: 0,
141            col: 0,
142            end_lnum: 0,
143            end_col: 6,
144        };
145        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
146        assert!(!res);
147    }
148
149    #[test]
150    fn skip_diagnostic_when_diagnosed_text_not_in_blacklist_returns_false() {
151        let test_blacklist = map! {"stdout": set!["instead of"]};
152        let filter = HarperLsFilter {
153            source: "Harper",
154            blacklist: &test_blacklist,
155            path_substring: None,
156        };
157        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
158        let diag = dict! {
159            source: "Harper",
160            message: "some message",
161            lnum: 0,
162            col: 0,
163            end_lnum: 0,
164            end_col: 6,
165        };
166        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
167        assert!(!res);
168    }
169
170    #[test]
171    fn skip_diagnostic_when_diagnosed_text_in_blacklist_but_message_no_match_returns_false() {
172        let test_blacklist = map! {"stderr": set!["instead of"]};
173        let filter = HarperLsFilter {
174            source: "Harper",
175            blacklist: &test_blacklist,
176            path_substring: None,
177        };
178        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
179        let diag = dict! {
180            source: "Harper",
181            message: "some other message",
182            lnum: 0,
183            col: 0,
184            end_lnum: 0,
185            end_col: 6,
186        };
187        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
188        assert!(!res);
189    }
190
191    #[test]
192    fn skip_diagnostic_when_all_conditions_met_returns_true() {
193        let test_blacklist = map! {"stderr": set!["instead of"]};
194        let filter = HarperLsFilter {
195            source: "Harper",
196            blacklist: &test_blacklist,
197            path_substring: None,
198        };
199        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
200        let diag = dict! {
201            source: "Harper",
202            message: "instead of something",
203            lnum: 0,
204            col: 0,
205            end_lnum: 0,
206            end_col: 6,
207        };
208        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
209        assert!(res);
210    }
211
212    #[test]
213    fn skip_diagnostic_when_diagnosed_text_cannot_be_extracted_returns_error() {
214        let test_blacklist = map! {"stderr": set!["instead of"]};
215        let filter = HarperLsFilter {
216            source: "Harper",
217            blacklist: &test_blacklist,
218            path_substring: None,
219        };
220        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["short"]);
221        let diag = dict! {
222            source: "Harper",
223            message: "instead of something",
224            lnum: 1,
225            col: 1,
226            end_col: 7,
227        };
228        assert2::assert!(let Err(err) = filter.skip_diagnostic(&buf, &diag));
229        assert!(err.to_string().contains("missing dict value"));
230        assert!(err.to_string().contains(r#""end_lnum""#));
231    }
232
233    #[test]
234    fn skip_diagnostic_when_lnum_greater_than_end_lnum_returns_error() {
235        let test_blacklist = map! {"stderr": set!["instead of"]};
236        let filter = HarperLsFilter {
237            source: "Harper",
238            blacklist: &test_blacklist,
239            path_substring: None,
240        };
241        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["hello world"]);
242        let diag = dict! {
243            source: "Harper",
244            message: "some message",
245            lnum: 1,
246            col: 0,
247            end_lnum: 0,
248            end_col: 5,
249        };
250        assert2::assert!(let Err(err) = filter.skip_diagnostic(&buf, &diag));
251        assert!(err.to_string().contains("inconsistent line boundaries"));
252        assert!(err.to_string().contains("lnum 1 > end_lnum 0"));
253    }
254
255    #[test]
256    fn skip_diagnostic_when_col_greater_than_end_col_returns_error() {
257        let test_blacklist = map! {"stderr": set!["instead of"]};
258        let filter = HarperLsFilter {
259            source: "Harper",
260            blacklist: &test_blacklist,
261            path_substring: None,
262        };
263        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["hello world"]);
264        let diag = dict! {
265            source: "Harper",
266            message: "some message",
267            lnum: 0,
268            col: 5,
269            end_lnum: 0,
270            end_col: 0,
271        };
272        assert2::assert!(let Err(err) = filter.skip_diagnostic(&buf, &diag));
273        assert!(err.to_string().contains("inconsistent col boundaries"));
274        assert!(err.to_string().contains("col 5 > end_col 0"));
275    }
276
277    #[test]
278    fn skip_diagnostic_when_start_col_out_of_bounds_returns_error() {
279        let test_blacklist = map! {"stderr": set!["instead of"]};
280        let filter = HarperLsFilter {
281            source: "Harper",
282            blacklist: &test_blacklist,
283            path_substring: None,
284        };
285        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["hi"]);
286        let diag = dict! {
287            source: "Harper",
288            message: "some message",
289            lnum: 0,
290            col: 10,
291            end_lnum: 0,
292            end_col: 15,
293        };
294        assert2::assert!(let Err(err) = filter.skip_diagnostic(&buf, &diag));
295        assert!(err.to_string().contains("cannot extract substring"));
296    }
297
298    #[test]
299    fn skip_diagnostic_when_empty_lines_returns_false() {
300        let test_blacklist = map! {"stderr": set!["instead of"]};
301        let filter = HarperLsFilter {
302            source: "Harper",
303            blacklist: &test_blacklist,
304            path_substring: None,
305        };
306        let buf = create_buffer_with_path_and_content("src/lib.rs", vec![]);
307        let diag = dict! {
308            source: "Harper",
309            message: "some message",
310            lnum: 0,
311            col: 0,
312            end_lnum: 0,
313            end_col: 5,
314        };
315        assert2::assert!(let Ok(res) = filter.skip_diagnostic(&buf, &diag));
316        assert!(!res);
317    }
318
319    fn create_buffer_with_path_and_content(path: &str, content: Vec<&str>) -> BufferWithPath {
320        BufferWithPath {
321            buffer: Box::new(MockBuffer::new(content.into_iter().map(str::to_string).collect())),
322            path: path.to_string(),
323        }
324    }
325}