nvrim/plugins/
statuscolumn.rs

1//! Statuscolumn drawing helpers for buffer-local indicators.
2//!
3//! Supplies `statuscolumn.dict()` exposing `draw`, rendering line numbers / extmarks while honoring
4//! special buffer types (e.g. minimal output for transient search buffers). Errors are notified via
5//! [`ytil_noxi::notify::error`].
6
7use core::fmt::Display;
8
9use nvim_oxi::Dictionary;
10use nvim_oxi::Object;
11use nvim_oxi::api::Buffer;
12use nvim_oxi::conversion::FromObject;
13use nvim_oxi::lua::Poppable;
14use nvim_oxi::lua::ffi::State;
15use nvim_oxi::serde::Deserializer;
16use serde::Deserialize;
17use ytil_noxi::buffer::BufferExt;
18
19use crate::diagnostics::DiagnosticSeverity;
20
21/// Markup for a visible space in the Nvim statuscolumn.
22/// Plain spaces (" ") are not rendered; they must be wrapped in highlight markup like `%#Normal# %*`.
23const EMPTY_SPACE: &str = "%#Normal# %*";
24
25/// [`Dictionary`] exposing statuscolumn draw helpers.
26pub fn dict() -> Dictionary {
27    dict! {
28        "draw": fn_from!(draw),
29    }
30}
31
32/// Draws the status column for the current buffer.
33///
34/// Special cases:
35/// - When `buftype == "grug-far"` returns a single space string to minimize visual noise in transient search buffers.
36///
37/// # Rationale
38/// Using `Option<String>` (instead of empty placeholder) allows caller-side distinction between an intentional blank
39/// status column (special buffer type) and an error acquiring required state.
40fn draw((cur_lnum, extmarks, opts): (String, Vec<Extmark>, Option<Opts>)) -> Option<String> {
41    let current_buffer = Buffer::current();
42    let buf_type = current_buffer.get_buf_type()?;
43
44    Some(draw_statuscolumn(
45        &buf_type,
46        &cur_lnum,
47        extmarks.into_iter().filter_map(Extmark::into_meta),
48        opts,
49    ))
50}
51
52/// Constructs the status column string for the current line.
53///
54/// # Assumptions
55/// - `metas` yields at most a small number of items (typical per-line sign density is low).
56/// - Caller has already restricted extmarks to those relevant for the line being drawn.
57///
58/// # Rationale
59/// - Single pass selects highest severity and first git sign to avoid repeated scans.
60/// - Early break once an Error (rank 5) and a Git sign are both determined prevents unnecessary iteration.
61/// - Manual string building reduces intermediate allocation versus collecting sign fragments.
62///
63/// # Performance
64/// - Allocates once with a conservative capacity heuristic (`lnum.len() + 64`).
65/// - O(n) over `metas`, short-circuiting when optimal state reached.
66/// - Rank computation is a simple match with small constant cost.
67fn draw_statuscolumn(
68    current_buffer_type: &str,
69    cur_lnum: &str,
70    metas: impl Iterator<Item = ExtmarkMeta>,
71    opts: Option<Opts>,
72) -> String {
73    if current_buffer_type == "grug-far" || current_buffer_type == "terminal" {
74        return String::new();
75    }
76
77    let mut highest_severity_diag: Option<SelectedDiag> = None;
78    let mut git_extmark: Option<ExtmarkMeta> = None;
79
80    for meta in metas {
81        match meta.sign_hl_group {
82            SignHlGroup::DiagnosticError
83            | SignHlGroup::DiagnosticWarn
84            | SignHlGroup::DiagnosticInfo
85            | SignHlGroup::DiagnosticHint
86            | SignHlGroup::DiagnosticOk => {
87                let rank = meta.sign_hl_group.rank();
88                match &highest_severity_diag {
89                    Some(sel) if sel.rank >= rank => {}
90                    _ => highest_severity_diag = Some(SelectedDiag { rank, meta }),
91                }
92            }
93            SignHlGroup::Git(_) if git_extmark.is_none() => git_extmark = Some(meta),
94            SignHlGroup::Git(_) | SignHlGroup::Other(_) => {}
95        }
96        // Early break: if we already have top severity (Error rank 5) and have determined git presence
97        // (either captured or impossible to capture later because we already saw a git sign or caller provided none).
98        if let Some(sel) = &highest_severity_diag
99            && sel.rank == 5
100            && git_extmark.is_some()
101        {
102            break;
103        }
104    }
105
106    // Capacity heuristic: each sign ~ 32 chars + lnum + static separators.
107    let mut out = String::with_capacity(cur_lnum.len().saturating_add(64));
108    if let Some(git_extmark) = git_extmark {
109        git_extmark.write(&mut out);
110    } else {
111        out.push_str(EMPTY_SPACE);
112    }
113    if let Some(highest_severity_diag) = highest_severity_diag {
114        highest_severity_diag.meta.write(&mut out);
115    } else {
116        out.push_str(EMPTY_SPACE);
117    }
118    if opts.is_some_and(|o| o.show_line_numbers) {
119        out.push(' ');
120        out.push_str("%=% ");
121        out.push_str(cur_lnum);
122        out.push(' ');
123    }
124    out
125}
126
127/// Configuration options for the status column.
128#[derive(Deserialize)]
129struct Opts {
130    /// Whether to display line numbers in the status column.
131    show_line_numbers: bool,
132}
133
134/// Implementation of [`FromObject`] for [`Opts`].
135impl FromObject for Opts {
136    fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
137        Self::deserialize(Deserializer::new(obj)).map_err(Into::into)
138    }
139}
140
141/// Implementation of [`Poppable`] for [`Opts`].
142impl Poppable for Opts {
143    unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
144        unsafe {
145            let obj = Object::pop(lstate)?;
146            Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
147        }
148    }
149}
150
151/// Internal selection of the highest ranked diagnostic extmark.
152///
153/// Captures both the numeric rank (see [`SignHlGroup::rank`]) and the associated
154/// [`ExtmarkMeta`] to allow deferred rendering after the scan completes.
155#[cfg_attr(test, derive(Debug))]
156struct SelectedDiag {
157    /// Severity rank (higher means more severe); non-diagnostic signs use 0.
158    rank: u8,
159    /// The metadata of the chosen diagnostic sign.
160    meta: ExtmarkMeta,
161}
162
163/// Represents an extmark in Nvim.
164#[derive(Deserialize)]
165#[expect(dead_code, reason = "Unused fields are kept for completeness")]
166struct Extmark(u32, usize, usize, Option<ExtmarkMeta>);
167
168impl Extmark {
169    /// Consumes the extmark returning its metadata (if any).
170    fn into_meta(self) -> Option<ExtmarkMeta> {
171        self.3
172    }
173}
174
175/// Implementation of [`FromObject`] for [`Extmark`].
176impl FromObject for Extmark {
177    fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
178        Self::deserialize(Deserializer::new(obj)).map_err(Into::into)
179    }
180}
181
182/// Implementation of [`Poppable`] for [`Extmark`].
183impl Poppable for Extmark {
184    unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
185        unsafe {
186            let obj = Object::pop(lstate)?;
187            Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
188        }
189    }
190}
191
192/// Metadata associated with an extmark.
193#[derive(Clone, Deserialize)]
194#[cfg_attr(test, derive(Debug))]
195struct ExtmarkMeta {
196    /// The highlight group for the sign.
197    sign_hl_group: SignHlGroup,
198    /// The text of the sign, optional due to grug-far buffers.
199    sign_text: Option<String>,
200}
201
202impl ExtmarkMeta {
203    /// Writes the formatted extmark metadata into `out`.
204    ///
205    /// - Performs inline normalization for diagnostic variants (except `Ok`), mapping them to canonical severity
206    ///   symbols from [`DiagnosticSeverity::symbol`].
207    /// - Leaves `Ok` / Git / Other variants using their existing trimmed `sign_text` (empty placeholder when absent).
208    ///
209    /// # Rationale
210    /// Appending directly avoids per-sign allocation of an intermediate [`String`].
211    fn write(&self, out: &mut String) {
212        let displayed_symbol: &str = match self.sign_hl_group {
213            SignHlGroup::DiagnosticError => DiagnosticSeverity::Error.symbol(),
214            SignHlGroup::DiagnosticWarn => DiagnosticSeverity::Warn.symbol(),
215            SignHlGroup::DiagnosticInfo => DiagnosticSeverity::Info.symbol(),
216            SignHlGroup::DiagnosticHint => DiagnosticSeverity::Hint.symbol(),
217            SignHlGroup::DiagnosticOk | SignHlGroup::Git(_) | SignHlGroup::Other(_) => {
218                self.sign_text.as_ref().map_or("", |x| x.trim())
219            }
220        };
221        // %#<HlGroup>#<text>%*
222        out.push('%');
223        out.push('#');
224        out.push_str(self.sign_hl_group.as_str());
225        out.push('#');
226        out.push_str(displayed_symbol);
227        out.push('%');
228        out.push('*');
229    }
230}
231
232/// Enumerates known and dynamic highlight groups for status column signs.
233///
234/// - Provides explicit variants for the standard diagnostic signs.
235/// - Captures Git related signs (`GitSigns*`) while retaining their concrete highlight group string in the
236///   [`SignHlGroup::Git`] variant.
237/// - Any other (custom / plugin) highlight group is retained verbatim in [`SignHlGroup::Other`].
238#[derive(Clone, Debug, Eq, PartialEq)]
239enum SignHlGroup {
240    /// `DiagnosticSignError` highlight group.
241    DiagnosticError,
242    /// `DiagnosticSignWarn` highlight group.
243    DiagnosticWarn,
244    /// `DiagnosticSignInfo` highlight group.
245    DiagnosticInfo,
246    /// `DiagnosticSignHint` highlight group.
247    DiagnosticHint,
248    /// `DiagnosticSignOk` highlight group.
249    DiagnosticOk,
250    /// A Git-related sign highlight group (contains `GitSigns`).
251    Git(String),
252    /// Any other highlight group string not matched above.
253    Other(String),
254}
255
256impl SignHlGroup {
257    /// Returns the canonical string form used by Nvim for this group.
258    const fn as_str(&self) -> &str {
259        match self {
260            Self::DiagnosticError => "DiagnosticSignError",
261            Self::DiagnosticWarn => "DiagnosticSignWarn",
262            Self::DiagnosticInfo => "DiagnosticSignInfo",
263            Self::DiagnosticHint => "DiagnosticSignHint",
264            Self::DiagnosticOk => "DiagnosticSignOk",
265            Self::Git(s) | Self::Other(s) => s.as_str(),
266        }
267    }
268
269    /// Severity ranking used to pick the highest diagnostic.
270    ///
271    /// # Rationale
272    /// Encapsulating the rank logic in the enum keeps selection code simpler and
273    /// removes the need for a standalone helper.
274    #[inline]
275    const fn rank(&self) -> u8 {
276        match self {
277            Self::DiagnosticError => 5,
278            Self::DiagnosticWarn => 4,
279            Self::DiagnosticInfo => 3,
280            Self::DiagnosticHint => 2,
281            Self::DiagnosticOk => 1,
282            Self::Git(_) | Self::Other(_) => 0,
283        }
284    }
285}
286
287impl Display for SignHlGroup {
288    /// Formats the highlight group as the raw group string.
289    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
290        f.write_str(self.as_str())
291    }
292}
293
294impl<'de> serde::Deserialize<'de> for SignHlGroup {
295    /// Deserializes a highlight group string into a typed [`SignHlGroup`].
296    ///
297    /// # Errors
298    /// Never returns an error beyond underlying string deserialization; every
299    /// string maps to some variant.
300    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
301    where
302        D: serde::Deserializer<'de>,
303    {
304        let s = String::deserialize(deserializer)?;
305        Ok(match s.as_str() {
306            "DiagnosticSignError" => Self::DiagnosticError,
307            "DiagnosticSignWarn" => Self::DiagnosticWarn,
308            "DiagnosticSignInfo" => Self::DiagnosticInfo,
309            "DiagnosticSignHint" => Self::DiagnosticHint,
310            "DiagnosticSignOk" => Self::DiagnosticOk,
311            git_hl_group if git_hl_group.contains("GitSigns") => Self::Git(git_hl_group.to_string()),
312            other_hl_group => Self::Other(other_hl_group.to_string()),
313        })
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use rstest::rstest;
320
321    use super::*;
322
323    #[test]
324    fn draw_statuscolumn_when_no_extmarks_returns_placeholders() {
325        let out = draw_statuscolumn(
326            "foo",
327            "42",
328            std::iter::empty(),
329            Some(Opts {
330                show_line_numbers: true,
331            }),
332        );
333        pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 "));
334    }
335
336    #[test]
337    fn draw_statuscolumn_when_diagnostic_error_and_warn_displays_error() {
338        let metas = vec![
339            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
340            mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
341        ];
342        let out = draw_statuscolumn(
343            "foo",
344            "42",
345            metas.into_iter(),
346            Some(Opts {
347                show_line_numbers: true,
348            }),
349        );
350        // Canonical normalized error sign text is 'x'.
351        pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}%#DiagnosticSignError#x%* %=% 42 "));
352    }
353
354    #[test]
355    fn draw_statuscolumn_when_git_sign_present_displays_git_sign() {
356        let metas = vec![mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|")];
357        let out = draw_statuscolumn(
358            "foo",
359            "42",
360            metas.into_iter(),
361            Some(Opts {
362                show_line_numbers: true,
363            }),
364        );
365        pretty_assertions::assert_eq!(out, format!("%#GitSignsFoo#|%*{EMPTY_SPACE} %=% 42 "));
366    }
367
368    #[test]
369    fn draw_statuscolumn_when_diagnostics_and_git_sign_displays_both() {
370        let metas = vec![
371            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
372            mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
373            mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
374        ];
375        let out = draw_statuscolumn(
376            "foo",
377            "42",
378            metas.into_iter(),
379            Some(Opts {
380                show_line_numbers: true,
381            }),
382        );
383        pretty_assertions::assert_eq!(out, "%#GitSignsFoo#|%*%#DiagnosticSignError#x%* %=% 42 ");
384    }
385
386    #[test]
387    fn draw_statuscolumn_when_grug_far_buffer_returns_single_space() {
388        let out = draw_statuscolumn(
389            "grug-far",
390            "7",
391            std::iter::empty(),
392            Some(Opts {
393                show_line_numbers: true,
394            }),
395        );
396        pretty_assertions::assert_eq!(out, "");
397    }
398
399    #[rstest]
400    #[case(None)]
401    #[case(Some(Opts { show_line_numbers: false }))]
402    fn draw_statuscolumn_when_line_numbers_disabled_returns_no_line_numbers(#[case] opts: Option<Opts>) {
403        let out = draw_statuscolumn("foo", "42", std::iter::empty(), opts);
404        pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE}"));
405    }
406
407    #[rstest]
408    #[case(None)]
409    #[case(Some(Opts { show_line_numbers: false }))]
410    fn draw_statuscolumn_when_line_numbers_disabled_with_extmarks_returns_no_line_numbers(#[case] opts: Option<Opts>) {
411        let metas = vec![
412            mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
413            mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
414        ];
415        let out = draw_statuscolumn("foo", "42", metas.into_iter(), opts);
416        pretty_assertions::assert_eq!(out, "%#GitSignsFoo#|%*%#DiagnosticSignError#x%*");
417    }
418
419    fn mk_extmark_meta(group: SignHlGroup, text: &str) -> ExtmarkMeta {
420        ExtmarkMeta {
421            sign_hl_group: group,
422            sign_text: Some(text.to_string()),
423        }
424    }
425}