1use 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
21const EMPTY_SPACE: &str = "%#Normal# %*";
24
25pub fn dict() -> Dictionary {
27 dict! {
28 "draw": fn_from!(draw),
29 }
30}
31
32fn 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
52fn 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 if let Some(sel) = &highest_severity_diag
99 && sel.rank == 5
100 && git_extmark.is_some()
101 {
102 break;
103 }
104 }
105
106 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#[derive(Deserialize)]
129struct Opts {
130 show_line_numbers: bool,
132}
133
134impl 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
141impl 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#[cfg_attr(test, derive(Debug))]
156struct SelectedDiag {
157 rank: u8,
159 meta: ExtmarkMeta,
161}
162
163#[derive(Deserialize)]
165#[expect(dead_code, reason = "Unused fields are kept for completeness")]
166struct Extmark(u32, usize, usize, Option<ExtmarkMeta>);
167
168impl Extmark {
169 fn into_meta(self) -> Option<ExtmarkMeta> {
171 self.3
172 }
173}
174
175impl 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
182impl 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#[derive(Clone, Deserialize)]
194#[cfg_attr(test, derive(Debug))]
195struct ExtmarkMeta {
196 sign_hl_group: SignHlGroup,
198 sign_text: Option<String>,
200}
201
202impl ExtmarkMeta {
203 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 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#[derive(Clone, Debug, Eq, PartialEq)]
239enum SignHlGroup {
240 DiagnosticError,
242 DiagnosticWarn,
244 DiagnosticInfo,
246 DiagnosticHint,
248 DiagnosticOk,
250 Git(String),
252 Other(String),
254}
255
256impl SignHlGroup {
257 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 #[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 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 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 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}