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