Skip to main content

mcpls_core/bridge/notifications/
pulled_index.rs

1//! Deciding whether a pushed diagnostic duplicates a pulled one, without
2//! comparing every pair.
3
4use std::collections::{HashMap, HashSet};
5
6use lsp_types::Diagnostic as LspDiagnostic;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10use super::message_as_str;
11
12/// Start-line distance within which same-code, same-severity diagnostics from
13/// the pull and push models are still considered the same underlying problem.
14///
15/// Derived from a captured rust-analyzer E0046 case (1 line apart between the
16/// pulled trait-name span and the pushed `impl` span): wide enough to absorb
17/// span drift between rust-analyzer's own spans and rustc's, narrow enough
18/// that two genuinely distinct same-code errors elsewhere in a file are not
19/// collapsed into one.
20pub(super) const DUPLICATE_RANGE_PROXIMITY_LINES: u32 = 3;
21
22/// A diagnostic's severity as `get_diagnostics` reports it: no severity and
23/// unrecognized ones read as information.
24///
25/// Ordered from most to least severe, which is also the order the size cap
26/// keeps diagnostics in.
27#[derive(
28    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
29)]
30#[serde(rename_all = "lowercase")]
31#[schemars(rename = "DiagnosticSeverity")]
32#[schemars(description = "Diagnostic severity.")]
33pub enum ReportedSeverity {
34    /// Error diagnostic.
35    Error,
36    /// Warning diagnostic.
37    Warning,
38    /// Informational diagnostic; also what a diagnostic without a usable
39    /// severity reads as.
40    Information,
41    /// Hint diagnostic.
42    Hint,
43}
44
45impl ReportedSeverity {
46    /// The severity `diagnostic` is reported with.
47    #[must_use]
48    pub const fn of(diagnostic: &LspDiagnostic) -> Self {
49        match diagnostic.severity {
50            Some(lsp_types::DiagnosticSeverity::Error) => Self::Error,
51            Some(lsp_types::DiagnosticSeverity::Warning) => Self::Warning,
52            Some(lsp_types::DiagnosticSeverity::Hint) => Self::Hint,
53            _ => Self::Information,
54        }
55    }
56}
57
58/// A diagnostic's code as `get_diagnostics` reports it: integers read as their
59/// decimal string.
60pub fn reported_code(diagnostic: &LspDiagnostic) -> Option<std::borrow::Cow<'_, str>> {
61    diagnostic.code.as_ref().map(|code| match code {
62        lsp_types::Code::Int(n) => std::borrow::Cow::Owned(n.to_string()),
63        lsp_types::Code::String(s) => std::borrow::Cow::Borrowed(s.as_str()),
64    })
65}
66
67/// `(line, character)` of a position, ordered the way positions are.
68pub(super) const fn point(position: lsp_types::Position) -> (u32, u32) {
69    (position.line, position.character)
70}
71
72/// The ranges of the pulled diagnostics that share one `(severity, code)`,
73/// answering "does a range overlap one of them or start within
74/// `DUPLICATE_RANGE_PROXIMITY_LINES` lines of one of them" without scanning.
75#[derive(Debug, Default)]
76struct RangeIndex {
77    /// `(start, end)` ordered by start.
78    ranges: Vec<((u32, u32), (u32, u32))>,
79    /// Greatest end among `ranges[..=i]`, for each `i`.
80    max_end_through: Vec<(u32, u32)>,
81}
82
83impl RangeIndex {
84    fn seal(&mut self) {
85        self.ranges.sort_unstable();
86        let mut max_end = (0, 0);
87        self.max_end_through = self
88            .ranges
89            .iter()
90            .map(|&(_, end)| {
91                max_end = max_end.max(end);
92                max_end
93            })
94            .collect();
95    }
96
97    fn is_near(&self, range: lsp_types::Range) -> bool {
98        let (start, end) = (point(range.start), point(range.end));
99        let started_before_end = self.ranges.partition_point(|&(s, _)| s <= end);
100        let overlaps = started_before_end
101            .checked_sub(1)
102            .and_then(|last| self.max_end_through.get(last))
103            .is_some_and(|&max_end| max_end >= start);
104        if overlaps {
105            return true;
106        }
107        let first_line = start.0.saturating_sub(DUPLICATE_RANGE_PROXIMITY_LINES);
108        let from = self.ranges.partition_point(|&(s, _)| s.0 < first_line);
109        self.ranges
110            .get(from)
111            .is_some_and(|&(s, _)| s.0 <= start.0.saturating_add(DUPLICATE_RANGE_PROXIMITY_LINES))
112    }
113}
114
115/// What identifies a diagnostic that has no `code`: with no stable identity,
116/// only the whole of range, severity and message counts.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
118struct UncodedKey<'a> {
119    severity: ReportedSeverity,
120    start: (u32, u32),
121    end: (u32, u32),
122    message: &'a str,
123}
124
125impl<'a> UncodedKey<'a> {
126    fn of(diagnostic: &'a LspDiagnostic) -> Self {
127        Self {
128            severity: ReportedSeverity::of(diagnostic),
129            start: point(diagnostic.range.start),
130            end: point(diagnostic.range.end),
131            message: message_as_str(&diagnostic.message),
132        }
133    }
134}
135
136/// The pulled diagnostics of a file, indexed so that deciding whether a pushed
137/// one duplicates one of them (see [`Self::contains_same_problem`]) costs a
138/// lookup instead of a scan of every pulled diagnostic.
139///
140/// `(severity, code)` is normalized once per pulled diagnostic, and a pushed
141/// one is only compared inside its own group.
142#[derive(Debug, Default)]
143pub(super) struct PulledIndex<'a> {
144    coded: HashMap<ReportedSeverity, HashMap<String, RangeIndex>>,
145    uncoded: HashSet<UncodedKey<'a>>,
146}
147
148impl<'a> PulledIndex<'a> {
149    pub(super) fn new(pulled: &'a [LspDiagnostic]) -> Self {
150        let mut index = Self::default();
151        for diagnostic in pulled {
152            let severity = ReportedSeverity::of(diagnostic);
153            let range = diagnostic.range;
154            match reported_code(diagnostic) {
155                Some(code) => {
156                    index
157                        .coded
158                        .entry(severity)
159                        .or_default()
160                        .entry(code.into_owned())
161                        .or_default()
162                        .ranges
163                        .push((point(range.start), point(range.end)));
164                }
165                None => {
166                    index.uncoded.insert(UncodedKey::of(diagnostic));
167                }
168            }
169        }
170        for group in index.coded.values_mut().flat_map(HashMap::values_mut) {
171            group.seal();
172        }
173        index
174    }
175
176    /// Whether `pushed` duplicates a pulled diagnostic.
177    ///
178    /// rust-analyzer reports the same logical problem through both paths with
179    /// different `range` and rendered `message` (#244), so exact equality never
180    /// collapses them. Two diagnostics that both carry a `code` are the same
181    /// problem when `(severity, code)` match and their ranges overlap or start
182    /// within `DUPLICATE_RANGE_PROXIMITY_LINES` lines of each other. Without a
183    /// `code` on both sides there is no stable identity, and only equality of
184    /// range, severity and message counts.
185    pub(super) fn contains_same_problem(&self, pushed: &LspDiagnostic) -> bool {
186        let severity = ReportedSeverity::of(pushed);
187        reported_code(pushed).map_or_else(
188            || self.uncoded.contains(&UncodedKey::of(pushed)),
189            |code| {
190                self.coded
191                    .get(&severity)
192                    .and_then(|groups| groups.get(code.as_ref()))
193                    .is_some_and(|group| group.is_near(pushed.range))
194            },
195        )
196    }
197}