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