nvrim/plugins/
statusline.rs

1//! Statusline drawing helpers with diagnostics aggregation.
2//!
3//! Provides `statusline.dict()` with a `draw` function combining cwd, buffer name, cursor position and
4//! LSP diagnostic severities / counts into a formatted status line; failures yield [`None`] and are
5//! surfaced through [`ytil_noxi::notify::error`].
6
7use nvim_oxi::Dictionary;
8use nvim_oxi::Object;
9use nvim_oxi::conversion::FromObject;
10use nvim_oxi::lua::Poppable;
11use nvim_oxi::lua::ffi::State;
12use nvim_oxi::serde::Deserializer;
13use serde::Deserialize;
14use strum::IntoEnumIterator;
15use ytil_noxi::buffer::CursorPosition;
16
17use crate::diagnostics::DiagnosticSeverity;
18
19const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
20
21/// [`Dictionary`] exposing statusline draw helpers.
22pub fn dict() -> Dictionary {
23    dict! {
24        "draw": fn_from!(draw),
25        "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
26    }
27}
28
29/// Draws the status line with diagnostic information.
30///
31/// # Rationale
32/// Returning [`None`] lets callers distinguish between a valid (possibly empty diagnostics) statusline and a data
33/// acquisition failure.
34fn draw(diagnostics: Vec<Diagnostic>) -> Option<String> {
35    let current_buffer = nvim_oxi::api::get_current_buf();
36    let current_buffer_path =
37        ytil_noxi::buffer::get_relative_path_to_cwd(&current_buffer).map(|x| x.display().to_string());
38
39    let current_buffer_nr = current_buffer.handle();
40    let mut statusline = Statusline {
41        current_buffer_path: current_buffer_path.as_deref(),
42        current_buffer_diags: SeverityBuckets::default(),
43        workspace_diags: SeverityBuckets::default(),
44        cursor_position: CursorPosition::get_current()?,
45    };
46    for diagnostic in diagnostics {
47        statusline.workspace_diags.inc(diagnostic.severity);
48        if current_buffer_nr == diagnostic.bufnr {
49            statusline.current_buffer_diags.inc(diagnostic.severity);
50        }
51    }
52
53    Some(statusline.draw())
54}
55
56/// Diagnostic emitted by Nvim.
57///
58/// Captures buffer association and severity for aggregation in the statusline.
59///
60/// # Rationale
61/// Minimal fields keep deserialization lean; position, message, etc. are not needed for summary counts.
62#[derive(Deserialize)]
63pub struct Diagnostic {
64    /// The buffer number.
65    bufnr: i32,
66    /// The severity of the diagnostic.
67    severity: DiagnosticSeverity,
68}
69
70/// Implementation of [`FromObject`] for [`Diagnostic`].
71impl FromObject for Diagnostic {
72    fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
73        Self::deserialize(Deserializer::new(obj)).map_err(Into::into)
74    }
75}
76
77/// Implementation of [`nvim_oxi::lua::Poppable`] for [`Diagnostic`].
78impl Poppable for Diagnostic {
79    unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
80        unsafe {
81            let obj = Object::pop(lstate)?;
82            Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
83        }
84    }
85}
86
87/// Fixed-size aggregation of counts per [`DiagnosticSeverity`].
88///
89/// Stores counts in an array indexed by a stable ordering declared by [`DiagnosticSeverity`] count.
90/// Iteration yields (severity, count) pairs.
91#[derive(Clone, Copy, Debug, Default)]
92struct SeverityBuckets {
93    counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
94}
95
96impl SeverityBuckets {
97    /// Increment severity count with saturating add.
98    fn inc(&mut self, sev: DiagnosticSeverity) {
99        let idx = sev as usize;
100        if let Some(slot) = self.counts.get_mut(idx) {
101            *slot = slot.saturating_add(1);
102        }
103    }
104
105    /// Get count for severity.
106    fn get(&self, sev: DiagnosticSeverity) -> u16 {
107        let idx = sev as usize;
108        self.counts.get(idx).copied().unwrap_or(0)
109    }
110
111    /// Iterate over (severity, count) pairs in canonical order (enum variant order per `EnumIter`).
112    fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
113        DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
114    }
115
116    /// Approximate rendered length (diagnostics segment only) for pre-allocation.
117    fn approx_render_len(&self) -> usize {
118        let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
119        // Each segment roughly: `"%#DiagnosticStatusLineWarn#W:123"` ~ 32 chars worst case; be conservative.
120        // Use saturating_mul to satisfy `clippy::arithmetic_side_effects` pedantic lint.
121        non_zero.saturating_mul(32)
122    }
123}
124
125/// Allow tests to build buckets from iterator of (severity, count).
126impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
127    fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
128        let mut buckets = Self::default();
129        for (sev, count) in iter {
130            let idx = sev as usize;
131            if let Some(slot) = buckets.counts.get_mut(idx) {
132                *slot = count; // Accept last-wins; tests construct unique severities
133            }
134        }
135        buckets
136    }
137}
138
139/// Represents the status line with buffer path and diagnostics.
140#[derive(Debug)]
141struct Statusline<'a> {
142    /// The current buffer path.
143    current_buffer_path: Option<&'a str>,
144    /// Diagnostics for the current buffer.
145    current_buffer_diags: SeverityBuckets,
146    /// Diagnostics for the workspace.
147    workspace_diags: SeverityBuckets,
148    /// Current cursor position used to render the trailing `row:col` segment.
149    cursor_position: CursorPosition,
150}
151
152impl Statusline<'_> {
153    /// Draws the status line as a formatted string.
154    ///
155    /// Invariants:
156    /// - Severity ordering stability defined by [`DiagnosticSeverity`] enum variants order.
157    /// - Zero-count severities are omitted (see [`draw_diagnostics`]).
158    /// - Column displayed is 1-based via [`CursorPosition::adjusted_col`].
159    /// - Row/column segment rendered as `row:col`.
160    /// - A `%#StatusLine#` highlight reset precedes the position segment.
161    fn draw(&self) -> String {
162        // Build current buffer diagnostics (with trailing space if any present) manually to avoid
163        // iterator allocation and secondary pass (.any()).
164        let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
165        let mut wrote_any = false;
166        for (sev, count) in self.current_buffer_diags.iter() {
167            if count == 0 {
168                continue;
169            }
170            if wrote_any {
171                current_buffer_diags_segment.push(' ');
172            }
173            current_buffer_diags_segment.push_str(&draw_diagnostics((sev, count)));
174            wrote_any = true;
175        }
176        if wrote_any {
177            current_buffer_diags_segment.push(' '); // maintain previous trailing space contract
178        }
179
180        // Workspace diagnostics (no trailing space).
181        let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
182        let mut first = true;
183        for (sev, count) in self.workspace_diags.iter() {
184            if count == 0 {
185                continue;
186            }
187            if !first {
188                workspace_diags_segment.push(' ');
189            }
190            workspace_diags_segment.push_str(&draw_diagnostics((sev, count)));
191            first = false;
192        }
193
194        let current_buffer_path_segment = self
195            .current_buffer_path
196            .map(|buf_path| format!("{buf_path} "))
197            .unwrap_or_default();
198
199        format!(
200            "{workspace_diags_segment}%#StatusLine# {current_buffer_path_segment}{}:{} {current_buffer_diags_segment}%#StatusLine#",
201            self.cursor_position.row,
202            self.cursor_position.adjusted_col()
203        )
204    }
205}
206
207/// Draws the diagnostic count for a (severity, count) pair.
208///
209/// Accepts a tuple so it can be passed directly to iterator adapters like `.map(draw_diagnostics)` without
210/// additional closure wrapping.
211///
212/// # Rationale
213/// Tuple parameter matches iterator `(DiagnosticSeverity, u16)` item shape, removing a tiny layer of syntactic noise
214/// (`.map(|(s,c)| draw_diagnostics(s,c))`). Keeping zero-elision here is a harmless guard.
215fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
216    if diags_count == 0 {
217        return String::new();
218    }
219    let hg_group_dyn_part = match severity {
220        DiagnosticSeverity::Error => "Error",
221        DiagnosticSeverity::Warn => "Warn",
222        DiagnosticSeverity::Info => "Info",
223        DiagnosticSeverity::Hint | DiagnosticSeverity::Other => "Hint",
224    };
225    format!("%#DiagnosticStatusLine{hg_group_dyn_part}#{diags_count}")
226}
227
228#[cfg(test)]
229mod tests {
230    use rstest::rstest;
231
232    use super::*;
233
234    #[rstest]
235    #[case::default_diags(Statusline {
236        current_buffer_path: Some("foo"),
237        current_buffer_diags: SeverityBuckets::default(),
238        workspace_diags: SeverityBuckets::default(),
239        cursor_position: CursorPosition { row: 42, col: 7 },
240    })]
241    #[case::buffer_zero(Statusline {
242        current_buffer_path: Some("foo"),
243        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
244        workspace_diags: SeverityBuckets::default(),
245        cursor_position: CursorPosition { row: 42, col: 7 },
246    })]
247    #[case::workspace_zero(Statusline {
248        current_buffer_path: Some("foo"),
249        current_buffer_diags: SeverityBuckets::default(),
250        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
251        cursor_position: CursorPosition { row: 42, col: 7 },
252    })]
253    #[case::both_zero(Statusline {
254        current_buffer_path: Some("foo"),
255        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
256        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
257        cursor_position: CursorPosition { row: 42, col: 7 },
258    })]
259    fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
260        pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# foo 42:8 %#StatusLine#");
261    }
262
263    #[test]
264    fn statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
265        let statusline = Statusline {
266            current_buffer_path: Some("foo"),
267            current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
268                .into_iter()
269                .collect(),
270            workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
271            cursor_position: CursorPosition { row: 42, col: 7 },
272        };
273        pretty_assertions::assert_eq!(
274            statusline.draw(),
275            "%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#",
276        );
277    }
278
279    #[test]
280    fn statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
281        let statusline = Statusline {
282            current_buffer_path: Some("foo"),
283            current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
284            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
285                .into_iter()
286                .collect(),
287            cursor_position: CursorPosition { row: 42, col: 7 },
288        };
289        pretty_assertions::assert_eq!(
290            statusline.draw(),
291            "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#",
292        );
293    }
294
295    #[test]
296    fn statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
297        let statusline = Statusline {
298            current_buffer_path: Some("foo"),
299            current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
300                .into_iter()
301                .collect(),
302            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
303                .into_iter()
304                .collect(), // unchanged (multi-element)
305            cursor_position: CursorPosition { row: 42, col: 7 },
306        };
307        pretty_assertions::assert_eq!(
308            statusline.draw(),
309            "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#",
310        );
311    }
312
313    #[test]
314    fn statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
315        // Insert in non-canonical order (Hint before Warn) and ensure output orders by severity (Warn then Hint).
316        let statusline = Statusline {
317            current_buffer_path: Some("foo"),
318            current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
319                .into_iter()
320                .collect(), // multi-element unchanged
321            workspace_diags: SeverityBuckets::default(),
322            cursor_position: CursorPosition { row: 42, col: 7 },
323        };
324        pretty_assertions::assert_eq!(
325            statusline.draw(),
326            "%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#",
327        );
328    }
329
330    #[rstest]
331    #[case::error(DiagnosticSeverity::Error)]
332    #[case::warn(DiagnosticSeverity::Warn)]
333    #[case::info(DiagnosticSeverity::Info)]
334    #[case::hint(DiagnosticSeverity::Hint)]
335    #[case::other(DiagnosticSeverity::Other)]
336    fn draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
337        // Any severity with zero count should yield empty string.
338        pretty_assertions::assert_eq!(draw_diagnostics((severity, 0)), String::new());
339    }
340
341    #[test]
342    fn statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
343        // Insert diagnostics in deliberately scrambled order to validate deterministic ordering.
344        let statusline = Statusline {
345            current_buffer_path: Some("foo"),
346            current_buffer_diags: [
347                (DiagnosticSeverity::Hint, 1),
348                (DiagnosticSeverity::Error, 4),
349                (DiagnosticSeverity::Info, 2),
350                (DiagnosticSeverity::Warn, 3),
351            ]
352            .into_iter()
353            .collect(),
354            workspace_diags: [
355                (DiagnosticSeverity::Warn, 7),
356                (DiagnosticSeverity::Info, 6),
357                (DiagnosticSeverity::Hint, 5),
358                (DiagnosticSeverity::Error, 8),
359            ]
360            .into_iter()
361            .collect(),
362            cursor_position: CursorPosition { row: 42, col: 7 },
363        };
364        // Affirm draw output matches severity ordering; equality macro takes (actual, expected).
365        pretty_assertions::assert_eq!(
366            statusline.draw(),
367            "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#",
368        );
369    }
370
371    #[rstest]
372    #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
373    #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
374    fn statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
375        // Column zero (internal 0-based) must render as 1 (human-facing).
376        // Non-zero column must render raw + 1.
377        let statusline = Statusline {
378            current_buffer_path: Some("foo"),
379            current_buffer_diags: SeverityBuckets::default(),
380            workspace_diags: SeverityBuckets::default(),
381            cursor_position: CursorPosition { row: 10, col },
382        };
383        pretty_assertions::assert_eq!(statusline.draw(), expected);
384    }
385}