1use std::cell::RefCell;
4use std::fmt::Write;
5
6use nvim_oxi::Dictionary;
7use nvim_oxi::Object;
8use serde::Deserialize;
9use strum::IntoEnumIterator;
10use ytil_noxi::buffer::BufferExt;
11use ytil_noxi::buffer::CursorPosition;
12
13use crate::diagnostics::DiagnosticSeverity;
14
15const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
16
17#[derive(Deserialize)]
19pub struct Diagnostic {
20 bufnr: i32,
22 severity: DiagnosticSeverity,
24}
25
26ytil_noxi::impl_nvim_deserializable!(Diagnostic);
27
28pub fn dict() -> Dictionary {
34 dict! {
35 "draw": fn_from!(draw),
36 "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
37 }
38}
39
40thread_local! {
41 static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
45}
46
47#[derive(Clone, Copy, Debug, Default)]
49struct SeverityBuckets {
50 counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
51}
52
53impl SeverityBuckets {
54 fn inc(&mut self, sev: DiagnosticSeverity) {
56 let idx = sev as usize;
57 if let Some(slot) = self.counts.get_mut(idx) {
58 *slot = slot.saturating_add(1);
59 }
60 }
61
62 fn get(&self, sev: DiagnosticSeverity) -> u16 {
64 let idx = sev as usize;
65 self.counts.get(idx).copied().unwrap_or(0)
66 }
67
68 fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
70 DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
71 }
72
73 fn approx_render_len(&self) -> usize {
75 let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
76 non_zero.saturating_mul(32)
79 }
80}
81
82impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
84 fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
85 let mut buckets = Self::default();
86 for (sev, count) in iter {
87 let idx = sev as usize;
88 if let Some(slot) = buckets.counts.get_mut(idx) {
89 *slot = count; }
91 }
92 buckets
93 }
94}
95
96#[derive(Debug)]
98struct Statusline<'a> {
99 current_buffer_path: Option<&'a str>,
100 current_buffer_diags: SeverityBuckets,
101 workspace_diags: SeverityBuckets,
102 cursor_position: Option<CursorPosition>,
103}
104
105impl Statusline<'_> {
106 fn draw(&self) -> String {
108 let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
111 let mut wrote_any = false;
112 for (sev, count) in self.current_buffer_diags.iter() {
113 if count == 0 {
114 continue;
115 }
116 if wrote_any {
117 current_buffer_diags_segment.push(' ');
118 }
119 write_diagnostics(&mut current_buffer_diags_segment, sev, count);
121 wrote_any = true;
122 }
123 if wrote_any {
124 current_buffer_diags_segment.push(' '); }
126
127 let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
129 let mut first = true;
130 for (sev, count) in self.workspace_diags.iter() {
131 if count == 0 {
132 continue;
133 }
134 if !first {
135 workspace_diags_segment.push(' ');
136 }
137 write_diagnostics(&mut workspace_diags_segment, sev, count);
139 first = false;
140 }
141
142 let estimated_len = workspace_diags_segment
145 .len()
146 .saturating_add(current_buffer_diags_segment.len())
147 .saturating_add(self.current_buffer_path.map_or(0, str::len))
148 .saturating_add(40);
149 let mut out = String::with_capacity(estimated_len);
150 let _ = write!(out, "{workspace_diags_segment}%#StatusLine# ");
151 if let Some(buf_path) = self.current_buffer_path {
152 let _ = write!(out, "{buf_path} ");
153 }
154 if let Some(ref pos) = self.cursor_position {
155 let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
156 }
157 let _ = write!(out, "{current_buffer_diags_segment}%#StatusLine#");
158 out
159 }
160}
161
162fn draw(diagnostics: Vec<Diagnostic>) -> String {
164 let current_buffer = nvim_oxi::api::get_current_buf();
165 let current_buffer_nr = current_buffer.handle();
166
167 if current_buffer.is_terminal() {
171 return "%#Normal#".to_string();
172 }
173
174 let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
177 let cached = cache.borrow();
178 if let Some((handle, ref path)) = *cached
179 && handle == current_buffer_nr
180 {
181 return path.clone();
182 }
183 drop(cached);
184 let path = ytil_noxi::buffer::get_relative_path_to_cwd(¤t_buffer).map(|x| x.display().to_string());
185 *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
186 path
187 });
188
189 let cursor_position = CursorPosition::get_current();
190
191 let mut statusline = Statusline {
192 current_buffer_path: current_buffer_path.as_deref(),
193 current_buffer_diags: SeverityBuckets::default(),
194 workspace_diags: SeverityBuckets::default(),
195 cursor_position,
196 };
197 for diagnostic in diagnostics {
198 statusline.workspace_diags.inc(diagnostic.severity);
199 if current_buffer_nr == diagnostic.bufnr {
200 statusline.current_buffer_diags.inc(diagnostic.severity);
201 }
202 }
203
204 statusline.draw()
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 test_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 test_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 test_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 test_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 test_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 test_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 test_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 test_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}