Skip to main content

nvrim/plugins/
statuscolumn.rs

1//! Statuscolumn drawing helpers for buffer-local indicators.
2
3use std::fmt::Display;
4use std::fmt::Formatter;
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::api::Buffer;
8use serde::Deserialize;
9use ytil_noxi::buffer::BufferExt;
10
11use crate::diagnostics::DiagnosticSeverity;
12
13/// Markup for a visible space in the Nvim statuscolumn.
14/// Plain spaces (" ") are not rendered; they must be wrapped in highlight markup like `%#Normal# %*`.
15const EMPTY_SPACE: &str = "%#Normal# %*";
16
17/// [`Dictionary`] exposing statuscolumn draw helpers.
18pub fn dict() -> Dictionary {
19    dict! {
20        "draw": fn_from!(draw),
21    }
22}
23
24/// Configuration options for the status column.
25#[derive(Deserialize)]
26struct Opts {
27    show_line_numbers: bool,
28}
29
30ytil_noxi::impl_nvim_deserializable!(Opts);
31
32/// Internal selection of the highest ranked diagnostic extmark.
33#[cfg_attr(test, derive(Debug))]
34struct SelectedDiag {
35    rank: u8,
36    meta: ExtmarkMeta,
37}
38
39/// Represents an extmark in Nvim.
40#[derive(Deserialize)]
41#[expect(dead_code, reason = "Unused fields are kept for completeness")]
42struct Extmark(u32, usize, usize, Option<ExtmarkMeta>);
43
44impl Extmark {
45    /// Consumes the extmark returning its metadata (if any).
46    fn into_meta(self) -> Option<ExtmarkMeta> {
47        self.3
48    }
49}
50
51ytil_noxi::impl_nvim_deserializable!(Extmark);
52
53/// Metadata associated with an extmark.
54#[derive(Clone, Deserialize)]
55#[cfg_attr(test, derive(Debug))]
56struct ExtmarkMeta {
57    sign_hl_group: SignHlGroup,
58    sign_text: Option<String>,
59}
60
61impl ExtmarkMeta {
62    /// Writes the formatted extmark metadata into `out`.
63    fn write(&self, out: &mut String) {
64        let displayed_symbol: &str = match self.sign_hl_group {
65            SignHlGroup::DiagnosticError => DiagnosticSeverity::Error.symbol(),
66            SignHlGroup::DiagnosticWarn => DiagnosticSeverity::Warn.symbol(),
67            SignHlGroup::DiagnosticInfo => DiagnosticSeverity::Info.symbol(),
68            SignHlGroup::DiagnosticHint => DiagnosticSeverity::Hint.symbol(),
69            SignHlGroup::DiagnosticOk | SignHlGroup::Git(_) | SignHlGroup::Other(_) => {
70                self.sign_text.as_ref().map_or("", |x| x.trim())
71            }
72        };
73        // %#<HlGroup>#<text>%*
74        out.push('%');
75        out.push('#');
76        out.push_str(self.sign_hl_group.as_str());
77        out.push('#');
78        out.push_str(displayed_symbol);
79        out.push('%');
80        out.push('*');
81    }
82}
83
84/// Enumerates known and dynamic highlight groups for status column signs.
85#[derive(Clone, Debug, Eq, PartialEq)]
86enum SignHlGroup {
87    DiagnosticError,
88    DiagnosticWarn,
89    DiagnosticInfo,
90    DiagnosticHint,
91    DiagnosticOk,
92    Git(String),
93    Other(String),
94}
95
96impl SignHlGroup {
97    /// Returns the canonical string form used by Nvim for this group.
98    const fn as_str(&self) -> &str {
99        match self {
100            Self::DiagnosticError => "DiagnosticSignError",
101            Self::DiagnosticWarn => "DiagnosticSignWarn",
102            Self::DiagnosticInfo => "DiagnosticSignInfo",
103            Self::DiagnosticHint => "DiagnosticSignHint",
104            Self::DiagnosticOk => "DiagnosticSignOk",
105            Self::Git(s) | Self::Other(s) => s.as_str(),
106        }
107    }
108
109    /// Severity ranking used to pick the highest diagnostic.
110    #[inline]
111    const fn rank(&self) -> u8 {
112        match self {
113            Self::DiagnosticError => 5,
114            Self::DiagnosticWarn => 4,
115            Self::DiagnosticInfo => 3,
116            Self::DiagnosticHint => 2,
117            Self::DiagnosticOk => 1,
118            Self::Git(_) | Self::Other(_) => 0,
119        }
120    }
121}
122
123impl Display for SignHlGroup {
124    /// Formats the highlight group as the raw group string.
125    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
126        f.write_str(self.as_str())
127    }
128}
129
130impl<'de> serde::Deserialize<'de> for SignHlGroup {
131    /// Deserializes a highlight group string into a typed [`SignHlGroup`].
132    ///
133    /// # Errors
134    /// Never returns an error beyond underlying string deserialization; every
135    /// string maps to some variant.
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: serde::Deserializer<'de>,
139    {
140        let s = String::deserialize(deserializer)?;
141        // Use if/else instead of match to move the already-owned `s` into Git/Other variants,
142        // avoiding a redundant `.to_string()` allocation on every non-diagnostic extmark.
143        Ok(if s == "DiagnosticSignError" {
144            Self::DiagnosticError
145        } else if s == "DiagnosticSignWarn" {
146            Self::DiagnosticWarn
147        } else if s == "DiagnosticSignInfo" {
148            Self::DiagnosticInfo
149        } else if s == "DiagnosticSignHint" {
150            Self::DiagnosticHint
151        } else if s == "DiagnosticSignOk" {
152            Self::DiagnosticOk
153        } else if s.contains("GitSigns") {
154            Self::Git(s)
155        } else {
156            Self::Other(s)
157        })
158    }
159}
160
161/// Draws the status column for the current buffer.
162fn draw((cur_lnum, extmarks, opts): (String, Vec<Extmark>, Option<Opts>)) -> Option<String> {
163    let current_buffer = Buffer::current();
164    let buf_type = current_buffer.get_buf_type()?;
165
166    Some(draw_statuscolumn(
167        &buf_type,
168        &cur_lnum,
169        extmarks.into_iter().filter_map(Extmark::into_meta),
170        opts,
171    ))
172}
173
174/// Constructs the status column string for the current line.
175fn draw_statuscolumn(
176    current_buffer_type: &str,
177    cur_lnum: &str,
178    metas: impl Iterator<Item = ExtmarkMeta>,
179    opts: Option<Opts>,
180) -> String {
181    if current_buffer_type == "grug-far" || current_buffer_type == "terminal" {
182        return String::new();
183    }
184
185    let mut highest_severity_diag: Option<SelectedDiag> = None;
186    let mut git_extmark: Option<ExtmarkMeta> = None;
187
188    for meta in metas {
189        match meta.sign_hl_group {
190            SignHlGroup::DiagnosticError
191            | SignHlGroup::DiagnosticWarn
192            | SignHlGroup::DiagnosticInfo
193            | SignHlGroup::DiagnosticHint
194            | SignHlGroup::DiagnosticOk => {
195                let rank = meta.sign_hl_group.rank();
196                match &highest_severity_diag {
197                    Some(sel) if sel.rank >= rank => {}
198                    _ => highest_severity_diag = Some(SelectedDiag { rank, meta }),
199                }
200            }
201            SignHlGroup::Git(_) if git_extmark.is_none() => git_extmark = Some(meta),
202            SignHlGroup::Git(_) | SignHlGroup::Other(_) => {}
203        }
204        // Early break: if we already have top severity (Error rank 5) and have determined git presence
205        // (either captured or impossible to capture later because we already saw a git sign or caller provided none).
206        if let Some(sel) = &highest_severity_diag
207            && sel.rank == 5
208            && git_extmark.is_some()
209        {
210            break;
211        }
212    }
213
214    // Capacity heuristic: each sign ~ 32 chars + lnum + static separators.
215    let mut out = String::with_capacity(cur_lnum.len().saturating_add(64));
216    if let Some(git_extmark) = git_extmark {
217        git_extmark.write(&mut out);
218    } else {
219        out.push_str(EMPTY_SPACE);
220    }
221    if let Some(highest_severity_diag) = highest_severity_diag {
222        highest_severity_diag.meta.write(&mut out);
223    } else {
224        out.push_str(EMPTY_SPACE);
225    }
226    out.push_str(EMPTY_SPACE);
227    if opts.is_some_and(|o| o.show_line_numbers) {
228        out.push(' ');
229        out.push_str("%=% ");
230        out.push_str(cur_lnum);
231        out.push(' ');
232    }
233    out
234}
235
236#[cfg(test)]
237mod tests {
238    use rstest::rstest;
239
240    use super::*;
241
242    #[test]
243    fn test_draw_statuscolumn_when_no_extmarks_returns_placeholders() {
244        let out = draw_statuscolumn(
245            "foo",
246            "42",
247            std::iter::empty(),
248            Some(Opts {
249                show_line_numbers: true,
250            }),
251        );
252        pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 "));
253    }
254
255    #[test]
256    fn test_draw_statuscolumn_when_diagnostic_error_and_warn_displays_error() {
257        let metas = vec![
258            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
259            mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
260        ];
261        let out = draw_statuscolumn(
262            "foo",
263            "42",
264            metas.into_iter(),
265            Some(Opts {
266                show_line_numbers: true,
267            }),
268        );
269        // Canonical normalized error sign text is 'E', followed by a Normal-highlighted space for constant width.
270        pretty_assertions::assert_eq!(
271            out,
272            format!("{EMPTY_SPACE}%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 ")
273        );
274    }
275
276    #[test]
277    fn test_draw_statuscolumn_when_git_sign_present_displays_git_sign() {
278        let metas = vec![mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|")];
279        let out = draw_statuscolumn(
280            "foo",
281            "42",
282            metas.into_iter(),
283            Some(Opts {
284                show_line_numbers: true,
285            }),
286        );
287        pretty_assertions::assert_eq!(out, format!("%#GitSignsFoo#|%*{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 "));
288    }
289
290    #[test]
291    fn test_draw_statuscolumn_when_diagnostics_and_git_sign_displays_both() {
292        let metas = vec![
293            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
294            mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
295            mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
296        ];
297        let out = draw_statuscolumn(
298            "foo",
299            "42",
300            metas.into_iter(),
301            Some(Opts {
302                show_line_numbers: true,
303            }),
304        );
305        pretty_assertions::assert_eq!(
306            out,
307            format!("%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 ")
308        );
309    }
310
311    #[test]
312    fn test_draw_statuscolumn_when_grug_far_buffer_returns_single_space() {
313        let out = draw_statuscolumn(
314            "grug-far",
315            "7",
316            std::iter::empty(),
317            Some(Opts {
318                show_line_numbers: true,
319            }),
320        );
321        pretty_assertions::assert_eq!(out, "");
322    }
323
324    #[rstest]
325    #[case(None)]
326    #[case(Some(Opts { show_line_numbers: false }))]
327    fn test_draw_statuscolumn_when_line_numbers_disabled_returns_no_line_numbers(#[case] opts: Option<Opts>) {
328        let out = draw_statuscolumn("foo", "42", std::iter::empty(), opts);
329        pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE}"));
330    }
331
332    #[rstest]
333    #[case(None)]
334    #[case(Some(Opts { show_line_numbers: false }))]
335    fn test_draw_statuscolumn_when_line_numbers_disabled_with_extmarks_returns_no_line_numbers(
336        #[case] opts: Option<Opts>,
337    ) {
338        let metas = vec![
339            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
340            mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
341        ];
342        let out = draw_statuscolumn("foo", "42", metas.into_iter(), opts);
343        pretty_assertions::assert_eq!(out, format!("%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE}"));
344    }
345
346    fn mk_extmark_meta(group: SignHlGroup, text: &str) -> ExtmarkMeta {
347        ExtmarkMeta {
348            sign_hl_group: group,
349            sign_text: Some(text.to_string()),
350        }
351    }
352}