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