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        if self.rel_infos.contains(&rel_info) {
81            return Ok(true);
82        }
83        Ok(false)
84    }
85}
86
87/// Common shape of a root LSP diagnostic and the elements of its "`user_data.lsp.relatedInformation`".
88#[derive(Eq, Hash, PartialEq)]
89struct RelatedInfo {
90    /// The starting column number.
91    col: i64,
92    /// The ending column number.
93    end_col: i64,
94    /// The ending line number.
95    end_lnum: i64,
96    /// The starting line number.
97    lnum: i64,
98    /// The diagnostic message.
99    message: String,
100}
101
102impl RelatedInfo {
103    /// Create a [`RelatedInfo`] from a root LSP diagnostic.
104    ///
105    /// # Errors
106    /// - Required keys (`message`, `lnum`, `col`, `end_lnum`, `end_col`) are missing or of unexpected type.
107    fn from_lsp_diagnostic(lsp_diagnostic: &Dictionary) -> rootcause::Result<Self> {
108        Ok(Self {
109            message: lsp_diagnostic.get_t::<nvim_oxi::String>("message")?,
110            lnum: lsp_diagnostic.get_t::<nvim_oxi::Integer>("lnum")?,
111            col: lsp_diagnostic.get_t::<nvim_oxi::Integer>("col")?,
112            end_lnum: lsp_diagnostic.get_t::<nvim_oxi::Integer>("end_lnum")?,
113            end_col: lsp_diagnostic.get_t::<nvim_oxi::Integer>("end_col")?,
114        })
115    }
116
117    /// Create a [`RelatedInfo`] from an element of an LSP diagnostic "`user_data.lsp.relatedInformation`" section.
118    ///
119    /// # Errors
120    /// - Required nested keys (range.start, range.end, message, line/character) are missing or wrong type.
121    fn from_related_info(rel_info: &Dictionary) -> rootcause::Result<Self> {
122        let (start, end) = {
123            let range_query = ["location", "range"];
124            let range = rel_info.get_required_dict(&range_query)?;
125
126            let start_query = ["start"];
127            let end_query = ["end"];
128            (
129                range.get_required_dict(&start_query)?,
130                range.get_required_dict(&end_query)?,
131            )
132        };
133
134        Ok(Self {
135            message: rel_info.get_t::<nvim_oxi::String>("message")?,
136            lnum: start.get_t::<nvim_oxi::Integer>("line")?,
137            col: start.get_t::<nvim_oxi::Integer>("character")?,
138            end_lnum: end.get_t::<nvim_oxi::Integer>("line")?,
139            end_col: end.get_t::<nvim_oxi::Integer>("character")?,
140        })
141    }
142}