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