diagnostics.rs

  1pub mod items;
  2mod toolbar_controls;
  3
  4mod diagnostic_renderer;
  5
  6#[cfg(test)]
  7mod diagnostics_tests;
  8
  9use anyhow::Result;
 10use collections::{BTreeSet, HashMap};
 11use diagnostic_renderer::DiagnosticBlock;
 12use editor::{
 13    DEFAULT_MULTIBUFFER_CONTEXT, Editor, EditorEvent, ExcerptRange, MultiBuffer, PathKey,
 14    display_map::{BlockPlacement, BlockProperties, BlockStyle, CustomBlockId},
 15    scroll::Autoscroll,
 16};
 17use gpui::{
 18    AnyElement, AnyView, App, AsyncApp, Context, Entity, EventEmitter, FocusHandle, Focusable,
 19    Global, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
 20    Subscription, Task, WeakEntity, Window, actions, div,
 21};
 22use language::{
 23    Bias, Buffer, BufferRow, BufferSnapshot, DiagnosticEntry, Point, ToTreeSitterPoint,
 24};
 25use lsp::DiagnosticSeverity;
 26use project::{DiagnosticSummary, Project, ProjectPath, project_settings::ProjectSettings};
 27use settings::Settings;
 28use std::{
 29    any::{Any, TypeId},
 30    cmp,
 31    cmp::Ordering,
 32    ops::{Range, RangeInclusive},
 33    sync::Arc,
 34    time::Duration,
 35};
 36use text::{BufferId, OffsetRangeExt};
 37use theme::ActiveTheme;
 38pub use toolbar_controls::ToolbarControls;
 39use ui::{Icon, IconName, Label, h_flex, prelude::*};
 40use util::ResultExt;
 41use workspace::{
 42    ItemNavHistory, ToolbarItemLocation, Workspace,
 43    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
 44    searchable::SearchableItemHandle,
 45};
 46
 47actions!(diagnostics, [Deploy, ToggleWarnings]);
 48
 49#[derive(Default)]
 50pub(crate) struct IncludeWarnings(bool);
 51impl Global for IncludeWarnings {}
 52
 53pub fn init(cx: &mut App) {
 54    editor::set_diagnostic_renderer(diagnostic_renderer::DiagnosticRenderer {}, cx);
 55    cx.observe_new(ProjectDiagnosticsEditor::register).detach();
 56}
 57
 58pub(crate) struct ProjectDiagnosticsEditor {
 59    project: Entity<Project>,
 60    workspace: WeakEntity<Workspace>,
 61    focus_handle: FocusHandle,
 62    editor: Entity<Editor>,
 63    diagnostics: HashMap<BufferId, Vec<DiagnosticEntry<text::Anchor>>>,
 64    blocks: HashMap<BufferId, Vec<CustomBlockId>>,
 65    summary: DiagnosticSummary,
 66    multibuffer: Entity<MultiBuffer>,
 67    paths_to_update: BTreeSet<ProjectPath>,
 68    include_warnings: bool,
 69    update_excerpts_task: Option<Task<Result<()>>>,
 70    _subscription: Subscription,
 71}
 72
 73impl EventEmitter<EditorEvent> for ProjectDiagnosticsEditor {}
 74
 75const DIAGNOSTICS_UPDATE_DELAY: Duration = Duration::from_millis(50);
 76
 77impl Render for ProjectDiagnosticsEditor {
 78    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 79        let warning_count = if self.include_warnings {
 80            self.summary.warning_count
 81        } else {
 82            0
 83        };
 84
 85        let child = if warning_count + self.summary.error_count == 0 {
 86            let label = if self.summary.warning_count == 0 {
 87                SharedString::new_static("No problems in workspace")
 88            } else {
 89                SharedString::new_static("No errors in workspace")
 90            };
 91            v_flex()
 92                .key_context("EmptyPane")
 93                .size_full()
 94                .gap_1()
 95                .justify_center()
 96                .items_center()
 97                .text_center()
 98                .bg(cx.theme().colors().editor_background)
 99                .child(Label::new(label).color(Color::Muted))
100                .when(self.summary.warning_count > 0, |this| {
101                    let plural_suffix = if self.summary.warning_count > 1 {
102                        "s"
103                    } else {
104                        ""
105                    };
106                    let label = format!(
107                        "Show {} warning{}",
108                        self.summary.warning_count, plural_suffix
109                    );
110                    this.child(
111                        Button::new("diagnostics-show-warning-label", label).on_click(cx.listener(
112                            |this, _, window, cx| {
113                                this.toggle_warnings(&Default::default(), window, cx);
114                                cx.notify();
115                            },
116                        )),
117                    )
118                })
119        } else {
120            div().size_full().child(self.editor.clone())
121        };
122
123        div()
124            .key_context("Diagnostics")
125            .track_focus(&self.focus_handle(cx))
126            .size_full()
127            .on_action(cx.listener(Self::toggle_warnings))
128            .child(child)
129    }
130}
131
132impl ProjectDiagnosticsEditor {
133    fn register(
134        workspace: &mut Workspace,
135        _window: Option<&mut Window>,
136        _: &mut Context<Workspace>,
137    ) {
138        workspace.register_action(Self::deploy);
139    }
140
141    fn new(
142        include_warnings: bool,
143        project_handle: Entity<Project>,
144        workspace: WeakEntity<Workspace>,
145        window: &mut Window,
146        cx: &mut Context<Self>,
147    ) -> Self {
148        let project_event_subscription =
149            cx.subscribe_in(&project_handle, window, |this, project, event, window, cx| match event {
150                project::Event::DiskBasedDiagnosticsStarted { .. } => {
151                    cx.notify();
152                }
153                project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
154                    log::debug!("disk based diagnostics finished for server {language_server_id}");
155                    this.update_stale_excerpts(window, cx);
156                }
157                project::Event::DiagnosticsUpdated {
158                    language_server_id,
159                    path,
160                } => {
161                    this.paths_to_update.insert(path.clone());
162                    this.summary = project.read(cx).diagnostic_summary(false, cx);
163                    cx.emit(EditorEvent::TitleChanged);
164
165                    if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) {
166                        log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change");
167                    } else {
168                        log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts");
169                        this.update_stale_excerpts(window, cx);
170                    }
171                }
172                _ => {}
173            });
174
175        let focus_handle = cx.focus_handle();
176        cx.on_focus_in(&focus_handle, window, |this, window, cx| {
177            this.focus_in(window, cx)
178        })
179        .detach();
180        cx.on_focus_out(&focus_handle, window, |this, _event, window, cx| {
181            this.focus_out(window, cx)
182        })
183        .detach();
184
185        let excerpts = cx.new(|cx| MultiBuffer::new(project_handle.read(cx).capability()));
186        let editor = cx.new(|cx| {
187            let mut editor =
188                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), window, cx);
189            editor.set_vertical_scroll_margin(5, cx);
190            editor.disable_inline_diagnostics();
191            editor.set_all_diagnostics_active(cx);
192            editor
193        });
194        cx.subscribe_in(
195            &editor,
196            window,
197            |this, _editor, event: &EditorEvent, window, cx| {
198                cx.emit(event.clone());
199                match event {
200                    EditorEvent::Focused => {
201                        if this.multibuffer.read(cx).is_empty() {
202                            window.focus(&this.focus_handle);
203                        }
204                    }
205                    EditorEvent::Blurred => this.update_stale_excerpts(window, cx),
206                    _ => {}
207                }
208            },
209        )
210        .detach();
211        cx.observe_global_in::<IncludeWarnings>(window, |this, window, cx| {
212            this.include_warnings = cx.global::<IncludeWarnings>().0;
213            this.diagnostics.clear();
214            this.update_all_excerpts(window, cx);
215        })
216        .detach();
217
218        let project = project_handle.read(cx);
219        let mut this = Self {
220            project: project_handle.clone(),
221            summary: project.diagnostic_summary(false, cx),
222            diagnostics: Default::default(),
223            blocks: Default::default(),
224            include_warnings,
225            workspace,
226            multibuffer: excerpts,
227            focus_handle,
228            editor,
229            paths_to_update: Default::default(),
230            update_excerpts_task: None,
231            _subscription: project_event_subscription,
232        };
233        this.update_all_excerpts(window, cx);
234        this
235    }
236
237    fn update_stale_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
238        if self.update_excerpts_task.is_some() {
239            return;
240        }
241        let project_handle = self.project.clone();
242        self.update_excerpts_task = Some(cx.spawn_in(window, async move |this, cx| {
243            cx.background_executor()
244                .timer(DIAGNOSTICS_UPDATE_DELAY)
245                .await;
246            loop {
247                let Some(path) = this.update(cx, |this, _| {
248                    let Some(path) = this.paths_to_update.pop_first() else {
249                        this.update_excerpts_task.take();
250                        return None;
251                    };
252                    Some(path)
253                })?
254                else {
255                    break;
256                };
257
258                if let Some(buffer) = project_handle
259                    .update(cx, |project, cx| project.open_buffer(path.clone(), cx))?
260                    .await
261                    .log_err()
262                {
263                    this.update_in(cx, |this, window, cx| {
264                        this.update_excerpts(buffer, window, cx)
265                    })?
266                    .await?;
267                }
268            }
269            Ok(())
270        }));
271    }
272
273    fn deploy(
274        workspace: &mut Workspace,
275        _: &Deploy,
276        window: &mut Window,
277        cx: &mut Context<Workspace>,
278    ) {
279        if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
280            let is_active = workspace
281                .active_item(cx)
282                .is_some_and(|item| item.item_id() == existing.item_id());
283            workspace.activate_item(&existing, true, !is_active, window, cx);
284        } else {
285            let workspace_handle = cx.entity().downgrade();
286
287            let include_warnings = match cx.try_global::<IncludeWarnings>() {
288                Some(include_warnings) => include_warnings.0,
289                None => ProjectSettings::get_global(cx).diagnostics.include_warnings,
290            };
291
292            let diagnostics = cx.new(|cx| {
293                ProjectDiagnosticsEditor::new(
294                    include_warnings,
295                    workspace.project().clone(),
296                    workspace_handle,
297                    window,
298                    cx,
299                )
300            });
301            workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, window, cx);
302        }
303    }
304
305    fn toggle_warnings(&mut self, _: &ToggleWarnings, _: &mut Window, cx: &mut Context<Self>) {
306        cx.set_global(IncludeWarnings(!self.include_warnings));
307    }
308
309    fn focus_in(&mut self, window: &mut Window, cx: &mut Context<Self>) {
310        if self.focus_handle.is_focused(window) && !self.multibuffer.read(cx).is_empty() {
311            self.editor.focus_handle(cx).focus(window)
312        }
313    }
314
315    fn focus_out(&mut self, window: &mut Window, cx: &mut Context<Self>) {
316        if !self.focus_handle.is_focused(window) && !self.editor.focus_handle(cx).is_focused(window)
317        {
318            self.update_stale_excerpts(window, cx);
319        }
320    }
321
322    /// Enqueue an update of all excerpts. Updates all paths that either
323    /// currently have diagnostics or are currently present in this view.
324    fn update_all_excerpts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
325        self.project.update(cx, |project, cx| {
326            let mut paths = project
327                .diagnostic_summaries(false, cx)
328                .map(|(path, _, _)| path)
329                .collect::<BTreeSet<_>>();
330            self.multibuffer.update(cx, |multibuffer, cx| {
331                for buffer in multibuffer.all_buffers() {
332                    if let Some(file) = buffer.read(cx).file() {
333                        paths.insert(ProjectPath {
334                            path: file.path().clone(),
335                            worktree_id: file.worktree_id(cx),
336                        });
337                    }
338                }
339            });
340            self.paths_to_update = paths;
341        });
342        self.update_stale_excerpts(window, cx);
343    }
344
345    fn diagnostics_are_unchanged(
346        &self,
347        existing: &Vec<DiagnosticEntry<text::Anchor>>,
348        new: &Vec<DiagnosticEntry<text::Anchor>>,
349        snapshot: &BufferSnapshot,
350    ) -> bool {
351        if existing.len() != new.len() {
352            return false;
353        }
354        existing.iter().zip(new.iter()).all(|(existing, new)| {
355            existing.diagnostic.message == new.diagnostic.message
356                && existing.diagnostic.severity == new.diagnostic.severity
357                && existing.diagnostic.is_primary == new.diagnostic.is_primary
358                && existing.range.to_offset(snapshot) == new.range.to_offset(snapshot)
359        })
360    }
361
362    fn update_excerpts(
363        &mut self,
364        buffer: Entity<Buffer>,
365        window: &mut Window,
366        cx: &mut Context<Self>,
367    ) -> Task<Result<()>> {
368        let was_empty = self.multibuffer.read(cx).is_empty();
369        let buffer_snapshot = buffer.read(cx).snapshot();
370        let buffer_id = buffer_snapshot.remote_id();
371        let max_severity = if self.include_warnings {
372            DiagnosticSeverity::WARNING
373        } else {
374            DiagnosticSeverity::ERROR
375        };
376
377        cx.spawn_in(window, async move |this, mut cx| {
378            let diagnostics = buffer_snapshot
379                .diagnostics_in_range::<_, text::Anchor>(
380                    Point::zero()..buffer_snapshot.max_point(),
381                    false,
382                )
383                .collect::<Vec<_>>();
384            let unchanged = this.update(cx, |this, _| {
385                if this.diagnostics.get(&buffer_id).is_some_and(|existing| {
386                    this.diagnostics_are_unchanged(existing, &diagnostics, &buffer_snapshot)
387                }) {
388                    return true;
389                }
390                this.diagnostics.insert(buffer_id, diagnostics.clone());
391                return false;
392            })?;
393            if unchanged {
394                return Ok(());
395            }
396
397            let mut grouped: HashMap<usize, Vec<_>> = HashMap::default();
398            for entry in diagnostics {
399                grouped
400                    .entry(entry.diagnostic.group_id)
401                    .or_default()
402                    .push(DiagnosticEntry {
403                        range: entry.range.to_point(&buffer_snapshot),
404                        diagnostic: entry.diagnostic,
405                    })
406            }
407            let mut blocks: Vec<DiagnosticBlock> = Vec::new();
408
409            for (_, group) in grouped {
410                let group_severity = group.iter().map(|d| d.diagnostic.severity).min();
411                if group_severity.is_none_or(|s| s > max_severity) {
412                    continue;
413                }
414                let more = cx.update(|_, cx| {
415                    crate::diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group(
416                        group,
417                        buffer_snapshot.remote_id(),
418                        Some(this.clone()),
419                        cx,
420                    )
421                })?;
422
423                for item in more {
424                    let insert_pos = blocks
425                        .binary_search_by(|existing| {
426                            match existing.initial_range.start.cmp(&item.initial_range.start) {
427                                Ordering::Equal => item
428                                    .initial_range
429                                    .end
430                                    .cmp(&existing.initial_range.end)
431                                    .reverse(),
432                                other => other,
433                            }
434                        })
435                        .unwrap_or_else(|pos| pos);
436
437                    blocks.insert(insert_pos, item);
438                }
439            }
440
441            let mut excerpt_ranges: Vec<ExcerptRange<Point>> = Vec::new();
442            for b in blocks.iter() {
443                let excerpt_range = context_range_for_entry(
444                    b.initial_range.clone(),
445                    DEFAULT_MULTIBUFFER_CONTEXT,
446                    buffer_snapshot.clone(),
447                    &mut cx,
448                )
449                .await;
450                excerpt_ranges.push(ExcerptRange {
451                    context: excerpt_range,
452                    primary: b.initial_range.clone(),
453                })
454            }
455
456            this.update_in(cx, |this, window, cx| {
457                if let Some(block_ids) = this.blocks.remove(&buffer_id) {
458                    this.editor.update(cx, |editor, cx| {
459                        editor.display_map.update(cx, |display_map, cx| {
460                            display_map.remove_blocks(block_ids.into_iter().collect(), cx)
461                        });
462                    })
463                }
464                let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| {
465                    multi_buffer.set_excerpt_ranges_for_path(
466                        PathKey::for_buffer(&buffer, cx),
467                        buffer.clone(),
468                        &buffer_snapshot,
469                        excerpt_ranges,
470                        cx,
471                    )
472                });
473                #[cfg(test)]
474                let cloned_blocks = blocks.clone();
475
476                if was_empty {
477                    if let Some(anchor_range) = anchor_ranges.first() {
478                        let range_to_select = anchor_range.start..anchor_range.start;
479                        this.editor.update(cx, |editor, cx| {
480                            editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
481                                s.select_anchor_ranges([range_to_select]);
482                            })
483                        });
484                        if this.focus_handle.is_focused(window) {
485                            this.editor.read(cx).focus_handle(cx).focus(window);
486                        }
487                    }
488                }
489
490                let editor_blocks =
491                    anchor_ranges
492                        .into_iter()
493                        .zip(blocks.into_iter())
494                        .map(|(anchor, block)| {
495                            let editor = this.editor.downgrade();
496                            BlockProperties {
497                                placement: BlockPlacement::Near(anchor.start),
498                                height: Some(1),
499                                style: BlockStyle::Flex,
500                                render: Arc::new(move |bcx| {
501                                    block.render_block(editor.clone(), bcx)
502                                }),
503                                priority: 1,
504                            }
505                        });
506                let block_ids = this.editor.update(cx, |editor, cx| {
507                    editor.display_map.update(cx, |display_map, cx| {
508                        display_map.insert_blocks(editor_blocks, cx)
509                    })
510                });
511
512                #[cfg(test)]
513                {
514                    for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) {
515                        let markdown = block.markdown.clone();
516                        editor::test::set_block_content_for_tests(
517                            &this.editor,
518                            *block_id,
519                            cx,
520                            move |cx| {
521                                markdown::MarkdownElement::rendered_text(
522                                    markdown.clone(),
523                                    cx,
524                                    editor::hover_markdown_style,
525                                )
526                            },
527                        );
528                    }
529                }
530
531                this.blocks.insert(buffer_id, block_ids);
532                cx.notify()
533            })
534        })
535    }
536}
537
538impl Focusable for ProjectDiagnosticsEditor {
539    fn focus_handle(&self, _: &App) -> FocusHandle {
540        self.focus_handle.clone()
541    }
542}
543
544impl Item for ProjectDiagnosticsEditor {
545    type Event = EditorEvent;
546
547    fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
548        Editor::to_item_events(event, f)
549    }
550
551    fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
552        self.editor
553            .update(cx, |editor, cx| editor.deactivated(window, cx));
554    }
555
556    fn navigate(
557        &mut self,
558        data: Box<dyn Any>,
559        window: &mut Window,
560        cx: &mut Context<Self>,
561    ) -> bool {
562        self.editor
563            .update(cx, |editor, cx| editor.navigate(data, window, cx))
564    }
565
566    fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
567        Some("Project Diagnostics".into())
568    }
569
570    fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
571        "Diagnostics".into()
572    }
573
574    fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
575        h_flex()
576            .gap_1()
577            .when(
578                self.summary.error_count == 0 && self.summary.warning_count == 0,
579                |then| {
580                    then.child(
581                        h_flex()
582                            .gap_1()
583                            .child(Icon::new(IconName::Check).color(Color::Success))
584                            .child(Label::new("No problems").color(params.text_color())),
585                    )
586                },
587            )
588            .when(self.summary.error_count > 0, |then| {
589                then.child(
590                    h_flex()
591                        .gap_1()
592                        .child(Icon::new(IconName::XCircle).color(Color::Error))
593                        .child(
594                            Label::new(self.summary.error_count.to_string())
595                                .color(params.text_color()),
596                        ),
597                )
598            })
599            .when(self.summary.warning_count > 0, |then| {
600                then.child(
601                    h_flex()
602                        .gap_1()
603                        .child(Icon::new(IconName::Warning).color(Color::Warning))
604                        .child(
605                            Label::new(self.summary.warning_count.to_string())
606                                .color(params.text_color()),
607                        ),
608                )
609            })
610            .into_any_element()
611    }
612
613    fn telemetry_event_text(&self) -> Option<&'static str> {
614        Some("Project Diagnostics Opened")
615    }
616
617    fn for_each_project_item(
618        &self,
619        cx: &App,
620        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
621    ) {
622        self.editor.for_each_project_item(cx, f)
623    }
624
625    fn is_singleton(&self, _: &App) -> bool {
626        false
627    }
628
629    fn set_nav_history(
630        &mut self,
631        nav_history: ItemNavHistory,
632        _: &mut Window,
633        cx: &mut Context<Self>,
634    ) {
635        self.editor.update(cx, |editor, _| {
636            editor.set_nav_history(Some(nav_history));
637        });
638    }
639
640    fn clone_on_split(
641        &self,
642        _workspace_id: Option<workspace::WorkspaceId>,
643        window: &mut Window,
644        cx: &mut Context<Self>,
645    ) -> Option<Entity<Self>>
646    where
647        Self: Sized,
648    {
649        Some(cx.new(|cx| {
650            ProjectDiagnosticsEditor::new(
651                self.include_warnings,
652                self.project.clone(),
653                self.workspace.clone(),
654                window,
655                cx,
656            )
657        }))
658    }
659
660    fn is_dirty(&self, cx: &App) -> bool {
661        self.multibuffer.read(cx).is_dirty(cx)
662    }
663
664    fn has_deleted_file(&self, cx: &App) -> bool {
665        self.multibuffer.read(cx).has_deleted_file(cx)
666    }
667
668    fn has_conflict(&self, cx: &App) -> bool {
669        self.multibuffer.read(cx).has_conflict(cx)
670    }
671
672    fn can_save(&self, _: &App) -> bool {
673        true
674    }
675
676    fn save(
677        &mut self,
678        format: bool,
679        project: Entity<Project>,
680        window: &mut Window,
681        cx: &mut Context<Self>,
682    ) -> Task<Result<()>> {
683        self.editor.save(format, project, window, cx)
684    }
685
686    fn save_as(
687        &mut self,
688        _: Entity<Project>,
689        _: ProjectPath,
690        _window: &mut Window,
691        _: &mut Context<Self>,
692    ) -> Task<Result<()>> {
693        unreachable!()
694    }
695
696    fn reload(
697        &mut self,
698        project: Entity<Project>,
699        window: &mut Window,
700        cx: &mut Context<Self>,
701    ) -> Task<Result<()>> {
702        self.editor.reload(project, window, cx)
703    }
704
705    fn act_as_type<'a>(
706        &'a self,
707        type_id: TypeId,
708        self_handle: &'a Entity<Self>,
709        _: &'a App,
710    ) -> Option<AnyView> {
711        if type_id == TypeId::of::<Self>() {
712            Some(self_handle.to_any())
713        } else if type_id == TypeId::of::<Editor>() {
714            Some(self.editor.to_any())
715        } else {
716            None
717        }
718    }
719
720    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
721        Some(Box::new(self.editor.clone()))
722    }
723
724    fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
725        ToolbarItemLocation::PrimaryLeft
726    }
727
728    fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
729        self.editor.breadcrumbs(theme, cx)
730    }
731
732    fn added_to_workspace(
733        &mut self,
734        workspace: &mut Workspace,
735        window: &mut Window,
736        cx: &mut Context<Self>,
737    ) {
738        self.editor.update(cx, |editor, cx| {
739            editor.added_to_workspace(workspace, window, cx)
740        });
741    }
742}
743
744const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32;
745
746async fn context_range_for_entry(
747    range: Range<Point>,
748    context: u32,
749    snapshot: BufferSnapshot,
750    cx: &mut AsyncApp,
751) -> Range<Point> {
752    if let Some(rows) = heuristic_syntactic_expand(
753        range.clone(),
754        DIAGNOSTIC_EXPANSION_ROW_LIMIT,
755        snapshot.clone(),
756        cx,
757    )
758    .await
759    {
760        return Range {
761            start: Point::new(*rows.start(), 0),
762            end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left),
763        };
764    }
765    Range {
766        start: Point::new(range.start.row.saturating_sub(context), 0),
767        end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left),
768    }
769}
770
771/// Expands the input range using syntax information from TreeSitter. This expansion will be limited
772/// to the specified `max_row_count`.
773///
774/// If there is a containing outline item that is less than `max_row_count`, it will be returned.
775/// Otherwise fairly arbitrary heuristics are applied to attempt to return a logical block of code.
776async fn heuristic_syntactic_expand(
777    input_range: Range<Point>,
778    max_row_count: u32,
779    snapshot: BufferSnapshot,
780    cx: &mut AsyncApp,
781) -> Option<RangeInclusive<BufferRow>> {
782    let input_row_count = input_range.end.row - input_range.start.row;
783    if input_row_count > max_row_count {
784        return None;
785    }
786
787    // If the outline node contains the diagnostic and is small enough, just use that.
788    let outline_range = snapshot.outline_range_containing(input_range.clone());
789    if let Some(outline_range) = outline_range.clone() {
790        // Remove blank lines from start and end
791        if let Some(start_row) = (outline_range.start.row..outline_range.end.row)
792            .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
793        {
794            if let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
795                .rev()
796                .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
797            {
798                let row_count = end_row.saturating_sub(start_row);
799                if row_count <= max_row_count {
800                    return Some(RangeInclusive::new(
801                        outline_range.start.row,
802                        outline_range.end.row,
803                    ));
804                }
805            }
806        }
807    }
808
809    let mut node = snapshot.syntax_ancestor(input_range.clone())?;
810
811    loop {
812        let node_start = Point::from_ts_point(node.start_position());
813        let node_end = Point::from_ts_point(node.end_position());
814        let node_range = node_start..node_end;
815        let row_count = node_end.row - node_start.row + 1;
816        let mut ancestor_range = None;
817        let reached_outline_node = cx.background_executor().scoped({
818                 let node_range = node_range.clone();
819                 let outline_range = outline_range.clone();
820                 let ancestor_range =  &mut ancestor_range;
821                |scope| {scope.spawn(async move {
822                    // Stop if we've exceeded the row count or reached an outline node. Then, find the interval
823                    // of node children which contains the query range. For example, this allows just returning
824                    // the header of a declaration rather than the entire declaration.
825                    if row_count > max_row_count || outline_range == Some(node_range.clone()) {
826                        let mut cursor = node.walk();
827                        let mut included_child_start = None;
828                        let mut included_child_end = None;
829                        let mut previous_end = node_start;
830                        if cursor.goto_first_child() {
831                            loop {
832                                let child_node = cursor.node();
833                                let child_range = previous_end..Point::from_ts_point(child_node.end_position());
834                                if included_child_start.is_none() && child_range.contains(&input_range.start) {
835                                    included_child_start = Some(child_range.start);
836                                }
837                                if child_range.contains(&input_range.end) {
838                                    included_child_end = Some(child_range.end);
839                                }
840                                previous_end = child_range.end;
841                                if !cursor.goto_next_sibling() {
842                                    break;
843                                }
844                            }
845                        }
846                        let end = included_child_end.unwrap_or(node_range.end);
847                        if let Some(start) = included_child_start {
848                            let row_count = end.row - start.row;
849                            if row_count < max_row_count {
850                                *ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row)));
851                                return;
852                            }
853                        }
854
855                        log::info!(
856                            "Expanding to ancestor started on {} node exceeding row limit of {max_row_count}.",
857                            node.grammar_name()
858                        );
859                        *ancestor_range = Some(None);
860                    }
861                })
862            }});
863        reached_outline_node.await;
864        if let Some(node) = ancestor_range {
865            return node;
866        }
867
868        let node_name = node.grammar_name();
869        let node_row_range = RangeInclusive::new(node_range.start.row, node_range.end.row);
870        if node_name.ends_with("block") {
871            return Some(node_row_range);
872        } else if node_name.ends_with("statement") || node_name.ends_with("declaration") {
873            // Expand to the nearest dedent or blank line for statements and declarations.
874            let tab_size = cx
875                .update(|cx| snapshot.settings_at(node_range.start, cx).tab_size.get())
876                .ok()?;
877            let indent_level = snapshot
878                .line_indent_for_row(node_range.start.row)
879                .len(tab_size);
880            let rows_remaining = max_row_count.saturating_sub(row_count);
881            let Some(start_row) = (node_range.start.row.saturating_sub(rows_remaining)
882                ..node_range.start.row)
883                .rev()
884                .find(|row| {
885                    is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
886                })
887            else {
888                return Some(node_row_range);
889            };
890            let rows_remaining = max_row_count.saturating_sub(node_range.end.row - start_row);
891            let Some(end_row) = (node_range.end.row + 1
892                ..cmp::min(
893                    node_range.end.row + rows_remaining + 1,
894                    snapshot.row_count(),
895                ))
896                .find(|row| {
897                    is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
898                })
899            else {
900                return Some(node_row_range);
901            };
902            return Some(RangeInclusive::new(start_row, end_row));
903        }
904
905        // TODO: doing this instead of walking a cursor as that doesn't work - why?
906        let Some(parent) = node.parent() else {
907            log::info!(
908                "Expanding to ancestor reached the top node, so using default context line count.",
909            );
910            return None;
911        };
912        node = parent;
913    }
914}
915
916fn is_line_blank_or_indented_less(
917    indent_level: u32,
918    row: u32,
919    tab_size: u32,
920    snapshot: &BufferSnapshot,
921) -> bool {
922    let line_indent = snapshot.line_indent_for_row(row);
923    line_indent.is_line_blank() || line_indent.len(tab_size) < indent_level
924}