1mod cargo;
2pub mod items;
3mod toolbar_controls;
4
5mod diagnostic_renderer;
6
7#[cfg(test)]
8mod diagnostics_tests;
9
10use anyhow::Result;
11use cargo::{
12 FetchStatus, FetchUpdate, cargo_diagnostics_sources, fetch_worktree_diagnostics,
13 is_outdated_cargo_fetch_diagnostic, map_rust_diagnostic_to_lsp, next_cargo_fetch_generation,
14 url_from_abs_path,
15};
16use collections::{BTreeSet, HashMap, HashSet};
17use diagnostic_renderer::DiagnosticBlock;
18use editor::{
19 DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
20 display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
21 scroll::Autoscroll,
22};
23use gpui::{
24 AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable,
25 Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
26 Subscription, Task, WeakEntity, Window, actions, div,
27};
28use language::{
29 Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, Point, ToTreeSitterPoint,
30};
31use lsp::{DiagnosticSeverity, LanguageServerId};
32use project::{
33 DiagnosticSummary, Project, ProjectPath, Worktree,
34 lsp_store::rust_analyzer_ext::{CARGO_DIAGNOSTICS_SOURCE_NAME, RUST_ANALYZER_NAME},
35 project_settings::ProjectSettings,
36};
37use settings::Settings;
38use std::{
39 any::{Any, TypeId},
40 cmp::{self, Ordering},
41 ops::{Range, RangeInclusive},
42 sync::Arc,
43 time::Duration,
44};
45use text::{BufferId, OffsetRangeExt};
46use theme::ActiveTheme;
47pub use toolbar_controls::ToolbarControls;
48use ui::{Icon, IconName, Label, h_flex, prelude::*};
49use util::ResultExt;
50use workspace::{
51 ItemNavHistory, ToolbarItemLocation, Workspace,
52 item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
53 searchable::SearchableItemHandle,
54};
55
56actions!(
57 diagnostics,
58 [Deploy, ToggleWarnings, ToggleDiagnosticsRefresh]
59);
60
61#[derive(Default)]
62pub(crate) struct IncludeWarnings(bool);
63impl Global for IncludeWarnings {}
64
65pub fn init(cx: &mut App) {
66 editor::set_diagnostic_renderer(diagnostic_renderer::DiagnosticRenderer {}, cx);
67 cx.observe_new(ProjectDiagnosticsEditor::register).detach();
68}
69
70pub(crate) struct ProjectDiagnosticsEditor {
71 project: Entity<Project>,
72 workspace: WeakEntity<Workspace>,
73 focus_handle: FocusHandle,
74 editor: Entity<Editor>,
75 diagnostics: HashMap<BufferId, Vec<DiagnosticEntry<text::Anchor>>>,
76 blocks: HashMap<BufferId, Vec<CustomBlockId>>,
77 summary: DiagnosticSummary,
78 multibuffer: Entity<MultiBuffer>,
79 paths_to_update: BTreeSet<ProjectPath>,
80 include_warnings: bool,
81 update_excerpts_task: Option<Task<Result<()>>>,
82 cargo_diagnostics_fetch: CargoDiagnosticsFetchState,
83 _subscription: Subscription,
84}
85
86struct CargoDiagnosticsFetchState {
87 task: Option<Task<()>>,
88 rust_analyzer: Option<LanguageServerId>,
89}
90
91impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
92
93const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50);
94
95impl Render for ProjectDiagnosticsEditor {
96 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
97 let warning_count = if self.include_warnings {
98 self.summary.warning_count
99 } else {
100 0
101 };
102
103 let child = if warning_count + self.summary.error_count == 0 {
104 let label = if self.summary.warning_count == 0 {
105 SharedString::new_static("No problems in workspace")
106 } else {
107 SharedString::new_static("No errors in workspace")
108 };
109 v_flex()
110 .key_context("EmptyPane")
111 .size_full()
112 .gap_1()
113 .justify_center()
114 .items_center()
115 .text_center()
116 .bg(cx.theme().colors().editor_background)
117 .child(Label::new(label).color(Color::Muted))
118 .when(self.summary.warning_count > 0, |this| {
119 let plural_suffix = if self.summary.warning_count > 1 {
120 "s"
121 } else {
122 ""
123 };
124 let label = format!(
125 "Show {} warning{}",
126 self.summary.warning_count, plural_suffix
127 );
128 this.child(
129 Button::new("diagnostics-show-warning-label", label).on_click(cx.listener(
130 |this, _, window, cx| {
131 this.toggle_warnings(&Default::default(), window, cx);
132 cx.notify();
133 },
134 )),
135 )
136 })
137 } else {
138 div().size_full().child(self.editor.clone())
139 };
140
141 div()
142 .key_context("Diagnostics")
143 .track_focus(&self.focus_handle(cx))
144 .size_full()
145 .on_action(cx.listener(Self::toggle_warnings))
146 .on_action(cx.listener(Self::toggle_diagnostics_refresh))
147 .child(child)
148 }
149}
150
151impl ProjectDiagnosticsEditor {
152 fn register(
153 workspace: &mut Workspace,
154 _window: Option<&mut Window>,
155 _: &mut Context<Workspace>,
156 ) {
157 workspace.register_action(Self::deploy);
158 }
159
160 fn new(
161 include_warnings: bool,
162 project_handle: Entity<Project>,
163 workspace: WeakEntity<Workspace>,
164 window: &mut Window,
165 cx: &mut Context<Self>,
166 ) -> Self {
167 let project_event_subscription =
168 cx.subscribe_in(&project_handle, window, |this, project, event, window, cx| match event {
169 project::Event::DiskBasedDiagnosticsStarted { .. } => {
170 cx.notify();
171 }
172 project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
173 log::debug!("disk based diagnostics finished for server {language_server_id}");
174 this.update_stale_excerpts(window, cx);
175 }
176 project::Event::DiagnosticsUpdated {
177 language_server_id,
178 path,
179 } => {
180 this.paths_to_update.insert(path.clone());
181 this.summary = project.read(cx).diagnostic_summary(false, cx);
182 cx.emit(EditorEvent::TitleChanged);
183
184 if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) {
185 log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change");
186 } else {
187 log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts");
188 this.update_stale_excerpts(window, cx);
189 }
190 }
191 _ => {}
192 });
193
194 let focus_handle = cx.focus_handle();
195 cx.on_focus_in(&focus_handle, window, |this, window, cx| {
196 this.focus_in(window, cx)
197 })
198 .detach();
199 cx.on_focus_out(&focus_handle, window, |this, _event, window, cx| {
200 this.focus_out(window, cx)
201 })
202 .detach();
203
204 let excerpts = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability()));
205 let editor = cx.new(|cx| {
206 let mut editor =
207 Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), window, cx);
208 editor.set_vertical_scroll_margin(5, cx);
209 editor.disable_inline_diagnostics();
210 editor.set_all_diagnostics_active(cx);
211 editor
212 });
213 cx.subscribe_in(
214 &editor,
215 window,
216 |this, _editor, event: &EditorEvent, window, cx| {
217 cx.emit(event.clone());
218 match event {
219 EditorEvent::Focused => {
220 if this.multibuffer.read(cx).is_empty() {
221 window.focus(&this.focus_handle);
222 }
223 }
224 EditorEvent::Blurred => this.update_stale_excerpts(window, cx),
225 _ => {}
226 }
227 },
228 )
229 .detach();
230 cx.observe_global_in::<IncludeWarnings>(window, |this, window, cx| {
231 this.include_warnings = cx.global::<IncludeWarnings>().0;
232 this.diagnostics.clear();
233 this.update_all_diagnostics(window, cx);
234 })
235 .detach();
236 cx.observe_release(&cx.entity(), |editor, _, cx| {
237 editor.stop_cargo_diagnostics_fetch(cx);
238 })
239 .detach();
240
241 let project = project_handle.read(cx);
242 let mut this = Self {
243 project: project_handle.clone(),
244 summary: project.diagnostic_summary(false, cx),
245 diagnostics: Default::default(),
246 blocks: Default::default(),
247 include_warnings,
248 workspace,
249 multibuffer: excerpts,
250 focus_handle,
251 editor,
252 paths_to_update: Default::default(),
253 update_excerpts_task: None,
254 cargo_diagnostics_fetch: CargoDiagnosticsFetchState {
255 task: None,
256 rust_analyzer: None,
257 },
258 _subscription: project_event_subscription,
259 };
260 this.update_all_diagnostics(window, cx);
261 this
262 }
263
264 fn update_stale_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
265 if self.update_excerpts_task.is_some() {
266 return;
267 }
268
269 let project_handle = self.project.clone();
270 self.update_excerpts_task = Some(cx.spawn_in(window, async move |this, cx| {
271 cx.background_executor()
272 .timer(DIAGNOSTICS_UPDATE_DELAY)
273 .await;
274 loop {
275 let Some(path) = this.update(cx, |this, cx| {
276 let Some(path) = this.paths_to_update.pop_first() else {
277 this.update_excerpts_task = None;
278 cx.notify();
279 return None;
280 };
281 Some(path)
282 })?
283 else {
284 break;
285 };
286
287 if let Some(buffer) = project_handle
288 .update(cx, |project, cx| project.open_buffer(path.clone(), cx))?
289 .await
290 .log_err()
291 {
292 this.update_in(cx, |this, window, cx| {
293 this.update_excerpts(buffer, window, cx)
294 })?
295 .await?;
296 }
297 }
298 Ok(())
299 }));
300 }
301
302 fn deploy(
303 workspace: &mut Workspace,
304 _: &Deploy,
305 window: &mut Window,
306 cx: &mut Context<Workspace>,
307 ) {
308 if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
309 let is_active = workspace
310 .active_item(cx)
311 .is_some_and(|item| item.item_id() == existing.item_id());
312 workspace.activate_item(&existing, true, !is_active, window, cx);
313 } else {
314 let workspace_handle = cx.entity().downgrade();
315
316 let include_warnings = match cx.try_global::<IncludeWarnings>() {
317 Some(include_warnings) => include_warnings.0,
318 None => ProjectSettings::get_global(cx).diagnostics.include_warnings,
319 };
320
321 let diagnostics = cx.new(|cx| {
322 ProjectDiagnosticsEditor::new(
323 include_warnings,
324 workspace.project().clone(),
325 workspace_handle,
326 window,
327 cx,
328 )
329 });
330 workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, window, cx);
331 }
332 }
333
334 fn toggle_warnings(&mut self, _: &ToggleWarnings, _: &mut Window, cx: &mut Context<Self>) {
335 cx.set_global(IncludeWarnings(!self.include_warnings));
336 }
337
338 fn toggle_diagnostics_refresh(
339 &mut self,
340 _: &ToggleDiagnosticsRefresh,
341 window: &mut Window,
342 cx: &mut Context<Self>,
343 ) {
344 let fetch_cargo_diagnostics = ProjectSettings::get_global(cx)
345 .diagnostics
346 .fetch_cargo_diagnostics();
347
348 if fetch_cargo_diagnostics {
349 if self.cargo_diagnostics_fetch.task.is_some() {
350 self.stop_cargo_diagnostics_fetch(cx);
351 } else {
352 self.update_all_diagnostics(window, cx);
353 }
354 } else {
355 if self.update_excerpts_task.is_some() {
356 self.update_excerpts_task = None;
357 } else {
358 self.update_all_diagnostics(window, cx);
359 }
360 }
361 cx.notify();
362 }
363
364 fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
365 if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
366 self.editor.focus_handle(cx).focus(window)
367 }
368 }
369
370 fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
371 if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
372 {
373 self.update_stale_excerpts(window, cx);
374 }
375 }
376
377 fn update_all_diagnostics(&mut self, window: &mut Window, cx: &mut Context<Self>) {
378 let cargo_diagnostics_sources = cargo_diagnostics_sources(self, cx);
379 if cargo_diagnostics_sources.is_empty() {
380 self.update_all_excerpts(window, cx);
381 } else {
382 self.fetch_cargo_diagnostics(Arc::new(cargo_diagnostics_sources), window, cx);
383 }
384 }
385
386 fn fetch_cargo_diagnostics(
387 &mut self,
388 diagnostics_sources: Arc<Vec<Entity<Worktree>>>,
389 window: &mut Window,
390 cx: &mut Context<Self>,
391 ) {
392 self.cargo_diagnostics_fetch.task = Some(cx.spawn_in(window, async move |editor, cx| {
393 let rust_analyzer_server = editor
394 .update(cx, |editor, cx| {
395 editor
396 .project
397 .read(cx)
398 .language_server_with_name(RUST_ANALYZER_NAME, cx)
399 })
400 .ok();
401 let rust_analyzer_server = match rust_analyzer_server {
402 Some(rust_analyzer_server) => rust_analyzer_server.await,
403 None => None,
404 };
405
406 let mut worktree_diagnostics_tasks = Vec::new();
407 let mut paths_with_reported_cargo_diagnostics = HashSet::default();
408 if let Some(rust_analyzer_server) = rust_analyzer_server {
409 let can_continue = editor
410 .update(cx, |editor, cx| {
411 editor.cargo_diagnostics_fetch.rust_analyzer = Some(rust_analyzer_server);
412 let status_inserted =
413 editor
414 .project
415 .read(cx)
416 .lsp_store()
417 .update(cx, |lsp_store, cx| {
418 if let Some(rust_analyzer_status) = lsp_store
419 .language_server_statuses
420 .get_mut(&rust_analyzer_server)
421 {
422 rust_analyzer_status
423 .progress_tokens
424 .insert(fetch_cargo_diagnostics_token());
425 paths_with_reported_cargo_diagnostics.extend(editor.diagnostics.iter().filter_map(|(buffer_id, diagnostics)| {
426 if diagnostics.iter().any(|d| d.diagnostic.source.as_deref() == Some(CARGO_DIAGNOSTICS_SOURCE_NAME)) {
427 Some(*buffer_id)
428 } else {
429 None
430 }
431 }).filter_map(|buffer_id| {
432 let buffer = lsp_store.buffer_store().read(cx).get(buffer_id)?;
433 let path = buffer.read(cx).file()?.as_local()?.abs_path(cx);
434 Some(url_from_abs_path(&path))
435 }));
436 true
437 } else {
438 false
439 }
440 });
441 if status_inserted {
442 editor.update_cargo_fetch_status(FetchStatus::Started, cx);
443 next_cargo_fetch_generation();
444 true
445 } else {
446 false
447 }
448 })
449 .unwrap_or(false);
450
451 if can_continue {
452 for worktree in diagnostics_sources.iter() {
453 if let Some(((_task, worktree_diagnostics), worktree_root)) = cx
454 .update(|_, cx| {
455 let worktree_root = worktree.read(cx).abs_path();
456 log::info!("Fetching cargo diagnostics for {worktree_root:?}");
457 fetch_worktree_diagnostics(&worktree_root, cx)
458 .zip(Some(worktree_root))
459 })
460 .ok()
461 .flatten()
462 {
463 let editor = editor.clone();
464 worktree_diagnostics_tasks.push(cx.spawn(async move |cx| {
465 let _task = _task;
466 let mut file_diagnostics = HashMap::default();
467 let mut diagnostics_total = 0;
468 let mut updated_urls = HashSet::default();
469 while let Ok(fetch_update) = worktree_diagnostics.recv().await {
470 match fetch_update {
471 FetchUpdate::Diagnostic(diagnostic) => {
472 for (url, diagnostic) in map_rust_diagnostic_to_lsp(
473 &worktree_root,
474 &diagnostic,
475 ) {
476 let file_diagnostics = file_diagnostics
477 .entry(url)
478 .or_insert_with(Vec::<lsp::Diagnostic>::new);
479 let i = file_diagnostics
480 .binary_search_by(|probe| {
481 probe.range.start.cmp(&diagnostic.range.start)
482 .then(probe.range.end.cmp(&diagnostic.range.end))
483 .then(Ordering::Greater)
484 })
485 .unwrap_or_else(|i| i);
486 file_diagnostics.insert(i, diagnostic);
487 }
488
489 let file_changed = file_diagnostics.len() > 1;
490 if file_changed {
491 if editor
492 .update_in(cx, |editor, window, cx| {
493 editor
494 .project
495 .read(cx)
496 .lsp_store()
497 .update(cx, |lsp_store, cx| {
498 for (uri, mut diagnostics) in
499 file_diagnostics.drain()
500 {
501 diagnostics.dedup();
502 diagnostics_total += diagnostics.len();
503 updated_urls.insert(uri.clone());
504
505 lsp_store.merge_diagnostics(
506 rust_analyzer_server,
507 lsp::PublishDiagnosticsParams {
508 uri,
509 diagnostics,
510 version: None,
511 },
512 &[],
513 |diagnostic, _| {
514 !is_outdated_cargo_fetch_diagnostic(diagnostic)
515 },
516 cx,
517 )?;
518 }
519 anyhow::Ok(())
520 })?;
521 editor.update_all_excerpts(window, cx);
522 anyhow::Ok(())
523 })
524 .ok()
525 .transpose()
526 .ok()
527 .flatten()
528 .is_none()
529 {
530 break;
531 }
532 }
533 }
534 FetchUpdate::Progress(message) => {
535 if editor
536 .update(cx, |editor, cx| {
537 editor.update_cargo_fetch_status(
538 FetchStatus::Progress { message },
539 cx,
540 );
541 })
542 .is_err()
543 {
544 return updated_urls;
545 }
546 }
547 }
548 }
549
550 editor
551 .update_in(cx, |editor, window, cx| {
552 editor
553 .project
554 .read(cx)
555 .lsp_store()
556 .update(cx, |lsp_store, cx| {
557 for (uri, mut diagnostics) in
558 file_diagnostics.drain()
559 {
560 diagnostics.dedup();
561 diagnostics_total += diagnostics.len();
562 updated_urls.insert(uri.clone());
563
564 lsp_store.merge_diagnostics(
565 rust_analyzer_server,
566 lsp::PublishDiagnosticsParams {
567 uri,
568 diagnostics,
569 version: None,
570 },
571 &[],
572 |diagnostic, _| {
573 !is_outdated_cargo_fetch_diagnostic(diagnostic)
574 },
575 cx,
576 )?;
577 }
578 anyhow::Ok(())
579 })?;
580 editor.update_all_excerpts(window, cx);
581 anyhow::Ok(())
582 })
583 .ok();
584 log::info!("Fetched {diagnostics_total} cargo diagnostics for worktree {worktree_root:?}");
585 updated_urls
586 }));
587 }
588 }
589 } else {
590 log::info!(
591 "No rust-analyzer language server found, skipping diagnostics fetch"
592 );
593 }
594 }
595
596
597 let updated_urls = futures::future::join_all(worktree_diagnostics_tasks).await.into_iter().flatten().collect();
598 if let Some(rust_analyzer_server) = rust_analyzer_server {
599 editor
600 .update_in(cx, |editor, window, cx| {
601 editor
602 .project
603 .read(cx)
604 .lsp_store()
605 .update(cx, |lsp_store, cx| {
606 for uri_to_cleanup in paths_with_reported_cargo_diagnostics.difference(&updated_urls).cloned() {
607 lsp_store.merge_diagnostics(
608 rust_analyzer_server,
609 lsp::PublishDiagnosticsParams {
610 uri: uri_to_cleanup,
611 diagnostics: Vec::new(),
612 version: None,
613 },
614 &[],
615 |diagnostic, _| {
616 !is_outdated_cargo_fetch_diagnostic(diagnostic)
617 },
618 cx,
619 ).ok();
620 }
621 });
622 editor.update_all_excerpts(window, cx);
623
624 editor.stop_cargo_diagnostics_fetch(cx);
625 cx.notify();
626 })
627 .ok();
628 }
629 }));
630 }
631
632 fn update_cargo_fetch_status(&self, status: FetchStatus, cx: &mut App) {
633 let Some(rust_analyzer) = self.cargo_diagnostics_fetch.rust_analyzer else {
634 return;
635 };
636
637 let work_done = match status {
638 FetchStatus::Started => lsp::WorkDoneProgress::Begin(lsp::WorkDoneProgressBegin {
639 title: "cargo".to_string(),
640 cancellable: None,
641 message: Some("Fetching cargo diagnostics".to_string()),
642 percentage: None,
643 }),
644 FetchStatus::Progress { message } => {
645 lsp::WorkDoneProgress::Report(lsp::WorkDoneProgressReport {
646 message: Some(message),
647 cancellable: None,
648 percentage: None,
649 })
650 }
651 FetchStatus::Finished => {
652 lsp::WorkDoneProgress::End(lsp::WorkDoneProgressEnd { message: None })
653 }
654 };
655 let progress = lsp::ProgressParams {
656 token: lsp::NumberOrString::String(fetch_cargo_diagnostics_token()),
657 value: lsp::ProgressParamsValue::WorkDone(work_done),
658 };
659
660 self.project
661 .read(cx)
662 .lsp_store()
663 .update(cx, |lsp_store, cx| {
664 lsp_store.on_lsp_progress(progress, rust_analyzer, None, cx)
665 });
666 }
667
668 fn stop_cargo_diagnostics_fetch(&mut self, cx: &mut App) {
669 self.update_cargo_fetch_status(FetchStatus::Finished, cx);
670 self.cargo_diagnostics_fetch.task = None;
671 log::info!("Finished fetching cargo diagnostics");
672 }
673
674 /// Enqueue an update of all excerpts. Updates all paths that either
675 /// currently have diagnostics or are currently present in this view.
676 fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
677 self.project.update(cx, |project, cx| {
678 let mut paths = project
679 .diagnostic_summaries(false, cx)
680 .map(|(path, _, _)| path)
681 .collect::<BTreeSet<_>>();
682 self.multibuffer.update(cx, |multibuffer, cx| {
683 for buffer in multibuffer.all_buffers() {
684 if let Some(file) = buffer.read(cx).file() {
685 paths.insert(ProjectPath {
686 path: file.path().clone(),
687 worktree_id: file.worktree_id(cx),
688 });
689 }
690 }
691 });
692 self.paths_to_update = paths;
693 });
694 self.update_stale_excerpts(window, cx);
695 }
696
697 fn diagnostics_are_unchanged(
698 &self,
699 existing: &Vec<DiagnosticEntry<text::Anchor>>,
700 new: &Vec<DiagnosticEntry<text::Anchor>>,
701 snapshot: &BufferSnapshot,
702 ) -> bool {
703 if existing.len() != new.len() {
704 return false;
705 }
706 existing.iter().zip(new.iter()).all(|(existing, new)| {
707 existing.diagnostic.message == new.diagnostic.message
708 && existing.diagnostic.severity == new.diagnostic.severity
709 && existing.diagnostic.is_primary == new.diagnostic.is_primary
710 && existing.range.to_offset(snapshot) == new.range.to_offset(snapshot)
711 })
712 }
713
714 fn update_excerpts(
715 &mut self,
716 buffer: Entity<Buffer>,
717 window: &mut Window,
718 cx: &mut Context<Self>,
719 ) -> Task<Result<()>> {
720 let was_empty = self.multibuffer.read(cx).is_empty();
721 let buffer_snapshot = buffer.read(cx).snapshot();
722 let buffer_id = buffer_snapshot.remote_id();
723 let max_severity = if self.include_warnings {
724 DiagnosticSeverity::WARNING
725 } else {
726 DiagnosticSeverity::ERROR
727 };
728
729 cx.spawn_in(window, async move |this, mut cx| {
730 let diagnostics = buffer_snapshot
731 .diagnostics_in_range::<_, text::Anchor>(
732 Point::zero()..buffer_snapshot.max_point(),
733 false,
734 )
735 .collect::<Vec<_>>();
736 let unchanged = this.update(cx, |this, _| {
737 if this.diagnostics.get(&buffer_id).is_some_and(|existing| {
738 this.diagnostics_are_unchanged(existing, &diagnostics, &buffer_snapshot)
739 }) {
740 return true;
741 }
742 this.diagnostics.insert(buffer_id, diagnostics.clone());
743 return false;
744 })?;
745 if unchanged {
746 return Ok(());
747 }
748
749 let mut grouped: HashMap<usize, Vec<_>> = HashMap::default();
750 for entry in diagnostics {
751 grouped
752 .entry(entry.diagnostic.group_id)
753 .or_default()
754 .push(DiagnosticEntry {
755 range: entry.range.to_point(&buffer_snapshot),
756 diagnostic: entry.diagnostic,
757 })
758 }
759 let mut blocks: Vec<DiagnosticBlock> = Vec::new();
760
761 for (_, group) in grouped {
762 let group_severity = group.iter().map(|d| d.diagnostic.severity).min();
763 if group_severity.is_none_or(|s| s > max_severity) {
764 continue;
765 }
766 let more = cx.update(|_, cx| {
767 crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
768 group,
769 buffer_snapshot.remote_id(),
770 Some(this.clone()),
771 cx,
772 )
773 })?;
774
775 for item in more {
776 let i = blocks
777 .binary_search_by(|probe| {
778 probe
779 .initial_range
780 .start
781 .cmp(&item.initial_range.start)
782 .then(probe.initial_range.end.cmp(&item.initial_range.end))
783 .then(Ordering::Greater)
784 })
785 .unwrap_or_else(|i| i);
786 blocks.insert(i, item);
787 }
788 }
789
790 let mut excerpt_ranges: Vec<ExcerptRange<Point>> = Vec::new();
791 for b in blocks.iter() {
792 let excerpt_range = context_range_for_entry(
793 b.initial_range.clone(),
794 DEFAULT_MULTIBUFFER_CONTEXT,
795 buffer_snapshot.clone(),
796 &mut cx,
797 )
798 .await;
799 let i = excerpt_ranges
800 .binary_search_by(|probe| {
801 probe
802 .context
803 .start
804 .cmp(&excerpt_range.start)
805 .then(probe.context.end.cmp(&excerpt_range.end))
806 .then(probe.primary.start.cmp(&b.initial_range.start))
807 .then(probe.primary.end.cmp(&b.initial_range.end))
808 .then(cmp::Ordering::Greater)
809 })
810 .unwrap_or_else(|i| i);
811 excerpt_ranges.insert(
812 i,
813 ExcerptRange {
814 context: excerpt_range,
815 primary: b.initial_range.clone(),
816 },
817 )
818 }
819
820 this.update_in(cx, |this, window, cx| {
821 if let Some(block_ids) = this.blocks.remove(&buffer_id) {
822 this.editor.update(cx, |editor, cx| {
823 editor.display_map.update(cx, |display_map, cx| {
824 display_map.remove_blocks(block_ids.into_iter().collect(), cx)
825 });
826 })
827 }
828 let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| {
829 multi_buffer.set_excerpt_ranges_for_path(
830 PathKey::for_buffer(&buffer, cx),
831 buffer.clone(),
832 &buffer_snapshot,
833 excerpt_ranges,
834 cx,
835 )
836 });
837 #[cfg(test)]
838 let cloned_blocks = blocks.clone();
839
840 if was_empty {
841 if let Some(anchor_range) = anchor_ranges.first() {
842 let range_to_select = anchor_range.start..anchor_range.start;
843 this.editor.update(cx, |editor, cx| {
844 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
845 s.select_anchor_ranges([range_to_select]);
846 })
847 });
848 if this.focus_handle.is_focused(window) {
849 this.editor.read(cx).focus_handle(cx).focus(window);
850 }
851 }
852 }
853
854 let editor_blocks =
855 anchor_ranges
856 .into_iter()
857 .zip(blocks.into_iter())
858 .map(|(anchor, block)| {
859 let editor = this.editor.downgrade();
860 BlockProperties {
861 placement: BlockPlacement::Near(anchor.start),
862 height: Some(1),
863 style: BlockStyle::Flex,
864 render: Arc::new(move |bcx| {
865 block.render_block(editor.clone(), bcx)
866 }),
867 priority: 1,
868 }
869 });
870 let block_ids = this.editor.update(cx, |editor, cx| {
871 editor.display_map.update(cx, |display_map, cx| {
872 display_map.insert_blocks(editor_blocks, cx)
873 })
874 });
875
876 #[cfg(test)]
877 {
878 for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) {
879 let markdown = block.markdown.clone();
880 editor::test::set_block_content_for_tests(
881 &this.editor,
882 *block_id,
883 cx,
884 move |cx| {
885 markdown::MarkdownElement::rendered_text(
886 markdown.clone(),
887 cx,
888 editor::hover_popover::diagnostics_markdown_style,
889 )
890 },
891 );
892 }
893 }
894
895 this.blocks.insert(buffer_id, block_ids);
896 cx.notify()
897 })
898 })
899 }
900}
901
902impl Focusable for ProjectDiagnosticsEditor {
903 fn focus_handle(&self, _: &App) -> FocusHandle {
904 self.focus_handle.clone()
905 }
906}
907
908impl Item for ProjectDiagnosticsEditor {
909 type Event = EditorEvent;
910
911 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
912 Editor::to_item_events(event, f)
913 }
914
915 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
916 self.editor
917 .update(cx, |editor, cx| editor.deactivated(window, cx));
918 }
919
920 fn navigate(
921 &mut self,
922 data: Box<dyn Any>,
923 window: &mut Window,
924 cx: &mut Context<Self>,
925 ) -> bool {
926 self.editor
927 .update(cx, |editor, cx| editor.navigate(data, window, cx))
928 }
929
930 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
931 Some("Project Diagnostics".into())
932 }
933
934 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
935 "Diagnostics".into()
936 }
937
938 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
939 h_flex()
940 .gap_1()
941 .when(
942 self.summary.error_count == 0 && self.summary.warning_count == 0,
943 |then| {
944 then.child(
945 h_flex()
946 .gap_1()
947 .child(Icon::new(IconName::Check).color(Color::Success))
948 .child(Label::new("No problems").color(params.text_color())),
949 )
950 },
951 )
952 .when(self.summary.error_count > 0, |then| {
953 then.child(
954 h_flex()
955 .gap_1()
956 .child(Icon::new(IconName::XCircle).color(Color::Error))
957 .child(
958 Label::new(self.summary.error_count.to_string())
959 .color(params.text_color()),
960 ),
961 )
962 })
963 .when(self.summary.warning_count > 0, |then| {
964 then.child(
965 h_flex()
966 .gap_1()
967 .child(Icon::new(IconName::Warning).color(Color::Warning))
968 .child(
969 Label::new(self.summary.warning_count.to_string())
970 .color(params.text_color()),
971 ),
972 )
973 })
974 .into_any_element()
975 }
976
977 fn telemetry_event_text(&self) -> Option<&'static str> {
978 Some("Project Diagnostics Opened")
979 }
980
981 fn for_each_project_item(
982 &self,
983 cx: &App,
984 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
985 ) {
986 self.editor.for_each_project_item(cx, f)
987 }
988
989 fn is_singleton(&self, _: &App) -> bool {
990 false
991 }
992
993 fn set_nav_history(
994 &mut self,
995 nav_history: ItemNavHistory,
996 _: &mut Window,
997 cx: &mut Context<Self>,
998 ) {
999 self.editor.update(cx, |editor, _| {
1000 editor.set_nav_history(Some(nav_history));
1001 });
1002 }
1003
1004 fn clone_on_split(
1005 &self,
1006 _workspace_id: Option<workspace::WorkspaceId>,
1007 window: &mut Window,
1008 cx: &mut Context<Self>,
1009 ) -> Option<Entity<Self>>
1010 where
1011 Self: Sized,
1012 {
1013 Some(cx.new(|cx| {
1014 ProjectDiagnosticsEditor::new(
1015 self.include_warnings,
1016 self.project.clone(),
1017 self.workspace.clone(),
1018 window,
1019 cx,
1020 )
1021 }))
1022 }
1023
1024 fn is_dirty(&self, cx: &App) -> bool {
1025 self.multibuffer.read(cx).is_dirty(cx)
1026 }
1027
1028 fn has_deleted_file(&self, cx: &App) -> bool {
1029 self.multibuffer.read(cx).has_deleted_file(cx)
1030 }
1031
1032 fn has_conflict(&self, cx: &App) -> bool {
1033 self.multibuffer.read(cx).has_conflict(cx)
1034 }
1035
1036 fn can_save(&self, _: &App) -> bool {
1037 true
1038 }
1039
1040 fn save(
1041 &mut self,
1042 format: bool,
1043 project: Entity<Project>,
1044 window: &mut Window,
1045 cx: &mut Context<Self>,
1046 ) -> Task<Result<()>> {
1047 self.editor.save(format, project, window, cx)
1048 }
1049
1050 fn save_as(
1051 &mut self,
1052 _: Entity<Project>,
1053 _: ProjectPath,
1054 _window: &mut Window,
1055 _: &mut Context<Self>,
1056 ) -> Task<Result<()>> {
1057 unreachable!()
1058 }
1059
1060 fn reload(
1061 &mut self,
1062 project: Entity<Project>,
1063 window: &mut Window,
1064 cx: &mut Context<Self>,
1065 ) -> Task<Result<()>> {
1066 self.editor.reload(project, window, cx)
1067 }
1068
1069 fn act_as_type<'a>(
1070 &'a self,
1071 type_id: TypeId,
1072 self_handle: &'a Entity<Self>,
1073 _: &'a App,
1074 ) -> Option<AnyView> {
1075 if type_id == TypeId::of::<Self>() {
1076 Some(self_handle.to_any())
1077 } else if type_id == TypeId::of::<Editor>() {
1078 Some(self.editor.to_any())
1079 } else {
1080 None
1081 }
1082 }
1083
1084 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
1085 Some(Box::new(self.editor.clone()))
1086 }
1087
1088 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
1089 ToolbarItemLocation::PrimaryLeft
1090 }
1091
1092 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
1093 self.editor.breadcrumbs(theme, cx)
1094 }
1095
1096 fn added_to_workspace(
1097 &mut self,
1098 workspace: &mut Workspace,
1099 window: &mut Window,
1100 cx: &mut Context<Self>,
1101 ) {
1102 self.editor.update(cx, |editor, cx| {
1103 editor.added_to_workspace(workspace, window, cx)
1104 });
1105 }
1106}
1107
1108const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32;
1109
1110async fn context_range_for_entry(
1111 range: Range<Point>,
1112 context: u32,
1113 snapshot: BufferSnapshot,
1114 cx: &mut AsyncApp,
1115) -> Range<Point> {
1116 if let Some(rows) = heuristic_syntactic_expand(
1117 range.clone(),
1118 DIAGNOSTIC_EXPANSION_ROW_LIMIT,
1119 snapshot.clone(),
1120 cx,
1121 )
1122 .await
1123 {
1124 return Range {
1125 start: Point::new(*rows.start(), 0),
1126 end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left),
1127 };
1128 }
1129 Range {
1130 start: Point::new(range.start.row.saturating_sub(context), 0),
1131 end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left),
1132 }
1133}
1134
1135/// Expands the input range using syntax information from TreeSitter. This expansion will be limited
1136/// to the specified `max_row_count`.
1137///
1138/// If there is a containing outline item that is less than `max_row_count`, it will be returned.
1139/// Otherwise fairly arbitrary heuristics are applied to attempt to return a logical block of code.
1140async fn heuristic_syntactic_expand(
1141 input_range: Range<Point>,
1142 max_row_count: u32,
1143 snapshot: BufferSnapshot,
1144 cx: &mut AsyncApp,
1145) -> Option<RangeInclusive<BufferRow>> {
1146 let input_row_count = input_range.end.row - input_range.start.row;
1147 if input_row_count > max_row_count {
1148 return None;
1149 }
1150
1151 // If the outline node contains the diagnostic and is small enough, just use that.
1152 let outline_range = snapshot.outline_range_containing(input_range.clone());
1153 if let Some(outline_range) = outline_range.clone() {
1154 // Remove blank lines from start and end
1155 if let Some(start_row) = (outline_range.start.row..outline_range.end.row)
1156 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
1157 {
1158 if let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
1159 .rev()
1160 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
1161 {
1162 let row_count = end_row.saturating_sub(start_row);
1163 if row_count <= max_row_count {
1164 return Some(RangeInclusive::new(
1165 outline_range.start.row,
1166 outline_range.end.row,
1167 ));
1168 }
1169 }
1170 }
1171 }
1172
1173 let mut node = snapshot.syntax_ancestor(input_range.clone())?;
1174
1175 loop {
1176 let node_start = Point::from_ts_point(node.start_position());
1177 let node_end = Point::from_ts_point(node.end_position());
1178 let node_range = node_start..node_end;
1179 let row_count = node_end.row - node_start.row + 1;
1180 let mut ancestor_range = None;
1181 let reached_outline_node = cx.background_executor().scoped({
1182 let node_range = node_range.clone();
1183 let outline_range = outline_range.clone();
1184 let ancestor_range = &mut ancestor_range;
1185 |scope| {scope.spawn(async move {
1186 // Stop if we've exceeded the row count or reached an outline node. Then, find the interval
1187 // of node children which contains the query range. For example, this allows just returning
1188 // the header of a declaration rather than the entire declaration.
1189 if row_count > max_row_count || outline_range == Some(node_range.clone()) {
1190 let mut cursor = node.walk();
1191 let mut included_child_start = None;
1192 let mut included_child_end = None;
1193 let mut previous_end = node_start;
1194 if cursor.goto_first_child() {
1195 loop {
1196 let child_node = cursor.node();
1197 let child_range = previous_end..Point::from_ts_point(child_node.end_position());
1198 if included_child_start.is_none() && child_range.contains(&input_range.start) {
1199 included_child_start = Some(child_range.start);
1200 }
1201 if child_range.contains(&input_range.end) {
1202 included_child_end = Some(child_range.end);
1203 }
1204 previous_end = child_range.end;
1205 if !cursor.goto_next_sibling() {
1206 break;
1207 }
1208 }
1209 }
1210 let end = included_child_end.unwrap_or(node_range.end);
1211 if let Some(start) = included_child_start {
1212 let row_count = end.row - start.row;
1213 if row_count < max_row_count {
1214 *ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row)));
1215 return;
1216 }
1217 }
1218
1219 log::info!(
1220 "Expanding to ancestor started on {} node exceeding row limit of {max_row_count}.",
1221 node.grammar_name()
1222 );
1223 *ancestor_range = Some(None);
1224 }
1225 })
1226 }});
1227 reached_outline_node.await;
1228 if let Some(node) = ancestor_range {
1229 return node;
1230 }
1231
1232 let node_name = node.grammar_name();
1233 let node_row_range = RangeInclusive::new(node_range.start.row, node_range.end.row);
1234 if node_name.ends_with("block") {
1235 return Some(node_row_range);
1236 } else if node_name.ends_with("statement") || node_name.ends_with("declaration") {
1237 // Expand to the nearest dedent or blank line for statements and declarations.
1238 let tab_size = cx
1239 .update(|cx| snapshot.settings_at(node_range.start, cx).tab_size.get())
1240 .ok()?;
1241 let indent_level = snapshot
1242 .line_indent_for_row(node_range.start.row)
1243 .len(tab_size);
1244 let rows_remaining = max_row_count.saturating_sub(row_count);
1245 let Some(start_row) = (node_range.start.row.saturating_sub(rows_remaining)
1246 ..node_range.start.row)
1247 .rev()
1248 .find(|row| {
1249 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
1250 })
1251 else {
1252 return Some(node_row_range);
1253 };
1254 let rows_remaining = max_row_count.saturating_sub(node_range.end.row - start_row);
1255 let Some(end_row) = (node_range.end.row + 1
1256 ..cmp::min(
1257 node_range.end.row + rows_remaining + 1,
1258 snapshot.row_count(),
1259 ))
1260 .find(|row| {
1261 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
1262 })
1263 else {
1264 return Some(node_row_range);
1265 };
1266 return Some(RangeInclusive::new(start_row, end_row));
1267 }
1268
1269 // TODO: doing this instead of walking a cursor as that doesn't work - why?
1270 let Some(parent) = node.parent() else {
1271 log::info!(
1272 "Expanding to ancestor reached the top node, so using default context line count.",
1273 );
1274 return None;
1275 };
1276 node = parent;
1277 }
1278}
1279
1280fn is_line_blank_or_indented_less(
1281 indent_level: u32,
1282 row: u32,
1283 tab_size: u32,
1284 snapshot: &BufferSnapshot,
1285) -> bool {
1286 let line_indent = snapshot.line_indent_for_row(row);
1287 line_indent.is_line_blank() || line_indent.len(tab_size) < indent_level
1288}
1289
1290fn fetch_cargo_diagnostics_token() -> String {
1291 "fetch_cargo_diagnostics".to_string()
1292}