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