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 test_that::prelude::*;
102    use ytil_noxi::buffer::mock::MockBuffer;
103
104    use super::*;
105    use crate::diagnostics::filters::BufferWithPath;
106
107    #[test]
108    fn test_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        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(false)));
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        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(false)));
145    }
146
147    #[test]
148    fn test_skip_diagnostic_when_diagnosed_text_not_in_blacklist_returns_false() {
149        let test_blacklist = map! {"stdout": set!["instead of"]};
150        let filter = HarperLsFilter {
151            source: "Harper",
152            blacklist: &test_blacklist,
153            path_substring: None,
154        };
155        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
156        let diag = dict! {
157            source: "Harper",
158            message: "some message",
159            lnum: 0,
160            col: 0,
161            end_lnum: 0,
162            end_col: 6,
163        };
164        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(false)));
165    }
166
167    #[test]
168    fn test_skip_diagnostic_when_diagnosed_text_in_blacklist_but_message_no_match_returns_false() {
169        let test_blacklist = map! {"stderr": set!["instead of"]};
170        let filter = HarperLsFilter {
171            source: "Harper",
172            blacklist: &test_blacklist,
173            path_substring: None,
174        };
175        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
176        let diag = dict! {
177            source: "Harper",
178            message: "some other message",
179            lnum: 0,
180            col: 0,
181            end_lnum: 0,
182            end_col: 6,
183        };
184        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(false)));
185    }
186
187    #[test]
188    fn test_skip_diagnostic_when_all_conditions_met_returns_true() {
189        let test_blacklist = map! {"stderr": set!["instead of"]};
190        let filter = HarperLsFilter {
191            source: "Harper",
192            blacklist: &test_blacklist,
193            path_substring: None,
194        };
195        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["stderr"]);
196        let diag = dict! {
197            source: "Harper",
198            message: "instead of something",
199            lnum: 0,
200            col: 0,
201            end_lnum: 0,
202            end_col: 6,
203        };
204        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(true)));
205    }
206
207    #[test]
208    fn test_skip_diagnostic_when_diagnosed_text_cannot_be_extracted_returns_error() {
209        let test_blacklist = map! {"stderr": set!["instead of"]};
210        let filter = HarperLsFilter {
211            source: "Harper",
212            blacklist: &test_blacklist,
213            path_substring: None,
214        };
215        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["short"]);
216        let diag = dict! {
217            source: "Harper",
218            message: "instead of something",
219            lnum: 1,
220            col: 1,
221            end_col: 7,
222        };
223        assert_that!(
224            (filter.skip_diagnostic(&buf, &diag)).map(|_| ()),
225            err(displays_as(all!(
226                contains_substring("missing dict value"),
227                contains_substring(r#""end_lnum""#)
228            )))
229        );
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        assert_that!(
250            (filter.skip_diagnostic(&buf, &diag)).map(|_| ()),
251            err(displays_as(all!(
252                contains_substring("inconsistent line boundaries"),
253                contains_substring("lnum 1 > end_lnum 0")
254            )))
255        );
256    }
257
258    #[test]
259    fn test_skip_diagnostic_when_col_greater_than_end_col_returns_error() {
260        let test_blacklist = map! {"stderr": set!["instead of"]};
261        let filter = HarperLsFilter {
262            source: "Harper",
263            blacklist: &test_blacklist,
264            path_substring: None,
265        };
266        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["hello world"]);
267        let diag = dict! {
268            source: "Harper",
269            message: "some message",
270            lnum: 0,
271            col: 5,
272            end_lnum: 0,
273            end_col: 0,
274        };
275        assert_that!(
276            (filter.skip_diagnostic(&buf, &diag)).map(|_| ()),
277            err(displays_as(all!(
278                contains_substring("inconsistent col boundaries"),
279                contains_substring("col 5 > end_col 0")
280            )))
281        );
282    }
283
284    #[test]
285    fn test_skip_diagnostic_when_start_col_out_of_bounds_returns_error() {
286        let test_blacklist = map! {"stderr": set!["instead of"]};
287        let filter = HarperLsFilter {
288            source: "Harper",
289            blacklist: &test_blacklist,
290            path_substring: None,
291        };
292        let buf = create_buffer_with_path_and_content("src/lib.rs", vec!["hi"]);
293        let diag = dict! {
294            source: "Harper",
295            message: "some message",
296            lnum: 0,
297            col: 10,
298            end_lnum: 0,
299            end_col: 15,
300        };
301        assert_that!(
302            (filter.skip_diagnostic(&buf, &diag)).map(|_| ()),
303            err(displays_as(contains_substring("cannot extract substring")))
304        );
305    }
306
307    #[test]
308    fn test_skip_diagnostic_when_empty_lines_returns_false() {
309        let test_blacklist = map! {"stderr": set!["instead of"]};
310        let filter = HarperLsFilter {
311            source: "Harper",
312            blacklist: &test_blacklist,
313            path_substring: None,
314        };
315        let buf = create_buffer_with_path_and_content("src/lib.rs", vec![]);
316        let diag = dict! {
317            source: "Harper",
318            message: "some message",
319            lnum: 0,
320            col: 0,
321            end_lnum: 0,
322            end_col: 5,
323        };
324        assert_that!(filter.skip_diagnostic(&buf, &diag), ok(eq(false)));
325    }
326
327    fn create_buffer_with_path_and_content(path: &str, content: Vec<&str>) -> BufferWithPath {
328        BufferWithPath {
329            buffer: Box::new(MockBuffer::new(content.into_iter().map(str::to_string).collect())),
330            path: path.to_string(),
331        }
332    }
333}