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