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