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 true,
420 cx,
421 )
422 })?;
423
424 for item in more {
425 let insert_pos = blocks
426 .binary_search_by(|existing| {
427 match existing.initial_range.start.cmp(&item.initial_range.start) {
428 Ordering::Equal => item
429 .initial_range
430 .end
431 .cmp(&existing.initial_range.end)
432 .reverse(),
433 other => other,
434 }
435 })
436 .unwrap_or_else(|pos| pos);
437
438 blocks.insert(insert_pos, item);
439 }
440 }
441
442 let mut excerpt_ranges: Vec<ExcerptRange<Point>> = Vec::new();
443 for b in blocks.iter() {
444 let excerpt_range = context_range_for_entry(
445 b.initial_range.clone(),
446 DEFAULT_MULTIBUFFER_CONTEXT,
447 buffer_snapshot.clone(),
448 &mut cx,
449 )
450 .await;
451 excerpt_ranges.push(ExcerptRange {
452 context: excerpt_range,
453 primary: b.initial_range.clone(),
454 })
455 }
456
457 this.update_in(cx, |this, window, cx| {
458 if let Some(block_ids) = this.blocks.remove(&buffer_id) {
459 this.editor.update(cx, |editor, cx| {
460 editor.display_map.update(cx, |display_map, cx| {
461 display_map.remove_blocks(block_ids.into_iter().collect(), cx)
462 });
463 })
464 }
465 let (anchor_ranges, _) = this.multibuffer.update(cx, |multi_buffer, cx| {
466 multi_buffer.set_excerpt_ranges_for_path(
467 PathKey::for_buffer(&buffer, cx),
468 buffer.clone(),
469 &buffer_snapshot,
470 excerpt_ranges,
471 cx,
472 )
473 });
474 #[cfg(test)]
475 let cloned_blocks = blocks.clone();
476
477 if was_empty {
478 if let Some(anchor_range) = anchor_ranges.first() {
479 let range_to_select = anchor_range.start..anchor_range.start;
480 this.editor.update(cx, |editor, cx| {
481 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
482 s.select_anchor_ranges([range_to_select]);
483 })
484 });
485 if this.focus_handle.is_focused(window) {
486 this.editor.read(cx).focus_handle(cx).focus(window);
487 }
488 }
489 }
490
491 let editor_blocks =
492 anchor_ranges
493 .into_iter()
494 .zip(blocks.into_iter())
495 .map(|(anchor, block)| {
496 let editor = this.editor.downgrade();
497 BlockProperties {
498 placement: BlockPlacement::Near(anchor.start),
499 height: Some(1),
500 style: BlockStyle::Flex,
501 render: Arc::new(move |bcx| {
502 block.render_block(editor.clone(), bcx)
503 }),
504 priority: 1,
505 }
506 });
507 let block_ids = this.editor.update(cx, |editor, cx| {
508 editor.display_map.update(cx, |display_map, cx| {
509 display_map.insert_blocks(editor_blocks, cx)
510 })
511 });
512
513 #[cfg(test)]
514 {
515 for (block_id, block) in block_ids.iter().zip(cloned_blocks.iter()) {
516 let markdown = block.markdown.clone();
517 editor::test::set_block_content_for_tests(
518 &this.editor,
519 *block_id,
520 cx,
521 move |cx| {
522 markdown::MarkdownElement::rendered_text(
523 markdown.clone(),
524 cx,
525 editor::hover_markdown_style,
526 )
527 },
528 );
529 }
530 }
531
532 this.blocks.insert(buffer_id, block_ids);
533 cx.notify()
534 })
535 })
536 }
537}
538
539impl Focusable for ProjectDiagnosticsEditor {
540 fn focus_handle(&self, _: &App) -> FocusHandle {
541 self.focus_handle.clone()
542 }
543}
544
545impl Item for ProjectDiagnosticsEditor {
546 type Event = EditorEvent;
547
548 fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) {
549 Editor::to_item_events(event, f)
550 }
551
552 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
553 self.editor
554 .update(cx, |editor, cx| editor.deactivated(window, cx));
555 }
556
557 fn navigate(
558 &mut self,
559 data: Box<dyn Any>,
560 window: &mut Window,
561 cx: &mut Context<Self>,
562 ) -> bool {
563 self.editor
564 .update(cx, |editor, cx| editor.navigate(data, window, cx))
565 }
566
567 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
568 Some("Project Diagnostics".into())
569 }
570
571 fn tab_content(&self, params: TabContentParams, _window: &Window, _: &App) -> AnyElement {
572 h_flex()
573 .gap_1()
574 .when(
575 self.summary.error_count == 0 && self.summary.warning_count == 0,
576 |then| {
577 then.child(
578 h_flex()
579 .gap_1()
580 .child(Icon::new(IconName::Check).color(Color::Success))
581 .child(Label::new("No problems").color(params.text_color())),
582 )
583 },
584 )
585 .when(self.summary.error_count > 0, |then| {
586 then.child(
587 h_flex()
588 .gap_1()
589 .child(Icon::new(IconName::XCircle).color(Color::Error))
590 .child(
591 Label::new(self.summary.error_count.to_string())
592 .color(params.text_color()),
593 ),
594 )
595 })
596 .when(self.summary.warning_count > 0, |then| {
597 then.child(
598 h_flex()
599 .gap_1()
600 .child(Icon::new(IconName::Warning).color(Color::Warning))
601 .child(
602 Label::new(self.summary.warning_count.to_string())
603 .color(params.text_color()),
604 ),
605 )
606 })
607 .into_any_element()
608 }
609
610 fn telemetry_event_text(&self) -> Option<&'static str> {
611 Some("Project Diagnostics Opened")
612 }
613
614 fn for_each_project_item(
615 &self,
616 cx: &App,
617 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
618 ) {
619 self.editor.for_each_project_item(cx, f)
620 }
621
622 fn is_singleton(&self, _: &App) -> bool {
623 false
624 }
625
626 fn set_nav_history(
627 &mut self,
628 nav_history: ItemNavHistory,
629 _: &mut Window,
630 cx: &mut Context<Self>,
631 ) {
632 self.editor.update(cx, |editor, _| {
633 editor.set_nav_history(Some(nav_history));
634 });
635 }
636
637 fn clone_on_split(
638 &self,
639 _workspace_id: Option<workspace::WorkspaceId>,
640 window: &mut Window,
641 cx: &mut Context<Self>,
642 ) -> Option<Entity<Self>>
643 where
644 Self: Sized,
645 {
646 Some(cx.new(|cx| {
647 ProjectDiagnosticsEditor::new(
648 self.include_warnings,
649 self.project.clone(),
650 self.workspace.clone(),
651 window,
652 cx,
653 )
654 }))
655 }
656
657 fn is_dirty(&self, cx: &App) -> bool {
658 self.multibuffer.read(cx).is_dirty(cx)
659 }
660
661 fn has_deleted_file(&self, cx: &App) -> bool {
662 self.multibuffer.read(cx).has_deleted_file(cx)
663 }
664
665 fn has_conflict(&self, cx: &App) -> bool {
666 self.multibuffer.read(cx).has_conflict(cx)
667 }
668
669 fn can_save(&self, _: &App) -> bool {
670 true
671 }
672
673 fn save(
674 &mut self,
675 format: bool,
676 project: Entity<Project>,
677 window: &mut Window,
678 cx: &mut Context<Self>,
679 ) -> Task<Result<()>> {
680 self.editor.save(format, project, window, cx)
681 }
682
683 fn save_as(
684 &mut self,
685 _: Entity<Project>,
686 _: ProjectPath,
687 _window: &mut Window,
688 _: &mut Context<Self>,
689 ) -> Task<Result<()>> {
690 unreachable!()
691 }
692
693 fn reload(
694 &mut self,
695 project: Entity<Project>,
696 window: &mut Window,
697 cx: &mut Context<Self>,
698 ) -> Task<Result<()>> {
699 self.editor.reload(project, window, cx)
700 }
701
702 fn act_as_type<'a>(
703 &'a self,
704 type_id: TypeId,
705 self_handle: &'a Entity<Self>,
706 _: &'a App,
707 ) -> Option<AnyView> {
708 if type_id == TypeId::of::<Self>() {
709 Some(self_handle.to_any())
710 } else if type_id == TypeId::of::<Editor>() {
711 Some(self.editor.to_any())
712 } else {
713 None
714 }
715 }
716
717 fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
718 Some(Box::new(self.editor.clone()))
719 }
720
721 fn breadcrumb_location(&self, _: &App) -> ToolbarItemLocation {
722 ToolbarItemLocation::PrimaryLeft
723 }
724
725 fn breadcrumbs(&self, theme: &theme::Theme, cx: &App) -> Option<Vec<BreadcrumbText>> {
726 self.editor.breadcrumbs(theme, cx)
727 }
728
729 fn added_to_workspace(
730 &mut self,
731 workspace: &mut Workspace,
732 window: &mut Window,
733 cx: &mut Context<Self>,
734 ) {
735 self.editor.update(cx, |editor, cx| {
736 editor.added_to_workspace(workspace, window, cx)
737 });
738 }
739}
740
741const DIAGNOSTIC_EXPANSION_ROW_LIMIT: u32 = 32;
742
743async fn context_range_for_entry(
744 range: Range<Point>,
745 context: u32,
746 snapshot: BufferSnapshot,
747 cx: &mut AsyncApp,
748) -> Range<Point> {
749 if let Some(rows) = heuristic_syntactic_expand(
750 range.clone(),
751 DIAGNOSTIC_EXPANSION_ROW_LIMIT,
752 snapshot.clone(),
753 cx,
754 )
755 .await
756 {
757 return Range {
758 start: Point::new(*rows.start(), 0),
759 end: snapshot.clip_point(Point::new(*rows.end(), u32::MAX), Bias::Left),
760 };
761 }
762 Range {
763 start: Point::new(range.start.row.saturating_sub(context), 0),
764 end: snapshot.clip_point(Point::new(range.end.row + context, u32::MAX), Bias::Left),
765 }
766}
767
768/// Expands the input range using syntax information from TreeSitter. This expansion will be limited
769/// to the specified `max_row_count`.
770///
771/// If there is a containing outline item that is less than `max_row_count`, it will be returned.
772/// Otherwise fairly arbitrary heuristics are applied to attempt to return a logical block of code.
773async fn heuristic_syntactic_expand(
774 input_range: Range<Point>,
775 max_row_count: u32,
776 snapshot: BufferSnapshot,
777 cx: &mut AsyncApp,
778) -> Option<RangeInclusive<BufferRow>> {
779 let input_row_count = input_range.end.row - input_range.start.row;
780 if input_row_count > max_row_count {
781 return None;
782 }
783
784 // If the outline node contains the diagnostic and is small enough, just use that.
785 let outline_range = snapshot.outline_range_containing(input_range.clone());
786 if let Some(outline_range) = outline_range.clone() {
787 // Remove blank lines from start and end
788 if let Some(start_row) = (outline_range.start.row..outline_range.end.row)
789 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
790 {
791 if let Some(end_row) = (outline_range.start.row..outline_range.end.row + 1)
792 .rev()
793 .find(|row| !snapshot.line_indent_for_row(*row).is_line_blank())
794 {
795 let row_count = end_row.saturating_sub(start_row);
796 if row_count <= max_row_count {
797 return Some(RangeInclusive::new(
798 outline_range.start.row,
799 outline_range.end.row,
800 ));
801 }
802 }
803 }
804 }
805
806 let mut node = snapshot.syntax_ancestor(input_range.clone())?;
807
808 loop {
809 let node_start = Point::from_ts_point(node.start_position());
810 let node_end = Point::from_ts_point(node.end_position());
811 let node_range = node_start..node_end;
812 let row_count = node_end.row - node_start.row + 1;
813 let mut ancestor_range = None;
814 let reached_outline_node = cx.background_executor().scoped({
815 let node_range = node_range.clone();
816 let outline_range = outline_range.clone();
817 let ancestor_range = &mut ancestor_range;
818 |scope| {scope.spawn(async move {
819 // Stop if we've exceeded the row count or reached an outline node. Then, find the interval
820 // of node children which contains the query range. For example, this allows just returning
821 // the header of a declaration rather than the entire declaration.
822 if row_count > max_row_count || outline_range == Some(node_range.clone()) {
823 let mut cursor = node.walk();
824 let mut included_child_start = None;
825 let mut included_child_end = None;
826 let mut previous_end = node_start;
827 if cursor.goto_first_child() {
828 loop {
829 let child_node = cursor.node();
830 let child_range = previous_end..Point::from_ts_point(child_node.end_position());
831 if included_child_start.is_none() && child_range.contains(&input_range.start) {
832 included_child_start = Some(child_range.start);
833 }
834 if child_range.contains(&input_range.end) {
835 included_child_end = Some(child_range.end);
836 }
837 previous_end = child_range.end;
838 if !cursor.goto_next_sibling() {
839 break;
840 }
841 }
842 }
843 let end = included_child_end.unwrap_or(node_range.end);
844 if let Some(start) = included_child_start {
845 let row_count = end.row - start.row;
846 if row_count < max_row_count {
847 *ancestor_range = Some(Some(RangeInclusive::new(start.row, end.row)));
848 return;
849 }
850 }
851
852 log::info!(
853 "Expanding to ancestor started on {} node exceeding row limit of {max_row_count}.",
854 node.grammar_name()
855 );
856 *ancestor_range = Some(None);
857 }
858 })
859 }});
860 reached_outline_node.await;
861 if let Some(node) = ancestor_range {
862 return node;
863 }
864
865 let node_name = node.grammar_name();
866 let node_row_range = RangeInclusive::new(node_range.start.row, node_range.end.row);
867 if node_name.ends_with("block") {
868 return Some(node_row_range);
869 } else if node_name.ends_with("statement") || node_name.ends_with("declaration") {
870 // Expand to the nearest dedent or blank line for statements and declarations.
871 let tab_size = cx
872 .update(|cx| snapshot.settings_at(node_range.start, cx).tab_size.get())
873 .ok()?;
874 let indent_level = snapshot
875 .line_indent_for_row(node_range.start.row)
876 .len(tab_size);
877 let rows_remaining = max_row_count.saturating_sub(row_count);
878 let Some(start_row) = (node_range.start.row.saturating_sub(rows_remaining)
879 ..node_range.start.row)
880 .rev()
881 .find(|row| {
882 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
883 })
884 else {
885 return Some(node_row_range);
886 };
887 let rows_remaining = max_row_count.saturating_sub(node_range.end.row - start_row);
888 let Some(end_row) = (node_range.end.row + 1
889 ..cmp::min(
890 node_range.end.row + rows_remaining + 1,
891 snapshot.row_count(),
892 ))
893 .find(|row| {
894 is_line_blank_or_indented_less(indent_level, *row, tab_size, &snapshot.clone())
895 })
896 else {
897 return Some(node_row_range);
898 };
899 return Some(RangeInclusive::new(start_row, end_row));
900 }
901
902 // TODO: doing this instead of walking a cursor as that doesn't work - why?
903 let Some(parent) = node.parent() else {
904 log::info!(
905 "Expanding to ancestor reached the top node, so using default context line count.",
906 );
907 return None;
908 };
909 node = parent;
910 }
911}
912
913fn is_line_blank_or_indented_less(
914 indent_level: u32,
915 row: u32,
916 tab_size: u32,
917 snapshot: &BufferSnapshot,
918) -> bool {
919 let line_indent = snapshot.line_indent_for_row(row);
920 line_indent.is_line_blank() || line_indent.len(tab_size) < indent_level
921}