nvrim/
diagnostics.rs

1//! Diagnostic processing utilities for LSP diagnostics.
2//!
3//! This module provides functionality to filter, format, and sort LSP diagnostics
4//! received from language servers in Nvim.
5
6use core::fmt;
7
8use nvim_oxi::Dictionary;
9use serde::de::Deserializer;
10use serde::de::Visitor;
11use strum::EnumCount;
12use strum::EnumIter;
13
14mod config;
15mod filter;
16mod filters;
17mod formatter;
18mod sorter;
19
20/// [`Dictionary`] of diagnostic processing helpers.
21///
22/// Includes:
23/// - `format` format function used by floating diagnostics window.
24/// - `sort` severity sorter (descending severity).
25/// - `filter` buffer / rules based filter.
26/// - `config` nested dictionary mirroring `vim.diagnostic.config({...})` currently defined in Lua.
27pub fn dict() -> Dictionary {
28    dict! {
29        "filter": fn_from!(filter::filter),
30        "sort": fn_from!(sorter::sort),
31        "format": fn_from!(formatter::format),
32        "config": config::get()
33    }
34}
35
36/// Diagnostic severity levels.
37///
38/// See the `Deserialize` impl below for accepted serialized forms.
39///
40/// Ordering contract:
41/// - The declared variant order (Error, Warn, Info, Hint, Other) defines the iteration order via [`EnumIter`], which
42///   downstream rendering (e.g. `statusline`) relies on to produce stable, predictable severity ordering.
43/// - Changing the variant order is a breaking change for components depending on deterministic ordering.
44#[derive(Clone, Copy, Debug, EnumCount, EnumIter, Eq, Hash, PartialEq)]
45#[allow(clippy::upper_case_acronyms)]
46pub enum DiagnosticSeverity {
47    /// Error severity.
48    Error,
49    /// Warning severity.
50    Warn,
51    /// Info severity.
52    Info,
53    /// Hint severity.
54    Hint,
55    /// Any other / unknown severity value.
56    Other,
57}
58
59impl DiagnosticSeverity {
60    /// Number of declared severity variants.
61    pub const VARIANT_COUNT: usize = <Self as strum::EnumCount>::COUNT;
62
63    /// Returns the numeric representation of a given [`DiagnosticSeverity`].
64    ///
65    /// The representation is the canonical LSP severity:
66    /// - 1 for Error
67    /// - 2 for Warning
68    /// - 3 for Information
69    /// - 4 for Hint
70    /// - 0 for Other to indicate an unmapped / unknown severity.
71    ///
72    ///
73    /// # Rationale
74    /// Avoids relying on implicit enum discriminant order via `as u8` casts,
75    /// making the mapping explicit and resilient to future variant reordering
76    /// or insertion. Using an inherent method keeps the API surface small while
77    /// centralizing the mapping logic in one place.
78    pub const fn to_number(self) -> u8 {
79        match self {
80            Self::Error => 1,
81            Self::Warn => 2,
82            Self::Info => 3,
83            Self::Hint => 4,
84            Self::Other => 0,
85        }
86    }
87
88    /// Returns the canonical single-character symbol for this severity.
89    ///
90    ///
91    /// # Rationale
92    /// Used by status UI components (statuscolumn) to render severity without allocating via [`core::fmt::Display`]
93    /// or `to_string()`. A `const fn` mapping gives zero runtime branching cost when inlined.
94    pub const fn symbol(self) -> &'static str {
95        match self {
96            Self::Error => "x",
97            Self::Warn => "⏶",
98            Self::Info => "◆",
99            Self::Hint | Self::Other => "",
100        }
101    }
102}
103
104/// Deserializes accepted severity representations:
105/// - Numeric u8
106/// - Numeric strings
107/// - Text aliases (case-insensitive)
108/// - Any other value maps to [`DiagnosticSeverity::Other`].
109impl<'de> serde::Deserialize<'de> for DiagnosticSeverity {
110    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111    where
112        D: Deserializer<'de>,
113    {
114        struct SevVisitor;
115
116        impl Visitor<'_> for SevVisitor {
117            type Value = DiagnosticSeverity;
118
119            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
120                write!(f, "a severity: 1-4 or (error|warn|info|hint) or short alias")
121            }
122
123            fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
124            where
125                E: serde::de::Error,
126            {
127                Ok(match v {
128                    1 => DiagnosticSeverity::Error,
129                    2 => DiagnosticSeverity::Warn,
130                    3 => DiagnosticSeverity::Info,
131                    4 => DiagnosticSeverity::Hint,
132                    _ => DiagnosticSeverity::Other,
133                })
134            }
135
136            fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
137            where
138                E: serde::de::Error,
139            {
140                if v < 0 {
141                    return Ok(DiagnosticSeverity::Other);
142                }
143                self.visit_u64(v.cast_unsigned())
144            }
145
146            fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
147            where
148                E: serde::de::Error,
149            {
150                let norm = s.trim().to_ascii_lowercase();
151                if let Ok(n) = norm.parse::<u64>() {
152                    return self.visit_u64(n);
153                }
154                Ok(match norm.as_str() {
155                    "error" | "err" | "e" => DiagnosticSeverity::Error,
156                    "warn" | "warning" | "w" => DiagnosticSeverity::Warn,
157                    "info" | "information" | "i" => DiagnosticSeverity::Info,
158                    "hint" | "h" => DiagnosticSeverity::Hint,
159                    _ => DiagnosticSeverity::Other,
160                })
161            }
162        }
163
164        deserializer.deserialize_any(SevVisitor)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use rstest::rstest;
171    use strum::IntoEnumIterator;
172
173    use super::*;
174
175    #[rstest]
176    #[case("1", DiagnosticSeverity::Error)]
177    #[case("2", DiagnosticSeverity::Warn)]
178    #[case("3", DiagnosticSeverity::Info)]
179    #[case("4", DiagnosticSeverity::Hint)]
180    #[case("\"1\"", DiagnosticSeverity::Error)]
181    #[case("\"2\"", DiagnosticSeverity::Warn)]
182    #[case("\"3\"", DiagnosticSeverity::Info)]
183    #[case("\"4\"", DiagnosticSeverity::Hint)]
184    #[case("\"error\"", DiagnosticSeverity::Error)]
185    #[case("\"err\"", DiagnosticSeverity::Error)]
186    #[case("\"e\"", DiagnosticSeverity::Error)]
187    #[case("\"warn\"", DiagnosticSeverity::Warn)]
188    #[case("\"warning\"", DiagnosticSeverity::Warn)]
189    #[case("\"w\"", DiagnosticSeverity::Warn)]
190    #[case("\"info\"", DiagnosticSeverity::Info)]
191    #[case("\"information\"", DiagnosticSeverity::Info)]
192    #[case("\"i\"", DiagnosticSeverity::Info)]
193    #[case("\"hint\"", DiagnosticSeverity::Hint)]
194    #[case("\"h\"", DiagnosticSeverity::Hint)]
195    #[case("\" Error \"", DiagnosticSeverity::Error)]
196    #[case("\"WARNING\"", DiagnosticSeverity::Warn)]
197    #[case("\" Info \"", DiagnosticSeverity::Info)]
198    #[case("\"H\"", DiagnosticSeverity::Hint)]
199    #[case("-1", DiagnosticSeverity::Other)]
200    #[case("0", DiagnosticSeverity::Other)]
201    #[case("5", DiagnosticSeverity::Other)]
202    #[case("\"unknown\"", DiagnosticSeverity::Other)]
203    fn diagnostic_severity_deserializes_strings_as_expected(#[case] input: &str, #[case] expected: DiagnosticSeverity) {
204        assert2::let_assert!(Ok(sev) = serde_json::from_str::<DiagnosticSeverity>(input));
205        assert_eq!(sev, expected);
206    }
207
208    #[test]
209    fn diagnostic_severity_when_iterated_via_enumiter_yields_declared_order_and_matches_variant_count() {
210        let expected = [
211            DiagnosticSeverity::Error,
212            DiagnosticSeverity::Warn,
213            DiagnosticSeverity::Info,
214            DiagnosticSeverity::Hint,
215            DiagnosticSeverity::Other,
216        ];
217        let collected: Vec<DiagnosticSeverity> = DiagnosticSeverity::iter().collect();
218        pretty_assertions::assert_eq!(collected.as_slice(), expected.as_slice());
219        pretty_assertions::assert_eq!(collected.len(), DiagnosticSeverity::VARIANT_COUNT);
220    }
221
222    #[rstest]
223    #[case(1_i64, DiagnosticSeverity::Error)]
224    #[case(2_i64, DiagnosticSeverity::Warn)]
225    #[case(3_i64, DiagnosticSeverity::Info)]
226    #[case(4_i64, DiagnosticSeverity::Hint)]
227    #[case(-1_i64, DiagnosticSeverity::Other)]
228    #[case(0_i64, DiagnosticSeverity::Other)]
229    #[case(5_i64, DiagnosticSeverity::Other)]
230    fn diagnostic_severity_deserializes_numeric_values_as_expected(
231        #[case] input: i64,
232        #[case] expected: DiagnosticSeverity,
233    ) {
234        let json = input.to_string();
235        assert2::let_assert!(Ok(sev) = serde_json::from_str::<DiagnosticSeverity>(&json));
236        assert_eq!(sev, expected);
237    }
238
239    #[test]
240    fn diagnostic_severity_deserializes_invalid_json_errors() {
241        assert2::let_assert!(Err(err) = serde_json::from_str::<DiagnosticSeverity>("error"));
242        let msg = err.to_string();
243        assert!(msg.contains("expected value"), "unexpected error message: {msg}");
244    }
245}