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 color_eyre::eyre::bail;
7use nvim_oxi::Dictionary;
8use nvim_oxi::api::Buffer;
9use ytil_noxi::buffer::BufferExt;
10use ytil_noxi::dict::DictionaryExt as _;
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 = color_eyre::eyre::Error;
36
37    fn try_from(value: Buffer) -> Result<Self, Self::Error> {
38        let path = value.get_name().map(|s| s.to_string_lossy().to_string())?;
39        Ok(Self {
40            path,
41            buffer: Box::new(value),
42        })
43    }
44}
45
46/// Represents the location of a diagnostic in a file.
47#[derive(Debug)]
48#[cfg_attr(test, derive(Eq, PartialEq))]
49struct DiagnosticLocation {
50    /// The 1-based line number where the diagnostic starts.
51    lnum: usize,
52    /// The 0-based column number where the diagnostic starts.
53    col: usize,
54    /// The 0-based column number where the diagnostic ends.
55    end_col: usize,
56    /// The 1-based line number where the diagnostic ends.
57    end_lnum: usize,
58}
59
60impl DiagnosticLocation {
61    /// Returns the start position of the diagnostic as (line, column).
62    pub const fn start(&self) -> (usize, usize) {
63        (self.lnum, self.col)
64    }
65
66    /// Returns the end position of the diagnostic as (line, column).
67    pub const fn end(&self) -> (usize, usize) {
68        (self.end_lnum, self.end_col)
69    }
70}
71
72impl TryFrom<&Dictionary> for DiagnosticLocation {
73    type Error = color_eyre::eyre::Error;
74
75    /// Attempts to convert an Nvim dictionary into a `DiagnosticLocation`.
76    ///
77    /// # Errors
78    /// - If required fields (`lnum`, `col`, `end_col`, `end_lnum`) are missing or invalid.
79    /// - If integer conversion to `usize` fails.
80    /// - If start position is after end position (inconsistent boundaries).
81    fn try_from(value: &Dictionary) -> Result<Self, Self::Error> {
82        let lnum = value
83            .get_t::<nvim_oxi::Integer>("lnum")
84            .and_then(|n| usize::try_from(n).map_err(From::from))?;
85        let col = value
86            .get_t::<nvim_oxi::Integer>("col")
87            .and_then(|n| usize::try_from(n).map_err(From::from))?;
88        let end_col = value
89            .get_t::<nvim_oxi::Integer>("end_col")
90            .and_then(|n| usize::try_from(n).map_err(From::from))?;
91        let end_lnum = value
92            .get_t::<nvim_oxi::Integer>("end_lnum")
93            .and_then(|n| usize::try_from(n).map_err(From::from))?;
94
95        if lnum > end_lnum {
96            bail!("inconsistent line boundaries lnum {lnum} > end_lnum {end_lnum}");
97        }
98        if lnum == end_lnum && col > end_col {
99            bail!("inconsistent col boundaries col {col} > end_col {end_col} on same line");
100        }
101
102        Ok(Self {
103            lnum,
104            col,
105            end_col,
106            end_lnum,
107        })
108    }
109}
110
111/// Trait for filtering diagnostics.
112pub trait DiagnosticsFilter {
113    /// Returns true if the diagnostic should be skipped.
114    ///
115    /// # Errors
116    /// - Access to required diagnostic fields (dictionary keys) fails (missing key or wrong type).
117    /// - Filter-specific logic (e.g. related info extraction) fails.
118    fn skip_diagnostic(&self, buf: &BufferWithPath, lsp_diag: &Dictionary) -> color_eyre::Result<bool>;
119}
120
121/// A collection of diagnostic filters.
122pub struct DiagnosticsFilters(Vec<Box<dyn DiagnosticsFilter>>);
123
124impl DiagnosticsFilters {
125    /// Creates all available diagnostic filters. The order of filters is IMPORTANT.
126    ///
127    /// # Errors
128    /// - Constructing the related info filter fails (dictionary traversal or type mismatch).
129    pub fn all(lsp_diags: &[Dictionary]) -> color_eyre::Result<Self> {
130        let mut filters = TyposLspFilter::filters();
131        filters.extend(HarperLsFilter::filters());
132        filters.push(Box::new(RelatedInfoFilter::new(lsp_diags)?));
133        Ok(Self(filters))
134    }
135}
136
137/// Implementation of [`DiagnosticsFilter`] for [`DiagnosticsFilters`].
138impl DiagnosticsFilter for DiagnosticsFilters {
139    /// Returns true if any filter skips the diagnostic.
140    ///
141    /// # Errors
142    /// - A filter implementation (invoked in sequence) returns an error; it is propagated unchanged.
143    fn skip_diagnostic(&self, buf: &BufferWithPath, lsp_diag: &Dictionary) -> color_eyre::Result<bool> {
144        // The first filter that returns true skips the LSP diagnostic and all subsequent filters
145        // evaluation.
146        for filter in &self.0 {
147            if filter.skip_diagnostic(buf, lsp_diag)? {
148                return Ok(true);
149            }
150        }
151        Ok(false)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn try_from_valid_dictionary_succeeds() {
161        let dict = create_diag(0, 1, 2, 3);
162        assert2::let_assert!(Ok(loc) = DiagnosticLocation::try_from(&dict));
163        pretty_assertions::assert_eq!(loc.lnum, 0);
164        pretty_assertions::assert_eq!(loc.col, 1);
165        pretty_assertions::assert_eq!(loc.end_lnum, 2);
166        pretty_assertions::assert_eq!(loc.end_col, 3);
167    }
168
169    #[test]
170    fn try_from_missing_lnum_key_fails() {
171        let dict = ytil_noxi::dict! { col: 1_i64, end_col: 3_i64, end_lnum: 2_i64 };
172        assert2::let_assert!(Err(err) = DiagnosticLocation::try_from(&dict));
173        assert!(err.to_string().contains("missing dict value"));
174    }
175
176    #[test]
177    fn try_from_wrong_type_for_lnum_fails() {
178        let dict = ytil_noxi::dict! { lnum: "not_an_int", col: 1_i64, end_col: 3_i64, end_lnum: 2_i64 };
179        assert2::let_assert!(Err(err) = DiagnosticLocation::try_from(&dict));
180        assert!(err.to_string().contains(r#"value "not_an_int" of key "lnum""#));
181        assert!(err.to_string().contains("is String but Integer was expected"));
182    }
183
184    #[test]
185    fn try_from_negative_lnum_fails() {
186        let dict = create_diag(-1, 1, 2, 3);
187        assert2::let_assert!(Err(err) = DiagnosticLocation::try_from(&dict));
188        assert!(err.to_string().contains("out of range"));
189    }
190
191    #[test]
192    fn try_from_lnum_greater_than_end_lnum_fails() {
193        let dict = create_diag(2, 1, 0, 3);
194        assert2::let_assert!(Err(err) = DiagnosticLocation::try_from(&dict));
195        assert!(err.to_string().contains("inconsistent line boundaries"));
196        assert!(err.to_string().contains("lnum 2 > end_lnum 0"));
197    }
198
199    #[test]
200    fn try_from_col_greater_than_end_col_fails() {
201        let dict = create_diag(0, 3, 0, 1);
202        assert2::let_assert!(Err(err) = DiagnosticLocation::try_from(&dict));
203        assert!(err.to_string().contains("inconsistent col boundaries"));
204        assert!(err.to_string().contains("col 3 > end_col 1 on same line"));
205    }
206
207    #[test]
208    fn try_from_equal_boundaries_succeeds() {
209        let dict = create_diag(1, 2, 1, 2);
210        assert2::let_assert!(Ok(loc) = DiagnosticLocation::try_from(&dict));
211        pretty_assertions::assert_eq!(
212            loc,
213            DiagnosticLocation {
214                lnum: 1,
215                col: 2,
216                end_lnum: 1,
217                end_col: 2
218            }
219        );
220    }
221
222    fn create_diag(lnum: i64, col: i64, end_lnum: i64, end_col: i64) -> Dictionary {
223        ytil_noxi::dict! { col: col, end_col: end_col, lnum: lnum, end_lnum: end_lnum }
224    }
225}