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