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::request::{GotoDefinition, GotoTypeDefinition};
706 use util::assert_set_eq;
707 use workspace::item::Item;
708
709 #[gpui::test]
710 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
711 init_test(cx, |_| {});
712
713 let mut cx = EditorLspTestContext::new_rust(
714 lsp::ServerCapabilities {
715 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
716 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
717 ..Default::default()
718 },
719 cx,
720 )
721 .await;
722
723 cx.set_state(indoc! {"
724 struct A;
725 let vˇariable = A;
726 "});
727 let screen_coord = cx.editor(|editor, cx| editor.pixel_position_of_cursor(cx));
728
729 // Basic hold cmd+shift, expect highlight in region if response contains type definition
730 let symbol_range = cx.lsp_range(indoc! {"
731 struct A;
732 let «variable» = A;
733 "});
734 let target_range = cx.lsp_range(indoc! {"
735 struct «A»;
736 let variable = A;
737 "});
738
739 cx.run_until_parked();
740
741 let mut requests =
742 cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
743 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
744 lsp::LocationLink {
745 origin_selection_range: Some(symbol_range),
746 target_uri: url.clone(),
747 target_range,
748 target_selection_range: target_range,
749 },
750 ])))
751 });
752
753 cx.cx
754 .cx
755 .simulate_mouse_move(screen_coord.unwrap(), Modifiers::command_shift());
756
757 requests.next().await;
758 cx.run_until_parked();
759 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
760 struct A;
761 let «variable» = A;
762 "});
763
764 cx.simulate_modifiers_change(Modifiers::secondary_key());
765 cx.run_until_parked();
766 // Assert no link highlights
767 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
768 struct A;
769 let variable = A;
770 "});
771
772 cx.cx
773 .cx
774 .simulate_click(screen_coord.unwrap(), Modifiers::command_shift());
775
776 cx.assert_editor_state(indoc! {"
777 struct «Aˇ»;
778 let variable = A;
779 "});
780 }
781
782 #[gpui::test]
783 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
784 init_test(cx, |_| {});
785
786 let mut cx = EditorLspTestContext::new_rust(
787 lsp::ServerCapabilities {
788 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
789 ..Default::default()
790 },
791 cx,
792 )
793 .await;
794
795 cx.set_state(indoc! {"
796 fn ˇtest() { do_work(); }
797 fn do_work() { test(); }
798 "});
799
800 // Basic hold cmd, expect highlight in region if response contains definition
801 let hover_point = cx.pixel_position(indoc! {"
802 fn test() { do_wˇork(); }
803 fn do_work() { test(); }
804 "});
805 let symbol_range = cx.lsp_range(indoc! {"
806 fn test() { «do_work»(); }
807 fn do_work() { test(); }
808 "});
809 let target_range = cx.lsp_range(indoc! {"
810 fn test() { do_work(); }
811 fn «do_work»() { test(); }
812 "});
813
814 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
815 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
816 lsp::LocationLink {
817 origin_selection_range: Some(symbol_range),
818 target_uri: url.clone(),
819 target_range,
820 target_selection_range: target_range,
821 },
822 ])))
823 });
824
825 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
826 requests.next().await;
827 cx.background_executor.run_until_parked();
828 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
829 fn test() { «do_work»(); }
830 fn do_work() { test(); }
831 "});
832
833 // Unpress cmd causes highlight to go away
834 cx.simulate_modifiers_change(Modifiers::none());
835 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
836 fn test() { do_work(); }
837 fn do_work() { test(); }
838 "});
839
840 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
841 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
842 lsp::LocationLink {
843 origin_selection_range: Some(symbol_range),
844 target_uri: url.clone(),
845 target_range,
846 target_selection_range: target_range,
847 },
848 ])))
849 });
850
851 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
852 requests.next().await;
853 cx.background_executor.run_until_parked();
854 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
855 fn test() { «do_work»(); }
856 fn do_work() { test(); }
857 "});
858
859 // Moving mouse to location with no response dismisses highlight
860 let hover_point = cx.pixel_position(indoc! {"
861 fˇn test() { do_work(); }
862 fn do_work() { test(); }
863 "});
864 let mut requests = cx
865 .lsp
866 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
867 // No definitions returned
868 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
869 });
870 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
871
872 requests.next().await;
873 cx.background_executor.run_until_parked();
874
875 // Assert no link highlights
876 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
877 fn test() { do_work(); }
878 fn do_work() { test(); }
879 "});
880
881 // // Move mouse without cmd and then pressing cmd triggers highlight
882 let hover_point = cx.pixel_position(indoc! {"
883 fn test() { do_work(); }
884 fn do_work() { teˇst(); }
885 "});
886 cx.simulate_mouse_move(hover_point, Modifiers::none());
887
888 // Assert no link highlights
889 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
890 fn test() { do_work(); }
891 fn do_work() { test(); }
892 "});
893
894 let symbol_range = cx.lsp_range(indoc! {"
895 fn test() { do_work(); }
896 fn do_work() { «test»(); }
897 "});
898 let target_range = cx.lsp_range(indoc! {"
899 fn «test»() { do_work(); }
900 fn do_work() { test(); }
901 "});
902
903 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
904 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
905 lsp::LocationLink {
906 origin_selection_range: Some(symbol_range),
907 target_uri: url,
908 target_range,
909 target_selection_range: target_range,
910 },
911 ])))
912 });
913
914 cx.simulate_modifiers_change(Modifiers::secondary_key());
915
916 requests.next().await;
917 cx.background_executor.run_until_parked();
918
919 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
920 fn test() { do_work(); }
921 fn do_work() { «test»(); }
922 "});
923
924 cx.deactivate_window();
925 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
926 fn test() { do_work(); }
927 fn do_work() { test(); }
928 "});
929
930 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
931 cx.background_executor.run_until_parked();
932 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
933 fn test() { do_work(); }
934 fn do_work() { «test»(); }
935 "});
936
937 // Moving again within the same symbol range doesn't re-request
938 let hover_point = cx.pixel_position(indoc! {"
939 fn test() { do_work(); }
940 fn do_work() { tesˇt(); }
941 "});
942 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
943 cx.background_executor.run_until_parked();
944 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
945 fn test() { do_work(); }
946 fn do_work() { «test»(); }
947 "});
948
949 // Cmd click with existing definition doesn't re-request and dismisses highlight
950 cx.simulate_click(hover_point, Modifiers::secondary_key());
951 cx.lsp
952 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
953 // Empty definition response to make sure we aren't hitting the lsp and using
954 // the cached location instead
955 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
956 });
957 cx.background_executor.run_until_parked();
958 cx.assert_editor_state(indoc! {"
959 fn «testˇ»() { do_work(); }
960 fn do_work() { test(); }
961 "});
962
963 // Assert no link highlights after jump
964 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
965 fn test() { do_work(); }
966 fn do_work() { test(); }
967 "});
968
969 // Cmd click without existing definition requests and jumps
970 let hover_point = cx.pixel_position(indoc! {"
971 fn test() { do_wˇork(); }
972 fn do_work() { test(); }
973 "});
974 let target_range = cx.lsp_range(indoc! {"
975 fn test() { do_work(); }
976 fn «do_work»() { test(); }
977 "});
978
979 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
980 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
981 lsp::LocationLink {
982 origin_selection_range: None,
983 target_uri: url,
984 target_range,
985 target_selection_range: target_range,
986 },
987 ])))
988 });
989 cx.simulate_click(hover_point, Modifiers::secondary_key());
990 requests.next().await;
991 cx.background_executor.run_until_parked();
992 cx.assert_editor_state(indoc! {"
993 fn test() { do_work(); }
994 fn «do_workˇ»() { test(); }
995 "});
996
997 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
998 // 2. Selection is completed, hovering
999 let hover_point = cx.pixel_position(indoc! {"
1000 fn test() { do_wˇork(); }
1001 fn do_work() { test(); }
1002 "});
1003 let target_range = cx.lsp_range(indoc! {"
1004 fn test() { do_work(); }
1005 fn «do_work»() { test(); }
1006 "});
1007 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1008 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1009 lsp::LocationLink {
1010 origin_selection_range: None,
1011 target_uri: url,
1012 target_range,
1013 target_selection_range: target_range,
1014 },
1015 ])))
1016 });
1017
1018 // create a pending selection
1019 let selection_range = cx.ranges(indoc! {"
1020 fn «test() { do_w»ork(); }
1021 fn do_work() { test(); }
1022 "})[0]
1023 .clone();
1024 cx.update_editor(|editor, cx| {
1025 let snapshot = editor.buffer().read(cx).snapshot(cx);
1026 let anchor_range = snapshot.anchor_before(selection_range.start)
1027 ..snapshot.anchor_after(selection_range.end);
1028 editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| {
1029 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1030 });
1031 });
1032 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
1033 cx.background_executor.run_until_parked();
1034 assert!(requests.try_next().is_err());
1035 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1036 fn test() { do_work(); }
1037 fn do_work() { test(); }
1038 "});
1039 cx.background_executor.run_until_parked();
1040 }
1041
1042 #[gpui::test]
1043 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1044 init_test(cx, |settings| {
1045 settings.defaults.inlay_hints = Some(InlayHintSettings {
1046 enabled: true,
1047 edit_debounce_ms: 0,
1048 scroll_debounce_ms: 0,
1049 show_type_hints: true,
1050 show_parameter_hints: true,
1051 show_other_hints: true,
1052 })
1053 });
1054
1055 let mut cx = EditorLspTestContext::new_rust(
1056 lsp::ServerCapabilities {
1057 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1058 ..Default::default()
1059 },
1060 cx,
1061 )
1062 .await;
1063 cx.set_state(indoc! {"
1064 struct TestStruct;
1065
1066 fn main() {
1067 let variableˇ = TestStruct;
1068 }
1069 "});
1070 let hint_start_offset = cx.ranges(indoc! {"
1071 struct TestStruct;
1072
1073 fn main() {
1074 let variableˇ = TestStruct;
1075 }
1076 "})[0]
1077 .start;
1078 let hint_position = cx.to_lsp(hint_start_offset);
1079 let target_range = cx.lsp_range(indoc! {"
1080 struct «TestStruct»;
1081
1082 fn main() {
1083 let variable = TestStruct;
1084 }
1085 "});
1086
1087 let expected_uri = cx.buffer_lsp_url.clone();
1088 let hint_label = ": TestStruct";
1089 cx.lsp
1090 .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1091 let expected_uri = expected_uri.clone();
1092 async move {
1093 assert_eq!(params.text_document.uri, expected_uri);
1094 Ok(Some(vec![lsp::InlayHint {
1095 position: hint_position,
1096 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1097 value: hint_label.to_string(),
1098 location: Some(lsp::Location {
1099 uri: params.text_document.uri,
1100 range: target_range,
1101 }),
1102 ..Default::default()
1103 }]),
1104 kind: Some(lsp::InlayHintKind::TYPE),
1105 text_edits: None,
1106 tooltip: None,
1107 padding_left: Some(false),
1108 padding_right: Some(false),
1109 data: None,
1110 }]))
1111 }
1112 })
1113 .next()
1114 .await;
1115 cx.background_executor.run_until_parked();
1116 cx.update_editor(|editor, cx| {
1117 let expected_layers = vec![hint_label.to_string()];
1118 assert_eq!(expected_layers, cached_hint_labels(editor));
1119 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1120 });
1121
1122 let inlay_range = cx
1123 .ranges(indoc! {"
1124 struct TestStruct;
1125
1126 fn main() {
1127 let variable« »= TestStruct;
1128 }
1129 "})
1130 .get(0)
1131 .cloned()
1132 .unwrap();
1133 let midpoint = cx.update_editor(|editor, cx| {
1134 let snapshot = editor.snapshot(cx);
1135 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1136 let next_valid = inlay_range.end.to_display_point(&snapshot);
1137 assert_eq!(previous_valid.row(), next_valid.row());
1138 assert!(previous_valid.column() < next_valid.column());
1139 DisplayPoint::new(
1140 previous_valid.row(),
1141 previous_valid.column() + (hint_label.len() / 2) as u32,
1142 )
1143 });
1144 // Press cmd to trigger highlight
1145 let hover_point = cx.pixel_position_for(midpoint);
1146 cx.simulate_mouse_move(hover_point, Modifiers::secondary_key());
1147 cx.background_executor.run_until_parked();
1148 cx.update_editor(|editor, cx| {
1149 let snapshot = editor.snapshot(cx);
1150 let actual_highlights = snapshot
1151 .inlay_highlights::<HoveredLinkState>()
1152 .into_iter()
1153 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1154 .collect::<Vec<_>>();
1155
1156 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1157 let expected_highlight = InlayHighlight {
1158 inlay: InlayId::Hint(0),
1159 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
1160 range: 0..hint_label.len(),
1161 };
1162 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1163 });
1164
1165 cx.simulate_mouse_move(hover_point, Modifiers::none());
1166 // Assert no link highlights
1167 cx.update_editor(|editor, cx| {
1168 let snapshot = editor.snapshot(cx);
1169 let actual_ranges = snapshot
1170 .text_highlight_ranges::<HoveredLinkState>()
1171 .map(|ranges| ranges.as_ref().clone().1)
1172 .unwrap_or_default();
1173
1174 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1175 });
1176
1177 cx.simulate_modifiers_change(Modifiers::secondary_key());
1178 cx.background_executor.run_until_parked();
1179 cx.simulate_click(hover_point, Modifiers::secondary_key());
1180 cx.background_executor.run_until_parked();
1181 cx.assert_editor_state(indoc! {"
1182 struct «TestStructˇ»;
1183
1184 fn main() {
1185 let variable = TestStruct;
1186 }
1187 "});
1188 }
1189
1190 #[gpui::test]
1191 async fn test_urls(cx: &mut gpui::TestAppContext) {
1192 init_test(cx, |_| {});
1193 let mut cx = EditorLspTestContext::new_rust(
1194 lsp::ServerCapabilities {
1195 ..Default::default()
1196 },
1197 cx,
1198 )
1199 .await;
1200
1201 cx.set_state(indoc! {"
1202 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1203 "});
1204
1205 let screen_coord = cx.pixel_position(indoc! {"
1206 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1207 "});
1208
1209 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1210 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1211 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1212 "});
1213
1214 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1215 assert_eq!(
1216 cx.opened_url(),
1217 Some("https://zed.dev/channel/had-(oops)".into())
1218 );
1219 }
1220
1221 #[gpui::test]
1222 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1223 init_test(cx, |_| {});
1224 let mut cx = EditorLspTestContext::new_rust(
1225 lsp::ServerCapabilities {
1226 ..Default::default()
1227 },
1228 cx,
1229 )
1230 .await;
1231
1232 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1233
1234 let screen_coord =
1235 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1236
1237 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1238 cx.assert_editor_text_highlights::<HoveredLinkState>(
1239 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1240 );
1241
1242 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1243 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1244 }
1245
1246 #[gpui::test]
1247 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1248 init_test(cx, |_| {});
1249 let mut cx = EditorLspTestContext::new_rust(
1250 lsp::ServerCapabilities {
1251 ..Default::default()
1252 },
1253 cx,
1254 )
1255 .await;
1256
1257 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1258
1259 let screen_coord =
1260 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1261
1262 cx.simulate_mouse_move(screen_coord, Modifiers::secondary_key());
1263 cx.assert_editor_text_highlights::<HoveredLinkState>(
1264 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1265 );
1266
1267 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1268 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1269 }
1270}