1use std::cell::RefCell;
4use std::fmt::Write as _;
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::Object;
8use serde::Deserialize;
9use strum::IntoEnumIterator;
10use ytil_noxi::buffer::BufferExt as _;
11use ytil_noxi::buffer::CursorPosition;
12
13use crate::diagnostics::DiagnosticSeverity;
14
15const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
16
17thread_local! {
18 static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
22}
23
24pub fn dict() -> Dictionary {
30 dict! {
31 "draw": fn_from!(draw),
32 "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
33 }
34}
35
36fn draw(diagnostics: Vec<Diagnostic>) -> String {
38 let current_buffer = nvim_oxi::api::get_current_buf();
39 let current_buffer_nr = current_buffer.handle();
40
41 if current_buffer.is_terminal() {
45 return "%#Normal#".to_string();
46 }
47
48 let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
51 let cached = cache.borrow();
52 if let Some((handle, ref path)) = *cached
53 && handle == current_buffer_nr
54 {
55 return path.clone();
56 }
57 drop(cached);
58 let path = ytil_noxi::buffer::get_relative_path_to_cwd(¤t_buffer).map(|x| x.display().to_string());
59 *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
60 path
61 });
62
63 let cursor_position = CursorPosition::get_current();
64
65 let mut statusline = Statusline {
66 current_buffer_path: current_buffer_path.as_deref(),
67 current_buffer_diags: SeverityBuckets::default(),
68 workspace_diags: SeverityBuckets::default(),
69 cursor_position,
70 };
71 for diagnostic in diagnostics {
72 statusline.workspace_diags.inc(diagnostic.severity);
73 if current_buffer_nr == diagnostic.bufnr {
74 statusline.current_buffer_diags.inc(diagnostic.severity);
75 }
76 }
77
78 statusline.draw()
79}
80
81#[derive(Deserialize)]
83pub struct Diagnostic {
84 bufnr: i32,
86 severity: DiagnosticSeverity,
88}
89
90ytil_noxi::impl_nvim_deserializable!(Diagnostic);
91
92#[derive(Clone, Copy, Debug, Default)]
94struct SeverityBuckets {
95 counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
96}
97
98impl SeverityBuckets {
99 fn inc(&mut self, sev: DiagnosticSeverity) {
101 let idx = sev as usize;
102 if let Some(slot) = self.counts.get_mut(idx) {
103 *slot = slot.saturating_add(1);
104 }
105 }
106
107 fn get(&self, sev: DiagnosticSeverity) -> u16 {
109 let idx = sev as usize;
110 self.counts.get(idx).copied().unwrap_or(0)
111 }
112
113 fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
115 DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
116 }
117
118 fn approx_render_len(&self) -> usize {
120 let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
121 non_zero.saturating_mul(32)
124 }
125}
126
127impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
129 fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
130 let mut buckets = Self::default();
131 for (sev, count) in iter {
132 let idx = sev as usize;
133 if let Some(slot) = buckets.counts.get_mut(idx) {
134 *slot = count; }
136 }
137 buckets
138 }
139}
140
141#[derive(Debug)]
143struct Statusline<'a> {
144 current_buffer_path: Option<&'a str>,
145 current_buffer_diags: SeverityBuckets,
146 workspace_diags: SeverityBuckets,
147 cursor_position: Option<CursorPosition>,
148}
149
150impl Statusline<'_> {
151 fn draw(&self) -> String {
153 let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
156 let mut wrote_any = false;
157 for (sev, count) in self.current_buffer_diags.iter() {
158 if count == 0 {
159 continue;
160 }
161 if wrote_any {
162 current_buffer_diags_segment.push(' ');
163 }
164 write_diagnostics(&mut current_buffer_diags_segment, sev, count);
166 wrote_any = true;
167 }
168 if wrote_any {
169 current_buffer_diags_segment.push(' '); }
171
172 let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
174 let mut first = true;
175 for (sev, count) in self.workspace_diags.iter() {
176 if count == 0 {
177 continue;
178 }
179 if !first {
180 workspace_diags_segment.push(' ');
181 }
182 write_diagnostics(&mut workspace_diags_segment, sev, count);
184 first = false;
185 }
186
187 let estimated_len = workspace_diags_segment
190 .len()
191 .saturating_add(current_buffer_diags_segment.len())
192 .saturating_add(self.current_buffer_path.map_or(0, str::len))
193 .saturating_add(40);
194 let mut out = String::with_capacity(estimated_len);
195 let _ = write!(out, "{workspace_diags_segment}%#StatusLine# ");
196 if let Some(buf_path) = self.current_buffer_path {
197 let _ = write!(out, "{buf_path} ");
198 }
199 if let Some(ref pos) = self.cursor_position {
200 let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
201 }
202 let _ = write!(out, "{current_buffer_diags_segment}%#StatusLine#");
203 out
204 }
205}
206
207fn write_diagnostics(target: &mut String, severity: DiagnosticSeverity, diags_count: u16) {
209 if diags_count == 0 {
210 return;
211 }
212 let hg_group_dyn_part = match severity {
213 DiagnosticSeverity::Error => "Error",
214 DiagnosticSeverity::Warn => "Warn",
215 DiagnosticSeverity::Info => "Info",
216 DiagnosticSeverity::Hint | DiagnosticSeverity::Other => "Hint",
217 };
218 let _ = write!(target, "%#DiagnosticStatusLine{hg_group_dyn_part}#{diags_count}");
220}
221
222#[cfg(test)]
225fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
226 let mut out = String::new();
227 write_diagnostics(&mut out, severity, diags_count);
228 out
229}
230
231#[cfg(test)]
232mod tests {
233 use rstest::rstest;
234
235 use super::*;
236
237 #[rstest]
238 #[case::default_diags(Statusline {
239 current_buffer_path: Some("foo"),
240 current_buffer_diags: SeverityBuckets::default(),
241 workspace_diags: SeverityBuckets::default(),
242 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
243 })]
244 #[case::buffer_zero(Statusline {
245 current_buffer_path: Some("foo"),
246 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
247 workspace_diags: SeverityBuckets::default(),
248 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
249 })]
250 #[case::workspace_zero(Statusline {
251 current_buffer_path: Some("foo"),
252 current_buffer_diags: SeverityBuckets::default(),
253 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
254 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
255 })]
256 #[case::both_zero(Statusline {
257 current_buffer_path: Some("foo"),
258 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
259 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
260 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
261 })]
262 fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
263 pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# foo 42:8 %#StatusLine#");
264 }
265
266 #[test]
267 fn statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
268 let statusline = Statusline {
269 current_buffer_path: Some("foo"),
270 current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
271 .into_iter()
272 .collect(),
273 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
274 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
275 };
276 pretty_assertions::assert_eq!(
277 statusline.draw(),
278 "%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#",
279 );
280 }
281
282 #[test]
283 fn statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
284 let statusline = Statusline {
285 current_buffer_path: Some("foo"),
286 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
287 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
288 .into_iter()
289 .collect(),
290 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
291 };
292 pretty_assertions::assert_eq!(
293 statusline.draw(),
294 "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#",
295 );
296 }
297
298 #[test]
299 fn statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
300 let statusline = Statusline {
301 current_buffer_path: Some("foo"),
302 current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
303 .into_iter()
304 .collect(),
305 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
306 .into_iter()
307 .collect(), cursor_position: Some(CursorPosition { row: 42, col: 7 }),
309 };
310 pretty_assertions::assert_eq!(
311 statusline.draw(),
312 "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#",
313 );
314 }
315
316 #[test]
317 fn statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
318 let statusline = Statusline {
320 current_buffer_path: Some("foo"),
321 current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
322 .into_iter()
323 .collect(), workspace_diags: SeverityBuckets::default(),
325 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
326 };
327 pretty_assertions::assert_eq!(
328 statusline.draw(),
329 "%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#",
330 );
331 }
332
333 #[rstest]
334 #[case::error(DiagnosticSeverity::Error)]
335 #[case::warn(DiagnosticSeverity::Warn)]
336 #[case::info(DiagnosticSeverity::Info)]
337 #[case::hint(DiagnosticSeverity::Hint)]
338 #[case::other(DiagnosticSeverity::Other)]
339 fn draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
340 pretty_assertions::assert_eq!(draw_diagnostics((severity, 0)), String::new());
342 }
343
344 #[test]
345 fn statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
346 let statusline = Statusline {
348 current_buffer_path: Some("foo"),
349 current_buffer_diags: [
350 (DiagnosticSeverity::Hint, 1),
351 (DiagnosticSeverity::Error, 4),
352 (DiagnosticSeverity::Info, 2),
353 (DiagnosticSeverity::Warn, 3),
354 ]
355 .into_iter()
356 .collect(),
357 workspace_diags: [
358 (DiagnosticSeverity::Warn, 7),
359 (DiagnosticSeverity::Info, 6),
360 (DiagnosticSeverity::Hint, 5),
361 (DiagnosticSeverity::Error, 8),
362 ]
363 .into_iter()
364 .collect(),
365 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
366 };
367 pretty_assertions::assert_eq!(
369 statusline.draw(),
370 "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#",
371 );
372 }
373
374 #[test]
375 fn statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
376 let statusline = Statusline {
378 current_buffer_path: None,
379 current_buffer_diags: SeverityBuckets::default(),
380 workspace_diags: SeverityBuckets::default(),
381 cursor_position: None,
382 };
383 pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# %#StatusLine#");
384 }
385
386 #[rstest]
387 #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
388 #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
389 fn statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
390 let statusline = Statusline {
393 current_buffer_path: Some("foo"),
394 current_buffer_diags: SeverityBuckets::default(),
395 workspace_diags: SeverityBuckets::default(),
396 cursor_position: Some(CursorPosition { row: 10, col }),
397 };
398 pretty_assertions::assert_eq!(statusline.draw(), expected);
399 }
400}