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
240 use super::*;
241
242 #[test]
243 fn test_draw_statuscolumn_when_no_extmarks_returns_placeholders() {
244 let out = draw_statuscolumn(
245 "foo",
246 "42",
247 std::iter::empty(),
248 Some(Opts {
249 show_line_numbers: true,
250 }),
251 );
252 pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 "));
253 }
254
255 #[test]
256 fn test_draw_statuscolumn_when_diagnostic_error_and_warn_displays_error() {
257 let metas = vec![
258 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
259 mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
260 ];
261 let out = draw_statuscolumn(
262 "foo",
263 "42",
264 metas.into_iter(),
265 Some(Opts {
266 show_line_numbers: true,
267 }),
268 );
269 pretty_assertions::assert_eq!(
271 out,
272 format!("{EMPTY_SPACE}%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 ")
273 );
274 }
275
276 #[test]
277 fn test_draw_statuscolumn_when_git_sign_present_displays_git_sign() {
278 let metas = vec![mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|")];
279 let out = draw_statuscolumn(
280 "foo",
281 "42",
282 metas.into_iter(),
283 Some(Opts {
284 show_line_numbers: true,
285 }),
286 );
287 pretty_assertions::assert_eq!(out, format!("%#GitSignsFoo#|%*{EMPTY_SPACE}{EMPTY_SPACE} %=% 42 "));
288 }
289
290 #[test]
291 fn test_draw_statuscolumn_when_diagnostics_and_git_sign_displays_both() {
292 let metas = vec![
293 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
294 mk_extmark_meta(SignHlGroup::DiagnosticWarn, "W"),
295 mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
296 ];
297 let out = draw_statuscolumn(
298 "foo",
299 "42",
300 metas.into_iter(),
301 Some(Opts {
302 show_line_numbers: true,
303 }),
304 );
305 pretty_assertions::assert_eq!(
306 out,
307 format!("%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE} %=% 42 ")
308 );
309 }
310
311 #[test]
312 fn test_draw_statuscolumn_when_grug_far_buffer_returns_single_space() {
313 let out = draw_statuscolumn(
314 "grug-far",
315 "7",
316 std::iter::empty(),
317 Some(Opts {
318 show_line_numbers: true,
319 }),
320 );
321 pretty_assertions::assert_eq!(out, "");
322 }
323
324 #[rstest]
325 #[case(None)]
326 #[case(Some(Opts { show_line_numbers: false }))]
327 fn test_draw_statuscolumn_when_line_numbers_disabled_returns_no_line_numbers(#[case] opts: Option<Opts>) {
328 let out = draw_statuscolumn("foo", "42", std::iter::empty(), opts);
329 pretty_assertions::assert_eq!(out, format!("{EMPTY_SPACE}{EMPTY_SPACE}{EMPTY_SPACE}"));
330 }
331
332 #[rstest]
333 #[case(None)]
334 #[case(Some(Opts { show_line_numbers: false }))]
335 fn test_draw_statuscolumn_when_line_numbers_disabled_with_extmarks_returns_no_line_numbers(
336 #[case] opts: Option<Opts>,
337 ) {
338 let metas = vec![
339 mk_extmark_meta(SignHlGroup::DiagnosticError, "E"),
340 mk_extmark_meta(SignHlGroup::Git("GitSignsFoo".into()), "|"),
341 ];
342 let out = draw_statuscolumn("foo", "42", metas.into_iter(), opts);
343 pretty_assertions::assert_eq!(out, format!("%#GitSignsFoo#|%*%#DiagnosticSignError#E%*{EMPTY_SPACE}"));
344 }
345
346 fn mk_extmark_meta(group: SignHlGroup, text: &str) -> ExtmarkMeta {
347 ExtmarkMeta {
348 sign_hl_group: group,
349 sign_text: Some(text.to_string()),
350 }
351 }
352}