1use 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
12const EMPTY_SPACE: &str = "%#Normal# %*";
15
16pub fn dict() -> Dictionary {
18 dict! {
19 "draw": fn_from!(draw),
20 }
21}
22
23fn 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
36fn 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 if let Some(sel) = &highest_severity_diag
69 && sel.rank == 5
70 && git_extmark.is_some()
71 {
72 break;
73 }
74 }
75
76 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#[derive(Deserialize)]
100struct Opts {
101 show_line_numbers: bool,
102}
103
104ytil_noxi::impl_nvim_deserializable!(Opts);
105
106#[cfg_attr(test, derive(Debug))]
108struct SelectedDiag {
109 rank: u8,
110 meta: ExtmarkMeta,
111}
112
113#[derive(Deserialize)]
115#[expect(dead_code, reason = "Unused fields are kept for completeness")]
116struct Extmark(u32, usize, usize, Option<ExtmarkMeta>);
117
118impl Extmark {
119 fn into_meta(self) -> Option<ExtmarkMeta> {
121 self.3
122 }
123}
124
125ytil_noxi::impl_nvim_deserializable!(Extmark);
126
127#[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 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 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#[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 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 #[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 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 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
211 where
212 D: serde::Deserializer<'de>,
213 {
214 let s = String::deserialize(deserializer)?;
215 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 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}