Skip to main content

nvrim/plugins/
statuscolumn.rs

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