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}