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