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