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    use test_that::prelude::*;
235
236    use super::*;
237
238    #[rstest]
239    #[case::default_diags(Statusline {
240        current_buffer_path: Some("foo"),
241        current_buffer_diags: SeverityBuckets::default(),
242        workspace_diags: SeverityBuckets::default(),
243        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
244    })]
245    #[case::buffer_zero(Statusline {
246        current_buffer_path: Some("foo"),
247        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
248        workspace_diags: SeverityBuckets::default(),
249        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
250    })]
251    #[case::workspace_zero(Statusline {
252        current_buffer_path: Some("foo"),
253        current_buffer_diags: SeverityBuckets::default(),
254        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
255        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
256    })]
257    #[case::both_zero(Statusline {
258        current_buffer_path: Some("foo"),
259        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
260        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
261        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
262    })]
263    fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
264        assert_that!(statusline.draw(), eq("%#StatusLine# foo 42:8 %#StatusLine#"));
265    }
266
267    #[test]
268    fn test_statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
269        let statusline = Statusline {
270            current_buffer_path: Some("foo"),
271            current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
272                .into_iter()
273                .collect(),
274            workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
275            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
276        };
277        assert_that!(
278            statusline.draw(),
279            eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#")
280        );
281    }
282
283    #[test]
284    fn test_statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
285        let statusline = Statusline {
286            current_buffer_path: Some("foo"),
287            current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
288            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
289                .into_iter()
290                .collect(),
291            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
292        };
293        assert_that!(
294            statusline.draw(),
295            eq("%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#")
296        );
297    }
298
299    #[test]
300    fn test_statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
301        let statusline = Statusline {
302            current_buffer_path: Some("foo"),
303            current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
304                .into_iter()
305                .collect(),
306            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
307                .into_iter()
308                .collect(), // unchanged (multi-element)
309            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
310        };
311        assert_that!(
312            statusline.draw(),
313            eq(
314                "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#"
315            )
316        );
317    }
318
319    #[test]
320    fn test_statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
321        // Insert in non-canonical order (Hint before Warn) and ensure output orders by severity (Warn then Hint).
322        let statusline = Statusline {
323            current_buffer_path: Some("foo"),
324            current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
325                .into_iter()
326                .collect(), // multi-element unchanged
327            workspace_diags: SeverityBuckets::default(),
328            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
329        };
330        assert_that!(
331            statusline.draw(),
332            eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#")
333        );
334    }
335
336    #[rstest]
337    #[case::error(DiagnosticSeverity::Error)]
338    #[case::warn(DiagnosticSeverity::Warn)]
339    #[case::info(DiagnosticSeverity::Info)]
340    #[case::hint(DiagnosticSeverity::Hint)]
341    #[case::other(DiagnosticSeverity::Other)]
342    fn test_draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
343        // Any severity with zero count should yield empty string.
344        assert_that!(draw_diagnostics((severity, 0)), eq(String::new()));
345    }
346
347    #[test]
348    fn test_statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
349        // Insert diagnostics in deliberately scrambled order to validate deterministic ordering.
350        let statusline = Statusline {
351            current_buffer_path: Some("foo"),
352            current_buffer_diags: [
353                (DiagnosticSeverity::Hint, 1),
354                (DiagnosticSeverity::Error, 4),
355                (DiagnosticSeverity::Info, 2),
356                (DiagnosticSeverity::Warn, 3),
357            ]
358            .into_iter()
359            .collect(),
360            workspace_diags: [
361                (DiagnosticSeverity::Warn, 7),
362                (DiagnosticSeverity::Info, 6),
363                (DiagnosticSeverity::Hint, 5),
364                (DiagnosticSeverity::Error, 8),
365            ]
366            .into_iter()
367            .collect(),
368            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
369        };
370        // Affirm draw output matches severity ordering; equality macro takes (actual, expected).
371        assert_that!(
372            statusline.draw(),
373            eq(
374                "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#"
375            )
376        );
377    }
378
379    #[test]
380    fn test_statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
381        // When both path and cursor position are absent, only the highlight groups remain.
382        let statusline = Statusline {
383            current_buffer_path: None,
384            current_buffer_diags: SeverityBuckets::default(),
385            workspace_diags: SeverityBuckets::default(),
386            cursor_position: None,
387        };
388        assert_that!(statusline.draw(), eq("%#StatusLine# %#StatusLine#"));
389    }
390
391    #[rstest]
392    #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
393    #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
394    fn test_statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
395        // Column zero (internal 0-based) must render as 1 (human-facing).
396        // Non-zero column must render raw + 1.
397        let statusline = Statusline {
398            current_buffer_path: Some("foo"),
399            current_buffer_diags: SeverityBuckets::default(),
400            workspace_diags: SeverityBuckets::default(),
401            cursor_position: Some(CursorPosition { row: 10, col }),
402        };
403        assert_that!(statusline.draw(), eq(expected));
404    }
405}