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 use test_that::prelude::*;
235
236 use super::*;
237
238 #[rstest]
239 #[case::default_diags(Statusline {
240 current_buffer_path: Some("foo"),
241 current_buffer_diags: SeverityBuckets::default(),
242 workspace_diags: SeverityBuckets::default(),
243 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
244 })]
245 #[case::buffer_zero(Statusline {
246 current_buffer_path: Some("foo"),
247 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
248 workspace_diags: SeverityBuckets::default(),
249 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
250 })]
251 #[case::workspace_zero(Statusline {
252 current_buffer_path: Some("foo"),
253 current_buffer_diags: SeverityBuckets::default(),
254 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
255 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
256 })]
257 #[case::both_zero(Statusline {
258 current_buffer_path: Some("foo"),
259 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
260 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
261 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
262 })]
263 fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
264 assert_that!(statusline.draw(), eq("%#StatusLine# foo 42:8 %#StatusLine#"));
265 }
266
267 #[test]
268 fn test_statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
269 let statusline = Statusline {
270 current_buffer_path: Some("foo"),
271 current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
272 .into_iter()
273 .collect(),
274 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
275 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
276 };
277 assert_that!(
278 statusline.draw(),
279 eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#")
280 );
281 }
282
283 #[test]
284 fn test_statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
285 let statusline = Statusline {
286 current_buffer_path: Some("foo"),
287 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
288 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
289 .into_iter()
290 .collect(),
291 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
292 };
293 assert_that!(
294 statusline.draw(),
295 eq("%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#")
296 );
297 }
298
299 #[test]
300 fn test_statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
301 let statusline = Statusline {
302 current_buffer_path: Some("foo"),
303 current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
304 .into_iter()
305 .collect(),
306 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
307 .into_iter()
308 .collect(), cursor_position: Some(CursorPosition { row: 42, col: 7 }),
310 };
311 assert_that!(
312 statusline.draw(),
313 eq(
314 "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#"
315 )
316 );
317 }
318
319 #[test]
320 fn test_statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
321 let statusline = Statusline {
323 current_buffer_path: Some("foo"),
324 current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
325 .into_iter()
326 .collect(), workspace_diags: SeverityBuckets::default(),
328 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
329 };
330 assert_that!(
331 statusline.draw(),
332 eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#")
333 );
334 }
335
336 #[rstest]
337 #[case::error(DiagnosticSeverity::Error)]
338 #[case::warn(DiagnosticSeverity::Warn)]
339 #[case::info(DiagnosticSeverity::Info)]
340 #[case::hint(DiagnosticSeverity::Hint)]
341 #[case::other(DiagnosticSeverity::Other)]
342 fn test_draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
343 assert_that!(draw_diagnostics((severity, 0)), eq(String::new()));
345 }
346
347 #[test]
348 fn test_statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
349 let statusline = Statusline {
351 current_buffer_path: Some("foo"),
352 current_buffer_diags: [
353 (DiagnosticSeverity::Hint, 1),
354 (DiagnosticSeverity::Error, 4),
355 (DiagnosticSeverity::Info, 2),
356 (DiagnosticSeverity::Warn, 3),
357 ]
358 .into_iter()
359 .collect(),
360 workspace_diags: [
361 (DiagnosticSeverity::Warn, 7),
362 (DiagnosticSeverity::Info, 6),
363 (DiagnosticSeverity::Hint, 5),
364 (DiagnosticSeverity::Error, 8),
365 ]
366 .into_iter()
367 .collect(),
368 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
369 };
370 assert_that!(
372 statusline.draw(),
373 eq(
374 "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#"
375 )
376 );
377 }
378
379 #[test]
380 fn test_statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
381 let statusline = Statusline {
383 current_buffer_path: None,
384 current_buffer_diags: SeverityBuckets::default(),
385 workspace_diags: SeverityBuckets::default(),
386 cursor_position: None,
387 };
388 assert_that!(statusline.draw(), eq("%#StatusLine# %#StatusLine#"));
389 }
390
391 #[rstest]
392 #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
393 #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
394 fn test_statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
395 let statusline = Statusline {
398 current_buffer_path: Some("foo"),
399 current_buffer_diags: SeverityBuckets::default(),
400 workspace_diags: SeverityBuckets::default(),
401 cursor_position: Some(CursorPosition { row: 10, col }),
402 };
403 assert_that!(statusline.draw(), eq(expected));
404 }
405}