Skip to main content

reovim_tui_mod_diagnostics/
lib.rs

1#![cfg_attr(coverage_nightly, allow(unused_features))]
2#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
3//! Diagnostics inline rendering module.
4//!
5//! Renders LSP diagnostic markers (underlines + virtual text) at buffer
6//! positions. Underlines are delivered via `inline_decorations()`, diagnostic
7//! messages via `virtual_lines()`.
8//!
9//! Migrated from `DiagnosticsExtension` (`TuiExtension`) to native
10//! `ClientModule` as part of M6 (#637).
11
12use std::collections::HashMap;
13
14use reovim_client_driver::{
15    BufferId, ClientModule, ClientModuleError, InlineDecoration, ModuleContext, ProbeResult, Style,
16    Version, VirtualLine, VirtualLinePosition,
17};
18
19use {reovim_arch::Color, serde::Deserialize};
20
21/// Deserialized diagnostic notification payload.
22#[derive(Debug, Deserialize)]
23#[serde(rename_all = "camelCase")]
24struct DiagnosticPayload {
25    active: bool,
26    #[serde(default)]
27    diagnostics: Vec<BufferDiagnostics>,
28}
29
30/// Diagnostics for a single buffer.
31#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase")]
33struct BufferDiagnostics {
34    buffer_id: u64,
35    items: Vec<DiagnosticItemPayload>,
36}
37
38/// A single diagnostic item from the server.
39#[derive(Debug, Clone, Deserialize)]
40#[serde(rename_all = "camelCase")]
41struct DiagnosticItemPayload {
42    start_line: u32,
43    start_col: u32,
44    end_line: u32,
45    end_col: u32,
46    severity: String,
47    message: String,
48    #[serde(default)]
49    #[allow(dead_code)]
50    source: Option<String>,
51}
52
53/// Map severity string to a color.
54const fn severity_color(severity: &str) -> Color {
55    match severity.as_bytes() {
56        b"error" => Color::Red,
57        b"warning" => Color::Yellow,
58        b"information" => Color::Cyan,
59        b"hint" => Color::Green,
60        _ => Color::Grey,
61    }
62}
63
64/// Map severity string to a display prefix.
65const fn severity_prefix(severity: &str) -> &str {
66    match severity.as_bytes() {
67        b"error" => "E",
68        b"warning" => "W",
69        b"information" => "I",
70        b"hint" => "H",
71        _ => "?",
72    }
73}
74
75/// Diagnostics inline rendering module.
76///
77/// Renders diagnostic underlines at buffer positions and shows
78/// diagnostic messages as virtual lines below the affected line.
79///
80/// Kind: `"diagnostics"` (matches server's diagnostics bridge).
81pub struct DiagnosticsModule {
82    active: bool,
83    buffers: Vec<BufferDiagnostics>,
84    active_buffer_id: Option<u64>,
85    /// Cached inline decorations grouped by line.
86    decorations_by_line: HashMap<usize, Vec<InlineDecoration>>,
87    /// Cached virtual lines for diagnostic messages.
88    cached_virtual_lines: Vec<VirtualLine>,
89}
90
91impl DiagnosticsModule {
92    #[must_use]
93    pub fn new() -> Self {
94        Self {
95            active: false,
96            buffers: Vec::new(),
97            active_buffer_id: None,
98            decorations_by_line: HashMap::new(),
99            cached_virtual_lines: Vec::new(),
100        }
101    }
102
103    /// Rebuild caches from current state.
104    #[allow(clippy::cast_possible_truncation)]
105    fn rebuild_caches(&mut self) {
106        self.decorations_by_line.clear();
107        self.cached_virtual_lines.clear();
108
109        if !self.active {
110            return;
111        }
112
113        let Some(buf_id) = self.active_buffer_id else {
114            return;
115        };
116
117        let Some(buffer_diags) = self.buffers.iter().find(|b| b.buffer_id == buf_id) else {
118            return;
119        };
120
121        for diag in &buffer_diags.items {
122            let color = severity_color(&diag.severity);
123
124            // Underline decorations (single-line diagnostics only)
125            if diag.start_line == diag.end_line {
126                let style = Style::new().fg(color);
127                self.decorations_by_line
128                    .entry(diag.start_line as usize)
129                    .or_default()
130                    .push(InlineDecoration {
131                        col_start: diag.start_col as u16,
132                        col_end: diag.end_col as u16,
133                        style,
134                    });
135            }
136
137            // Virtual line with diagnostic message
138            let prefix = severity_prefix(&diag.severity);
139            let content = format!(" {prefix}: {}", diag.message);
140            let style = Style::new().fg(color);
141
142            self.cached_virtual_lines.push(VirtualLine {
143                buffer_line: diag.start_line as usize,
144                position: VirtualLinePosition::After,
145                content,
146                style,
147            });
148        }
149    }
150}
151
152impl Default for DiagnosticsModule {
153    fn default() -> Self {
154        Self::new()
155    }
156}
157
158#[cfg_attr(coverage_nightly, coverage(off))]
159impl ClientModule for DiagnosticsModule {
160    fn id(&self) -> &'static str {
161        "diagnostics"
162    }
163
164    fn kind(&self) -> &'static str {
165        "diagnostics"
166    }
167
168    fn name(&self) -> &'static str {
169        "Diagnostics"
170    }
171
172    fn version(&self) -> Version {
173        Version::new(0, 1, 0)
174    }
175
176    fn init(&mut self, _ctx: &ModuleContext) -> ProbeResult {
177        ProbeResult::Success
178    }
179
180    fn exit(&mut self) -> Result<(), ClientModuleError> {
181        Ok(())
182    }
183
184    fn has_buffer_contrib(&self) -> bool {
185        self.active
186    }
187
188    fn on_notification(&mut self, data: &str) {
189        let Ok(payload) = serde_json::from_str::<DiagnosticPayload>(data) else {
190            return;
191        };
192
193        self.active = payload.active;
194
195        if self.active {
196            self.buffers = payload.diagnostics;
197        } else {
198            self.buffers.clear();
199        }
200
201        self.rebuild_caches();
202    }
203
204    #[allow(clippy::cast_possible_truncation)]
205    fn on_buffer_focus(&mut self, buffer_id: BufferId) {
206        self.active_buffer_id = Some(buffer_id.0 as u64);
207        self.rebuild_caches();
208    }
209
210    fn inline_decorations(&self, line: usize) -> &[InlineDecoration] {
211        self.decorations_by_line
212            .get(&line)
213            .map_or(&[], Vec::as_slice)
214    }
215
216    fn virtual_lines(&self) -> &[VirtualLine] {
217        &self.cached_virtual_lines
218    }
219}
220
221#[cfg(test)]
222mod tests;