1use 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
13const EMPTY_SPACE: &str = "%#Normal# %*";
16
17pub fn dict() -> Dictionary {
19 dict! {
20 "draw": fn_from!(draw),
21 }
22}
23
24#[derive(Deserialize)]
26struct Opts {
27 show_line_numbers: bool,
28}
29
30ytil_noxi::impl_nvim_deserializable!(Opts);
31
32#[cfg_attr(test, derive(Debug))]
34struct SelectedDiag {
35 rank: u8,
36 meta: ExtmarkMeta,
37}
38
39#[derive(Deserialize)]
41#[expect(dead_code, reason = "Unused fields are kept for completeness")]
42struct Extmark(u32, usize, usize, Option<ExtmarkMeta>);
43
44impl Extmark {
45 fn into_meta(self) -> Option<ExtmarkMeta> {
47 self.3
48 }
49}
50
51ytil_noxi::impl_nvim_deserializable!(Extmark);
52
53#[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 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 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#[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 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 #[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 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 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137 where
138 D: serde::Deserializer<'de>,
139 {
140 let s = String::deserialize(deserializer)?;
141 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
161fn 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
174fn 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 if let Some(sel) = &highest_severity_diag
207 && sel.rank == 5
208 && git_extmark.is_some()
209 {
210 break;
211 }
212 }
213
214 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 use test_that::prelude::*;
240
241 use super::*;
242
243 #[test]
244 fn test_draw_statuscolumn_when_no_extmarks_returns_placeholders() {
245 let out = draw_statuscolumn(
246 "foo",
247 "42",
248 std::iter::empty(),
249 Some(Opts {
250 show_line_numbers: true,
251 }),
252 );
253 assert_that!(out, eq(format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 ")));
254 }
255
256 #[test]
257 fn test_draw_statuscolumn_when_diagnostic_error_and_warn_displays_error() {
258 let metas = vec![
259 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
260 mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
261 ];
262 let out = draw_statuscolumn(
263 "foo",
264 "42",
265 metas.into_iter(),
266 Some(Opts {
267 show_line_numbers: true,
268 }),
269 );
270 assert_that!(
272 out,
273 eq(format!("{EMPTY_SPACE}%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 "))
274 );
275 }
276
277 #[test]
278 fn test_draw_statuscolumn_when_git_sign_present_displays_git_sign() {
279 let metas = vec![mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|")];
280 let out = draw_statuscolumn(
281 "foo",
282 "42",
283 metas.into_iter(),
284 Some(Opts {
285 show_line_numbers: true,
286 }),
287 );
288 assert_that!(out, eq(format!("%#GitSignsFoo#|%*{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 ")));
289 }
290
291 #[test]
292 fn test_draw_statuscolumn_when_diagnostics_and_git_sign_displays_both() {
293 let metas = vec![
294 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
295 mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
296 mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
297 ];
298 let out = draw_statuscolumn(
299 "foo",
300 "42",
301 metas.into_iter(),
302 Some(Opts {
303 show_line_numbers: true,
304 }),
305 );
306 assert_that!(
307 out,
308 eq(format!(
309 "%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 "
310 ))
311 );
312 }
313
314 #[test]
315 fn test_draw_statuscolumn_when_grug_far_buffer_returns_single_space() {
316 let out = draw_statuscolumn(
317 "grug-far",
318 "7",
319 std::iter::empty(),
320 Some(Opts {
321 show_line_numbers: true,
322 }),
323 );
324 assert_that!(out, eq(""));
325 }
326
327 #[rstest]
328 #[case(None)]
329 #[case(Some(Opts { show_line_numbers: false }))]
330 fn test_draw_statuscolumn_when_line_numbers_disabled_returns_no_line_numbers(#[case] opts: Option<Opts>) {
331 let out = draw_statuscolumn("foo", "42", std::iter::empty(), opts);
332 assert_that!(out, eq(format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE}")));
333 }
334
335 #[rstest]
336 #[case(None)]
337 #[case(Some(Opts { show_line_numbers: false }))]
338 fn test_draw_statuscolumn_when_line_numbers_disabled_with_extmarks_returns_no_line_numbers(
339 #[case] opts: Option<Opts>,
340 ) {
341 let metas = vec![
342 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
343 mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
344 ];
345 let out = draw_statuscolumn("foo", "42", metas.into_iter(), opts);
346 assert_that!(
347 out,
348 eq(format!("%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE}"))
349 );
350 }
351
352 fn mk_extmark_meta(group: SignHlGroup, text: &str) -> ExtmarkMeta {
353 ExtmarkMeta {
354 sign_hl_group: group,
355 sign_text: Some(text.to_string()),
356 }
357 }
358}