1use nvim_oxi::Dictionary;
8use nvim_oxi::Object;
9use nvim_oxi::conversion::FromObject;
10use nvim_oxi::lua::Poppable;
11use nvim_oxi::lua::ffi::State;
12use nvim_oxi::serde::Deserializer;
13use serde::Deserialize;
14use strum::IntoEnumIterator;
15use ytil_noxi::buffer::CursorPosition;
16
17use crate::diagnostics::DiagnosticSeverity;
18
19const DRAW_TRIGGERS: &[&str] = &["DiagnosticChanged", "BufEnter", "CursorMoved"];
20
21pub fn dict() -> Dictionary {
23 dict! {
24 "draw": fn_from!(draw),
25 "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
26 }
27}
28
29fn draw(diagnostics: Vec<Diagnostic>) -> Option<String> {
35 let current_buffer = nvim_oxi::api::get_current_buf();
36 let current_buffer_path =
37 ytil_noxi::buffer::get_relative_path_to_cwd(¤t_buffer).map(|x| x.display().to_string());
38
39 let current_buffer_nr = current_buffer.handle();
40 let mut statusline = Statusline {
41 current_buffer_path: current_buffer_path.as_deref(),
42 current_buffer_diags: SeverityBuckets::default(),
43 workspace_diags: SeverityBuckets::default(),
44 cursor_position: CursorPosition::get_current()?,
45 };
46 for diagnostic in diagnostics {
47 statusline.workspace_diags.inc(diagnostic.severity);
48 if current_buffer_nr == diagnostic.bufnr {
49 statusline.current_buffer_diags.inc(diagnostic.severity);
50 }
51 }
52
53 Some(statusline.draw())
54}
55
56#[derive(Deserialize)]
63pub struct Diagnostic {
64 bufnr: i32,
66 severity: DiagnosticSeverity,
68}
69
70impl FromObject for Diagnostic {
72 fn from_object(obj: Object) -> Result<Self, nvim_oxi::conversion::Error> {
73 Self::deserialize(Deserializer::new(obj)).map_err(Into::into)
74 }
75}
76
77impl Poppable for Diagnostic {
79 unsafe fn pop(lstate: *mut State) -> Result<Self, nvim_oxi::lua::Error> {
80 unsafe {
81 let obj = Object::pop(lstate)?;
82 Self::from_object(obj).map_err(nvim_oxi::lua::Error::pop_error_from_err::<Self, _>)
83 }
84 }
85}
86
87#[derive(Clone, Copy, Debug, Default)]
92struct SeverityBuckets {
93 counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
94}
95
96impl SeverityBuckets {
97 fn inc(&mut self, sev: DiagnosticSeverity) {
99 let idx = sev as usize;
100 if let Some(slot) = self.counts.get_mut(idx) {
101 *slot = slot.saturating_add(1);
102 }
103 }
104
105 fn get(&self, sev: DiagnosticSeverity) -> u16 {
107 let idx = sev as usize;
108 self.counts.get(idx).copied().unwrap_or(0)
109 }
110
111 fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
113 DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
114 }
115
116 fn approx_render_len(&self) -> usize {
118 let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
119 non_zero.saturating_mul(32)
122 }
123}
124
125impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
127 fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
128 let mut buckets = Self::default();
129 for (sev, count) in iter {
130 let idx = sev as usize;
131 if let Some(slot) = buckets.counts.get_mut(idx) {
132 *slot = count; }
134 }
135 buckets
136 }
137}
138
139#[derive(Debug)]
141struct Statusline<'a> {
142 current_buffer_path: Option<&'a str>,
144 current_buffer_diags: SeverityBuckets,
146 workspace_diags: SeverityBuckets,
148 cursor_position: CursorPosition,
150}
151
152impl Statusline<'_> {
153 fn draw(&self) -> String {
162 let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
165 let mut wrote_any = false;
166 for (sev, count) in self.current_buffer_diags.iter() {
167 if count == 0 {
168 continue;
169 }
170 if wrote_any {
171 current_buffer_diags_segment.push(' ');
172 }
173 current_buffer_diags_segment.push_str(&draw_diagnostics((sev, count)));
174 wrote_any = true;
175 }
176 if wrote_any {
177 current_buffer_diags_segment.push(' '); }
179
180 let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
182 let mut first = true;
183 for (sev, count) in self.workspace_diags.iter() {
184 if count == 0 {
185 continue;
186 }
187 if !first {
188 workspace_diags_segment.push(' ');
189 }
190 workspace_diags_segment.push_str(&draw_diagnostics((sev, count)));
191 first = false;
192 }
193
194 let current_buffer_path_segment = self
195 .current_buffer_path
196 .map(|buf_path| format!("{buf_path} "))
197 .unwrap_or_default();
198
199 format!(
200 "{workspace_diags_segment}%#StatusLine# {current_buffer_path_segment}{}:{} {current_buffer_diags_segment}%#StatusLine#",
201 self.cursor_position.row,
202 self.cursor_position.adjusted_col()
203 )
204 }
205}
206
207fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
216 if diags_count == 0 {
217 return String::new();
218 }
219 let hg_group_dyn_part = match severity {
220 DiagnosticSeverity::Error => "Error",
221 DiagnosticSeverity::Warn => "Warn",
222 DiagnosticSeverity::Info => "Info",
223 DiagnosticSeverity::Hint | DiagnosticSeverity::Other => "Hint",
224 };
225 format!("%#DiagnosticStatusLine{hg_group_dyn_part}#{diags_count}")
226}
227
228#[cfg(test)]
229mod tests {
230 use rstest::rstest;
231
232 use super::*;
233
234 #[rstest]
235 #[case::default_diags(Statusline {
236 current_buffer_path: Some("foo"),
237 current_buffer_diags: SeverityBuckets::default(),
238 workspace_diags: SeverityBuckets::default(),
239 cursor_position: CursorPosition { row: 42, col: 7 },
240 })]
241 #[case::buffer_zero(Statusline {
242 current_buffer_path: Some("foo"),
243 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
244 workspace_diags: SeverityBuckets::default(),
245 cursor_position: CursorPosition { row: 42, col: 7 },
246 })]
247 #[case::workspace_zero(Statusline {
248 current_buffer_path: Some("foo"),
249 current_buffer_diags: SeverityBuckets::default(),
250 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
251 cursor_position: CursorPosition { row: 42, col: 7 },
252 })]
253 #[case::both_zero(Statusline {
254 current_buffer_path: Some("foo"),
255 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
256 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
257 cursor_position: CursorPosition { row: 42, col: 7 },
258 })]
259 fn statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(#[case] statusline: Statusline) {
260 pretty_assertions::assert_eq!(statusline.draw(), "%#StatusLine# foo 42:8 %#StatusLine#");
261 }
262
263 #[test]
264 fn statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_prefix() {
265 let statusline = Statusline {
266 current_buffer_path: Some("foo"),
267 current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
268 .into_iter()
269 .collect(),
270 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
271 cursor_position: CursorPosition { row: 42, col: 7 },
272 };
273 pretty_assertions::assert_eq!(
274 statusline.draw(),
275 "%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1 %#StatusLine#",
276 );
277 }
278
279 #[test]
280 fn statusline_draw_when_workspace_has_diagnostics_renders_workspace_suffix() {
281 let statusline = Statusline {
282 current_buffer_path: Some("foo"),
283 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
284 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
285 .into_iter()
286 .collect(),
287 cursor_position: CursorPosition { row: 42, col: 7 },
288 };
289 pretty_assertions::assert_eq!(
290 statusline.draw(),
291 "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#StatusLine#",
292 );
293 }
294
295 #[test]
296 fn statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_both_prefix_and_suffix() {
297 let statusline = Statusline {
298 current_buffer_path: Some("foo"),
299 current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
300 .into_iter()
301 .collect(),
302 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
303 .into_iter()
304 .collect(), cursor_position: CursorPosition { row: 42, col: 7 },
306 };
307 pretty_assertions::assert_eq!(
308 statusline.draw(),
309 "%#DiagnosticStatusLineError#3 %#DiagnosticStatusLineInfo#1%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#2 %#DiagnosticStatusLineHint#3 %#StatusLine#",
310 );
311 }
312
313 #[test]
314 fn statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
315 let statusline = Statusline {
317 current_buffer_path: Some("foo"),
318 current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
319 .into_iter()
320 .collect(), workspace_diags: SeverityBuckets::default(),
322 cursor_position: CursorPosition { row: 42, col: 7 },
323 };
324 pretty_assertions::assert_eq!(
325 statusline.draw(),
326 "%#StatusLine# foo 42:8 %#DiagnosticStatusLineWarn#1 %#DiagnosticStatusLineHint#5 %#StatusLine#",
327 );
328 }
329
330 #[rstest]
331 #[case::error(DiagnosticSeverity::Error)]
332 #[case::warn(DiagnosticSeverity::Warn)]
333 #[case::info(DiagnosticSeverity::Info)]
334 #[case::hint(DiagnosticSeverity::Hint)]
335 #[case::other(DiagnosticSeverity::Other)]
336 fn draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
337 pretty_assertions::assert_eq!(draw_diagnostics((severity, 0)), String::new());
339 }
340
341 #[test]
342 fn statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
343 let statusline = Statusline {
345 current_buffer_path: Some("foo"),
346 current_buffer_diags: [
347 (DiagnosticSeverity::Hint, 1),
348 (DiagnosticSeverity::Error, 4),
349 (DiagnosticSeverity::Info, 2),
350 (DiagnosticSeverity::Warn, 3),
351 ]
352 .into_iter()
353 .collect(),
354 workspace_diags: [
355 (DiagnosticSeverity::Warn, 7),
356 (DiagnosticSeverity::Info, 6),
357 (DiagnosticSeverity::Hint, 5),
358 (DiagnosticSeverity::Error, 8),
359 ]
360 .into_iter()
361 .collect(),
362 cursor_position: CursorPosition { row: 42, col: 7 },
363 };
364 pretty_assertions::assert_eq!(
366 statusline.draw(),
367 "%#DiagnosticStatusLineError#8 %#DiagnosticStatusLineWarn#7 %#DiagnosticStatusLineInfo#6 %#DiagnosticStatusLineHint#5%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#4 %#DiagnosticStatusLineWarn#3 %#DiagnosticStatusLineInfo#2 %#DiagnosticStatusLineHint#1 %#StatusLine#",
368 );
369 }
370
371 #[rstest]
372 #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
373 #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
374 fn statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
375 let statusline = Statusline {
378 current_buffer_path: Some("foo"),
379 current_buffer_diags: SeverityBuckets::default(),
380 workspace_diags: SeverityBuckets::default(),
381 cursor_position: CursorPosition { row: 10, col },
382 };
383 pretty_assertions::assert_eq!(statusline.draw(), expected);
384 }
385}