Skip to main content

nvrim/diagnostics/
filters.rs

1//! Collection and construction of diagnostic filters.
2//!
3//! Defines [`DiagnosticsFilter`] trait plus ordered creation of all active filters (message blacklist,
4//! source‑specific sets, related info deduper). Ordering is significant for short‑circuit behavior.
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::api::Buffer;
8use rootcause::bail;
9use ytil_noxi::buffer::BufferExt;
10use ytil_noxi::dict::DictionaryExt;
11
12use crate::diagnostics::filters::lsps::harper_ls::HarperLsFilter;
13use crate::diagnostics::filters::lsps::typos_lsp::TyposLspFilter;
14use crate::diagnostics::filters::related_info::RelatedInfoFilter;
15
16pub mod buffer;
17pub mod lsps;
18pub mod related_info;
19
20/// Represents a buffer associated with its filepath.
21pub struct BufferWithPath {
22    /// The buffer instance.
23    buffer: Box<dyn BufferExt>,
24    /// The filepath associated with the buffer.
25    path: String,
26}
27
28impl BufferWithPath {
29    pub fn path(&self) -> &str {
30        &self.path
31    }
32}
33
34impl TryFrom<Buffer> for BufferWithPath {
35    type Error = rootcause::Report;
36
37    fn try_from(value: Buffer) -> Result<Self, Self::Error> {
38        let path = value.get_name().map(|s| s.to_string_lossy().into_owned())?;
39        Ok(Self {
40            path,
41            buffer: Box::new(value),
42        })
43    }
44}
45
46/// Trait for filtering diagnostics.
47pub trait DiagnosticsFilter {
48    /// Returns true if the diagnostic should be skipped.
49    ///
50    /// # Errors
51    /// - Access to required diagnostic fields (dictionary keys) fails (missing key or wrong type).
52    /// - Filter-specific logic (e.g. related info extraction) fails.
53    fn skip_diagnostic(&self, buf: &BufferWithPath, lsp_diag: &Dictionary) -> rootcause::Result<bool>;
54}
55
56/// A collection of diagnostic filters.
57pub struct DiagnosticsFilters(Vec<Box<dyn DiagnosticsFilter>>);
58
59impl DiagnosticsFilters {
60    /// Creates all available diagnostic filters. The order of filters is IMPORTANT.
61    ///
62    /// # Errors
63    /// - Constructing the related info filter fails (dictionary traversal or type mismatch).
64    pub fn all(lsp_diags: &[Dictionary]) -> rootcause::Result<Self> {
65        let mut filters = TyposLspFilter::filters();
66        filters.extend(HarperLsFilter::filters());
67        filters.push(Box::new(RelatedInfoFilter::new(lsp_diags)?));
68        Ok(Self(filters))
69    }
70}
71
72/// Implementation of [`DiagnosticsFilter`] for [`DiagnosticsFilters`].
73impl DiagnosticsFilter for DiagnosticsFilters {
74    /// Returns true if any filter skips the diagnostic.
75    ///
76    /// # Errors
77    /// - A filter implementation (invoked in sequence) returns an error; it is propagated unchanged.
78    fn skip_diagnostic(&self, buf: &BufferWithPath, lsp_diag: &Dictionary) -> rootcause::Result<bool> {
79        // The first filter that returns true skips the LSP diagnostic and all subsequent filters
80        // evaluation.
81        for filter in &self.0 {
82            if filter.skip_diagnostic(buf, lsp_diag)? {
83                return Ok(true);
84            }
85        }
86        Ok(false)
87    }
88}
89
90/// Represents the location of a diagnostic in a file.
91#[derive(Debug)]
92#[cfg_attr(test, derive(Eq, PartialEq))]
93struct DiagnosticLocation {
94    /// The 1-based line number where the diagnostic starts.
95    lnum: usize,
96    /// The 0-based column number where the diagnostic starts.
97    col: usize,
98    /// The 0-based column number where the diagnostic ends.
99    end_col: usize,
100    /// The 1-based line number where the diagnostic ends.
101    end_lnum: usize,
102}
103
104impl DiagnosticLocation {
105    /// Returns the start position of the diagnostic as (line, column).
106    pub const fn start(&self) -> (usize, usize) {
107        (self.lnum, self.col)
108    }
109
110    /// Returns the end position of the diagnostic as (line, column).
111    pub const fn end(&self) -> (usize, usize) {
112        (self.end_lnum, self.end_col)
113    }
114}
115
116impl TryFrom<&Dictionary> for DiagnosticLocation {
117    type Error = rootcause::Report;
118
119    /// Attempts to convert an Nvim dictionary into a `DiagnosticLocation`.
120    ///
121    /// # Errors
122    /// - If required fields (`lnum`, `col`, `end_col`, `end_lnum`) are missing or invalid.
123    /// - If integer conversion to `usize` fails.
124    /// - If start position is after end position (inconsistent boundaries).
125    fn try_from(value: &Dictionary) -> Result<Self, Self::Error> {
126        let lnum = value
127            .get_t::<nvim_oxi::Integer>("lnum")
128            .and_then(|n| usize::try_from(n).map_err(From::from))?;
129        let col = value
130            .get_t::<nvim_oxi::Integer>("col")
131            .and_then(|n| usize::try_from(n).map_err(From::from))?;
132        let end_col = value
133            .get_t::<nvim_oxi::Integer>("end_col")
134            .and_then(|n| usize::try_from(n).map_err(From::from))?;
135        let end_lnum = value
136            .get_t::<nvim_oxi::Integer>("end_lnum")
137            .and_then(|n| usize::try_from(n).map_err(From::from))?;
138
139        if lnum > end_lnum {
140            bail!("inconsistent line boundaries lnum {lnum} > end_lnum {end_lnum}");
141        }
142        if lnum == end_lnum && col > end_col {
143            bail!("inconsistent col boundaries col {col} > end_col {end_col} on same line");
144        }
145
146        Ok(Self {
147            lnum,
148            col,
149            end_col,
150            end_lnum,
151        })
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use test_that::prelude::*;
158
159    use super::*;
160
161    #[test]
162    fn test_try_from_valid_dictionary_succeeds() {
163        let dict = create_diag(0, 1, 2, 3);
164        assert_that!(
165            DiagnosticLocation::try_from(&dict),
166            ok(eq(DiagnosticLocation {
167                lnum: 0,
168                col: 1,
169                end_lnum: 2,
170                end_col: 3,
171            }))
172        );
173    }
174
175    #[test]
176    fn test_try_from_missing_lnum_key_fails() {
177        let dict = ytil_noxi::dict! { col: 1_i64, end_col: 3_i64, end_lnum: 2_i64 };
178        assert_that!(
179            DiagnosticLocation::try_from(&dict),
180            err(displays_as(contains_substring("missing dict value")))
181        );
182    }
183
184    #[test]
185    fn test_try_from_wrong_type_for_lnum_fails() {
186        let dict = ytil_noxi::dict! { lnum: "not_an_int", col: 1_i64, end_col: 3_i64, end_lnum: 2_i64 };
187        assert_that!(
188            DiagnosticLocation::try_from(&dict),
189            err(all!(
190                displays_as(contains_substring(r#"value "not_an_int" of key "lnum""#)),
191                displays_as(contains_substring("is String but Integer was expected")),
192            ))
193        );
194    }
195
196    #[test]
197    fn test_try_from_negative_lnum_fails() {
198        let dict = create_diag(-1, 1, 2, 3);
199        assert_that!(DiagnosticLocation::try_from(&dict), err(anything()));
200    }
201
202    #[test]
203    fn test_try_from_lnum_greater_than_end_lnum_fails() {
204        let dict = create_diag(2, 1, 0, 3);
205        assert_that!(
206            DiagnosticLocation::try_from(&dict),
207            err(displays_as(all!(
208                contains_substring("inconsistent line boundaries"),
209                contains_substring("lnum 2 > end_lnum 0")
210            )))
211        );
212    }
213
214    #[test]
215    fn test_try_from_col_greater_than_end_col_fails() {
216        let dict = create_diag(0, 3, 0, 1);
217        assert_that!(
218            DiagnosticLocation::try_from(&dict),
219            err(displays_as(all!(
220                contains_substring("inconsistent col boundaries"),
221                contains_substring("col 3 > end_col 1 on same line")
222            )))
223        );
224    }
225
226    #[test]
227    fn test_try_from_equal_boundaries_succeeds() {
228        let dict = create_diag(1, 2, 1, 2);
229        assert_that!(
230            DiagnosticLocation::try_from(&dict),
231            ok(eq(DiagnosticLocation {
232                lnum: 1,
233                col: 2,
234                end_lnum: 1,
235                end_col: 2
236            }))
237        );
238    }
239
240    fn create_diag(lnum: i64, col: i64, end_lnum: i64, end_col: i64) -> Dictionary {
241        ytil_noxi::dict! { col: col, end_col: end_col, lnum: lnum, end_lnum: end_lnum }
242    }
243}