Skip to main content

nvrim/plugins/
statusline.rs

1//! Statusline drawing helpers with diagnostics and Git change aggregation.
2
3use std::cell::RefCell;
4use std::fmt::Write;
5use std::path::Path;
6use std::path::PathBuf;
7
8use nvim_oxi::Array;
9use nvim_oxi::Dictionary;
10use nvim_oxi::Object;
11use nvim_oxi::api::Buffer;
12use serde::Deserialize;
13use strum::IntoEnumIterator;
14use ytil_noxi::buffer::BufferExt;
15use ytil_noxi::buffer::CursorPosition;
16
17use crate::diagnostics::DiagnosticSeverity;
18
19const DRAW_TRIGGERS: &[&str] = &[
20    "BufEnter",
21    "BufFilePost",
22    "BufWritePost",
23    "CursorMoved",
24    "DiagnosticChanged",
25    "DirChanged",
26    "FocusGained",
27    "ShellCmdPost",
28    "VimResume",
29];
30const GIT_ADDED_HIGHLIGHT: &str = "Added";
31const GIT_REMOVED_HIGHLIGHT: &str = "Removed";
32
33/// Diagnostic emitted by Nvim for statusline aggregation.
34#[derive(Deserialize)]
35pub struct Diagnostic {
36    /// The buffer number.
37    bufnr: i32,
38    /// The severity of the diagnostic.
39    severity: DiagnosticSeverity,
40}
41
42ytil_noxi::impl_nvim_deserializable!(Diagnostic);
43
44/// [`Dictionary`] exposing statusline draw helpers.
45///
46/// Note: `draw_triggers` creates a new Object each call. This cannot be cached in a static
47/// because [`nvim_oxi::Object`] is tied to the Neovim Lua state (not Sync) and unavailable at
48/// static initialization. Since [`dict()`] is called once at plugin init, the overhead is minimal.
49pub fn dict() -> Dictionary {
50    dict! {
51        "draw": fn_from!(draw),
52        "invalidate_git_stats": fn_from!(invalidate_git_stats),
53        "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
54    }
55}
56
57thread_local! {
58    /// Cached `(buffer_handle, relative_path)` to avoid recomputing the buffer path on every
59    /// `CursorMoved` event. Automatically invalidated when the active buffer handle changes
60    /// (e.g. on `BufEnter`).
61    static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
62
63    /// Cached Git statistics for the active buffer or current working directory.
64    static CACHED_GIT_STATS: RefCell<Option<CachedGitStats>> = const { RefCell::new(None) };
65}
66
67/// Added and removed tracked lines for one status-line scope.
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69struct GitLineStats {
70    added: usize,
71    removed: usize,
72}
73
74impl GitLineStats {
75    /// Adds one file's Git statistics to this aggregate.
76    const fn add_file(&mut self, file_stats: &ytil_git::diff::FileDiffStats) {
77        self.added = self.added.saturating_add(file_stats.added);
78        self.removed = self.removed.saturating_add(file_stats.removed);
79    }
80
81    /// Returns whether this scope has no line changes to display.
82    const fn is_empty(self) -> bool {
83        self.added == 0 && self.removed == 0
84    }
85
86    /// Writes the colored `+N` and `-N` metrics to a status-line string.
87    fn write_to(self, target: &mut String, prepend_space: bool) {
88        if self.is_empty() {
89            return;
90        }
91
92        if self.added > 0 {
93            if prepend_space {
94                target.push(' ');
95            }
96            let _ = write!(target, "%#{GIT_ADDED_HIGHLIGHT}#+{}", self.added);
97        } else if prepend_space {
98            target.push(' ');
99        }
100
101        if self.removed > 0 {
102            if self.added > 0 {
103                target.push(' ');
104            }
105            let _ = write!(target, "%#{GIT_REMOVED_HIGHLIGHT}#-{}", self.removed);
106        }
107    }
108}
109
110/// Git line statistics for the whole repository and the active buffer.
111#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112struct GitStats {
113    workspace: GitLineStats,
114    current_buffer: GitLineStats,
115}
116
117impl GitStats {
118    /// Aggregates repository statistics and selects the active buffer's statistics when present.
119    fn from_file_stats<I>(file_stats: I, current_buffer_path: Option<&Path>) -> Self
120    where
121        I: IntoIterator<Item = ytil_git::diff::FileDiffStats>,
122    {
123        let mut stats = Self::default();
124        for file_stats in file_stats {
125            stats.workspace.add_file(&file_stats);
126            if current_buffer_path.is_some_and(|path| file_stats.path == path) {
127                stats.current_buffer.add_file(&file_stats);
128            }
129        }
130        stats
131    }
132}
133
134/// Cached Git statistics keyed by the active buffer and repository discovery path.
135#[derive(Debug)]
136struct CachedGitStats {
137    buffer_nr: i32,
138    discovery_path: PathBuf,
139    stats: GitStats,
140}
141
142/// Fixed-size aggregation of counts per [`DiagnosticSeverity`].
143#[derive(Clone, Copy, Debug, Default)]
144struct SeverityBuckets {
145    counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
146}
147
148impl SeverityBuckets {
149    /// Increment severity count with saturating add.
150    fn inc(&mut self, sev: DiagnosticSeverity) {
151        let idx = sev as usize;
152        if let Some(slot) = self.counts.get_mut(idx) {
153            *slot = slot.saturating_add(1);
154        }
155    }
156
157    /// Get count for severity.
158    fn get(&self, sev: DiagnosticSeverity) -> u16 {
159        let idx = sev as usize;
160        self.counts.get(idx).copied().unwrap_or(0)
161    }
162
163    /// Iterate over (severity, count) pairs.
164    fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
165        DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
166    }
167
168    /// Approximate rendered length for pre-allocation.
169    fn approx_render_len(&self) -> usize {
170        let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
171        // Each segment roughly: `"%#DiagnosticStatusLineWarn#W:123"` ~ 32 chars worst case; be conservative.
172        // Use saturating_mul to satisfy `clippy::arithmetic_side_effects` pedantic lint.
173        non_zero.saturating_mul(32)
174    }
175}
176
177/// Build buckets from iterator of (severity, count).
178impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
179    fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
180        let mut buckets = Self::default();
181        for (sev, count) in iter {
182            let idx = sev as usize;
183            if let Some(slot) = buckets.counts.get_mut(idx) {
184                *slot = count; // Accept last-wins; tests construct unique severities
185            }
186        }
187        buckets
188    }
189}
190
191/// Represents the status line with buffer path, diagnostics, and Git statistics.
192#[derive(Debug)]
193struct Statusline<'a> {
194    current_buffer_path: Option<&'a str>,
195    current_buffer_diags: SeverityBuckets,
196    workspace_diags: SeverityBuckets,
197    git_stats: GitStats,
198    cursor_position: Option<CursorPosition>,
199}
200
201impl Statusline<'_> {
202    /// Draws the status line as a formatted string.
203    fn draw(&self) -> String {
204        // Build current buffer diagnostics (with trailing space if any present) manually to avoid
205        // iterator allocation and secondary pass (.any()).
206        let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
207        let mut wrote_any = false;
208        for (sev, count) in self.current_buffer_diags.iter() {
209            if count == 0 {
210                continue;
211            }
212            if wrote_any {
213                current_buffer_diags_segment.push(' ');
214            }
215            // Write directly to string to avoid intermediate allocation
216            write_diagnostics(&mut current_buffer_diags_segment, sev, count);
217            wrote_any = true;
218        }
219        if wrote_any {
220            current_buffer_diags_segment.push(' '); // maintain previous trailing space contract
221        }
222
223        // Workspace diagnostics (no trailing space).
224        let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
225        let mut first = true;
226        for (sev, count) in self.workspace_diags.iter() {
227            if count == 0 {
228                continue;
229            }
230            if !first {
231                workspace_diags_segment.push(' ');
232            }
233            // Write directly to string to avoid intermediate allocation
234            write_diagnostics(&mut workspace_diags_segment, sev, count);
235            first = false;
236        }
237
238        // Build final statusline in a single pre-allocated buffer to avoid intermediate
239        // format! allocation and the current_buffer_path_segment temporary String.
240        let estimated_len = workspace_diags_segment
241            .len()
242            .saturating_add(current_buffer_diags_segment.len())
243            .saturating_add(self.current_buffer_path.map_or(0, str::len))
244            .saturating_add(40);
245        let mut out = String::with_capacity(estimated_len);
246        out.push_str(&current_buffer_diags_segment);
247        self.git_stats.current_buffer.write_to(&mut out, false);
248        let _ = write!(out, "%#StatusLine# ");
249        if let Some(buf_path) = self.current_buffer_path {
250            let _ = write!(out, "{buf_path} ");
251        }
252        if let Some(ref pos) = self.cursor_position {
253            let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
254        }
255        out.push_str(&workspace_diags_segment);
256        self.git_stats
257            .workspace
258            .write_to(&mut out, !workspace_diags_segment.is_empty());
259        let _ = write!(out, "%#StatusLine#");
260        out
261    }
262}
263
264/// Invalidates cached Git statistics so the next draw reads the repository again.
265fn invalidate_git_stats(_: ()) {
266    CACHED_GIT_STATS.with(|cache| *cache.borrow_mut() = None);
267}
268
269/// Retrieves Git statistics for the active buffer, or the current working directory when unnamed.
270fn get_git_stats(current_buffer: &Buffer, current_buffer_nr: i32) -> GitStats {
271    let current_buffer_path = ytil_noxi::buffer::get_absolute_path(Some(current_buffer));
272    let Some(discovery_path) = current_buffer_path.clone().or_else(get_current_working_directory) else {
273        return GitStats::default();
274    };
275
276    let cached_stats = CACHED_GIT_STATS.with(|cache| {
277        let cache = cache.borrow();
278        cache
279            .as_ref()
280            .filter(|cached| cached.buffer_nr == current_buffer_nr && cached.discovery_path == discovery_path)
281            .map(|cached| cached.stats)
282    });
283    if let Some(cached_stats) = cached_stats {
284        return cached_stats;
285    }
286
287    let Ok(repo) = ytil_git::repo::discover(&discovery_path) else {
288        return GitStats::default();
289    };
290    let repo_root = ytil_git::repo::get_root(&repo);
291    let Ok(relative_buffer_path) = current_buffer_path
292        .as_deref()
293        .map(|path| path.strip_prefix(&repo_root).map(Path::to_path_buf))
294        .transpose()
295    else {
296        return GitStats::default();
297    };
298
299    let stats = ytil_git::diff::get_line_stats(&repo_root).map_or_else(
300        |_| GitStats::default(),
301        |file_stats| GitStats::from_file_stats(file_stats, relative_buffer_path.as_deref()),
302    );
303    CACHED_GIT_STATS.with(|cache| {
304        *cache.borrow_mut() = Some(CachedGitStats {
305            buffer_nr: current_buffer_nr,
306            discovery_path,
307            stats,
308        });
309    });
310    stats
311}
312
313/// Retrieves Neovim's current working directory for Git discovery without a named buffer.
314fn get_current_working_directory() -> Option<PathBuf> {
315    nvim_oxi::api::call_function::<_, String>("getcwd", Array::new())
316        .ok()
317        .map(PathBuf::from)
318}
319
320/// Draws the status line with diagnostic and Git change information.
321fn draw(diagnostics: Vec<Diagnostic>) -> String {
322    let current_buffer = nvim_oxi::api::get_current_buf();
323    let current_buffer_nr = current_buffer.handle();
324
325    // Return `%#Normal#` instead of empty string in case of terminal buffers
326    // to blend the statusline with the editor background even when a statusline
327    // background color is set.
328    if current_buffer.is_terminal() {
329        return "%#Normal#".to_string();
330    }
331
332    // Use cached buffer path when the buffer handle hasn't changed (avoids FFI + PathBuf work on
333    // every CursorMoved). The cache is invalidated implicitly when the handle changes (BufEnter).
334    let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
335        let cached = cache.borrow();
336        if let Some((handle, ref path)) = *cached
337            && handle == current_buffer_nr
338        {
339            return path.clone();
340        }
341        drop(cached);
342        let path = ytil_noxi::buffer::get_relative_path_to_cwd(&current_buffer).map(|x| x.display().to_string());
343        *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
344        path
345    });
346
347    let cursor_position = CursorPosition::get_current();
348    let git_stats = get_git_stats(&current_buffer, current_buffer_nr);
349
350    let mut statusline = Statusline {
351        current_buffer_path: current_buffer_path.as_deref(),
352        current_buffer_diags: SeverityBuckets::default(),
353        workspace_diags: SeverityBuckets::default(),
354        git_stats,
355        cursor_position,
356    };
357    for diagnostic in diagnostics {
358        statusline.workspace_diags.inc(diagnostic.severity);
359        if current_buffer_nr == diagnostic.bufnr {
360            statusline.current_buffer_diags.inc(diagnostic.severity);
361        }
362    }
363
364    statusline.draw()
365}
366
367/// Writes the diagnostic count directly to the target string, avoiding intermediate allocation.
368fn write_diagnostics(target: &mut String, severity: DiagnosticSeverity, diags_count: u16) {
369    if diags_count == 0 {
370        return;
371    }
372    let (hg_group_dyn_part, severity_label) = match severity {
373        DiagnosticSeverity::Error => ("Error", "E"),
374        DiagnosticSeverity::Warn => ("Warn", "W"),
375        DiagnosticSeverity::Info => ("Info", "I"),
376        DiagnosticSeverity::Hint | DiagnosticSeverity::Other => ("Hint", "H"),
377    };
378    // write! to String is infallible, so we can safely ignore the result
379    let _ = write!(
380        target,
381        "%#DiagnosticStatusLine{hg_group_dyn_part}#{severity_label}:{diags_count}"
382    );
383}
384
385/// Draws the diagnostic count for a (severity, count) pair.
386/// Kept for test compatibility.
387#[cfg(test)]
388fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
389    let mut out = String::new();
390    write_diagnostics(&mut out, severity, diags_count);
391    out
392}
393
394#[cfg(test)]
395mod tests {
396    use rstest::rstest;
397    use test_that::prelude::*;
398
399    use super::*;
400
401    #[rstest]
402    #[case::default_diags(Statusline {
403        current_buffer_path: Some("foo"),
404        current_buffer_diags: SeverityBuckets::default(),
405        workspace_diags: SeverityBuckets::default(),
406        git_stats: GitStats::default(),
407        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
408    })]
409    #[case::buffer_zero(Statusline {
410        current_buffer_path: Some("foo"),
411        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
412        workspace_diags: SeverityBuckets::default(),
413        git_stats: GitStats::default(),
414        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
415    })]
416    #[case::workspace_zero(Statusline {
417        current_buffer_path: Some("foo"),
418        current_buffer_diags: SeverityBuckets::default(),
419        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
420        git_stats: GitStats::default(),
421        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
422    })]
423    #[case::both_zero(Statusline {
424        current_buffer_path: Some("foo"),
425        current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
426        workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
427        git_stats: GitStats::default(),
428        cursor_position: Some(CursorPosition { row: 42, col: 7 }),
429    })]
430    fn test_statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(
431        #[case] statusline: Statusline,
432    ) {
433        assert_that!(statusline.draw(), eq("%#StatusLine# foo 42:8 %#StatusLine#"));
434    }
435
436    #[test]
437    fn test_statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_group_before_path() {
438        let statusline = Statusline {
439            current_buffer_path: Some("foo"),
440            current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
441                .into_iter()
442                .collect(),
443            workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
444            git_stats: GitStats::default(),
445            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
446        };
447        assert_that!(
448            statusline.draw(),
449            eq("%#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1 %#StatusLine# foo 42:8 %#StatusLine#")
450        );
451    }
452
453    #[test]
454    fn test_statusline_draw_when_workspace_has_diagnostics_renders_workspace_group_after_path() {
455        let statusline = Statusline {
456            current_buffer_path: Some("foo"),
457            current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
458            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
459                .into_iter()
460                .collect(),
461            git_stats: GitStats::default(),
462            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
463        };
464        assert_that!(
465            statusline.draw(),
466            eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1%#StatusLine#")
467        );
468    }
469
470    #[test]
471    fn test_statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_separate_groups() {
472        let statusline = Statusline {
473            current_buffer_path: Some("foo"),
474            current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
475                .into_iter()
476                .collect(),
477            workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
478                .into_iter()
479                .collect(), // unchanged (multi-element)
480            git_stats: GitStats::default(),
481            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
482        };
483        assert_that!(
484            statusline.draw(),
485            eq(
486                "%#DiagnosticStatusLineWarn#W:2 %#DiagnosticStatusLineHint#H:3 %#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1%#StatusLine#"
487            )
488        );
489    }
490
491    #[test]
492    fn test_statusline_draw_when_git_stats_exist_renders_separate_buffer_and_workspace_groups() {
493        let statusline = Statusline {
494            current_buffer_path: Some("foo"),
495            current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 1)).collect(),
496            workspace_diags: std::iter::once((DiagnosticSeverity::Error, 2)).collect(),
497            git_stats: GitStats {
498                workspace: GitLineStats { added: 12, removed: 4 },
499                current_buffer: GitLineStats { added: 3, removed: 1 },
500            },
501            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
502        };
503
504        assert_that!(
505            statusline.draw(),
506            eq(
507                "%#DiagnosticStatusLineInfo#I:1 %#Added#+3 %#Removed#-1%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:2 %#Added#+12 %#Removed#-4%#StatusLine#"
508            )
509        );
510    }
511
512    #[rstest]
513    #[case::added_only(GitLineStats { added: 3, removed: 0 }, "%#Added#+3")]
514    #[case::removed_only(GitLineStats { added: 0, removed: 2 }, "%#Removed#-2")]
515    #[case::no_changes(GitLineStats::default(), "")]
516    fn test_git_line_stats_write_to_when_counts_vary_renders_expected_metrics(
517        #[case] stats: GitLineStats,
518        #[case] expected: &str,
519    ) {
520        let mut output = String::new();
521        stats.write_to(&mut output, false);
522        assert_that!(output, eq(expected));
523    }
524
525    #[test]
526    fn test_git_stats_from_file_stats_when_multiple_files_exist_aggregates_workspace_and_selects_buffer() {
527        let stats = GitStats::from_file_stats(
528            vec![
529                ytil_git::diff::FileDiffStats {
530                    path: "src/main.rs".into(),
531                    added: 3,
532                    removed: 1,
533                },
534                ytil_git::diff::FileDiffStats {
535                    path: "src/lib.rs".into(),
536                    added: 5,
537                    removed: 2,
538                },
539            ],
540            Some(Path::new("src/main.rs")),
541        );
542
543        assert_eq!(
544            stats,
545            GitStats {
546                workspace: GitLineStats { added: 8, removed: 3 },
547                current_buffer: GitLineStats { added: 3, removed: 1 },
548            }
549        );
550    }
551
552    #[test]
553    fn test_git_stats_from_file_stats_when_buffer_path_is_missing_keeps_only_workspace_stats() {
554        let stats = GitStats::from_file_stats(
555            vec![ytil_git::diff::FileDiffStats {
556                path: "src/main.rs".into(),
557                added: 3,
558                removed: 1,
559            }],
560            None,
561        );
562
563        assert_eq!(
564            stats,
565            GitStats {
566                workspace: GitLineStats { added: 3, removed: 1 },
567                current_buffer: GitLineStats::default(),
568            }
569        );
570    }
571
572    #[test]
573    fn test_statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
574        // Insert in non-canonical order (Hint before Warn) and ensure output orders by severity (Warn then Hint).
575        let statusline = Statusline {
576            current_buffer_path: Some("foo"),
577            current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
578                .into_iter()
579                .collect(), // multi-element unchanged
580            workspace_diags: SeverityBuckets::default(),
581            git_stats: GitStats::default(),
582            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
583        };
584        assert_that!(
585            statusline.draw(),
586            eq("%#DiagnosticStatusLineWarn#W:1 %#DiagnosticStatusLineHint#H:5 %#StatusLine# foo 42:8 %#StatusLine#")
587        );
588    }
589
590    #[rstest]
591    #[case::error(DiagnosticSeverity::Error)]
592    #[case::warn(DiagnosticSeverity::Warn)]
593    #[case::info(DiagnosticSeverity::Info)]
594    #[case::hint(DiagnosticSeverity::Hint)]
595    #[case::other(DiagnosticSeverity::Other)]
596    fn test_draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
597        // Any severity with zero count should yield empty string.
598        assert_that!(draw_diagnostics((severity, 0)), eq(String::new()));
599    }
600
601    #[test]
602    fn test_statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
603        // Insert diagnostics in deliberately scrambled order to validate deterministic ordering.
604        let statusline = Statusline {
605            current_buffer_path: Some("foo"),
606            current_buffer_diags: [
607                (DiagnosticSeverity::Hint, 1),
608                (DiagnosticSeverity::Error, 4),
609                (DiagnosticSeverity::Info, 2),
610                (DiagnosticSeverity::Warn, 3),
611            ]
612            .into_iter()
613            .collect(),
614            workspace_diags: [
615                (DiagnosticSeverity::Warn, 7),
616                (DiagnosticSeverity::Info, 6),
617                (DiagnosticSeverity::Hint, 5),
618                (DiagnosticSeverity::Error, 8),
619            ]
620            .into_iter()
621            .collect(),
622            git_stats: GitStats::default(),
623            cursor_position: Some(CursorPosition { row: 42, col: 7 }),
624        };
625        // Affirm draw output matches severity ordering; equality macro takes (actual, expected).
626        assert_that!(
627            statusline.draw(),
628            eq(
629                "%#DiagnosticStatusLineError#E:4 %#DiagnosticStatusLineWarn#W:3 %#DiagnosticStatusLineInfo#I:2 %#DiagnosticStatusLineHint#H:1 %#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:8 %#DiagnosticStatusLineWarn#W:7 %#DiagnosticStatusLineInfo#I:6 %#DiagnosticStatusLineHint#H:5%#StatusLine#"
630            )
631        );
632    }
633
634    #[test]
635    fn test_statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
636        // When both path and cursor position are absent, only the highlight groups remain.
637        let statusline = Statusline {
638            current_buffer_path: None,
639            current_buffer_diags: SeverityBuckets::default(),
640            workspace_diags: SeverityBuckets::default(),
641            git_stats: GitStats::default(),
642            cursor_position: None,
643        };
644        assert_that!(statusline.draw(), eq("%#StatusLine# %#StatusLine#"));
645    }
646
647    #[rstest]
648    #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
649    #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
650    fn test_statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
651        // Column zero (internal 0-based) must render as 1 (human-facing).
652        // Non-zero column must render raw + 1.
653        let statusline = Statusline {
654            current_buffer_path: Some("foo"),
655            current_buffer_diags: SeverityBuckets::default(),
656            workspace_diags: SeverityBuckets::default(),
657            git_stats: GitStats::default(),
658            cursor_position: Some(CursorPosition { row: 10, col }),
659        };
660        assert_that!(statusline.draw(), eq(expected));
661    }
662}