1use crate::{
2 hover_popover::{self, InlayHover},
3 Anchor, Editor, EditorSnapshot, FindAllReferences, GoToDefinition, GoToTypeDefinition, InlayId,
4 PointForPosition, SelectPhase,
5};
6use gpui::{px, AsyncWindowContext, Model, Modifiers, Task, ViewContext};
7use language::{Bias, ToOffset};
8use linkify::{LinkFinder, LinkKind};
9use lsp::LanguageServerId;
10use project::{
11 HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink,
12 ResolveState,
13};
14use std::{cmp, ops::Range};
15use text::Point;
16use theme::ActiveTheme as _;
17use util::{maybe, ResultExt, TryFutureExt};
18
19#[derive(Debug)]
20pub struct HoveredLinkState {
21 pub last_trigger_point: TriggerPoint,
22 pub preferred_kind: LinkDefinitionKind,
23 pub symbol_range: Option<RangeInEditor>,
24 pub links: Vec<HoverLink>,
25 pub task: Option<Task<Option<()>>>,
26}
27
28#[derive(Debug, Eq, PartialEq, Clone)]
29pub enum RangeInEditor {
30 Text(Range<Anchor>),
31 Inlay(InlayHighlight),
32}
33
34impl RangeInEditor {
35 pub fn as_text_range(&self) -> Option<Range<Anchor>> {
36 match self {
37 Self::Text(range) => Some(range.clone()),
38 Self::Inlay(_) => None,
39 }
40 }
41
42 fn point_within_range(&self, trigger_point: &TriggerPoint, snapshot: &EditorSnapshot) -> bool {
43 match (self, trigger_point) {
44 (Self::Text(range), TriggerPoint::Text(point)) => {
45 let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot).is_le();
46 point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot).is_ge()
47 }
48 (Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => {
49 highlight.inlay == point.inlay
50 && highlight.range.contains(&point.range.start)
51 && highlight.range.contains(&point.range.end)
52 }
53 (Self::Inlay(_), TriggerPoint::Text(_))
54 | (Self::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
60pub enum HoverLink {
61 Url(String),
62 Text(LocationLink),
63 InlayHint(lsp::Location, LanguageServerId),
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub(crate) struct InlayHighlight {
68 pub inlay: InlayId,
69 pub inlay_position: Anchor,
70 pub range: Range<usize>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
74pub enum TriggerPoint {
75 Text(Anchor),
76 InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
77}
78
79impl TriggerPoint {
80 fn anchor(&self) -> &Anchor {
81 match self {
82 TriggerPoint::Text(anchor) => anchor,
83 TriggerPoint::InlayHint(inlay_range, _, _) => &inlay_range.inlay_position,
84 }
85 }
86}
87
88impl Editor {
89 pub(crate) fn update_hovered_link(
90 &mut self,
91 point_for_position: PointForPosition,
92 snapshot: &EditorSnapshot,
93 modifiers: Modifiers,
94 cx: &mut ViewContext<Self>,
95 ) {
96 if !modifiers.secondary() || self.has_pending_selection() {
97 self.hide_hovered_link(cx);
98 return;
99 }
100
101 match point_for_position.as_valid() {
102 Some(point) => {
103 let trigger_point = TriggerPoint::Text(
104 snapshot
105 .buffer_snapshot
106 .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)),
107 );
108
109 show_link_definition(modifiers.shift, self, trigger_point, snapshot, cx);
110 }
111 None => {
112 update_inlay_link_and_hover_points(
113 &snapshot,
114 point_for_position,
115 self,
116 modifiers.secondary(),
117 modifiers.shift,
118 cx,
119 );
120 }
121 }
122 }
123
124 pub(crate) fn hide_hovered_link(&mut self, cx: &mut ViewContext<Self>) {
125 self.hovered_link_state.take();
126 self.clear_highlights::<HoveredLinkState>(cx);
127 }
128
129 pub(crate) fn handle_click_hovered_link(
130 &mut self,
131 point: PointForPosition,
132 modifiers: Modifiers,
133 cx: &mut ViewContext<Editor>,
134 ) {
135 let selection_before_revealing = self.selections.newest::<Point>(cx);
136 let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx);
137 let before_revealing_head = selection_before_revealing.head();
138 let before_revealing_tail = selection_before_revealing.tail();
139 let before_revealing = match before_revealing_tail.cmp(&before_revealing_head) {
140 cmp::Ordering::Equal | cmp::Ordering::Less => {
141 multi_buffer_snapshot.anchor_after(before_revealing_head)
142 ..multi_buffer_snapshot.anchor_before(before_revealing_tail)
143 }
144 cmp::Ordering::Greater => {
145 multi_buffer_snapshot.anchor_before(before_revealing_tail)
146 ..multi_buffer_snapshot.anchor_after(before_revealing_head)
147 }
148 };
149 drop(multi_buffer_snapshot);
150
151 let reveal_task = self.cmd_click_reveal_task(point, modifiers, cx);
152 cx.spawn(|editor, mut cx| async move {
153 let definition_revealed = reveal_task.await.log_err().unwrap_or(false);
154 let find_references = editor
155 .update(&mut cx, |editor, cx| {
156 if definition_revealed && revealed_elsewhere(editor, before_revealing, cx) {
157 return None;
158 }
159 editor.find_all_references(&FindAllReferences, cx)
160 })
161 .ok()
162 .flatten();
163 if let Some(find_references) = find_references {
164 find_references.await.log_err();
165 }
166 })
167 .detach();
168 }
169
170 fn cmd_click_reveal_task(
171 &mut self,
172 point: PointForPosition,
173 modifiers: Modifiers,
174 cx: &mut ViewContext<Editor>,
175 ) -> Task<anyhow::Result<bool>> {
176 if let Some(hovered_link_state) = self.hovered_link_state.take() {
177 self.hide_hovered_link(cx);
178 if !hovered_link_state.links.is_empty() {
179 if !self.focus_handle.is_focused(cx) {
180 cx.focus(&self.focus_handle);
181 }
182
183 return self.navigate_to_hover_links(
184 None,
185 hovered_link_state.links,
186 modifiers.alt,
187 cx,
188 );
189 }
190 }
191
192 // We don't have the correct kind of link cached, set the selection on
193 // click and immediately trigger GoToDefinition.
194 self.select(
195 SelectPhase::Begin {
196 position: point.next_valid,
197 add: false,
198 click_count: 1,
199 },
200 cx,
201 );
202
203 if point.as_valid().is_some() {
204 if modifiers.shift {
205 self.go_to_type_definition(&GoToTypeDefinition, cx)
206 } else {
207 self.go_to_definition(&GoToDefinition, cx)
208 }
209 } else {
210 Task::ready(Ok(false))
211 }
212 }
213}
214
215fn revealed_elsewhere(
216 editor: &mut Editor,
217 before_revealing: Range<Anchor>,
218 cx: &mut ViewContext<'_, Editor>,
219) -> bool {
220 let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
221
222 let selection_after_revealing = editor.selections.newest::<Point>(cx);
223 let after_revealing_head = selection_after_revealing.head();
224 let after_revealing_tail = selection_after_revealing.tail();
225 let after_revealing = match after_revealing_tail.cmp(&after_revealing_head) {
226 cmp::Ordering::Equal | cmp::Ordering::Less => {
227 multi_buffer_snapshot.anchor_after(after_revealing_tail)
228 ..multi_buffer_snapshot.anchor_before(after_revealing_head)
229 }
230 cmp::Ordering::Greater => {
231 multi_buffer_snapshot.anchor_after(after_revealing_head)
232 ..multi_buffer_snapshot.anchor_before(after_revealing_tail)
233 }
234 };
235
236 let before_intersects_after_range = (before_revealing
237 .start
238 .cmp(&after_revealing.start, &multi_buffer_snapshot)
239 .is_ge()
240 && before_revealing
241 .start
242 .cmp(&after_revealing.end, &multi_buffer_snapshot)
243 .is_le())
244 || (before_revealing
245 .end
246 .cmp(&after_revealing.start, &multi_buffer_snapshot)
247 .is_ge()
248 && before_revealing
249 .end
250 .cmp(&after_revealing.end, &multi_buffer_snapshot)
251 .is_le());
252 !before_intersects_after_range
253}
254
255pub fn update_inlay_link_and_hover_points(
256 snapshot: &EditorSnapshot,
257 point_for_position: PointForPosition,
258 editor: &mut Editor,
259 secondary_held: bool,
260 shift_held: bool,
261 cx: &mut ViewContext<'_, Editor>,
262) {
263 let hovered_offset = if point_for_position.column_overshoot_after_line_end == 0 {
264 Some(snapshot.display_point_to_inlay_offset(point_for_position.exact_unclipped, Bias::Left))
265 } else {
266 None
267 };
268 let mut go_to_definition_updated = false;
269 let mut hover_updated = false;
270 if let Some(hovered_offset) = hovered_offset {
271 let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
272 let previous_valid_anchor = buffer_snapshot.anchor_at(
273 point_for_position.previous_valid.to_point(snapshot),
274 Bias::Left,
275 );
276 let next_valid_anchor = buffer_snapshot.anchor_at(
277 point_for_position.next_valid.to_point(snapshot),
278 Bias::Right,
279 );
280 if let Some(hovered_hint) = editor
281 .visible_inlay_hints(cx)
282 .into_iter()
283 .skip_while(|hint| {
284 hint.position
285 .cmp(&previous_valid_anchor, &buffer_snapshot)
286 .is_lt()
287 })
288 .take_while(|hint| {
289 hint.position
290 .cmp(&next_valid_anchor, &buffer_snapshot)
291 .is_le()
292 })
293 .max_by_key(|hint| hint.id)
294 {
295 let inlay_hint_cache = editor.inlay_hint_cache();
296 let excerpt_id = previous_valid_anchor.excerpt_id;
297 if let Some(cached_hint) = inlay_hint_cache.hint_by_id(excerpt_id, hovered_hint.id) {
298 match cached_hint.resolve_state {
299 ResolveState::CanResolve(_, _) => {
300 if let Some(buffer_id) = previous_valid_anchor.buffer_id {
301 inlay_hint_cache.spawn_hint_resolve(
302 buffer_id,
303 excerpt_id,
304 hovered_hint.id,
305 cx,
306 );
307 }
308 }
309 ResolveState::Resolved => {
310 let mut extra_shift_left = 0;
311 let mut extra_shift_right = 0;
312 if cached_hint.padding_left {
313 extra_shift_left += 1;
314 extra_shift_right += 1;
315 }
316 if cached_hint.padding_right {
317 extra_shift_right += 1;
318 }
319 match cached_hint.label {
320 project::InlayHintLabel::String(_) => {
321 if let Some(tooltip) = cached_hint.tooltip {
322 hover_popover::hover_at_inlay(
323 editor,
324 InlayHover {
325 excerpt: excerpt_id,
326 tooltip: match tooltip {
327 InlayHintTooltip::String(text) => HoverBlock {
328 text,
329 kind: HoverBlockKind::PlainText,
330 },
331 InlayHintTooltip::MarkupContent(content) => {
332 HoverBlock {
333 text: content.value,
334 kind: content.kind,
335 }
336 }
337 },
338 range: InlayHighlight {
339 inlay: hovered_hint.id,
340 inlay_position: hovered_hint.position,
341 range: extra_shift_left
342 ..hovered_hint.text.len() + extra_shift_right,
343 },
344 },
345 cx,
346 );
347 hover_updated = true;
348 }
349 }
350 project::InlayHintLabel::LabelParts(label_parts) => {
351 let hint_start =
352 snapshot.anchor_to_inlay_offset(hovered_hint.position);
353 if let Some((hovered_hint_part, part_range)) =
354 hover_popover::find_hovered_hint_part(
355 label_parts,
356 hint_start,
357 hovered_offset,
358 )
359 {
360 let highlight_start =
361 (part_range.start - hint_start).0 + extra_shift_left;
362 let highlight_end =
363 (part_range.end - hint_start).0 + extra_shift_right;
364 let highlight = InlayHighlight {
365 inlay: hovered_hint.id,
366 inlay_position: hovered_hint.position,
367 range: highlight_start..highlight_end,
368 };
369 if let Some(tooltip) = hovered_hint_part.tooltip {
370 hover_popover::hover_at_inlay(
371 editor,
372 InlayHover {
373 excerpt: excerpt_id,
374 tooltip: match tooltip {
375 InlayHintLabelPartTooltip::String(text) => {
376 HoverBlock {
377 text,
378 kind: HoverBlockKind::PlainText,
379 }
380 }
381 InlayHintLabelPartTooltip::MarkupContent(
382 content,
383 ) => HoverBlock {
384 text: content.value,
385 kind: content.kind,
386 },
387 },
388 range: highlight.clone(),
389 },
390 cx,
391 );
392 hover_updated = true;
393 }
394 if let Some((language_server_id, location)) =
395 hovered_hint_part.location
396 {
397 if secondary_held
398 && !editor.has_pending_nonempty_selection()
399 {
400 go_to_definition_updated = true;
401 show_link_definition(
402 shift_held,
403 editor,
404 TriggerPoint::InlayHint(
405 highlight,
406 location,
407 language_server_id,
408 ),
409 snapshot,
410 cx,
411 );
412 }
413 }
414 }
415 }
416 };
417 }
418 ResolveState::Resolving => {}
419 }
420 }
421 }
422 }
423
424 if !go_to_definition_updated {
425 editor.hide_hovered_link(cx)
426 }
427 if !hover_updated {
428 hover_popover::hover_at(editor, None, cx);
429 }
430}
431
432#[derive(Debug, Clone, Copy, PartialEq)]
433pub enum LinkDefinitionKind {
434 Symbol,
435 Type,
436}
437
438pub fn show_link_definition(
439 shift_held: bool,
440 editor: &mut Editor,
441 trigger_point: TriggerPoint,
442 snapshot: &EditorSnapshot,
443 cx: &mut ViewContext<Editor>,
444) {
445 let preferred_kind = match trigger_point {
446 TriggerPoint::Text(_) if !shift_held => LinkDefinitionKind::Symbol,
447 _ => LinkDefinitionKind::Type,
448 };
449
450 let (mut hovered_link_state, is_cached) =
451 if let Some(existing) = editor.hovered_link_state.take() {
452 (existing, true)
453 } else {
454 (
455 HoveredLinkState {
456 last_trigger_point: trigger_point.clone(),
457 symbol_range: None,
458 preferred_kind,
459 links: vec![],
460 task: None,
461 },
462 false,
463 )
464 };
465
466 if editor.pending_rename.is_some() {
467 return;
468 }
469
470 let trigger_anchor = trigger_point.anchor();
471 let Some((buffer, buffer_position)) = editor
472 .buffer
473 .read(cx)
474 .text_anchor_for_position(*trigger_anchor, cx)
475 else {
476 return;
477 };
478
479 let Some((excerpt_id, _, _)) = editor
480 .buffer()
481 .read(cx)
482 .excerpt_containing(*trigger_anchor, cx)
483 else {
484 return;
485 };
486
487 let same_kind = hovered_link_state.preferred_kind == preferred_kind
488 || hovered_link_state
489 .links
490 .first()
491 .is_some_and(|d| matches!(d, HoverLink::Url(_)));
492
493 if same_kind {
494 if is_cached && (&hovered_link_state.last_trigger_point == &trigger_point)
495 || hovered_link_state
496 .symbol_range
497 .as_ref()
498 .is_some_and(|symbol_range| {
499 symbol_range.point_within_range(&trigger_point, &snapshot)
500 })
501 {
502 editor.hovered_link_state = Some(hovered_link_state);
503 return;
504 }
505 } else {
506 editor.hide_hovered_link(cx)
507 }
508 let project = editor.project.clone();
509
510 let snapshot = snapshot.buffer_snapshot.clone();
511 hovered_link_state.task = Some(cx.spawn(|this, mut cx| {
512 async move {
513 let result = match &trigger_point {
514 TriggerPoint::Text(_) => {
515 if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
516 this.update(&mut cx, |_, _| {
517 let range = maybe!({
518 let start =
519 snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
520 let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?;
521 Some(RangeInEditor::Text(start..end))
522 });
523 (range, vec![HoverLink::Url(url)])
524 })
525 .ok()
526 } else if let Some(project) = project {
527 // query the LSP for definition info
528 project
529 .update(&mut cx, |project, cx| match preferred_kind {
530 LinkDefinitionKind::Symbol => {
531 project.definition(&buffer, buffer_position, cx)
532 }
533
534 LinkDefinitionKind::Type => {
535 project.type_definition(&buffer, buffer_position, cx)
536 }
537 })?
538 .await
539 .ok()
540 .map(|definition_result| {
541 (
542 definition_result.iter().find_map(|link| {
543 link.origin.as_ref().and_then(|origin| {
544 let start = snapshot.anchor_in_excerpt(
545 excerpt_id,
546 origin.range.start,
547 )?;
548 let end = snapshot
549 .anchor_in_excerpt(excerpt_id, origin.range.end)?;
550 Some(RangeInEditor::Text(start..end))
551 })
552 }),
553 definition_result.into_iter().map(HoverLink::Text).collect(),
554 )
555 })
556 } else {
557 None
558 }
559 }
560 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
561 Some(RangeInEditor::Inlay(highlight.clone())),
562 vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
563 )),
564 };
565
566 this.update(&mut cx, |editor, cx| {
567 // Clear any existing highlights
568 editor.clear_highlights::<HoveredLinkState>(cx);
569 let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
570 editor.hide_hovered_link(cx);
571 return;
572 };
573 hovered_link_state.preferred_kind = preferred_kind;
574 hovered_link_state.symbol_range = result
575 .as_ref()
576 .and_then(|(symbol_range, _)| symbol_range.clone());
577
578 if let Some((symbol_range, definitions)) = result {
579 hovered_link_state.links = definitions;
580
581 let underline_hovered_link = hovered_link_state.links.len() > 0
582 || hovered_link_state.symbol_range.is_some();
583
584 if underline_hovered_link {
585 let style = gpui::HighlightStyle {
586 underline: Some(gpui::UnderlineStyle {
587 thickness: px(1.),
588 ..Default::default()
589 }),
590 color: Some(cx.theme().colors().link_text_hover),
591 ..Default::default()
592 };
593 let highlight_range =
594 symbol_range.unwrap_or_else(|| match &trigger_point {
595 TriggerPoint::Text(trigger_anchor) => {
596 // If no symbol range returned from language server, use the surrounding word.
597 let (offset_range, _) =
598 snapshot.surrounding_word(*trigger_anchor);
599 RangeInEditor::Text(
600 snapshot.anchor_before(offset_range.start)
601 ..snapshot.anchor_after(offset_range.end),
602 )
603 }
604 TriggerPoint::InlayHint(highlight, _, _) => {
605 RangeInEditor::Inlay(highlight.clone())
606 }
607 });
608
609 match highlight_range {
610 RangeInEditor::Text(text_range) => editor
611 .highlight_text::<HoveredLinkState>(vec![text_range], style, cx),
612 RangeInEditor::Inlay(highlight) => editor
613 .highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
614 }
615 }
616 } else {
617 editor.hide_hovered_link(cx);
618 }
619 })?;
620
621 Ok::<_, anyhow::Error>(())
622 }
623 .log_err()
624 }));
625
626 editor.hovered_link_state = Some(hovered_link_state);
627}
628
629pub(crate) fn find_url(
630 buffer: &Model<language::Buffer>,
631 position: text::Anchor,
632 mut cx: AsyncWindowContext,
633) -> Option<(Range<text::Anchor>, String)> {
634 const LIMIT: usize = 2048;
635
636 let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
637 return None;
638 };
639
640 let offset = position.to_offset(&snapshot);
641 let mut token_start = offset;
642 let mut token_end = offset;
643 let mut found_start = false;
644 let mut found_end = false;
645
646 for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
647 if ch.is_whitespace() {
648 found_start = true;
649 break;
650 }
651 token_start -= ch.len_utf8();
652 }
653 // Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
654 if !found_start && token_start != 0 {
655 return None;
656 }
657
658 for ch in snapshot
659 .chars_at(offset)
660 .take(LIMIT - (offset - token_start))
661 {
662 if ch.is_whitespace() {
663 found_end = true;
664 break;
665 }
666 token_end += ch.len_utf8();
667 }
668 // Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
669 // which at this point would happen only if we reached the end of buffer
670 if !found_end && (token_end - token_start >= LIMIT) {
671 return None;
672 }
673
674 let mut finder = LinkFinder::new();
675 finder.kinds(&[LinkKind::Url]);
676 let input = snapshot
677 .text_for_range(token_start..token_end)
678 .collect::<String>();
679
680 let relative_offset = offset - token_start;
681 for link in finder.links(&input) {
682 if link.start() <= relative_offset && link.end() >= relative_offset {
683 let range = snapshot.anchor_before(token_start + link.start())
684 ..snapshot.anchor_after(token_start + link.end());
685 return Some((range, link.as_str().to_string()));
686 }
687 }
688 None
689}
690
691#[cfg(test)]
692mod tests {
693 use super::*;
694 use crate::{
695 display_map::ToDisplayPoint,
696 editor_tests::init_test,
697 inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
698 test::editor_lsp_test_context::EditorLspTestContext,
699 DisplayPoint,
700 };
701 use futures::StreamExt;
702 use gpui::Modifiers;
703 use indoc::indoc;
704 use language::language_settings::InlayHintSettings;
705 use lsp::{
706 request::{GotoDefinition, GotoTypeDefinition},
707 References,
708 };
709 use util::assert_set_eq;
710 use workspace::item::Item;
711
712 #[gpui::test]
713 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
714 init_test(cx, |_| {});
715
716 let mut cx = EditorLspTestContext::new_rust(
717 lsp::ServerCapabilities {
718 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
719 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
720 ..Default::default()
721 },
722 cx,
723 )
724 .await;
725
726 cx.set_state(indoc! {"
727 struct A;
728 let vˇariable = A;
729 "});
730 let screen_coord = cx.editor(|editor, cx| editor.pixel_position_of_cursor(cx));
731
732 // Basic hold cmd+shift, expect highlight in region if response contains type definition
733 let symbol_range = cx.lsp_range(indoc! {"
734 struct A;
735 let «variable» = A;
736 "});
737 let target_range = cx.lsp_range(indoc! {"
738 struct «A»;
739 let variable = A;
740 "});
741
742 cx.run_until_parked();
743
744 let mut requests =
745 cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
746 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
747 lsp::LocationLink {
748 origin_selection_range: Some(symbol_range),
749 target_uri: url.clone(),
750 target_range,
751 target_selection_range: target_range,
752 },
753 ])))
754 });
755
756 cx.cx
757 .cx
758 .simulate_mouse_move(screen_coord.unwrap(), Modifiers::command_shift());
759
760 requests.next().await;
761 cx.run_until_parked();
762 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
763 struct A;
764 let «variable» = A;
765 "});
766
767 cx.simulate_modifiers_change(Modifiers::secondary_key());
768 cx.run_until_parked();
769 // Assert no link highlights
770 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
771 struct A;
772 let variable = A;
773 "});
774
775 cx.cx
776 .cx
777 .simulate_click(screen_coord.unwrap(), Modifiers::command_shift());
778
779 cx.assert_editor_state(indoc! {"
780 struct «Aˇ»;
781 let variable = A;
782 "});
783 }
784
785 #[gpui::test]
786 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
787 init_test(cx, |_| {});
788
789 let mut cx = EditorLspTestContext::new_rust(
790 lsp::ServerCapabilities {
791 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
792 ..Default::default()
793 },
794 cx,
795 )
796 .await;
797
798 cx.set_state(indoc! {"
799 fn ˇtest() { do_work(); }
800 fn do_work() { test(); }
801 "});
802
803 // Basic hold cmd, expect highlight in region if response contains definition
804 let hover_point = cx.pixel_position(indoc! {"
805 fn test() { do_wˇork(); }
806 fn do_work() { test(); }
807 "});
808 let symbol_range = cx.lsp_range(indoc! {"
809 fn test() { «do_work»(); }
810 fn do_work() { test(); }
811 "});
812 let target_range = cx.lsp_range(indoc! {"
813 fn test() { do_work(); }
814 fn «do_work»() { test(); }
815 "});
816
817 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
818 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
819 lsp::LocationLink {
820 origin_selection_range: Some(symbol_range),
821 target_uri: url.clone(),
822 target_range,
823 target_selection_range: target_range,
824 },
825 ])))
826 });
827
828 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
829 requests.next().await;
830 cx.background_executor.run_until_parked();
831 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
832 fn test() { «do_work»(); }
833 fn do_work() { test(); }
834 "});
835
836 // Unpress cmd causes highlight to go away
837 cx.simulate_modifiers_change(Modifiers::none());
838 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
839 fn test() { do_work(); }
840 fn do_work() { test(); }
841 "});
842
843 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
844 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
845 lsp::LocationLink {
846 origin_selection_range: Some(symbol_range),
847 target_uri: url.clone(),
848 target_range,
849 target_selection_range: target_range,
850 },
851 ])))
852 });
853
854 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
855 requests.next().await;
856 cx.background_executor.run_until_parked();
857 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
858 fn test() { «do_work»(); }
859 fn do_work() { test(); }
860 "});
861
862 // Moving mouse to location with no response dismisses highlight
863 let hover_point = cx.pixel_position(indoc! {"
864 fˇn test() { do_work(); }
865 fn do_work() { test(); }
866 "});
867 let mut requests = cx
868 .lsp
869 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
870 // No definitions returned
871 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
872 });
873 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
874
875 requests.next().await;
876 cx.background_executor.run_until_parked();
877
878 // Assert no link highlights
879 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
880 fn test() { do_work(); }
881 fn do_work() { test(); }
882 "});
883
884 // // Move mouse without cmd and then pressing cmd triggers highlight
885 let hover_point = cx.pixel_position(indoc! {"
886 fn test() { do_work(); }
887 fn do_work() { teˇst(); }
888 "});
889 cx.simulate_mouse_move(hover_point, Modifiers::none());
890
891 // Assert no link highlights
892 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
893 fn test() { do_work(); }
894 fn do_work() { test(); }
895 "});
896
897 let symbol_range = cx.lsp_range(indoc! {"
898 fn test() { do_work(); }
899 fn do_work() { «test»(); }
900 "});
901 let target_range = cx.lsp_range(indoc! {"
902 fn «test»() { do_work(); }
903 fn do_work() { test(); }
904 "});
905
906 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
907 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
908 lsp::LocationLink {
909 origin_selection_range: Some(symbol_range),
910 target_uri: url,
911 target_range,
912 target_selection_range: target_range,
913 },
914 ])))
915 });
916
917 cx.simulate_modifiers_change(Modifiers::secondary_key());
918
919 requests.next().await;
920 cx.background_executor.run_until_parked();
921
922 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
923 fn test() { do_work(); }
924 fn do_work() { «test»(); }
925 "});
926
927 cx.deactivate_window();
928 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
929 fn test() { do_work(); }
930 fn do_work() { test(); }
931 "});
932
933 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
934 cx.background_executor.run_until_parked();
935 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
936 fn test() { do_work(); }
937 fn do_work() { «test»(); }
938 "});
939
940 // Moving again within the same symbol range doesn't re-request
941 let hover_point = cx.pixel_position(indoc! {"
942 fn test() { do_work(); }
943 fn do_work() { tesˇt(); }
944 "});
945 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
946 cx.background_executor.run_until_parked();
947 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
948 fn test() { do_work(); }
949 fn do_work() { «test»(); }
950 "});
951
952 // Cmd click with existing definition doesn't re-request and dismisses highlight
953 cx.simulate_click(hover_point, Modifiers::secondary_key());
954 cx.lsp
955 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
956 // Empty definition response to make sure we aren't hitting the lsp and using
957 // the cached location instead
958 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
959 });
960 cx.background_executor.run_until_parked();
961 cx.assert_editor_state(indoc! {"
962 fn «testˇ»() { do_work(); }
963 fn do_work() { test(); }
964 "});
965
966 // Assert no link highlights after jump
967 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
968 fn test() { do_work(); }
969 fn do_work() { test(); }
970 "});
971
972 // Cmd click without existing definition requests and jumps
973 let hover_point = cx.pixel_position(indoc! {"
974 fn test() { do_wˇork(); }
975 fn do_work() { test(); }
976 "});
977 let target_range = cx.lsp_range(indoc! {"
978 fn test() { do_work(); }
979 fn «do_work»() { test(); }
980 "});
981
982 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
983 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
984 lsp::LocationLink {
985 origin_selection_range: None,
986 target_uri: url,
987 target_range,
988 target_selection_range: target_range,
989 },
990 ])))
991 });
992 cx.simulate_click(hover_point, Modifiers::secondary_key());
993 requests.next().await;
994 cx.background_executor.run_until_parked();
995 cx.assert_editor_state(indoc! {"
996 fn test() { do_work(); }
997 fn «do_workˇ»() { test(); }
998 "});
999
1000 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1001 // 2. Selection is completed, hovering
1002 let hover_point = cx.pixel_position(indoc! {"
1003 fn test() { do_wˇork(); }
1004 fn do_work() { test(); }
1005 "});
1006 let target_range = cx.lsp_range(indoc! {"
1007 fn test() { do_work(); }
1008 fn «do_work»() { test(); }
1009 "});
1010 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1011 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1012 lsp::LocationLink {
1013 origin_selection_range: None,
1014 target_uri: url,
1015 target_range,
1016 target_selection_range: target_range,
1017 },
1018 ])))
1019 });
1020
1021 // create a pending selection
1022 let selection_range = cx.ranges(indoc! {"
1023 fn «test() { do_w»ork(); }
1024 fn do_work() { test(); }
1025 "})[0]
1026 .clone();
1027 cx.update_editor(|editor, cx| {
1028 let snapshot = editor.buffer().read(cx).snapshot(cx);
1029 let anchor_range = snapshot.anchor_before(selection_range.start)
1030 ..snapshot.anchor_after(selection_range.end);
1031 editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| {
1032 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1033 });
1034 });
1035 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
1036 cx.background_executor.run_until_parked();
1037 assert!(requests.try_next().is_err());
1038 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1039 fn test() { do_work(); }
1040 fn do_work() { test(); }
1041 "});
1042 cx.background_executor.run_until_parked();
1043 }
1044
1045 #[gpui::test]
1046 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1047 init_test(cx, |settings| {
1048 settings.defaults.inlay_hints = Some(InlayHintSettings {
1049 enabled: true,
1050 edit_debounce_ms: 0,
1051 scroll_debounce_ms: 0,
1052 show_type_hints: true,
1053 show_parameter_hints: true,
1054 show_other_hints: true,
1055 })
1056 });
1057
1058 let mut cx = EditorLspTestContext::new_rust(
1059 lsp::ServerCapabilities {
1060 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1061 ..Default::default()
1062 },
1063 cx,
1064 )
1065 .await;
1066 cx.set_state(indoc! {"
1067 struct TestStruct;
1068
1069 fn main() {
1070 let variableˇ = TestStruct;
1071 }
1072 "});
1073 let hint_start_offset = cx.ranges(indoc! {"
1074 struct TestStruct;
1075
1076 fn main() {
1077 let variableˇ = TestStruct;
1078 }
1079 "})[0]
1080 .start;
1081 let hint_position = cx.to_lsp(hint_start_offset);
1082 let target_range = cx.lsp_range(indoc! {"
1083 struct «TestStruct»;
1084
1085 fn main() {
1086 let variable = TestStruct;
1087 }
1088 "});
1089
1090 let expected_uri = cx.buffer_lsp_url.clone();
1091 let hint_label = ": TestStruct";
1092 cx.lsp
1093 .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1094 let expected_uri = expected_uri.clone();
1095 async move {
1096 assert_eq!(params.text_document.uri, expected_uri);
1097 Ok(Some(vec![lsp::InlayHint {
1098 position: hint_position,
1099 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1100 value: hint_label.to_string(),
1101 location: Some(lsp::Location {
1102 uri: params.text_document.uri,
1103 range: target_range,
1104 }),
1105 ..Default::default()
1106 }]),
1107 kind: Some(lsp::InlayHintKind::TYPE),
1108 text_edits: None,
1109 tooltip: None,
1110 padding_left: Some(false),
1111 padding_right: Some(false),
1112 data: None,
1113 }]))
1114 }
1115 })
1116 .next()
1117 .await;
1118 cx.background_executor.run_until_parked();
1119 cx.update_editor(|editor, cx| {
1120 let expected_layers = vec![hint_label.to_string()];
1121 assert_eq!(expected_layers, cached_hint_labels(editor));
1122 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1123 });
1124
1125 let inlay_range = cx
1126 .ranges(indoc! {"
1127 struct TestStruct;
1128
1129 fn main() {
1130 let variable« »= TestStruct;
1131 }
1132 "})
1133 .get(0)
1134 .cloned()
1135 .unwrap();
1136 let midpoint = cx.update_editor(|editor, cx| {
1137 let snapshot = editor.snapshot(cx);
1138 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1139 let next_valid = inlay_range.end.to_display_point(&snapshot);
1140 assert_eq!(previous_valid.row(), next_valid.row());
1141 assert!(previous_valid.column() < next_valid.column());
1142 DisplayPoint::new(
1143 previous_valid.row(),
1144 previous_valid.column() + (hint_label.len() / 2) as u32,
1145 )
1146 });
1147 // Press cmd to trigger highlight
1148 let hover_point = cx.pixel_position_for(midpoint);
1149 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
1150 cx.background_executor.run_until_parked();
1151 cx.update_editor(|editor, cx| {
1152 let snapshot = editor.snapshot(cx);
1153 let actual_highlights = snapshot
1154 .inlay_highlights::<HoveredLinkState>()
1155 .into_iter()
1156 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1157 .collect::<Vec<_>>();
1158
1159 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1160 let expected_highlight = InlayHighlight {
1161 inlay: InlayId::Hint(0),
1162 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
1163 range: 0..hint_label.len(),
1164 };
1165 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1166 });
1167
1168 cx.simulate_mouse_move(hover_point, Modifiers::none());
1169 // Assert no link highlights
1170 cx.update_editor(|editor, cx| {
1171 let snapshot = editor.snapshot(cx);
1172 let actual_ranges = snapshot
1173 .text_highlight_ranges::<HoveredLinkState>()
1174 .map(|ranges| ranges.as_ref().clone().1)
1175 .unwrap_or_default();
1176
1177 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1178 });
1179
1180 cx.simulate_modifiers_change(Modifiers::secondary_key());
1181 cx.background_executor.run_until_parked();
1182 cx.simulate_click(hover_point, Modifiers::secondary_key());
1183 cx.background_executor.run_until_parked();
1184 cx.assert_editor_state(indoc! {"
1185 struct «TestStructˇ»;
1186
1187 fn main() {
1188 let variable = TestStruct;
1189 }
1190 "});
1191 }
1192
1193 #[gpui::test]
1194 async fn test_urls(cx: &mut gpui::TestAppContext) {
1195 init_test(cx, |_| {});
1196 let mut cx = EditorLspTestContext::new_rust(
1197 lsp::ServerCapabilities {
1198 ..Default::default()
1199 },
1200 cx,
1201 )
1202 .await;
1203
1204 cx.set_state(indoc! {"
1205 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1206 "});
1207
1208 let screen_coord = cx.pixel_position(indoc! {"
1209 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1210 "});
1211
1212 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1213 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1214 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1215 "});
1216
1217 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1218 assert_eq!(
1219 cx.opened_url(),
1220 Some("https://zed.dev/channel/had-(oops)".into())
1221 );
1222 }
1223
1224 #[gpui::test]
1225 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1226 init_test(cx, |_| {});
1227 let mut cx = EditorLspTestContext::new_rust(
1228 lsp::ServerCapabilities {
1229 ..Default::default()
1230 },
1231 cx,
1232 )
1233 .await;
1234
1235 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1236
1237 let screen_coord =
1238 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1239
1240 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1241 cx.assert_editor_text_highlights::<HoveredLinkState>(
1242 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1243 );
1244
1245 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1246 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1247 }
1248
1249 #[gpui::test]
1250 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1251 init_test(cx, |_| {});
1252 let mut cx = EditorLspTestContext::new_rust(
1253 lsp::ServerCapabilities {
1254 ..Default::default()
1255 },
1256 cx,
1257 )
1258 .await;
1259
1260 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1261
1262 let screen_coord =
1263 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1264
1265 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1266 cx.assert_editor_text_highlights::<HoveredLinkState>(
1267 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1268 );
1269
1270 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1271 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1272 }
1273
1274 #[gpui::test]
1275 async fn test_cmd_click_back_and_forth(cx: &mut gpui::TestAppContext) {
1276 init_test(cx, |_| {});
1277 let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
1278 cx.set_state(indoc! {"
1279 fn test() {
1280 do_work();
1281 }ˇ
1282
1283 fn do_work() {
1284 test();
1285 }
1286 "});
1287
1288 // cmd-click on `test` definition and usage, and expect Zed to allow going back and forth,
1289 // because cmd-click first searches for definitions to go to, and then fall backs to symbol usages to go to.
1290 let definition_hover_point = cx.pixel_position(indoc! {"
1291 fn testˇ() {
1292 do_work();
1293 }
1294
1295 fn do_work() {
1296 test();
1297 }
1298 "});
1299 let definition_display_point = cx.display_point(indoc! {"
1300 fn testˇ() {
1301 do_work();
1302 }
1303
1304 fn do_work() {
1305 test();
1306 }
1307 "});
1308 let definition_range = cx.lsp_range(indoc! {"
1309 fn «test»() {
1310 do_work();
1311 }
1312
1313 fn do_work() {
1314 test();
1315 }
1316 "});
1317 let reference_hover_point = cx.pixel_position(indoc! {"
1318 fn test() {
1319 do_work();
1320 }
1321
1322 fn do_work() {
1323 testˇ();
1324 }
1325 "});
1326 let reference_display_point = cx.display_point(indoc! {"
1327 fn test() {
1328 do_work();
1329 }
1330
1331 fn do_work() {
1332 testˇ();
1333 }
1334 "});
1335 let reference_range = cx.lsp_range(indoc! {"
1336 fn test() {
1337 do_work();
1338 }
1339
1340 fn do_work() {
1341 «test»();
1342 }
1343 "});
1344 let expected_uri = cx.buffer_lsp_url.clone();
1345 cx.lsp
1346 .handle_request::<GotoDefinition, _, _>(move |params, _| {
1347 let expected_uri = expected_uri.clone();
1348 async move {
1349 assert_eq!(
1350 params.text_document_position_params.text_document.uri,
1351 expected_uri
1352 );
1353 let position = params.text_document_position_params.position;
1354 Ok(Some(lsp::GotoDefinitionResponse::Link(
1355 if position.line == reference_display_point.row()
1356 && position.character == reference_display_point.column()
1357 {
1358 vec![lsp::LocationLink {
1359 origin_selection_range: None,
1360 target_uri: params.text_document_position_params.text_document.uri,
1361 target_range: definition_range,
1362 target_selection_range: definition_range,
1363 }]
1364 } else {
1365 // We cannot navigate to the definition outside of its reference point
1366 Vec::new()
1367 },
1368 )))
1369 }
1370 });
1371 let expected_uri = cx.buffer_lsp_url.clone();
1372 cx.lsp.handle_request::<References, _, _>(move |params, _| {
1373 let expected_uri = expected_uri.clone();
1374 async move {
1375 assert_eq!(
1376 params.text_document_position.text_document.uri,
1377 expected_uri
1378 );
1379 let position = params.text_document_position.position;
1380 // Zed should not look for references if GotoDefinition works or returns non-empty result
1381 assert_eq!(position.line, definition_display_point.row());
1382 assert_eq!(position.character, definition_display_point.column());
1383 Ok(Some(vec![lsp::Location {
1384 uri: params.text_document_position.text_document.uri,
1385 range: reference_range,
1386 }]))
1387 }
1388 });
1389
1390 for _ in 0..5 {
1391 cx.simulate_click(definition_hover_point, Modifiers::secondary_key());
1392 cx.background_executor.run_until_parked();
1393 cx.assert_editor_state(indoc! {"
1394 fn test() {
1395 do_work();
1396 }
1397
1398 fn do_work() {
1399 «testˇ»();
1400 }
1401 "});
1402
1403 cx.simulate_click(reference_hover_point, Modifiers::secondary_key());
1404 cx.background_executor.run_until_parked();
1405 cx.assert_editor_state(indoc! {"
1406 fn «testˇ»() {
1407 do_work();
1408 }
1409
1410 fn do_work() {
1411 test();
1412 }
1413 "});
1414 }
1415 }
1416}