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 super::*;
158
159    #[test]
160    fn test_try_from_valid_dictionary_succeeds() {
161        let dict = create_diag(0, 1, 2, 3);
162        assert2::assert!(let 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 test_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::assert!(let Err(err) = DiagnosticLocation::try_from(&dict));
173        assert!(err.to_string().contains("missing dict value"));
174    }
175
176    #[test]
177    fn test_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::assert!(let 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 test_try_from_negative_lnum_fails() {
186        let dict = create_diag(-1, 1, 2, 3);
187        assert2::assert!(let Err(err) = DiagnosticLocation::try_from(&dict));
188        assert!(err.to_string().contains("out of range"));
189    }
190
191    #[test]
192    fn test_try_from_lnum_greater_than_end_lnum_fails() {
193        let dict = create_diag(2, 1, 0, 3);
194        assert2::assert!(let 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 test_try_from_col_greater_than_end_col_fails() {
201        let dict = create_diag(0, 3, 0, 1);
202        assert2::assert!(let 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 test_try_from_equal_boundaries_succeeds() {
209        let dict = create_diag(1, 2, 1, 2);
210        assert2::assert!(let 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}