Skip to main content

nvrim/diagnostics/filters/
related_info.rs

1//! Filter for deduplicating diagnostics based on related information arrays.
2//!
3//! Extracts `user_data.lsp.relatedInformation` entries and skips root diagnostics whose rendered
4//! information is already represented, reducing noise (especially repeated hints).
5
6use std::collections::HashSet;
7
8use nvim_oxi::Array;
9use nvim_oxi::Dictionary;
10use nvim_oxi::ObjectKind;
11use nvim_oxi::conversion::FromObject;
12use rootcause::prelude::ResultExt;
13use ytil_noxi::dict::DictionaryExt;
14
15use crate::diagnostics::filters::BufferWithPath;
16use crate::diagnostics::filters::DiagnosticsFilter;
17
18/// Filters out diagnostics already represented by other ones
19/// (e.g. HINTs pointing to a location already mentioned by other ERROR's rendered message)
20pub struct RelatedInfoFilter {
21    /// The set of already-seen related infos extracted from LSP diagnostics.
22    /// Used to skip duplicate root diagnostics that only repeat information.
23    /// Uses [`HashSet`] for O(1) lookup instead of Vec's O(n).
24    rel_infos: HashSet<RelatedInfo>,
25}
26
27impl RelatedInfoFilter {
28    /// Creates a new [`RelatedInfoFilter`] from LSP diagnostics.
29    ///
30    /// # Errors
31    /// - Extracting related information arrays fails (missing key or wrong type).
32    pub fn new(lsp_diags: &[Dictionary]) -> rootcause::Result<Self> {
33        Ok(Self {
34            rel_infos: Self::get_related_infos(lsp_diags)?,
35        })
36    }
37
38    /// Get the [`RelatedInfo`] of an LSP diagnostic represented by a [`Dictionary`].
39    ///
40    /// # Errors
41    /// - Traversing diagnostics fails (unexpected value kinds or conversion errors).
42    fn get_related_infos(lsp_diags: &[Dictionary]) -> rootcause::Result<HashSet<RelatedInfo>> {
43        // Pre-allocate with estimated capacity (average ~2 related infos per diagnostic)
44        let mut out = HashSet::with_capacity(lsp_diags.len().saturating_mul(2));
45        for lsp_diag in lsp_diags {
46            // Not all LSPs have "user_data.lsp.relatedInformation"; skip those missing it
47            let Some(lsp) = lsp_diag.get_dict(&["user_data", "lsp"])? else {
48                continue;
49            };
50            let rel_infos_key = "relatedInformation";
51            let Some(rel_infos) = lsp.get(rel_infos_key) else {
52                continue;
53            };
54
55            let rel_infos = Array::from_object(rel_infos.clone())
56                .context("unexpected object kind")
57                .attach_with(|| {
58                    ytil_noxi::extract::unexpected_kind_error_msg(rel_infos, rel_infos_key, &lsp, ObjectKind::Array)
59                })?;
60            for rel_info in rel_infos {
61                let rel_info = Dictionary::try_from(rel_info)?;
62                out.insert(RelatedInfo::from_related_info(&rel_info)?);
63            }
64        }
65        Ok(out)
66    }
67}
68
69impl DiagnosticsFilter for RelatedInfoFilter {
70    /// Returns true if the diagnostic is related information already covered.
71    ///
72    /// # Errors
73    /// - Building the candidate related info shape from the diagnostic fails.
74    fn skip_diagnostic(&self, _buf: &BufferWithPath, lsp_diag: &Dictionary) -> rootcause::Result<bool> {
75        if self.rel_infos.is_empty() {
76            return Ok(false);
77        }
78        // All LSPs diagnostics should be deserializable into [`RelatedInfo`]
79        let rel_info = RelatedInfo::from_lsp_diagnostic(lsp_diag)?;
80        Ok(self.rel_infos.contains(&rel_info))
81    }
82}
83
84/// Common shape of a root LSP diagnostic and the elements of its "`user_data.lsp.relatedInformation`".
85#[derive(Eq, Hash, PartialEq)]
86struct RelatedInfo {
87    /// The starting column number.
88    col: i64,
89    /// The ending column number.
90    end_col: i64,
91    /// The ending line number.
92    end_lnum: i64,
93    /// The starting line number.
94    lnum: i64,
95    /// The diagnostic message.
96    message: String,
97}
98
99impl RelatedInfo {
100    /// Create a [`RelatedInfo`] from a root LSP diagnostic.
101    ///
102    /// # Errors
103    /// - Required keys (`message`, `lnum`, `col`, `end_lnum`, `end_col`) are missing or of unexpected type.
104    fn from_lsp_diagnostic(lsp_diagnostic: &Dictionary) -> rootcause::Result<Self> {
105        Ok(Self {
106            message: lsp_diagnostic.get_t::<nvim_oxi::String>("message")?,
107            lnum: lsp_diagnostic.get_t::<nvim_oxi::Integer>("lnum")?,
108            col: lsp_diagnostic.get_t::<nvim_oxi::Integer>("col")?,
109            end_lnum: lsp_diagnostic.get_t::<nvim_oxi::Integer>("end_lnum")?,
110            end_col: lsp_diagnostic.get_t::<nvim_oxi::Integer>("end_col")?,
111        })
112    }
113
114    /// Create a [`RelatedInfo`] from an element of an LSP diagnostic "`user_data.lsp.relatedInformation`" section.
115    ///
116    /// # Errors
117    /// - Required nested keys (range.start, range.end, message, line/character) are missing or wrong type.
118    fn from_related_info(rel_info: &Dictionary) -> rootcause::Result<Self> {
119        let (start, end) = {
120            let range_query = ["location", "range"];
121            let range = rel_info.get_required_dict(&range_query)?;
122
123            let start_query = ["start"];
124            let end_query = ["end"];
125            (
126                range.get_required_dict(&start_query)?,
127                range.get_required_dict(&end_query)?,
128            )
129        };
130
131        Ok(Self {
132            message: rel_info.get_t::<nvim_oxi::String>("message")?,
133            lnum: start.get_t::<nvim_oxi::Integer>("line")?,
134            col: start.get_t::<nvim_oxi::Integer>("character")?,
135            end_lnum: end.get_t::<nvim_oxi::Integer>("line")?,
136            end_col: end.get_t::<nvim_oxi::Integer>("character")?,
137        })
138    }
139}