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