Skip to main content

nvrim/plugins/
statusline.rs

1//! Statusline drawing helpers with diagnostics aggregation.
2
3use std::cell::RefCell;
4use std::fmt::Write as _;
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::Object;
8use serde::Deserialize;
9use strum::IntoEnumIterator;
10use ytil_noxi::buffer::BufferExt as _;
11use ytil_noxi::buffer::CursorPosition;
12
13use crate::diagnostics::DiagnosticSeverity;
14
15const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
16
17thread_local! {
18    /// Cached `(buffer_handle, relative_path)` to avoid recomputing the buffer path on every
19    /// `CursorMoved` event. Automatically invalidated when the active buffer handle changes
20    /// (e.g. on `BufEnter`).
21    static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
22}
23
24/// [`Dictionary`] exposing statusline draw helpers.
25///
26/// Note: `draw_triggers` creates a new Object each call. This cannot be cached in a static
27/// because [`nvim_oxi::Object`] is tied to the Neovim Lua state (not Sync) and unavailable at
28/// static initialization. Since [`dict()`] is called once at plugin init, the overhead is minimal.
29pub fn dict() -> Dictionary {
30    dict! {
31        "draw": fn_from!(draw),
32        "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
33    }
34}
35
36/// Draws the status line with diagnostic information.
37fn draw(diagnostics: Vec<Diagnostic>) -> String {
38    let current_buffer = nvim_oxi::api::get_current_buf();
39    let current_buffer_nr = current_buffer.handle();
40
41    // Return `%#Normal#` instead of empty string in case of terminal buffers
42    // to blend the statusline with the editor background even when a statusline
43    // background color is set.
44    if current_buffer.is_terminal() {
45        return "%#Normal#".to_string();
46    }
47
48    // Use cached buffer path when the buffer handle hasn't changed (avoids FFI + PathBuf work on
49    // every CursorMoved). The cache is invalidated implicitly when the handle changes (BufEnter).
50    let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
51        let cached = cache.borrow();
52        if let Some((handle, ref path)) = *cached
53            && handle == current_buffer_nr
54        {
55            return path.clone();
56        }
57        drop(cached);
58        let path = ytil_noxi::buffer::get_relative_path_to_cwd(&current_buffer).map(|x| x.display().to_string());
59        *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
60        path
61    });
62
63    let cursor_position = CursorPosition::get_current();
64
65    let mut statusline = Statusline {
66        current_buffer_path: current_buffer_path.as_deref(),
67        current_buffer_diags: SeverityBuckets::default(),
68        workspace_diags: SeverityBuckets::default(),
69        cursor_position,
70    };
71    for diagnostic in diagnostics {
72        statusline.workspace_diags.inc(diagnostic.severity);
73        if current_buffer_nr == diagnostic.bufnr {
74            statusline.current_buffer_diags.inc(diagnostic.severity);
75        }
76    }
77
78    statusline.draw()
79}
80
81/// Diagnostic emitted by Nvim for statusline aggregation.
82#[derive(Deserialize)]
83pub struct Diagnostic {
84    /// The buffer number.
85    bufnr: i32,
86    /// The severity of the diagnostic.
87    severity: DiagnosticSeverity,
88}
89
90ytil_noxi::impl_nvim_deserializable!(Diagnostic);
91
92/// Fixed-size aggregation of counts per [`DiagnosticSeverity`].
93#[derive(Clone, Copy, Debug, Default)]
94struct SeverityBuckets {
95    counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
96}
97
98impl SeverityBuckets {
99    /// Increment severity count with saturating add.
100    fn inc(&mut self, sev: DiagnosticSeverity) {
101        let idx = sev as usize;
102        if let Some(slot) = self.counts.get_mut(idx) {
103            *slot = slot.saturating_add(1);
104        }
105    }
106
107    /// Get count for severity.
108    fn get(&self, sev: DiagnosticSeverity) -> u16 {
109        let idx = sev as usize;
110        self.counts.get(idx).copied().unwrap_or(0)
111    }
112
113    /// Iterate over (severity, count) pairs.
114    fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
115        DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
116    }
117
118    /// Approximate rendered length for pre-allocation.
119    fn approx_render_len(&self) -> usize {
120        let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
121        // Each segment roughly: `"%#DiagnosticStatusLineWarn#W:123"` ~ 32 chars worst case; be conservative.
122        // Use saturating_mul to satisfy `clippy::arithmetic_side_effects` pedantic lint.
123        non_zero.saturating_mul(32)
124    }
125}
126
127/// Build buckets from iterator of (severity, count).
128impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
129    fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
130        let mut buckets = Self::default();
131        for (sev, count) in iter {
132            let idx = sev as usize;
133            if let Some(slot) = buckets.counts.get_mut(idx) {
134                *slot = count; // Accept last-wins; tests construct unique severities
135            }
136        }
137        buckets
138    }
139}
140
141/// Represents the status line with buffer path and diagnostics.
142#[derive(Debug)]
143struct Statusline<'a> {
144    current_buffer_path: Option<&'a str>,
145    current_buffer_diags: SeverityBuckets,
146    workspace_diags: SeverityBuckets,
147    cursor_position: Option<CursorPosition>,
148}
149
150impl Statusline<'_> {
151    /// Draws the status line as a formatted string.
152    fn draw(&self) -> String {
153        // Build current buffer diagnostics (with trailing space if any present) manually to avoid
154        // iterator allocation and secondary pass (.any()).
155        let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
156        let mut wrote_any = false;
157        for (sev, count) in self.current_buffer_diags.iter() {
158            if count == 0 {
159                continue;
160            }
161            if wrote_any {
162                current_buffer_diags_segment.push(' ');
163            }
164            // Write directly to string to avoid intermediate allocation
165            write_diagnostics(&mut current_buffer_diags_segment, sev, count);
166            wrote_any = true;
167        }
168        if wrote_any {
169            current_buffer_diags_segment.push(' '); // maintain previous trailing space contract
170        }
171
172        // Workspace diagnostics (no trailing space).
173        let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
174        let mut first = true;
175        for (sev, count) in self.workspace_diags.iter() {
176            if count == 0 {
177                continue;
178            }
179            if !first {
180                workspace_diags_segment.push(' ');
181            }
182            // Write directly to string to avoid intermediate allocation
183            write_diagnostics(&mut workspace_diags_segment, sev, count);
184            first = false;
185        }
186
187        // Build final statusline in a single pre-allocated buffer to avoid intermediate
188        // format! allocation and the current_buffer_path_segment temporary String.
189        let estimated_len = workspace_diags_segment
190            .len()
191            .saturating_add(current_buffer_diags_segment.len())
192            .saturating_add(self.current_buffer_path.map_or(0, str::len))
193            .saturating_add(40);
194        let mut out = String::with_capacity(estimated_len);
195        let _ = write!(out, "{workspace_diags_segment}%#StatusLine# ");
196        if let Some(buf_path) = self.current_buffer_path {
197            let _ = write!(out, "{buf_path} ");
198        }
199        if let Some(ref pos) = self.cursor_position {
200            let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
201        }
202        let _ = write!(out, "{current_buffer_diags_segment}%#StatusLine#");
203        out
204    }
205}
206
207/// Writes the diagnostic count directly to the target string, avoiding intermediate allocation.
208fn write_diagnostics(target: &mut String, severity: DiagnosticSeverity, diags_count: u16) {
209    if diags_count == 0 {
210        return;
211    }
212    let hg_group_dyn_part = match severity {
213        DiagnosticSeverity::Error => "Error",
214        DiagnosticSeverity::Warn => "Warn",
215        DiagnosticSeverity::Info => "Info",
216        DiagnosticSeverity::Hint | DiagnosticSeverity::Other => "Hint",
217    };
218    // write! to String is infallible, so we can safely ignore the result
219    let _ = write!(target, "%#DiagnosticStatusLine{hg_group_dyn_part}#{diags_count}");
220}
221
222/// Draws the diagnostic count for a (severity, count) pair.
223/// Kept for test compatibility.
224#[cfg(test)]
225fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
226    let mut out = String::new();
227    write_diagnostics(&mut out, severity, diags_count);
228    out
229}
230
231#[cfg(test)]
232mod tests {
233    use rstest::rstest;
234
235    use super::*;
236
237    #[rstest]
238    #[case::default_diags(Statusline {
239        current_buffer_path: Some("foo"),
240        current_buffer_diags: SeverityBuckets::default(),
241        workspace_diags: SeverityBuckets::default(),
242        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
243    })]
244    #[case::buffer_zero(Statusline {
245        current_buffer_path: Some("foo"),
246        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
247        workspace_diags: SeverityBuckets::default(),
248        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
249    })]
250    #[case::workspace_zero(Statusline {
251        current_buffer_path: Some("foo"),
252        current_buffer_diags: SeverityBuckets::default(),
253        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
254        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
255    })]
256    #[case::both_zero(Statusline {
257        current_buffer_path: Some("foo"),
258        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
259        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
260        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
261    })]
262    fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
263        pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# foo 42:8 %#StatusLine#");
264    }
265
266    #[test]
267    fn statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
268        let statusline = Statusline {
269            current_buffer_path: Some("foo"),
270            current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
271                .into_iter()
272                .collect(),
273            workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
274            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
275        };
276        pretty_assertions::assert_eq!(
277            statusline.draw(),
278            "%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#",
279        );
280    }
281
282    #[test]
283    fn statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
284        let statusline = Statusline {
285            current_buffer_path: Some("foo"),
286            current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
287            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
288                .into_iter()
289                .collect(),
290            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
291        };
292        pretty_assertions::assert_eq!(
293            statusline.draw(),
294            "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#",
295        );
296    }
297
298    #[test]
299    fn statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
300        let statusline = Statusline {
301            current_buffer_path: Some("foo"),
302            current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
303                .into_iter()
304                .collect(),
305            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
306                .into_iter()
307                .collect(), // unchanged (multi-element)
308            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
309        };
310        pretty_assertions::assert_eq!(
311            statusline.draw(),
312            "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#",
313        );
314    }
315
316    #[test]
317    fn statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
318        // Insert in non-canonical order (Hint before Warn) and ensure output orders by severity (Warn then Hint).
319        let statusline = Statusline {
320            current_buffer_path: Some("foo"),
321            current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
322                .into_iter()
323                .collect(), // multi-element unchanged
324            workspace_diags: SeverityBuckets::default(),
325            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
326        };
327        pretty_assertions::assert_eq!(
328            statusline.draw(),
329            "%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#",
330        );
331    }
332
333    #[rstest]
334    #[case::error(DiagnosticSeverity::Error)]
335    #[case::warn(DiagnosticSeverity::Warn)]
336    #[case::info(DiagnosticSeverity::Info)]
337    #[case::hint(DiagnosticSeverity::Hint)]
338    #[case::other(DiagnosticSeverity::Other)]
339    fn draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
340        // Any severity with zero count should yield empty string.
341        pretty_assertions::assert_eq!(draw_diagnostics((severity, 0)), String::new());
342    }
343
344    #[test]
345    fn statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
346        // Insert diagnostics in deliberately scrambled order to validate deterministic ordering.
347        let statusline = Statusline {
348            current_buffer_path: Some("foo"),
349            current_buffer_diags: [
350                (DiagnosticSeverity::Hint, 1),
351                (DiagnosticSeverity::Error, 4),
352                (DiagnosticSeverity::Info, 2),
353                (DiagnosticSeverity::Warn, 3),
354            ]
355            .into_iter()
356            .collect(),
357            workspace_diags: [
358                (DiagnosticSeverity::Warn, 7),
359                (DiagnosticSeverity::Info, 6),
360                (DiagnosticSeverity::Hint, 5),
361                (DiagnosticSeverity::Error, 8),
362            ]
363            .into_iter()
364            .collect(),
365            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
366        };
367        // Affirm draw output matches severity ordering; equality macro takes (actual, expected).
368        pretty_assertions::assert_eq!(
369            statusline.draw(),
370            "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#",
371        );
372    }
373
374    #[test]
375    fn statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
376        // When both path and cursor position are absent, only the highlight groups remain.
377        let statusline = Statusline {
378            current_buffer_path: None,
379            current_buffer_diags: SeverityBuckets::default(),
380            workspace_diags: SeverityBuckets::default(),
381            cursor_position: None,
382        };
383        pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# %#StatusLine#");
384    }
385
386    #[rstest]
387    #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
388    #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
389    fn statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
390        // Column zero (internal 0-based) must render as 1 (human-facing).
391        // Non-zero column must render raw + 1.
392        let statusline = Statusline {
393            current_buffer_path: Some("foo"),
394            current_buffer_diags: SeverityBuckets::default(),
395            workspace_diags: SeverityBuckets::default(),
396            cursor_position: Some(CursorPosition { row: 10, col }),
397        };
398        pretty_assertions::assert_eq!(statusline.draw(), expected);
399    }
400}