Skip to main content

nvrim/plugins/
statusline.rs

1//! Statusline drawing helpers with diagnostics aggregation.
2
3use std::cell::RefCell;
4use std::fmt::Write;
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::Object;
8use serde::Deserialize;
9use strum::IntoEnumIterator;
10use ytil_noxi::buffer::BufferExt;
11use ytil_noxi::buffer::CursorPosition;
12
13use crate::diagnostics::DiagnosticSeverity;
14
15const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
16
17/// Diagnostic emitted by Nvim for statusline aggregation.
18#[derive(Deserialize)]
19pub struct Diagnostic {
20    /// The buffer number.
21    bufnr: i32,
22    /// The severity of the diagnostic.
23    severity: DiagnosticSeverity,
24}
25
26ytil_noxi::impl_nvim_deserializable!(Diagnostic);
27
28/// [`Dictionary`] exposing statusline draw helpers.
29///
30/// Note: `draw_triggers` creates a new Object each call. This cannot be cached in a static
31/// because [`nvim_oxi::Object`] is tied to the Neovim Lua state (not Sync) and unavailable at
32/// static initialization. Since [`dict()`] is called once at plugin init, the overhead is minimal.
33pub fn dict() -> Dictionary {
34    dict! {
35        "draw": fn_from!(draw),
36        "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
37    }
38}
39
40thread_local! {
41    /// Cached `(buffer_handle, relative_path)` to avoid recomputing the buffer path on every
42    /// `CursorMoved` event. Automatically invalidated when the active buffer handle changes
43    /// (e.g. on `BufEnter`).
44    static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
45}
46
47/// Fixed-size aggregation of counts per [`DiagnosticSeverity`].
48#[derive(Clone, Copy, Debug, Default)]
49struct SeverityBuckets {
50    counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
51}
52
53impl SeverityBuckets {
54    /// Increment severity count with saturating add.
55    fn inc(&mut self, sev: DiagnosticSeverity) {
56        let idx = sev as usize;
57        if let Some(slot) = self.counts.get_mut(idx) {
58            *slot = slot.saturating_add(1);
59        }
60    }
61
62    /// Get count for severity.
63    fn get(&self, sev: DiagnosticSeverity) -> u16 {
64        let idx = sev as usize;
65        self.counts.get(idx).copied().unwrap_or(0)
66    }
67
68    /// Iterate over (severity, count) pairs.
69    fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
70        DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
71    }
72
73    /// Approximate rendered length for pre-allocation.
74    fn approx_render_len(&self) -> usize {
75        let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
76        // Each segment roughly: `"%#DiagnosticStatusLineWarn#W:123"` ~ 32 chars worst case; be conservative.
77        // Use saturating_mul to satisfy `clippy::arithmetic_side_effects` pedantic lint.
78        non_zero.saturating_mul(32)
79    }
80}
81
82/// Build buckets from iterator of (severity, count).
83impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
84    fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
85        let mut buckets = Self::default();
86        for (sev, count) in iter {
87            let idx = sev as usize;
88            if let Some(slot) = buckets.counts.get_mut(idx) {
89                *slot = count; // Accept last-wins; tests construct unique severities
90            }
91        }
92        buckets
93    }
94}
95
96/// Represents the status line with buffer path and diagnostics.
97#[derive(Debug)]
98struct Statusline<'a> {
99    current_buffer_path: Option<&'a str>,
100    current_buffer_diags: SeverityBuckets,
101    workspace_diags: SeverityBuckets,
102    cursor_position: Option<CursorPosition>,
103}
104
105impl Statusline<'_> {
106    /// Draws the status line as a formatted string.
107    fn draw(&self) -> String {
108        // Build current buffer diagnostics (with trailing space if any present) manually to avoid
109        // iterator allocation and secondary pass (.any()).
110        let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
111        let mut wrote_any = false;
112        for (sev, count) in self.current_buffer_diags.iter() {
113            if count == 0 {
114                continue;
115            }
116            if wrote_any {
117                current_buffer_diags_segment.push(' ');
118            }
119            // Write directly to string to avoid intermediate allocation
120            write_diagnostics(&mut current_buffer_diags_segment, sev, count);
121            wrote_any = true;
122        }
123        if wrote_any {
124            current_buffer_diags_segment.push(' '); // maintain previous trailing space contract
125        }
126
127        // Workspace diagnostics (no trailing space).
128        let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
129        let mut first = true;
130        for (sev, count) in self.workspace_diags.iter() {
131            if count == 0 {
132                continue;
133            }
134            if !first {
135                workspace_diags_segment.push(' ');
136            }
137            // Write directly to string to avoid intermediate allocation
138            write_diagnostics(&mut workspace_diags_segment, sev, count);
139            first = false;
140        }
141
142        // Build final statusline in a single pre-allocated buffer to avoid intermediate
143        // format! allocation and the current_buffer_path_segment temporary String.
144        let estimated_len = workspace_diags_segment
145            .len()
146            .saturating_add(current_buffer_diags_segment.len())
147            .saturating_add(self.current_buffer_path.map_or(0, str::len))
148            .saturating_add(40);
149        let mut out = String::with_capacity(estimated_len);
150        let _ = write!(out, "{workspace_diags_segment}%#StatusLine# ");
151        if let Some(buf_path) = self.current_buffer_path {
152            let _ = write!(out, "{buf_path} ");
153        }
154        if let Some(ref pos) = self.cursor_position {
155            let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
156        }
157        let _ = write!(out, "{current_buffer_diags_segment}%#StatusLine#");
158        out
159    }
160}
161
162/// Draws the status line with diagnostic information.
163fn draw(diagnostics: Vec<Diagnostic>) -> String {
164    let current_buffer = nvim_oxi::api::get_current_buf();
165    let current_buffer_nr = current_buffer.handle();
166
167    // Return `%#Normal#` instead of empty string in case of terminal buffers
168    // to blend the statusline with the editor background even when a statusline
169    // background color is set.
170    if current_buffer.is_terminal() {
171        return "%#Normal#".to_string();
172    }
173
174    // Use cached buffer path when the buffer handle hasn't changed (avoids FFI + PathBuf work on
175    // every CursorMoved). The cache is invalidated implicitly when the handle changes (BufEnter).
176    let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
177        let cached = cache.borrow();
178        if let Some((handle, ref path)) = *cached
179            && handle == current_buffer_nr
180        {
181            return path.clone();
182        }
183        drop(cached);
184        let path = ytil_noxi::buffer::get_relative_path_to_cwd(&current_buffer).map(|x| x.display().to_string());
185        *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
186        path
187    });
188
189    let cursor_position = CursorPosition::get_current();
190
191    let mut statusline = Statusline {
192        current_buffer_path: current_buffer_path.as_deref(),
193        current_buffer_diags: SeverityBuckets::default(),
194        workspace_diags: SeverityBuckets::default(),
195        cursor_position,
196    };
197    for diagnostic in diagnostics {
198        statusline.workspace_diags.inc(diagnostic.severity);
199        if current_buffer_nr == diagnostic.bufnr {
200            statusline.current_buffer_diags.inc(diagnostic.severity);
201        }
202    }
203
204    statusline.draw()
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 test_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 test_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 test_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 test_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 test_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 test_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 test_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 test_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}