1use std::cell::RefCell;
4use std::fmt::Write;
5use std::path::Path;
6use std::path::PathBuf;
7
8use nvim_oxi::Array;
9use nvim_oxi::Dictionary;
10use nvim_oxi::Object;
11use nvim_oxi::api::Buffer;
12use serde::Deserialize;
13use strum::IntoEnumIterator;
14use ytil_noxi::buffer::BufferExt;
15use ytil_noxi::buffer::CursorPosition;
16
17use crate::diagnostics::DiagnosticSeverity;
18
19const DRAW_TRIGGERS: &[&str] = &[
20 "BufEnter",
21 "BufFilePost",
22 "BufWritePost",
23 "CursorMoved",
24 "DiagnosticChanged",
25 "DirChanged",
26 "FocusGained",
27 "ShellCmdPost",
28 "VimResume",
29];
30const GIT_ADDED_HIGHLIGHT: &str = "Added";
31const GIT_REMOVED_HIGHLIGHT: &str = "Removed";
32
33#[derive(Deserialize)]
35pub struct Diagnostic {
36 bufnr: i32,
38 severity: DiagnosticSeverity,
40}
41
42ytil_noxi::impl_nvim_deserializable!(Diagnostic);
43
44pub fn dict() -> Dictionary {
50 dict! {
51 "draw": fn_from!(draw),
52 "invalidate_git_stats": fn_from!(invalidate_git_stats),
53 "draw_triggers": DRAW_TRIGGERS.iter().map(ToString::to_string).collect::<Object>()
54 }
55}
56
57thread_local! {
58 static CACHED_BUFFER_PATH: RefCell<Option<(i32, Option<String>)>> = const { RefCell::new(None) };
62
63 static CACHED_GIT_STATS: RefCell<Option<CachedGitStats>> = const { RefCell::new(None) };
65}
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
69struct GitLineStats {
70 added: usize,
71 removed: usize,
72}
73
74impl GitLineStats {
75 const fn add_file(&mut self, file_stats: &ytil_git::diff::FileDiffStats) {
77 self.added = self.added.saturating_add(file_stats.added);
78 self.removed = self.removed.saturating_add(file_stats.removed);
79 }
80
81 const fn is_empty(self) -> bool {
83 self.added == 0 && self.removed == 0
84 }
85
86 fn write_to(self, target: &mut String, prepend_space: bool) {
88 if self.is_empty() {
89 return;
90 }
91
92 if self.added > 0 {
93 if prepend_space {
94 target.push(' ');
95 }
96 let _ = write!(target, "%#{GIT_ADDED_HIGHLIGHT}#+{}", self.added);
97 } else if prepend_space {
98 target.push(' ');
99 }
100
101 if self.removed > 0 {
102 if self.added > 0 {
103 target.push(' ');
104 }
105 let _ = write!(target, "%#{GIT_REMOVED_HIGHLIGHT}#-{}", self.removed);
106 }
107 }
108}
109
110#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
112struct GitStats {
113 workspace: GitLineStats,
114 current_buffer: GitLineStats,
115}
116
117impl GitStats {
118 fn from_file_stats<I>(file_stats: I, current_buffer_path: Option<&Path>) -> Self
120 where
121 I: IntoIterator<Item = ytil_git::diff::FileDiffStats>,
122 {
123 let mut stats = Self::default();
124 for file_stats in file_stats {
125 stats.workspace.add_file(&file_stats);
126 if current_buffer_path.is_some_and(|path| file_stats.path == path) {
127 stats.current_buffer.add_file(&file_stats);
128 }
129 }
130 stats
131 }
132}
133
134#[derive(Debug)]
136struct CachedGitStats {
137 buffer_nr: i32,
138 discovery_path: PathBuf,
139 stats: GitStats,
140}
141
142#[derive(Clone, Copy, Debug, Default)]
144struct SeverityBuckets {
145 counts: [u16; DiagnosticSeverity::VARIANT_COUNT],
146}
147
148impl SeverityBuckets {
149 fn inc(&mut self, sev: DiagnosticSeverity) {
151 let idx = sev as usize;
152 if let Some(slot) = self.counts.get_mut(idx) {
153 *slot = slot.saturating_add(1);
154 }
155 }
156
157 fn get(&self, sev: DiagnosticSeverity) -> u16 {
159 let idx = sev as usize;
160 self.counts.get(idx).copied().unwrap_or(0)
161 }
162
163 fn iter(&self) -> impl Iterator<Item = (DiagnosticSeverity, u16)> + '_ {
165 DiagnosticSeverity::iter().map(|s| (s, self.get(s)))
166 }
167
168 fn approx_render_len(&self) -> usize {
170 let non_zero = self.counts.iter().filter(|&&c| c > 0).count();
171 non_zero.saturating_mul(32)
174 }
175}
176
177impl FromIterator<(DiagnosticSeverity, u16)> for SeverityBuckets {
179 fn from_iter<T: IntoIterator<Item = (DiagnosticSeverity, u16)>>(iter: T) -> Self {
180 let mut buckets = Self::default();
181 for (sev, count) in iter {
182 let idx = sev as usize;
183 if let Some(slot) = buckets.counts.get_mut(idx) {
184 *slot = count; }
186 }
187 buckets
188 }
189}
190
191#[derive(Debug)]
193struct Statusline<'a> {
194 current_buffer_path: Option<&'a str>,
195 current_buffer_diags: SeverityBuckets,
196 workspace_diags: SeverityBuckets,
197 git_stats: GitStats,
198 cursor_position: Option<CursorPosition>,
199}
200
201impl Statusline<'_> {
202 fn draw(&self) -> String {
204 let mut current_buffer_diags_segment = String::with_capacity(self.current_buffer_diags.approx_render_len());
207 let mut wrote_any = false;
208 for (sev, count) in self.current_buffer_diags.iter() {
209 if count == 0 {
210 continue;
211 }
212 if wrote_any {
213 current_buffer_diags_segment.push(' ');
214 }
215 write_diagnostics(&mut current_buffer_diags_segment, sev, count);
217 wrote_any = true;
218 }
219 if wrote_any {
220 current_buffer_diags_segment.push(' '); }
222
223 let mut workspace_diags_segment = String::with_capacity(self.workspace_diags.approx_render_len());
225 let mut first = true;
226 for (sev, count) in self.workspace_diags.iter() {
227 if count == 0 {
228 continue;
229 }
230 if !first {
231 workspace_diags_segment.push(' ');
232 }
233 write_diagnostics(&mut workspace_diags_segment, sev, count);
235 first = false;
236 }
237
238 let estimated_len = workspace_diags_segment
241 .len()
242 .saturating_add(current_buffer_diags_segment.len())
243 .saturating_add(self.current_buffer_path.map_or(0, str::len))
244 .saturating_add(40);
245 let mut out = String::with_capacity(estimated_len);
246 out.push_str(¤t_buffer_diags_segment);
247 self.git_stats.current_buffer.write_to(&mut out, false);
248 let _ = write!(out, "%#StatusLine# ");
249 if let Some(buf_path) = self.current_buffer_path {
250 let _ = write!(out, "{buf_path} ");
251 }
252 if let Some(ref pos) = self.cursor_position {
253 let _ = write!(out, "{}:{} ", pos.row, pos.adjusted_col());
254 }
255 out.push_str(&workspace_diags_segment);
256 self.git_stats
257 .workspace
258 .write_to(&mut out, !workspace_diags_segment.is_empty());
259 let _ = write!(out, "%#StatusLine#");
260 out
261 }
262}
263
264fn invalidate_git_stats(_: ()) {
266 CACHED_GIT_STATS.with(|cache| *cache.borrow_mut() = None);
267}
268
269fn get_git_stats(current_buffer: &Buffer, current_buffer_nr: i32) -> GitStats {
271 let current_buffer_path = ytil_noxi::buffer::get_absolute_path(Some(current_buffer));
272 let Some(discovery_path) = current_buffer_path.clone().or_else(get_current_working_directory) else {
273 return GitStats::default();
274 };
275
276 let cached_stats = CACHED_GIT_STATS.with(|cache| {
277 let cache = cache.borrow();
278 cache
279 .as_ref()
280 .filter(|cached| cached.buffer_nr == current_buffer_nr && cached.discovery_path == discovery_path)
281 .map(|cached| cached.stats)
282 });
283 if let Some(cached_stats) = cached_stats {
284 return cached_stats;
285 }
286
287 let Ok(repo) = ytil_git::repo::discover(&discovery_path) else {
288 return GitStats::default();
289 };
290 let repo_root = ytil_git::repo::get_root(&repo);
291 let Ok(relative_buffer_path) = current_buffer_path
292 .as_deref()
293 .map(|path| path.strip_prefix(&repo_root).map(Path::to_path_buf))
294 .transpose()
295 else {
296 return GitStats::default();
297 };
298
299 let stats = ytil_git::diff::get_line_stats(&repo_root).map_or_else(
300 |_| GitStats::default(),
301 |file_stats| GitStats::from_file_stats(file_stats, relative_buffer_path.as_deref()),
302 );
303 CACHED_GIT_STATS.with(|cache| {
304 *cache.borrow_mut() = Some(CachedGitStats {
305 buffer_nr: current_buffer_nr,
306 discovery_path,
307 stats,
308 });
309 });
310 stats
311}
312
313fn get_current_working_directory() -> Option<PathBuf> {
315 nvim_oxi::api::call_function::<_, String>("getcwd", Array::new())
316 .ok()
317 .map(PathBuf::from)
318}
319
320fn draw(diagnostics: Vec<Diagnostic>) -> String {
322 let current_buffer = nvim_oxi::api::get_current_buf();
323 let current_buffer_nr = current_buffer.handle();
324
325 if current_buffer.is_terminal() {
329 return "%#Normal#".to_string();
330 }
331
332 let current_buffer_path = CACHED_BUFFER_PATH.with(|cache| {
335 let cached = cache.borrow();
336 if let Some((handle, ref path)) = *cached
337 && handle == current_buffer_nr
338 {
339 return path.clone();
340 }
341 drop(cached);
342 let path = ytil_noxi::buffer::get_relative_path_to_cwd(¤t_buffer).map(|x| x.display().to_string());
343 *cache.borrow_mut() = Some((current_buffer_nr, path.clone()));
344 path
345 });
346
347 let cursor_position = CursorPosition::get_current();
348 let git_stats = get_git_stats(¤t_buffer, current_buffer_nr);
349
350 let mut statusline = Statusline {
351 current_buffer_path: current_buffer_path.as_deref(),
352 current_buffer_diags: SeverityBuckets::default(),
353 workspace_diags: SeverityBuckets::default(),
354 git_stats,
355 cursor_position,
356 };
357 for diagnostic in diagnostics {
358 statusline.workspace_diags.inc(diagnostic.severity);
359 if current_buffer_nr == diagnostic.bufnr {
360 statusline.current_buffer_diags.inc(diagnostic.severity);
361 }
362 }
363
364 statusline.draw()
365}
366
367fn write_diagnostics(target: &mut String, severity: DiagnosticSeverity, diags_count: u16) {
369 if diags_count == 0 {
370 return;
371 }
372 let (hg_group_dyn_part, severity_label) = match severity {
373 DiagnosticSeverity::Error => ("Error", "E"),
374 DiagnosticSeverity::Warn => ("Warn", "W"),
375 DiagnosticSeverity::Info => ("Info", "I"),
376 DiagnosticSeverity::Hint | DiagnosticSeverity::Other => ("Hint", "H"),
377 };
378 let _ = write!(
380 target,
381 "%#DiagnosticStatusLine{hg_group_dyn_part}#{severity_label}:{diags_count}"
382 );
383}
384
385#[cfg(test)]
388fn draw_diagnostics((severity, diags_count): (DiagnosticSeverity, u16)) -> String {
389 let mut out = String::new();
390 write_diagnostics(&mut out, severity, diags_count);
391 out
392}
393
394#[cfg(test)]
395mod tests {
396 use rstest::rstest;
397 use test_that::prelude::*;
398
399 use super::*;
400
401 #[rstest]
402 #[case::default_diags(Statusline {
403 current_buffer_path: Some("foo"),
404 current_buffer_diags: SeverityBuckets::default(),
405 workspace_diags: SeverityBuckets::default(),
406 git_stats: GitStats::default(),
407 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
408 })]
409 #[case::buffer_zero(Statusline {
410 current_buffer_path: Some("foo"),
411 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
412 workspace_diags: SeverityBuckets::default(),
413 git_stats: GitStats::default(),
414 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
415 })]
416 #[case::workspace_zero(Statusline {
417 current_buffer_path: Some("foo"),
418 current_buffer_diags: SeverityBuckets::default(),
419 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
420 git_stats: GitStats::default(),
421 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
422 })]
423 #[case::both_zero(Statusline {
424 current_buffer_path: Some("foo"),
425 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
426 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
427 git_stats: GitStats::default(),
428 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
429 })]
430 fn test_statusline_draw_when_all_diagnostics_absent_or_zero_renders_plain_statusline(
431 #[case] statusline: Statusline,
432 ) {
433 assert_that!(statusline.draw(), eq("%#StatusLine# foo 42:8 %#StatusLine#"));
434 }
435
436 #[test]
437 fn test_statusline_draw_when_current_buffer_has_diagnostics_renders_buffer_group_before_path() {
438 let statusline = Statusline {
439 current_buffer_path: Some("foo"),
440 current_buffer_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
441 .into_iter()
442 .collect(),
443 workspace_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
444 git_stats: GitStats::default(),
445 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
446 };
447 assert_that!(
448 statusline.draw(),
449 eq("%#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1 %#StatusLine# foo 42:8 %#StatusLine#")
450 );
451 }
452
453 #[test]
454 fn test_statusline_draw_when_workspace_has_diagnostics_renders_workspace_group_after_path() {
455 let statusline = Statusline {
456 current_buffer_path: Some("foo"),
457 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 0)).collect(),
458 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
459 .into_iter()
460 .collect(),
461 git_stats: GitStats::default(),
462 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
463 };
464 assert_that!(
465 statusline.draw(),
466 eq("%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1%#StatusLine#")
467 );
468 }
469
470 #[test]
471 fn test_statusline_draw_when_both_buffer_and_workspace_have_diagnostics_renders_separate_groups() {
472 let statusline = Statusline {
473 current_buffer_path: Some("foo"),
474 current_buffer_diags: [(DiagnosticSeverity::Hint, 3), (DiagnosticSeverity::Warn, 2)]
475 .into_iter()
476 .collect(),
477 workspace_diags: [(DiagnosticSeverity::Info, 1), (DiagnosticSeverity::Error, 3)]
478 .into_iter()
479 .collect(), git_stats: GitStats::default(),
481 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
482 };
483 assert_that!(
484 statusline.draw(),
485 eq(
486 "%#DiagnosticStatusLineWarn#W:2 %#DiagnosticStatusLineHint#H:3 %#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:3 %#DiagnosticStatusLineInfo#I:1%#StatusLine#"
487 )
488 );
489 }
490
491 #[test]
492 fn test_statusline_draw_when_git_stats_exist_renders_separate_buffer_and_workspace_groups() {
493 let statusline = Statusline {
494 current_buffer_path: Some("foo"),
495 current_buffer_diags: std::iter::once((DiagnosticSeverity::Info, 1)).collect(),
496 workspace_diags: std::iter::once((DiagnosticSeverity::Error, 2)).collect(),
497 git_stats: GitStats {
498 workspace: GitLineStats { added: 12, removed: 4 },
499 current_buffer: GitLineStats { added: 3, removed: 1 },
500 },
501 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
502 };
503
504 assert_that!(
505 statusline.draw(),
506 eq(
507 "%#DiagnosticStatusLineInfo#I:1 %#Added#+3 %#Removed#-1%#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:2 %#Added#+12 %#Removed#-4%#StatusLine#"
508 )
509 );
510 }
511
512 #[rstest]
513 #[case::added_only(GitLineStats { added: 3, removed: 0 }, "%#Added#+3")]
514 #[case::removed_only(GitLineStats { added: 0, removed: 2 }, "%#Removed#-2")]
515 #[case::no_changes(GitLineStats::default(), "")]
516 fn test_git_line_stats_write_to_when_counts_vary_renders_expected_metrics(
517 #[case] stats: GitLineStats,
518 #[case] expected: &str,
519 ) {
520 let mut output = String::new();
521 stats.write_to(&mut output, false);
522 assert_that!(output, eq(expected));
523 }
524
525 #[test]
526 fn test_git_stats_from_file_stats_when_multiple_files_exist_aggregates_workspace_and_selects_buffer() {
527 let stats = GitStats::from_file_stats(
528 vec![
529 ytil_git::diff::FileDiffStats {
530 path: "src/main.rs".into(),
531 added: 3,
532 removed: 1,
533 },
534 ytil_git::diff::FileDiffStats {
535 path: "src/lib.rs".into(),
536 added: 5,
537 removed: 2,
538 },
539 ],
540 Some(Path::new("src/main.rs")),
541 );
542
543 assert_eq!(
544 stats,
545 GitStats {
546 workspace: GitLineStats { added: 8, removed: 3 },
547 current_buffer: GitLineStats { added: 3, removed: 1 },
548 }
549 );
550 }
551
552 #[test]
553 fn test_git_stats_from_file_stats_when_buffer_path_is_missing_keeps_only_workspace_stats() {
554 let stats = GitStats::from_file_stats(
555 vec![ytil_git::diff::FileDiffStats {
556 path: "src/main.rs".into(),
557 added: 3,
558 removed: 1,
559 }],
560 None,
561 );
562
563 assert_eq!(
564 stats,
565 GitStats {
566 workspace: GitLineStats { added: 3, removed: 1 },
567 current_buffer: GitLineStats::default(),
568 }
569 );
570 }
571
572 #[test]
573 fn test_statusline_draw_when_buffer_diagnostics_inserted_unordered_orders_by_severity() {
574 let statusline = Statusline {
576 current_buffer_path: Some("foo"),
577 current_buffer_diags: [(DiagnosticSeverity::Hint, 5), (DiagnosticSeverity::Warn, 1)]
578 .into_iter()
579 .collect(), workspace_diags: SeverityBuckets::default(),
581 git_stats: GitStats::default(),
582 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
583 };
584 assert_that!(
585 statusline.draw(),
586 eq("%#DiagnosticStatusLineWarn#W:1 %#DiagnosticStatusLineHint#H:5 %#StatusLine# foo 42:8 %#StatusLine#")
587 );
588 }
589
590 #[rstest]
591 #[case::error(DiagnosticSeverity::Error)]
592 #[case::warn(DiagnosticSeverity::Warn)]
593 #[case::info(DiagnosticSeverity::Info)]
594 #[case::hint(DiagnosticSeverity::Hint)]
595 #[case::other(DiagnosticSeverity::Other)]
596 fn test_draw_diagnostics_when_zero_count_returns_empty_string(#[case] severity: DiagnosticSeverity) {
597 assert_that!(draw_diagnostics((severity, 0)), eq(String::new()));
599 }
600
601 #[test]
602 fn test_statusline_draw_when_all_severity_counts_present_orders_buffer_and_workspace_diagnostics_by_severity() {
603 let statusline = Statusline {
605 current_buffer_path: Some("foo"),
606 current_buffer_diags: [
607 (DiagnosticSeverity::Hint, 1),
608 (DiagnosticSeverity::Error, 4),
609 (DiagnosticSeverity::Info, 2),
610 (DiagnosticSeverity::Warn, 3),
611 ]
612 .into_iter()
613 .collect(),
614 workspace_diags: [
615 (DiagnosticSeverity::Warn, 7),
616 (DiagnosticSeverity::Info, 6),
617 (DiagnosticSeverity::Hint, 5),
618 (DiagnosticSeverity::Error, 8),
619 ]
620 .into_iter()
621 .collect(),
622 git_stats: GitStats::default(),
623 cursor_position: Some(CursorPosition { row: 42, col: 7 }),
624 };
625 assert_that!(
627 statusline.draw(),
628 eq(
629 "%#DiagnosticStatusLineError#E:4 %#DiagnosticStatusLineWarn#W:3 %#DiagnosticStatusLineInfo#I:2 %#DiagnosticStatusLineHint#H:1 %#StatusLine# foo 42:8 %#DiagnosticStatusLineError#E:8 %#DiagnosticStatusLineWarn#W:7 %#DiagnosticStatusLineInfo#I:6 %#DiagnosticStatusLineHint#H:5%#StatusLine#"
630 )
631 );
632 }
633
634 #[test]
635 fn test_statusline_draw_when_no_path_and_no_cursor_renders_only_highlight_groups() {
636 let statusline = Statusline {
638 current_buffer_path: None,
639 current_buffer_diags: SeverityBuckets::default(),
640 workspace_diags: SeverityBuckets::default(),
641 git_stats: GitStats::default(),
642 cursor_position: None,
643 };
644 assert_that!(statusline.draw(), eq("%#StatusLine# %#StatusLine#"));
645 }
646
647 #[rstest]
648 #[case::zero_column(0, "%#StatusLine# foo 10:1 %#StatusLine#")]
649 #[case::non_zero_column(5, "%#StatusLine# foo 10:6 %#StatusLine#")]
650 fn test_statusline_draw_when_cursor_column_renders_correctly(#[case] col: usize, #[case] expected: &str) {
651 let statusline = Statusline {
654 current_buffer_path: Some("foo"),
655 current_buffer_diags: SeverityBuckets::default(),
656 workspace_diags: SeverityBuckets::default(),
657 git_stats: GitStats::default(),
658 cursor_position: Some(CursorPosition { row: 10, col }),
659 };
660 assert_that!(statusline.draw(), eq(expected));
661 }
662}