1use crate::{
2 Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition,
3 GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase,
4 display_map::InlayOffset,
5 editor_settings::GoToDefinitionFallback,
6 hover_popover::{self, HoverState, InlayHover},
7 scroll::ScrollAmount,
8};
9use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Task, Window, px};
10use language::{Bias, ToOffset};
11use linkify::{LinkFinder, LinkKind};
12use lsp::LanguageServerId;
13use project::{
14 HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project,
15 ResolveState, ResolvedPath,
16};
17use settings::Settings;
18use std::ops::Range;
19use text;
20use theme::ActiveTheme as _;
21
22use util::{ResultExt, TryFutureExt as _, maybe};
23
24#[derive(Debug)]
25pub struct HoveredLinkState {
26 pub last_trigger_point: TriggerPoint,
27 pub preferred_kind: GotoDefinitionKind,
28 pub symbol_range: Option<RangeInEditor>,
29 pub links: Vec<HoverLink>,
30 pub task: Option<Task<Option<()>>>,
31}
32
33#[derive(Debug, Eq, PartialEq, Clone)]
34pub enum RangeInEditor {
35 Text(Range<Anchor>),
36 Inlay(InlayHighlight),
37}
38
39impl RangeInEditor {
40 pub fn as_text_range(&self) -> Option<Range<Anchor>> {
41 match self {
42 Self::Text(range) => Some(range.clone()),
43 Self::Inlay(_) => None,
44 }
45 }
46
47 pub fn point_within_range(
48 &self,
49 trigger_point: &TriggerPoint,
50 snapshot: &EditorSnapshot,
51 ) -> bool {
52 match (self, trigger_point) {
53 (Self::Text(range), TriggerPoint::Text(point)) => {
54 let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot).is_le();
55 point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot).is_ge()
56 }
57 (Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => {
58 highlight.inlay == point.inlay
59 && highlight.range.contains(&point.range.start)
60 && highlight.range.contains(&point.range.end)
61 }
62 (Self::Inlay(_), TriggerPoint::Text(_))
63 | (Self::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
69pub enum HoverLink {
70 Url(String),
71 File(ResolvedPath),
72 Text(LocationLink),
73 InlayHint(lsp::Location, LanguageServerId),
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct InlayHighlight {
78 pub inlay: InlayId,
79 pub inlay_position: Anchor,
80 pub range: Range<usize>,
81}
82
83#[derive(Debug, Clone, PartialEq)]
84pub enum TriggerPoint {
85 Text(Anchor),
86 InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
87}
88
89impl TriggerPoint {
90 fn anchor(&self) -> &Anchor {
91 match self {
92 TriggerPoint::Text(anchor) => anchor,
93 TriggerPoint::InlayHint(inlay_range, _, _) => &inlay_range.inlay_position,
94 }
95 }
96}
97
98pub fn exclude_link_to_position(
99 buffer: &Entity<language::Buffer>,
100 current_position: &text::Anchor,
101 location: &LocationLink,
102 cx: &App,
103) -> bool {
104 // Exclude definition links that points back to cursor position.
105 // (i.e., currently cursor upon definition).
106 let snapshot = buffer.read(cx).snapshot();
107 !(buffer == &location.target.buffer
108 && current_position
109 .bias_right(&snapshot)
110 .cmp(&location.target.range.start, &snapshot)
111 .is_ge()
112 && current_position
113 .cmp(&location.target.range.end, &snapshot)
114 .is_le())
115}
116
117impl Editor {
118 pub(crate) fn update_hovered_link(
119 &mut self,
120 point_for_position: PointForPosition,
121 snapshot: &EditorSnapshot,
122 modifiers: Modifiers,
123 window: &mut Window,
124 cx: &mut Context<Self>,
125 ) {
126 let hovered_link_modifier = Editor::multi_cursor_modifier(false, &modifiers, cx);
127
128 // Allow inlay hover points to be updated even without modifier key
129 if point_for_position.as_valid().is_none() {
130 // Hovering over inlay - check for hover tooltips
131 update_inlay_link_and_hover_points(
132 snapshot,
133 point_for_position,
134 self,
135 hovered_link_modifier,
136 modifiers.shift,
137 window,
138 cx,
139 );
140 return;
141 }
142
143 if !hovered_link_modifier || self.has_pending_selection() {
144 self.hide_hovered_link(cx);
145 return;
146 }
147
148 match point_for_position.as_valid() {
149 Some(point) => {
150 let trigger_point = TriggerPoint::Text(
151 snapshot
152 .buffer_snapshot
153 .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)),
154 );
155
156 show_link_definition(modifiers.shift, self, trigger_point, snapshot, window, cx);
157 }
158 None => {
159 // This case should not be reached anymore as we handle it above
160 unreachable!("Invalid position should have been handled earlier");
161 }
162 }
163 }
164
165 pub(crate) fn hide_hovered_link(&mut self, cx: &mut Context<Self>) {
166 self.hovered_link_state.take();
167 self.clear_highlights::<HoveredLinkState>(cx);
168 self.clear_background_highlights::<HoverState>(cx);
169 }
170
171 pub(crate) fn handle_click_hovered_link(
172 &mut self,
173 point: PointForPosition,
174 modifiers: Modifiers,
175 window: &mut Window,
176 cx: &mut Context<Editor>,
177 ) {
178 let reveal_task = self.cmd_click_reveal_task(point, modifiers, window, cx);
179 cx.spawn_in(window, async move |editor, cx| {
180 let definition_revealed = reveal_task.await.log_err().unwrap_or(Navigated::No);
181 let find_references = editor
182 .update_in(cx, |editor, window, cx| {
183 if definition_revealed == Navigated::Yes {
184 return None;
185 }
186 match EditorSettings::get_global(cx).go_to_definition_fallback {
187 GoToDefinitionFallback::None => None,
188 GoToDefinitionFallback::FindAllReferences => {
189 editor.find_all_references(&FindAllReferences, window, cx)
190 }
191 }
192 })
193 .ok()
194 .flatten();
195 if let Some(find_references) = find_references {
196 find_references.await.log_err();
197 }
198 })
199 .detach();
200 }
201
202 pub fn scroll_hover(
203 &mut self,
204 amount: &ScrollAmount,
205 window: &mut Window,
206 cx: &mut Context<Self>,
207 ) -> bool {
208 let selection = self.selections.newest_anchor().head();
209 let snapshot = self.snapshot(window, cx);
210
211 let Some(popover) = self.hover_state.info_popovers.iter().find(|popover| {
212 popover
213 .symbol_range
214 .point_within_range(&TriggerPoint::Text(selection), &snapshot)
215 }) else {
216 return false;
217 };
218 popover.scroll(amount, window, cx);
219 true
220 }
221
222 fn cmd_click_reveal_task(
223 &mut self,
224 point: PointForPosition,
225 modifiers: Modifiers,
226 window: &mut Window,
227 cx: &mut Context<Editor>,
228 ) -> Task<anyhow::Result<Navigated>> {
229 if let Some(hovered_link_state) = self.hovered_link_state.take() {
230 self.hide_hovered_link(cx);
231 if !hovered_link_state.links.is_empty() {
232 if !self.focus_handle.is_focused(window) {
233 window.focus(&self.focus_handle);
234 }
235
236 // exclude links pointing back to the current anchor
237 let current_position = point
238 .next_valid
239 .to_point(&self.snapshot(window, cx).display_snapshot);
240 let Some((buffer, anchor)) = self
241 .buffer()
242 .read(cx)
243 .text_anchor_for_position(current_position, cx)
244 else {
245 return Task::ready(Ok(Navigated::No));
246 };
247 let links = hovered_link_state
248 .links
249 .into_iter()
250 .filter(|link| {
251 if let HoverLink::Text(location) = link {
252 exclude_link_to_position(&buffer, &anchor, location, cx)
253 } else {
254 true
255 }
256 })
257 .collect();
258 let navigate_task =
259 self.navigate_to_hover_links(None, links, modifiers.alt, window, cx);
260 self.select(SelectPhase::End, window, cx);
261 return navigate_task;
262 }
263 }
264
265 // We don't have the correct kind of link cached, set the selection on
266 // click and immediately trigger GoToDefinition.
267 self.select(
268 SelectPhase::Begin {
269 position: point.next_valid,
270 add: false,
271 click_count: 1,
272 },
273 window,
274 cx,
275 );
276
277 let navigate_task = if point.as_valid().is_some() {
278 if modifiers.shift {
279 self.go_to_type_definition(&GoToTypeDefinition, window, cx)
280 } else {
281 self.go_to_definition(&GoToDefinition, window, cx)
282 }
283 } else {
284 Task::ready(Ok(Navigated::No))
285 };
286 self.select(SelectPhase::End, window, cx);
287 return navigate_task;
288 }
289}
290
291pub fn update_inlay_link_and_hover_points(
292 snapshot: &EditorSnapshot,
293 point_for_position: PointForPosition,
294 editor: &mut Editor,
295 secondary_held: bool,
296 shift_held: bool,
297 window: &mut Window,
298 cx: &mut Context<Editor>,
299) {
300 // For inlay hints, we need to use the exact position where the mouse is
301 // But we must clip it to valid bounds to avoid panics
302 let clipped_point = snapshot.clip_point(point_for_position.exact_unclipped, Bias::Left);
303 let hovered_offset = snapshot.display_point_to_inlay_offset(clipped_point, Bias::Left);
304
305 let mut go_to_definition_updated = false;
306
307 // Get all visible inlay hints
308 let visible_hints = editor.visible_inlay_hints(cx);
309
310 // Find if we're hovering over an inlay hint
311 let found_inlay = visible_hints.into_iter().find(|inlay| {
312 // Only process hint inlays
313 if !matches!(inlay.id, InlayId::Hint(_)) {
314 return false;
315 }
316
317 // Check if the hovered position falls within this inlay's display range
318 let inlay_start = snapshot.anchor_to_inlay_offset(inlay.position);
319 let inlay_end = InlayOffset(inlay_start.0 + inlay.text.len());
320
321 hovered_offset >= inlay_start && hovered_offset < inlay_end
322 });
323
324 if let Some(hovered_inlay) = found_inlay {
325 let inlay_hint_cache = editor.inlay_hint_cache();
326 let excerpt_id = hovered_inlay.position.excerpt_id;
327
328 // Extract the hint ID from the inlay
329 if let InlayId::Hint(_hint_id) = hovered_inlay.id {
330 if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_inlay.id) {
331 // Check if we should process this hint for hover
332 let should_process_hint = match cached_hint.resolve_state {
333 ResolveState::CanResolve(_, _) => {
334 // For unresolved hints, spawn resolution
335 if let Some(buffer_id) = hovered_inlay.position.buffer_id {
336 inlay_hint_cache.spawn_hint_resolve(
337 buffer_id,
338 excerpt_id,
339 hovered_inlay.id,
340 window,
341 cx,
342 );
343 }
344 false // Don't process unresolved hints
345 }
346 ResolveState::Resolved => true,
347 ResolveState::Resolving => {
348 false // Don't process further
349 }
350 };
351
352 if should_process_hint {
353 let mut extra_shift_left = 0;
354 let mut extra_shift_right = 0;
355 if cached_hint.padding_left {
356 extra_shift_left += 1;
357 extra_shift_right += 1;
358 }
359 if cached_hint.padding_right {
360 extra_shift_right += 1;
361 }
362 match cached_hint.label {
363 project::InlayHintLabel::String(_) => {
364 if let Some(tooltip) = cached_hint.tooltip {
365 hover_popover::hover_at_inlay(
366 editor,
367 InlayHover {
368 tooltip: match tooltip {
369 InlayHintTooltip::String(text) => HoverBlock {
370 text,
371 kind: HoverBlockKind::PlainText,
372 },
373 InlayHintTooltip::MarkupContent(content) => {
374 HoverBlock {
375 text: content.value,
376 kind: content.kind,
377 }
378 }
379 },
380 range: InlayHighlight {
381 inlay: hovered_inlay.id,
382 inlay_position: hovered_inlay.position,
383 range: extra_shift_left
384 ..hovered_inlay.text.len() + extra_shift_right,
385 },
386 },
387 window,
388 cx,
389 );
390 }
391 }
392 project::InlayHintLabel::LabelParts(label_parts) => {
393 // Find which specific part is being hovered
394 let hint_start =
395 snapshot.anchor_to_inlay_offset(hovered_inlay.position);
396
397 if let Some((part, part_range)) = hover_popover::find_hovered_hint_part(
398 label_parts,
399 hint_start,
400 hovered_offset,
401 ) {
402 let part_offset = (part_range.start - hint_start).0;
403 let part_len = (part_range.end - part_range.start).0;
404
405 if part.tooltip.is_some() || part.location.is_some() {
406 // Found the meaningful part - show hover for it
407 let highlight_start = part_offset + extra_shift_left;
408 let highlight_end = part_offset + part_len + extra_shift_right;
409
410 let highlight = InlayHighlight {
411 inlay: hovered_inlay.id,
412 inlay_position: hovered_inlay.position,
413 range: highlight_start..highlight_end,
414 };
415
416 if let Some(tooltip) = part.tooltip {
417 hover_popover::hover_at_inlay(
418 editor,
419 InlayHover {
420 tooltip: match tooltip {
421 InlayHintLabelPartTooltip::String(text) => {
422 HoverBlock {
423 text,
424 kind: HoverBlockKind::PlainText,
425 }
426 }
427 InlayHintLabelPartTooltip::MarkupContent(
428 content,
429 ) => HoverBlock {
430 text: content.value,
431 kind: content.kind,
432 },
433 },
434 range: highlight.clone(),
435 },
436 window,
437 cx,
438 );
439 } else if let Some((_language_server_id, location)) =
440 part.location.clone()
441 {
442 // When there's no tooltip but we have a location, perform a "Go to Definition" style operation
443 let filename = location
444 .uri
445 .path()
446 .split('/')
447 .next_back()
448 .unwrap_or("unknown")
449 .to_string();
450
451 hover_popover::hover_at_inlay(
452 editor,
453 InlayHover {
454 tooltip: HoverBlock {
455 text: "Loading documentation...".to_string(),
456 kind: HoverBlockKind::PlainText,
457 },
458 range: highlight.clone(),
459 },
460 window,
461 cx,
462 );
463 // Now perform the "Go to Definition" flow to get hover documentation
464 // Prepare data needed for the async task
465 if let Some(project) = editor.project.clone() {
466 let highlight = highlight.clone();
467 let hint_value = part.value.clone();
468
469 cx.spawn_in(window, async move |editor, cx| {
470 async move {
471 // Convert LSP URL to file path
472 let file_path = location.uri.to_file_path()
473 .map_err(|_| anyhow::anyhow!("Invalid file URL"))?;
474
475 // Open the definition file
476 let definition_buffer = project
477 .update(cx, |project, cx| {
478 project.open_local_buffer(file_path, cx)
479 })?
480 .await?;
481
482 // Register the buffer with language servers
483 let _lsp_handle = project.update(cx, |project, cx| {
484 project.register_buffer_with_language_servers(&definition_buffer, cx)
485 })?;
486
487 // Give LSP a moment to process the didOpen notification
488 cx.background_executor()
489 .timer(std::time::Duration::from_millis(100))
490 .await;
491
492 // Try to get hover documentation from LSP
493 let hover_position = location.range.start;
494
495 // Convert LSP position to a point
496 let hover_point = definition_buffer.update(cx, |buffer, _| {
497 let point_utf16 = language::point_from_lsp(hover_position);
498 let snapshot = buffer.snapshot();
499 let point = snapshot.clip_point_utf16(point_utf16, Bias::Left);
500 snapshot.anchor_after(point)
501 })?;
502
503 let hover_response = project
504 .update(cx, |project, cx| {
505 project.hover(&definition_buffer, hover_point, cx)
506 })?
507 .await;
508
509 if !hover_response.is_empty() {
510 // Get the first hover response
511 let hover = &hover_response[0];
512 if !hover.contents.is_empty() {
513 // Format the hover blocks as markdown
514 let mut formatted_docs = String::new();
515
516 // Add the type signature first
517 formatted_docs.push_str(&format!("```rust\n{}\n```\n\n", hint_value.trim()));
518
519 // Add all the hover content
520 for block in &hover.contents {
521 match &block.kind {
522 HoverBlockKind::Markdown => {
523 formatted_docs.push_str(&block.text);
524 formatted_docs.push_str("\n\n");
525 }
526 HoverBlockKind::Code { language } => {
527 formatted_docs.push_str(&format!("```{}\n{}\n```\n\n", language, block.text));
528 }
529 HoverBlockKind::PlainText => {
530 formatted_docs.push_str(&block.text);
531 formatted_docs.push_str("\n\n");
532 }
533 }
534 }
535
536 editor.update_in(cx, |editor, window, cx| {
537 hover_popover::hover_at_inlay(
538 editor,
539 InlayHover {
540 tooltip: HoverBlock {
541 text: formatted_docs.trim().to_string(),
542 kind: HoverBlockKind::Markdown,
543 },
544 range: highlight,
545 },
546 window,
547 cx,
548 );
549 }).log_err();
550
551 return Ok(());
552 }
553 }
554
555 // Fallback: Extract documentation directly from the source
556 let documentation = definition_buffer.update(cx, |buffer, _| {
557 let line_number = location.range.start.line as usize;
558
559 // Get the text of the buffer
560 let text = buffer.text();
561 let lines: Vec<&str> = text.lines().collect();
562
563 // Look backwards from the definition line to find doc comments
564 let mut doc_lines = Vec::new();
565 let mut current_line = line_number.saturating_sub(1);
566
567 // Skip any attributes like #[derive(...)]
568 while current_line > 0 && lines.get(current_line).map_or(false, |line| {
569 let trimmed = line.trim();
570 trimmed.starts_with("#[") || trimmed.is_empty()
571 }) {
572 current_line = current_line.saturating_sub(1);
573 }
574
575 // Collect doc comments
576 while current_line > 0 {
577 if let Some(line) = lines.get(current_line) {
578 let trimmed = line.trim();
579 if trimmed.starts_with("///") {
580 // Remove the /// and any leading space
581 let doc_text = trimmed.strip_prefix("///").unwrap_or("")
582 .strip_prefix(" ").unwrap_or_else(|| trimmed.strip_prefix("///").unwrap_or(""));
583 doc_lines.push(doc_text.to_string());
584 } else if !trimmed.is_empty() {
585 // Stop at the first non-doc, non-empty line
586 break;
587 }
588 }
589 current_line = current_line.saturating_sub(1);
590 }
591
592 // Reverse to get correct order
593 doc_lines.reverse();
594
595 // Also get the actual definition line
596 let definition = lines.get(line_number)
597 .map(|s| s.trim().to_string())
598 .unwrap_or_else(|| hint_value.clone());
599
600 if doc_lines.is_empty() {
601 None
602 } else {
603 let docs = doc_lines.join("\n");
604 Some((definition, docs))
605 }
606 })?;
607
608 if let Some((definition, docs)) = documentation {
609 // Format as markdown with the definition as a code block
610 let formatted_docs = format!("```rust\n{}\n```\n\n{}", definition, docs);
611
612 editor.update_in(cx, |editor, window, cx| {
613 hover_popover::hover_at_inlay(
614 editor,
615 InlayHover {
616 tooltip: HoverBlock {
617 text: formatted_docs,
618 kind: HoverBlockKind::Markdown,
619 },
620 range: highlight,
621 },
622 window,
623 cx,
624 );
625 }).log_err();
626 } else {
627 // Fallback to showing just the location info
628 let fallback_text = format!(
629 "{}\n\nDefined in {} at line {}",
630 hint_value.trim(),
631 filename,
632 location.range.start.line + 1
633 );
634 editor.update_in(cx, |editor, window, cx| {
635 hover_popover::hover_at_inlay(
636 editor,
637 InlayHover {
638 tooltip: HoverBlock {
639 text: fallback_text,
640 kind: HoverBlockKind::PlainText,
641 },
642 range: highlight,
643 },
644 window,
645 cx,
646 );
647 }).log_err();
648 }
649
650 anyhow::Ok(())
651 }
652 .log_err()
653 .await
654 }).detach();
655 }
656 }
657
658 if let Some((language_server_id, location)) = &part.location {
659 if secondary_held
660 && !editor.has_pending_nonempty_selection()
661 {
662 go_to_definition_updated = true;
663 show_link_definition(
664 shift_held,
665 editor,
666 TriggerPoint::InlayHint(
667 highlight,
668 location.clone(),
669 *language_server_id,
670 ),
671 snapshot,
672 window,
673 cx,
674 );
675 }
676 }
677 }
678 }
679 }
680 };
681 }
682 }
683 }
684 } else {
685 // No inlay is being hovered, hide any existing inlay hover
686 if editor
687 .hover_state
688 .info_popovers
689 .iter()
690 .any(|popover| matches!(popover.symbol_range, RangeInEditor::Inlay(_)))
691 {
692 hover_popover::hide_hover(editor, cx);
693 }
694 }
695
696 if !go_to_definition_updated {
697 editor.hide_hovered_link(cx)
698 }
699}
700
701pub fn show_link_definition(
702 shift_held: bool,
703 editor: &mut Editor,
704 trigger_point: TriggerPoint,
705 snapshot: &EditorSnapshot,
706 window: &mut Window,
707 cx: &mut Context<Editor>,
708) {
709 let preferred_kind = match trigger_point {
710 TriggerPoint::Text(_) if !shift_held => GotoDefinitionKind::Symbol,
711 _ => GotoDefinitionKind::Type,
712 };
713
714 let (mut hovered_link_state, is_cached) =
715 if let Some(existing) = editor.hovered_link_state.take() {
716 (existing, true)
717 } else {
718 (
719 HoveredLinkState {
720 last_trigger_point: trigger_point.clone(),
721 symbol_range: None,
722 preferred_kind,
723 links: vec![],
724 task: None,
725 },
726 false,
727 )
728 };
729
730 if editor.pending_rename.is_some() {
731 return;
732 }
733
734 let trigger_anchor = trigger_point.anchor();
735 let Some((buffer, buffer_position)) = editor
736 .buffer
737 .read(cx)
738 .text_anchor_for_position(*trigger_anchor, cx)
739 else {
740 return;
741 };
742
743 let Some((excerpt_id, _, _)) = editor
744 .buffer()
745 .read(cx)
746 .excerpt_containing(*trigger_anchor, cx)
747 else {
748 return;
749 };
750
751 let same_kind = hovered_link_state.preferred_kind == preferred_kind
752 || hovered_link_state
753 .links
754 .first()
755 .is_some_and(|d| matches!(d, HoverLink::Url(_)));
756
757 if same_kind {
758 if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
759 || hovered_link_state
760 .symbol_range
761 .as_ref()
762 .is_some_and(|symbol_range| {
763 symbol_range.point_within_range(&trigger_point, snapshot)
764 })
765 {
766 editor.hovered_link_state = Some(hovered_link_state);
767 return;
768 }
769 } else {
770 editor.hide_hovered_link(cx)
771 }
772 let project = editor.project.clone();
773 let provider = editor.semantics_provider.clone();
774
775 let snapshot = snapshot.buffer_snapshot.clone();
776 hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| {
777 async move {
778 let result = match &trigger_point {
779 TriggerPoint::Text(_) => {
780 if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
781 this.read_with(cx, |_, _| {
782 let range = maybe!({
783 let start =
784 snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
785 let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?;
786 Some(RangeInEditor::Text(start..end))
787 });
788 (range, vec![HoverLink::Url(url)])
789 })
790 .ok()
791 } else if let Some((filename_range, filename)) =
792 find_file(&buffer, project.clone(), buffer_position, cx).await
793 {
794 let range = maybe!({
795 let start =
796 snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?;
797 let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?;
798 Some(RangeInEditor::Text(start..end))
799 });
800
801 Some((range, vec![HoverLink::File(filename)]))
802 } else if let Some(provider) = provider {
803 let task = cx.update(|_, cx| {
804 provider.definitions(&buffer, buffer_position, preferred_kind, cx)
805 })?;
806 if let Some(task) = task {
807 task.await.ok().map(|definition_result| {
808 (
809 definition_result.iter().find_map(|link| {
810 link.origin.as_ref().and_then(|origin| {
811 let start = snapshot.anchor_in_excerpt(
812 excerpt_id,
813 origin.range.start,
814 )?;
815 let end = snapshot
816 .anchor_in_excerpt(excerpt_id, origin.range.end)?;
817 Some(RangeInEditor::Text(start..end))
818 })
819 }),
820 definition_result.into_iter().map(HoverLink::Text).collect(),
821 )
822 })
823 } else {
824 None
825 }
826 } else {
827 None
828 }
829 }
830 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
831 Some(RangeInEditor::Inlay(highlight.clone())),
832 vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
833 )),
834 };
835
836 this.update(cx, |editor, cx| {
837 // Clear any existing highlights
838 editor.clear_highlights::<HoveredLinkState>(cx);
839 let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
840 editor.hide_hovered_link(cx);
841 return;
842 };
843 hovered_link_state.preferred_kind = preferred_kind;
844 hovered_link_state.symbol_range = result
845 .as_ref()
846 .and_then(|(symbol_range, _)| symbol_range.clone());
847
848 if let Some((symbol_range, definitions)) = result {
849 hovered_link_state.links = definitions;
850
851 let underline_hovered_link = !hovered_link_state.links.is_empty()
852 || hovered_link_state.symbol_range.is_some();
853
854 if underline_hovered_link {
855 let style = gpui::HighlightStyle {
856 underline: Some(gpui::UnderlineStyle {
857 thickness: px(1.),
858 ..Default::default()
859 }),
860 color: Some(cx.theme().colors().link_text_hover),
861 ..Default::default()
862 };
863 let highlight_range =
864 symbol_range.unwrap_or_else(|| match &trigger_point {
865 TriggerPoint::Text(trigger_anchor) => {
866 // If no symbol range returned from language server, use the surrounding word.
867 let (offset_range, _) =
868 snapshot.surrounding_word(*trigger_anchor, false);
869 RangeInEditor::Text(
870 snapshot.anchor_before(offset_range.start)
871 ..snapshot.anchor_after(offset_range.end),
872 )
873 }
874 TriggerPoint::InlayHint(highlight, _, _) => {
875 RangeInEditor::Inlay(highlight.clone())
876 }
877 });
878
879 match highlight_range {
880 RangeInEditor::Text(text_range) => editor
881 .highlight_text::<HoveredLinkState>(vec![text_range], style, cx),
882 RangeInEditor::Inlay(highlight) => editor
883 .highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
884 }
885 }
886 } else {
887 editor.hide_hovered_link(cx);
888 }
889 })?;
890
891 anyhow::Ok(())
892 }
893 .log_err()
894 .await
895 }));
896
897 editor.hovered_link_state = Some(hovered_link_state);
898}
899
900pub(crate) fn find_url(
901 buffer: &Entity<language::Buffer>,
902 position: text::Anchor,
903 mut cx: AsyncWindowContext,
904) -> Option<(Range<text::Anchor>, String)> {
905 const LIMIT: usize = 2048;
906
907 let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
908 return None;
909 };
910
911 let offset = position.to_offset(&snapshot);
912 let mut token_start = offset;
913 let mut token_end = offset;
914 let mut found_start = false;
915 let mut found_end = false;
916
917 for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
918 if ch.is_whitespace() {
919 found_start = true;
920 break;
921 }
922 token_start -= ch.len_utf8();
923 }
924 // Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
925 if !found_start && token_start != 0 {
926 return None;
927 }
928
929 for ch in snapshot
930 .chars_at(offset)
931 .take(LIMIT - (offset - token_start))
932 {
933 if ch.is_whitespace() {
934 found_end = true;
935 break;
936 }
937 token_end += ch.len_utf8();
938 }
939 // Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
940 // which at this point would happen only if we reached the end of buffer
941 if !found_end && (token_end - token_start >= LIMIT) {
942 return None;
943 }
944
945 let mut finder = LinkFinder::new();
946 finder.kinds(&[LinkKind::Url]);
947 let input = snapshot
948 .text_for_range(token_start..token_end)
949 .collect::<String>();
950
951 let relative_offset = offset - token_start;
952 for link in finder.links(&input) {
953 if link.start() <= relative_offset && link.end() >= relative_offset {
954 let range = snapshot.anchor_before(token_start + link.start())
955 ..snapshot.anchor_after(token_start + link.end());
956 return Some((range, link.as_str().to_string()));
957 }
958 }
959 None
960}
961
962pub(crate) fn find_url_from_range(
963 buffer: &Entity<language::Buffer>,
964 range: Range<text::Anchor>,
965 mut cx: AsyncWindowContext,
966) -> Option<String> {
967 const LIMIT: usize = 2048;
968
969 let Ok(snapshot) = buffer.read_with(&mut cx, |buffer, _| buffer.snapshot()) else {
970 return None;
971 };
972
973 let start_offset = range.start.to_offset(&snapshot);
974 let end_offset = range.end.to_offset(&snapshot);
975
976 let mut token_start = start_offset.min(end_offset);
977 let mut token_end = start_offset.max(end_offset);
978
979 let range_len = token_end - token_start;
980
981 if range_len >= LIMIT {
982 return None;
983 }
984
985 // Skip leading whitespace
986 for ch in snapshot.chars_at(token_start).take(range_len) {
987 if !ch.is_whitespace() {
988 break;
989 }
990 token_start += ch.len_utf8();
991 }
992
993 // Skip trailing whitespace
994 for ch in snapshot.reversed_chars_at(token_end).take(range_len) {
995 if !ch.is_whitespace() {
996 break;
997 }
998 token_end -= ch.len_utf8();
999 }
1000
1001 if token_start >= token_end {
1002 return None;
1003 }
1004
1005 let text = snapshot
1006 .text_for_range(token_start..token_end)
1007 .collect::<String>();
1008
1009 let mut finder = LinkFinder::new();
1010 finder.kinds(&[LinkKind::Url]);
1011
1012 if let Some(link) = finder.links(&text).next() {
1013 if link.start() == 0 && link.end() == text.len() {
1014 return Some(link.as_str().to_string());
1015 }
1016 }
1017
1018 None
1019}
1020
1021pub(crate) async fn find_file(
1022 buffer: &Entity<language::Buffer>,
1023 project: Option<Entity<Project>>,
1024 position: text::Anchor,
1025 cx: &mut AsyncWindowContext,
1026) -> Option<(Range<text::Anchor>, ResolvedPath)> {
1027 let project = project?;
1028 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?;
1029 let scope = snapshot.language_scope_at(position);
1030 let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
1031
1032 async fn check_path(
1033 candidate_file_path: &str,
1034 project: &Entity<Project>,
1035 buffer: &Entity<language::Buffer>,
1036 cx: &mut AsyncWindowContext,
1037 ) -> Option<ResolvedPath> {
1038 project
1039 .update(cx, |project, cx| {
1040 project.resolve_path_in_buffer(&candidate_file_path, buffer, cx)
1041 })
1042 .ok()?
1043 .await
1044 .filter(|s| s.is_file())
1045 }
1046
1047 if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
1048 return Some((range, existing_path));
1049 }
1050
1051 if let Some(scope) = scope {
1052 for suffix in scope.path_suffixes() {
1053 if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
1054 continue;
1055 }
1056
1057 let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
1058 if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
1059 {
1060 return Some((range, existing_path));
1061 }
1062 }
1063 }
1064
1065 None
1066}
1067
1068fn surrounding_filename(
1069 snapshot: language::BufferSnapshot,
1070 position: text::Anchor,
1071) -> Option<(Range<text::Anchor>, String)> {
1072 const LIMIT: usize = 2048;
1073
1074 let offset = position.to_offset(&snapshot);
1075 let mut token_start = offset;
1076 let mut token_end = offset;
1077 let mut found_start = false;
1078 let mut found_end = false;
1079 let mut inside_quotes = false;
1080
1081 let mut filename = String::new();
1082
1083 let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
1084 while let Some(ch) = backwards.next() {
1085 // Escaped whitespace
1086 if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
1087 filename.push(ch);
1088 token_start -= ch.len_utf8();
1089 backwards.next();
1090 token_start -= '\\'.len_utf8();
1091 continue;
1092 }
1093 if ch.is_whitespace() {
1094 found_start = true;
1095 break;
1096 }
1097 if (ch == '"' || ch == '\'') && !inside_quotes {
1098 found_start = true;
1099 inside_quotes = true;
1100 break;
1101 }
1102
1103 filename.push(ch);
1104 token_start -= ch.len_utf8();
1105 }
1106 if !found_start && token_start != 0 {
1107 return None;
1108 }
1109
1110 filename = filename.chars().rev().collect();
1111
1112 let mut forwards = snapshot
1113 .chars_at(offset)
1114 .take(LIMIT - (offset - token_start))
1115 .peekable();
1116 while let Some(ch) = forwards.next() {
1117 // Skip escaped whitespace
1118 if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
1119 token_end += ch.len_utf8();
1120 let whitespace = forwards.next().unwrap();
1121 token_end += whitespace.len_utf8();
1122 filename.push(whitespace);
1123 continue;
1124 }
1125
1126 if ch.is_whitespace() {
1127 found_end = true;
1128 break;
1129 }
1130 if ch == '"' || ch == '\'' {
1131 // If we're inside quotes, we stop when we come across the next quote
1132 if inside_quotes {
1133 found_end = true;
1134 break;
1135 } else {
1136 // Otherwise, we skip the quote
1137 inside_quotes = true;
1138 continue;
1139 }
1140 }
1141 filename.push(ch);
1142 token_end += ch.len_utf8();
1143 }
1144
1145 if !found_end && (token_end - token_start >= LIMIT) {
1146 return None;
1147 }
1148
1149 if filename.is_empty() {
1150 return None;
1151 }
1152
1153 let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
1154
1155 Some((range, filename))
1156}
1157
1158#[cfg(test)]
1159mod tests {
1160 use super::*;
1161 use crate::{
1162 DisplayPoint,
1163 display_map::ToDisplayPoint,
1164 editor_tests::init_test,
1165 inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
1166 test::editor_lsp_test_context::EditorLspTestContext,
1167 };
1168 use futures::StreamExt;
1169 use gpui::Modifiers;
1170 use indoc::indoc;
1171 use language::language_settings::InlayHintSettings;
1172 use lsp::request::{GotoDefinition, GotoTypeDefinition};
1173 use util::{assert_set_eq, path};
1174 use workspace::item::Item;
1175
1176 #[gpui::test]
1177 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
1178 init_test(cx, |_| {});
1179
1180 let mut cx = EditorLspTestContext::new_rust(
1181 lsp::ServerCapabilities {
1182 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1183 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
1184 ..Default::default()
1185 },
1186 cx,
1187 )
1188 .await;
1189
1190 cx.set_state(indoc! {"
1191 struct A;
1192 let vˇariable = A;
1193 "});
1194 let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
1195
1196 // Basic hold cmd+shift, expect highlight in region if response contains type definition
1197 let symbol_range = cx.lsp_range(indoc! {"
1198 struct A;
1199 let «variable» = A;
1200 "});
1201 let target_range = cx.lsp_range(indoc! {"
1202 struct «A»;
1203 let variable = A;
1204 "});
1205
1206 cx.run_until_parked();
1207
1208 let mut requests =
1209 cx.set_request_handler::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
1210 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
1211 lsp::LocationLink {
1212 origin_selection_range: Some(symbol_range),
1213 target_uri: url.clone(),
1214 target_range,
1215 target_selection_range: target_range,
1216 },
1217 ])))
1218 });
1219
1220 let modifiers = if cfg!(target_os = "macos") {
1221 Modifiers::command_shift()
1222 } else {
1223 Modifiers::control_shift()
1224 };
1225
1226 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
1227
1228 requests.next().await;
1229 cx.run_until_parked();
1230 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1231 struct A;
1232 let «variable» = A;
1233 "});
1234
1235 cx.simulate_modifiers_change(Modifiers::secondary_key());
1236 cx.run_until_parked();
1237 // Assert no link highlights
1238 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1239 struct A;
1240 let variable = A;
1241 "});
1242
1243 cx.simulate_click(screen_coord.unwrap(), modifiers);
1244
1245 cx.assert_editor_state(indoc! {"
1246 struct «Aˇ»;
1247 let variable = A;
1248 "});
1249 }
1250
1251 #[gpui::test]
1252 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
1253 init_test(cx, |_| {});
1254
1255 let mut cx = EditorLspTestContext::new_rust(
1256 lsp::ServerCapabilities {
1257 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1258 definition_provider: Some(lsp::OneOf::Left(true)),
1259 ..Default::default()
1260 },
1261 cx,
1262 )
1263 .await;
1264
1265 cx.set_state(indoc! {"
1266 fn ˇtest() { do_work(); }
1267 fn do_work() { test(); }
1268 "});
1269
1270 // Basic hold cmd, expect highlight in region if response contains definition
1271 let hover_point = cx.pixel_position(indoc! {"
1272 fn test() { do_wˇork(); }
1273 fn do_work() { test(); }
1274 "});
1275 let symbol_range = cx.lsp_range(indoc! {"
1276 fn test() { «do_work»(); }
1277 fn do_work() { test(); }
1278 "});
1279 let target_range = cx.lsp_range(indoc! {"
1280 fn test() { do_work(); }
1281 fn «do_work»() { test(); }
1282 "});
1283
1284 let mut requests =
1285 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1286 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1287 lsp::LocationLink {
1288 origin_selection_range: Some(symbol_range),
1289 target_uri: url.clone(),
1290 target_range,
1291 target_selection_range: target_range,
1292 },
1293 ])))
1294 });
1295
1296 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1297 requests.next().await;
1298 cx.background_executor.run_until_parked();
1299 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1300 fn test() { «do_work»(); }
1301 fn do_work() { test(); }
1302 "});
1303
1304 // Unpress cmd causes highlight to go away
1305 cx.simulate_modifiers_change(Modifiers::none());
1306 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1307 fn test() { do_work(); }
1308 fn do_work() { test(); }
1309 "});
1310
1311 let mut requests =
1312 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1313 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1314 lsp::LocationLink {
1315 origin_selection_range: Some(symbol_range),
1316 target_uri: url.clone(),
1317 target_range,
1318 target_selection_range: target_range,
1319 },
1320 ])))
1321 });
1322
1323 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1324 requests.next().await;
1325 cx.background_executor.run_until_parked();
1326 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1327 fn test() { «do_work»(); }
1328 fn do_work() { test(); }
1329 "});
1330
1331 // Moving mouse to location with no response dismisses highlight
1332 let hover_point = cx.pixel_position(indoc! {"
1333 fˇn test() { do_work(); }
1334 fn do_work() { test(); }
1335 "});
1336 let mut requests =
1337 cx.lsp
1338 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1339 // No definitions returned
1340 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1341 });
1342 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1343
1344 requests.next().await;
1345 cx.background_executor.run_until_parked();
1346
1347 // Assert no link highlights
1348 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1349 fn test() { do_work(); }
1350 fn do_work() { test(); }
1351 "});
1352
1353 // // Move mouse without cmd and then pressing cmd triggers highlight
1354 let hover_point = cx.pixel_position(indoc! {"
1355 fn test() { do_work(); }
1356 fn do_work() { teˇst(); }
1357 "});
1358 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1359
1360 // Assert no link highlights
1361 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1362 fn test() { do_work(); }
1363 fn do_work() { test(); }
1364 "});
1365
1366 let symbol_range = cx.lsp_range(indoc! {"
1367 fn test() { do_work(); }
1368 fn do_work() { «test»(); }
1369 "});
1370 let target_range = cx.lsp_range(indoc! {"
1371 fn «test»() { do_work(); }
1372 fn do_work() { test(); }
1373 "});
1374
1375 let mut requests =
1376 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1377 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1378 lsp::LocationLink {
1379 origin_selection_range: Some(symbol_range),
1380 target_uri: url,
1381 target_range,
1382 target_selection_range: target_range,
1383 },
1384 ])))
1385 });
1386
1387 cx.simulate_modifiers_change(Modifiers::secondary_key());
1388
1389 requests.next().await;
1390 cx.background_executor.run_until_parked();
1391
1392 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1393 fn test() { do_work(); }
1394 fn do_work() { «test»(); }
1395 "});
1396
1397 cx.deactivate_window();
1398 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1399 fn test() { do_work(); }
1400 fn do_work() { test(); }
1401 "});
1402
1403 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1404 cx.background_executor.run_until_parked();
1405 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1406 fn test() { do_work(); }
1407 fn do_work() { «test»(); }
1408 "});
1409
1410 // Moving again within the same symbol range doesn't re-request
1411 let hover_point = cx.pixel_position(indoc! {"
1412 fn test() { do_work(); }
1413 fn do_work() { tesˇt(); }
1414 "});
1415 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1416 cx.background_executor.run_until_parked();
1417 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1418 fn test() { do_work(); }
1419 fn do_work() { «test»(); }
1420 "});
1421
1422 // Cmd click with existing definition doesn't re-request and dismisses highlight
1423 cx.simulate_click(hover_point, Modifiers::secondary_key());
1424 cx.lsp
1425 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1426 // Empty definition response to make sure we aren't hitting the lsp and using
1427 // the cached location instead
1428 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1429 });
1430 cx.background_executor.run_until_parked();
1431 cx.assert_editor_state(indoc! {"
1432 fn «testˇ»() { do_work(); }
1433 fn do_work() { test(); }
1434 "});
1435
1436 // Assert no link highlights after jump
1437 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1438 fn test() { do_work(); }
1439 fn do_work() { test(); }
1440 "});
1441
1442 // Cmd click without existing definition requests and jumps
1443 let hover_point = cx.pixel_position(indoc! {"
1444 fn test() { do_wˇork(); }
1445 fn do_work() { test(); }
1446 "});
1447 let target_range = cx.lsp_range(indoc! {"
1448 fn test() { do_work(); }
1449 fn «do_work»() { test(); }
1450 "});
1451
1452 let mut requests =
1453 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1454 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1455 lsp::LocationLink {
1456 origin_selection_range: None,
1457 target_uri: url,
1458 target_range,
1459 target_selection_range: target_range,
1460 },
1461 ])))
1462 });
1463 cx.simulate_click(hover_point, Modifiers::secondary_key());
1464 requests.next().await;
1465 cx.background_executor.run_until_parked();
1466 cx.assert_editor_state(indoc! {"
1467 fn test() { do_work(); }
1468 fn «do_workˇ»() { test(); }
1469 "});
1470
1471 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1472 // 2. Selection is completed, hovering
1473 let hover_point = cx.pixel_position(indoc! {"
1474 fn test() { do_wˇork(); }
1475 fn do_work() { test(); }
1476 "});
1477 let target_range = cx.lsp_range(indoc! {"
1478 fn test() { do_work(); }
1479 fn «do_work»() { test(); }
1480 "});
1481 let mut requests =
1482 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1483 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1484 lsp::LocationLink {
1485 origin_selection_range: None,
1486 target_uri: url,
1487 target_range,
1488 target_selection_range: target_range,
1489 },
1490 ])))
1491 });
1492
1493 // create a pending selection
1494 let selection_range = cx.ranges(indoc! {"
1495 fn «test() { do_w»ork(); }
1496 fn do_work() { test(); }
1497 "})[0]
1498 .clone();
1499 cx.update_editor(|editor, window, cx| {
1500 let snapshot = editor.buffer().read(cx).snapshot(cx);
1501 let anchor_range = snapshot.anchor_before(selection_range.start)
1502 ..snapshot.anchor_after(selection_range.end);
1503 editor.change_selections(Default::default(), window, cx, |s| {
1504 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1505 });
1506 });
1507 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1508 cx.background_executor.run_until_parked();
1509 assert!(requests.try_next().is_err());
1510 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1511 fn test() { do_work(); }
1512 fn do_work() { test(); }
1513 "});
1514 cx.background_executor.run_until_parked();
1515 }
1516
1517 #[gpui::test]
1518 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1519 init_test(cx, |settings| {
1520 settings.defaults.inlay_hints = Some(InlayHintSettings {
1521 enabled: true,
1522 show_value_hints: false,
1523 edit_debounce_ms: 0,
1524 scroll_debounce_ms: 0,
1525 show_type_hints: true,
1526 show_parameter_hints: true,
1527 show_other_hints: true,
1528 show_background: false,
1529 toggle_on_modifiers_press: None,
1530 })
1531 });
1532
1533 let mut cx = EditorLspTestContext::new_rust(
1534 lsp::ServerCapabilities {
1535 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1536 ..Default::default()
1537 },
1538 cx,
1539 )
1540 .await;
1541 cx.set_state(indoc! {"
1542 struct TestStruct;
1543
1544 fn main() {
1545 let variableˇ = TestStruct;
1546 }
1547 "});
1548 let hint_start_offset = cx.ranges(indoc! {"
1549 struct TestStruct;
1550
1551 fn main() {
1552 let variableˇ = TestStruct;
1553 }
1554 "})[0]
1555 .start;
1556 let hint_position = cx.to_lsp(hint_start_offset);
1557 let target_range = cx.lsp_range(indoc! {"
1558 struct «TestStruct»;
1559
1560 fn main() {
1561 let variable = TestStruct;
1562 }
1563 "});
1564
1565 let expected_uri = cx.buffer_lsp_url.clone();
1566 let hint_label = ": TestStruct";
1567 cx.lsp
1568 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1569 let expected_uri = expected_uri.clone();
1570 async move {
1571 assert_eq!(params.text_document.uri, expected_uri);
1572 Ok(Some(vec![lsp::InlayHint {
1573 position: hint_position,
1574 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1575 value: hint_label.to_string(),
1576 location: Some(lsp::Location {
1577 uri: params.text_document.uri,
1578 range: target_range,
1579 }),
1580 ..Default::default()
1581 }]),
1582 kind: Some(lsp::InlayHintKind::TYPE),
1583 text_edits: None,
1584 tooltip: None,
1585 padding_left: Some(false),
1586 padding_right: Some(false),
1587 data: None,
1588 }]))
1589 }
1590 })
1591 .next()
1592 .await;
1593 cx.background_executor.run_until_parked();
1594 cx.update_editor(|editor, _window, cx| {
1595 let expected_layers = vec![hint_label.to_string()];
1596 assert_eq!(expected_layers, cached_hint_labels(editor));
1597 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1598 });
1599
1600 let inlay_range = cx
1601 .ranges(indoc! {"
1602 struct TestStruct;
1603
1604 fn main() {
1605 let variable« »= TestStruct;
1606 }
1607 "})
1608 .first()
1609 .cloned()
1610 .unwrap();
1611 let midpoint = cx.update_editor(|editor, window, cx| {
1612 let snapshot = editor.snapshot(window, cx);
1613 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1614 let next_valid = inlay_range.end.to_display_point(&snapshot);
1615 assert_eq!(previous_valid.row(), next_valid.row());
1616 assert!(previous_valid.column() < next_valid.column());
1617 DisplayPoint::new(
1618 previous_valid.row(),
1619 previous_valid.column() + (hint_label.len() / 2) as u32,
1620 )
1621 });
1622 // Press cmd to trigger highlight
1623 let hover_point = cx.pixel_position_for(midpoint);
1624 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1625 cx.background_executor.run_until_parked();
1626 cx.update_editor(|editor, window, cx| {
1627 let snapshot = editor.snapshot(window, cx);
1628 let actual_highlights = snapshot
1629 .inlay_highlights::<HoveredLinkState>()
1630 .into_iter()
1631 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1632 .collect::<Vec<_>>();
1633
1634 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1635 let expected_highlight = InlayHighlight {
1636 inlay: InlayId::Hint(0),
1637 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
1638 range: 0..hint_label.len(),
1639 };
1640 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1641 });
1642
1643 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1644 // Assert no link highlights
1645 cx.update_editor(|editor, window, cx| {
1646 let snapshot = editor.snapshot(window, cx);
1647 let actual_ranges = snapshot
1648 .text_highlight_ranges::<HoveredLinkState>()
1649 .map(|ranges| ranges.as_ref().clone().1)
1650 .unwrap_or_default();
1651
1652 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1653 });
1654
1655 cx.simulate_modifiers_change(Modifiers::secondary_key());
1656 cx.background_executor.run_until_parked();
1657 cx.simulate_click(hover_point, Modifiers::secondary_key());
1658 cx.background_executor.run_until_parked();
1659 cx.assert_editor_state(indoc! {"
1660 struct «TestStructˇ»;
1661
1662 fn main() {
1663 let variable = TestStruct;
1664 }
1665 "});
1666 }
1667
1668 #[gpui::test]
1669 async fn test_urls(cx: &mut gpui::TestAppContext) {
1670 init_test(cx, |_| {});
1671 let mut cx = EditorLspTestContext::new_rust(
1672 lsp::ServerCapabilities {
1673 ..Default::default()
1674 },
1675 cx,
1676 )
1677 .await;
1678
1679 cx.set_state(indoc! {"
1680 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1681 "});
1682
1683 let screen_coord = cx.pixel_position(indoc! {"
1684 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1685 "});
1686
1687 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1688 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1689 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1690 "});
1691
1692 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1693 assert_eq!(
1694 cx.opened_url(),
1695 Some("https://zed.dev/channel/had-(oops)".into())
1696 );
1697 }
1698
1699 #[gpui::test]
1700 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1701 init_test(cx, |_| {});
1702 let mut cx = EditorLspTestContext::new_rust(
1703 lsp::ServerCapabilities {
1704 ..Default::default()
1705 },
1706 cx,
1707 )
1708 .await;
1709
1710 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1711
1712 let screen_coord =
1713 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1714
1715 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1716 cx.assert_editor_text_highlights::<HoveredLinkState>(
1717 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1718 );
1719
1720 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1721 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1722 }
1723
1724 #[gpui::test]
1725 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1726 init_test(cx, |_| {});
1727 let mut cx = EditorLspTestContext::new_rust(
1728 lsp::ServerCapabilities {
1729 ..Default::default()
1730 },
1731 cx,
1732 )
1733 .await;
1734
1735 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1736
1737 let screen_coord =
1738 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1739
1740 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1741 cx.assert_editor_text_highlights::<HoveredLinkState>(
1742 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1743 );
1744
1745 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1746 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1747 }
1748
1749 #[gpui::test]
1750 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1751 init_test(cx, |_| {});
1752 let mut cx = EditorLspTestContext::new_rust(
1753 lsp::ServerCapabilities {
1754 ..Default::default()
1755 },
1756 cx,
1757 )
1758 .await;
1759
1760 let test_cases = [
1761 ("file ˇ name", None),
1762 ("ˇfile name", Some("file")),
1763 ("file ˇname", Some("name")),
1764 ("fiˇle name", Some("file")),
1765 ("filenˇame", Some("filename")),
1766 // Absolute path
1767 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1768 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1769 // Windows
1770 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1771 // Whitespace
1772 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1773 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1774 // Tilde
1775 ("ˇ~/file.txt", Some("~/file.txt")),
1776 ("~/fiˇle.txt", Some("~/file.txt")),
1777 // Double quotes
1778 ("\"fˇile.txt\"", Some("file.txt")),
1779 ("ˇ\"file.txt\"", Some("file.txt")),
1780 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1781 // Single quotes
1782 ("'fˇile.txt'", Some("file.txt")),
1783 ("ˇ'file.txt'", Some("file.txt")),
1784 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1785 ];
1786
1787 for (input, expected) in test_cases {
1788 cx.set_state(input);
1789
1790 let (position, snapshot) = cx.editor(|editor, _, cx| {
1791 let positions = editor.selections.newest_anchor().head().text_anchor;
1792 let snapshot = editor
1793 .buffer()
1794 .clone()
1795 .read(cx)
1796 .as_singleton()
1797 .unwrap()
1798 .read(cx)
1799 .snapshot();
1800 (positions, snapshot)
1801 });
1802
1803 let result = surrounding_filename(snapshot, position);
1804
1805 if let Some(expected) = expected {
1806 assert!(result.is_some(), "Failed to find file path: {}", input);
1807 let (_, path) = result.unwrap();
1808 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1809 } else {
1810 assert!(
1811 result.is_none(),
1812 "Expected no result, but got one: {:?}",
1813 result
1814 );
1815 }
1816 }
1817 }
1818
1819 #[gpui::test]
1820 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1821 init_test(cx, |_| {});
1822 let mut cx = EditorLspTestContext::new_rust(
1823 lsp::ServerCapabilities {
1824 ..Default::default()
1825 },
1826 cx,
1827 )
1828 .await;
1829
1830 // Insert a new file
1831 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1832 fs.as_fake()
1833 .insert_file(
1834 path!("/root/dir/file2.rs"),
1835 "This is file2.rs".as_bytes().to_vec(),
1836 )
1837 .await;
1838
1839 #[cfg(not(target_os = "windows"))]
1840 cx.set_state(indoc! {"
1841 You can't go to a file that does_not_exist.txt.
1842 Go to file2.rs if you want.
1843 Or go to ../dir/file2.rs if you want.
1844 Or go to /root/dir/file2.rs if project is local.
1845 Or go to /root/dir/file2 if this is a Rust file.ˇ
1846 "});
1847 #[cfg(target_os = "windows")]
1848 cx.set_state(indoc! {"
1849 You can't go to a file that does_not_exist.txt.
1850 Go to file2.rs if you want.
1851 Or go to ../dir/file2.rs if you want.
1852 Or go to C:/root/dir/file2.rs if project is local.
1853 Or go to C:/root/dir/file2 if this is a Rust file.ˇ
1854 "});
1855
1856 // File does not exist
1857 #[cfg(not(target_os = "windows"))]
1858 let screen_coord = cx.pixel_position(indoc! {"
1859 You can't go to a file that dˇoes_not_exist.txt.
1860 Go to file2.rs if you want.
1861 Or go to ../dir/file2.rs if you want.
1862 Or go to /root/dir/file2.rs if project is local.
1863 Or go to /root/dir/file2 if this is a Rust file.
1864 "});
1865 #[cfg(target_os = "windows")]
1866 let screen_coord = cx.pixel_position(indoc! {"
1867 You can't go to a file that dˇoes_not_exist.txt.
1868 Go to file2.rs if you want.
1869 Or go to ../dir/file2.rs if you want.
1870 Or go to C:/root/dir/file2.rs if project is local.
1871 Or go to C:/root/dir/file2 if this is a Rust file.
1872 "});
1873 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1874 // No highlight
1875 cx.update_editor(|editor, window, cx| {
1876 assert!(
1877 editor
1878 .snapshot(window, cx)
1879 .text_highlight_ranges::<HoveredLinkState>()
1880 .unwrap_or_default()
1881 .1
1882 .is_empty()
1883 );
1884 });
1885
1886 // Moving the mouse over a file that does exist should highlight it.
1887 #[cfg(not(target_os = "windows"))]
1888 let screen_coord = cx.pixel_position(indoc! {"
1889 You can't go to a file that does_not_exist.txt.
1890 Go to fˇile2.rs if you want.
1891 Or go to ../dir/file2.rs if you want.
1892 Or go to /root/dir/file2.rs if project is local.
1893 Or go to /root/dir/file2 if this is a Rust file.
1894 "});
1895 #[cfg(target_os = "windows")]
1896 let screen_coord = cx.pixel_position(indoc! {"
1897 You can't go to a file that does_not_exist.txt.
1898 Go to fˇile2.rs if you want.
1899 Or go to ../dir/file2.rs if you want.
1900 Or go to C:/root/dir/file2.rs if project is local.
1901 Or go to C:/root/dir/file2 if this is a Rust file.
1902 "});
1903
1904 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1905 #[cfg(not(target_os = "windows"))]
1906 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1907 You can't go to a file that does_not_exist.txt.
1908 Go to «file2.rsˇ» if you want.
1909 Or go to ../dir/file2.rs if you want.
1910 Or go to /root/dir/file2.rs if project is local.
1911 Or go to /root/dir/file2 if this is a Rust file.
1912 "});
1913 #[cfg(target_os = "windows")]
1914 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1915 You can't go to a file that does_not_exist.txt.
1916 Go to «file2.rsˇ» if you want.
1917 Or go to ../dir/file2.rs if you want.
1918 Or go to C:/root/dir/file2.rs if project is local.
1919 Or go to C:/root/dir/file2 if this is a Rust file.
1920 "});
1921
1922 // Moving the mouse over a relative path that does exist should highlight it
1923 #[cfg(not(target_os = "windows"))]
1924 let screen_coord = cx.pixel_position(indoc! {"
1925 You can't go to a file that does_not_exist.txt.
1926 Go to file2.rs if you want.
1927 Or go to ../dir/fˇile2.rs if you want.
1928 Or go to /root/dir/file2.rs if project is local.
1929 Or go to /root/dir/file2 if this is a Rust file.
1930 "});
1931 #[cfg(target_os = "windows")]
1932 let screen_coord = cx.pixel_position(indoc! {"
1933 You can't go to a file that does_not_exist.txt.
1934 Go to file2.rs if you want.
1935 Or go to ../dir/fˇile2.rs if you want.
1936 Or go to C:/root/dir/file2.rs if project is local.
1937 Or go to C:/root/dir/file2 if this is a Rust file.
1938 "});
1939
1940 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1941 #[cfg(not(target_os = "windows"))]
1942 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1943 You can't go to a file that does_not_exist.txt.
1944 Go to file2.rs if you want.
1945 Or go to «../dir/file2.rsˇ» if you want.
1946 Or go to /root/dir/file2.rs if project is local.
1947 Or go to /root/dir/file2 if this is a Rust file.
1948 "});
1949 #[cfg(target_os = "windows")]
1950 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1951 You can't go to a file that does_not_exist.txt.
1952 Go to file2.rs if you want.
1953 Or go to «../dir/file2.rsˇ» if you want.
1954 Or go to C:/root/dir/file2.rs if project is local.
1955 Or go to C:/root/dir/file2 if this is a Rust file.
1956 "});
1957
1958 // Moving the mouse over an absolute path that does exist should highlight it
1959 #[cfg(not(target_os = "windows"))]
1960 let screen_coord = cx.pixel_position(indoc! {"
1961 You can't go to a file that does_not_exist.txt.
1962 Go to file2.rs if you want.
1963 Or go to ../dir/file2.rs if you want.
1964 Or go to /root/diˇr/file2.rs if project is local.
1965 Or go to /root/dir/file2 if this is a Rust file.
1966 "});
1967
1968 #[cfg(target_os = "windows")]
1969 let screen_coord = cx.pixel_position(indoc! {"
1970 You can't go to a file that does_not_exist.txt.
1971 Go to file2.rs if you want.
1972 Or go to ../dir/file2.rs if you want.
1973 Or go to C:/root/diˇr/file2.rs if project is local.
1974 Or go to C:/root/dir/file2 if this is a Rust file.
1975 "});
1976
1977 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1978 #[cfg(not(target_os = "windows"))]
1979 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1980 You can't go to a file that does_not_exist.txt.
1981 Go to file2.rs if you want.
1982 Or go to ../dir/file2.rs if you want.
1983 Or go to «/root/dir/file2.rsˇ» if project is local.
1984 Or go to /root/dir/file2 if this is a Rust file.
1985 "});
1986 #[cfg(target_os = "windows")]
1987 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1988 You can't go to a file that does_not_exist.txt.
1989 Go to file2.rs if you want.
1990 Or go to ../dir/file2.rs if you want.
1991 Or go to «C:/root/dir/file2.rsˇ» if project is local.
1992 Or go to C:/root/dir/file2 if this is a Rust file.
1993 "});
1994
1995 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1996 #[cfg(not(target_os = "windows"))]
1997 let screen_coord = cx.pixel_position(indoc! {"
1998 You can't go to a file that does_not_exist.txt.
1999 Go to file2.rs if you want.
2000 Or go to ../dir/file2.rs if you want.
2001 Or go to /root/dir/file2.rs if project is local.
2002 Or go to /root/diˇr/file2 if this is a Rust file.
2003 "});
2004 #[cfg(target_os = "windows")]
2005 let screen_coord = cx.pixel_position(indoc! {"
2006 You can't go to a file that does_not_exist.txt.
2007 Go to file2.rs if you want.
2008 Or go to ../dir/file2.rs if you want.
2009 Or go to C:/root/dir/file2.rs if project is local.
2010 Or go to C:/root/diˇr/file2 if this is a Rust file.
2011 "});
2012
2013 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2014 #[cfg(not(target_os = "windows"))]
2015 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
2016 You can't go to a file that does_not_exist.txt.
2017 Go to file2.rs if you want.
2018 Or go to ../dir/file2.rs if you want.
2019 Or go to /root/dir/file2.rs if project is local.
2020 Or go to «/root/dir/file2ˇ» if this is a Rust file.
2021 "});
2022 #[cfg(target_os = "windows")]
2023 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
2024 You can't go to a file that does_not_exist.txt.
2025 Go to file2.rs if you want.
2026 Or go to ../dir/file2.rs if you want.
2027 Or go to C:/root/dir/file2.rs if project is local.
2028 Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
2029 "});
2030
2031 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2032
2033 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2034 cx.update_workspace(|workspace, _, cx| {
2035 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2036
2037 let buffer = active_editor
2038 .read(cx)
2039 .buffer()
2040 .read(cx)
2041 .as_singleton()
2042 .unwrap();
2043
2044 let file = buffer.read(cx).file().unwrap();
2045 let file_path = file.as_local().unwrap().abs_path(cx);
2046
2047 assert_eq!(
2048 file_path,
2049 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
2050 );
2051 });
2052 }
2053
2054 #[gpui::test]
2055 async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
2056 init_test(cx, |_| {});
2057 let mut cx = EditorLspTestContext::new_rust(
2058 lsp::ServerCapabilities {
2059 ..Default::default()
2060 },
2061 cx,
2062 )
2063 .await;
2064
2065 // Insert a new file
2066 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2067 fs.as_fake()
2068 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
2069 .await;
2070
2071 cx.set_state(indoc! {"
2072 You can't open ../diˇr because it's a directory.
2073 "});
2074
2075 // File does not exist
2076 let screen_coord = cx.pixel_position(indoc! {"
2077 You can't open ../diˇr because it's a directory.
2078 "});
2079 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2080
2081 // No highlight
2082 cx.update_editor(|editor, window, cx| {
2083 assert!(
2084 editor
2085 .snapshot(window, cx)
2086 .text_highlight_ranges::<HoveredLinkState>()
2087 .unwrap_or_default()
2088 .1
2089 .is_empty()
2090 );
2091 });
2092
2093 // Does not open the directory
2094 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2095 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2096 }
2097}