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