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 multi_buffer::MultiBufferOffset;
742 use settings::InlayHintSettingsContent;
743 use util::{assert_set_eq, path};
744 use workspace::item::Item;
745
746 #[gpui::test]
747 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
748 init_test(cx, |_| {});
749
750 let mut cx = EditorLspTestContext::new_rust(
751 lsp::ServerCapabilities {
752 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
753 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
754 ..Default::default()
755 },
756 cx,
757 )
758 .await;
759
760 cx.set_state(indoc! {"
761 struct A;
762 let vˇariable = A;
763 "});
764 let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
765
766 // Basic hold cmd+shift, expect highlight in region if response contains type definition
767 let symbol_range = cx.lsp_range(indoc! {"
768 struct A;
769 let «variable» = A;
770 "});
771 let target_range = cx.lsp_range(indoc! {"
772 struct «A»;
773 let variable = A;
774 "});
775
776 cx.run_until_parked();
777
778 let mut requests =
779 cx.set_request_handler::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
780 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
781 lsp::LocationLink {
782 origin_selection_range: Some(symbol_range),
783 target_uri: url.clone(),
784 target_range,
785 target_selection_range: target_range,
786 },
787 ])))
788 });
789
790 let modifiers = if cfg!(target_os = "macos") {
791 Modifiers::command_shift()
792 } else {
793 Modifiers::control_shift()
794 };
795
796 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
797
798 requests.next().await;
799 cx.run_until_parked();
800 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
801 struct A;
802 let «variable» = A;
803 "});
804
805 cx.simulate_modifiers_change(Modifiers::secondary_key());
806 cx.run_until_parked();
807 // Assert no link highlights
808 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
809 struct A;
810 let variable = A;
811 "});
812
813 cx.simulate_click(screen_coord.unwrap(), modifiers);
814
815 cx.assert_editor_state(indoc! {"
816 struct «Aˇ»;
817 let variable = A;
818 "});
819 }
820
821 #[gpui::test]
822 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
823 init_test(cx, |_| {});
824
825 let mut cx = EditorLspTestContext::new_rust(
826 lsp::ServerCapabilities {
827 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
828 definition_provider: Some(lsp::OneOf::Left(true)),
829 ..Default::default()
830 },
831 cx,
832 )
833 .await;
834
835 cx.set_state(indoc! {"
836 fn ˇtest() { do_work(); }
837 fn do_work() { test(); }
838 "});
839
840 // Basic hold cmd, expect highlight in region if response contains definition
841 let hover_point = cx.pixel_position(indoc! {"
842 fn test() { do_wˇork(); }
843 fn do_work() { test(); }
844 "});
845 let symbol_range = cx.lsp_range(indoc! {"
846 fn test() { «do_work»(); }
847 fn do_work() { test(); }
848 "});
849 let target_range = cx.lsp_range(indoc! {"
850 fn test() { do_work(); }
851 fn «do_work»() { test(); }
852 "});
853
854 let mut requests =
855 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
856 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
857 lsp::LocationLink {
858 origin_selection_range: Some(symbol_range),
859 target_uri: url.clone(),
860 target_range,
861 target_selection_range: target_range,
862 },
863 ])))
864 });
865
866 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
867 requests.next().await;
868 cx.background_executor.run_until_parked();
869 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
870 fn test() { «do_work»(); }
871 fn do_work() { test(); }
872 "});
873
874 // Unpress cmd causes highlight to go away
875 cx.simulate_modifiers_change(Modifiers::none());
876 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
877 fn test() { do_work(); }
878 fn do_work() { test(); }
879 "});
880
881 let mut requests =
882 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
883 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
884 lsp::LocationLink {
885 origin_selection_range: Some(symbol_range),
886 target_uri: url.clone(),
887 target_range,
888 target_selection_range: target_range,
889 },
890 ])))
891 });
892
893 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
894 requests.next().await;
895 cx.background_executor.run_until_parked();
896 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
897 fn test() { «do_work»(); }
898 fn do_work() { test(); }
899 "});
900
901 // Moving mouse to location with no response dismisses highlight
902 let hover_point = cx.pixel_position(indoc! {"
903 fˇn test() { do_work(); }
904 fn do_work() { test(); }
905 "});
906 let mut requests =
907 cx.lsp
908 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
909 // No definitions returned
910 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
911 });
912 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
913
914 requests.next().await;
915 cx.background_executor.run_until_parked();
916
917 // Assert no link highlights
918 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
919 fn test() { do_work(); }
920 fn do_work() { test(); }
921 "});
922
923 // // Move mouse without cmd and then pressing cmd triggers highlight
924 let hover_point = cx.pixel_position(indoc! {"
925 fn test() { do_work(); }
926 fn do_work() { teˇst(); }
927 "});
928 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
929
930 // Assert no link highlights
931 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
932 fn test() { do_work(); }
933 fn do_work() { test(); }
934 "});
935
936 let symbol_range = cx.lsp_range(indoc! {"
937 fn test() { do_work(); }
938 fn do_work() { «test»(); }
939 "});
940 let target_range = cx.lsp_range(indoc! {"
941 fn «test»() { do_work(); }
942 fn do_work() { test(); }
943 "});
944
945 let mut requests =
946 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
947 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
948 lsp::LocationLink {
949 origin_selection_range: Some(symbol_range),
950 target_uri: url,
951 target_range,
952 target_selection_range: target_range,
953 },
954 ])))
955 });
956
957 cx.simulate_modifiers_change(Modifiers::secondary_key());
958
959 requests.next().await;
960 cx.background_executor.run_until_parked();
961
962 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
963 fn test() { do_work(); }
964 fn do_work() { «test»(); }
965 "});
966
967 cx.deactivate_window();
968 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
969 fn test() { do_work(); }
970 fn do_work() { test(); }
971 "});
972
973 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
974 cx.background_executor.run_until_parked();
975 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
976 fn test() { do_work(); }
977 fn do_work() { «test»(); }
978 "});
979
980 // Moving again within the same symbol range doesn't re-request
981 let hover_point = cx.pixel_position(indoc! {"
982 fn test() { do_work(); }
983 fn do_work() { tesˇt(); }
984 "});
985 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
986 cx.background_executor.run_until_parked();
987 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
988 fn test() { do_work(); }
989 fn do_work() { «test»(); }
990 "});
991
992 // Cmd click with existing definition doesn't re-request and dismisses highlight
993 cx.simulate_click(hover_point, Modifiers::secondary_key());
994 cx.lsp
995 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
996 // Empty definition response to make sure we aren't hitting the lsp and using
997 // the cached location instead
998 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
999 });
1000 cx.background_executor.run_until_parked();
1001 cx.assert_editor_state(indoc! {"
1002 fn «testˇ»() { do_work(); }
1003 fn do_work() { test(); }
1004 "});
1005
1006 // Assert no link highlights after jump
1007 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1008 fn test() { do_work(); }
1009 fn do_work() { test(); }
1010 "});
1011
1012 // Cmd click without existing definition requests and jumps
1013 let hover_point = cx.pixel_position(indoc! {"
1014 fn test() { do_wˇork(); }
1015 fn do_work() { test(); }
1016 "});
1017 let target_range = cx.lsp_range(indoc! {"
1018 fn test() { do_work(); }
1019 fn «do_work»() { test(); }
1020 "});
1021
1022 let mut requests =
1023 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1024 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1025 lsp::LocationLink {
1026 origin_selection_range: None,
1027 target_uri: url,
1028 target_range,
1029 target_selection_range: target_range,
1030 },
1031 ])))
1032 });
1033 cx.simulate_click(hover_point, Modifiers::secondary_key());
1034 requests.next().await;
1035 cx.background_executor.run_until_parked();
1036 cx.assert_editor_state(indoc! {"
1037 fn test() { do_work(); }
1038 fn «do_workˇ»() { test(); }
1039 "});
1040
1041 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1042 // 2. Selection is completed, hovering
1043 let hover_point = cx.pixel_position(indoc! {"
1044 fn test() { do_wˇork(); }
1045 fn do_work() { test(); }
1046 "});
1047 let target_range = cx.lsp_range(indoc! {"
1048 fn test() { do_work(); }
1049 fn «do_work»() { test(); }
1050 "});
1051 let mut requests =
1052 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1053 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1054 lsp::LocationLink {
1055 origin_selection_range: None,
1056 target_uri: url,
1057 target_range,
1058 target_selection_range: target_range,
1059 },
1060 ])))
1061 });
1062
1063 // create a pending selection
1064 let selection_range = cx.ranges(indoc! {"
1065 fn «test() { do_w»ork(); }
1066 fn do_work() { test(); }
1067 "})[0]
1068 .clone();
1069 cx.update_editor(|editor, window, cx| {
1070 let snapshot = editor.buffer().read(cx).snapshot(cx);
1071 let anchor_range = snapshot.anchor_before(MultiBufferOffset(selection_range.start))
1072 ..snapshot.anchor_after(MultiBufferOffset(selection_range.end));
1073 editor.change_selections(Default::default(), window, cx, |s| {
1074 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1075 });
1076 });
1077 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1078 cx.background_executor.run_until_parked();
1079 assert!(requests.try_next().is_err());
1080 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1081 fn test() { do_work(); }
1082 fn do_work() { test(); }
1083 "});
1084 cx.background_executor.run_until_parked();
1085 }
1086
1087 #[gpui::test]
1088 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1089 init_test(cx, |settings| {
1090 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1091 enabled: Some(true),
1092 show_value_hints: Some(false),
1093 edit_debounce_ms: Some(0),
1094 scroll_debounce_ms: Some(0),
1095 show_type_hints: Some(true),
1096 show_parameter_hints: Some(true),
1097 show_other_hints: Some(true),
1098 show_background: Some(false),
1099 toggle_on_modifiers_press: None,
1100 })
1101 });
1102
1103 let mut cx = EditorLspTestContext::new_rust(
1104 lsp::ServerCapabilities {
1105 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1106 ..Default::default()
1107 },
1108 cx,
1109 )
1110 .await;
1111 cx.set_state(indoc! {"
1112 struct TestStruct;
1113
1114 fn main() {
1115 let variableˇ = TestStruct;
1116 }
1117 "});
1118 let hint_start_offset = cx.ranges(indoc! {"
1119 struct TestStruct;
1120
1121 fn main() {
1122 let variableˇ = TestStruct;
1123 }
1124 "})[0]
1125 .start;
1126 let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1127 let target_range = cx.lsp_range(indoc! {"
1128 struct «TestStruct»;
1129
1130 fn main() {
1131 let variable = TestStruct;
1132 }
1133 "});
1134
1135 let expected_uri = cx.buffer_lsp_url.clone();
1136 let hint_label = ": TestStruct";
1137 cx.lsp
1138 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1139 let expected_uri = expected_uri.clone();
1140 async move {
1141 assert_eq!(params.text_document.uri, expected_uri);
1142 Ok(Some(vec![lsp::InlayHint {
1143 position: hint_position,
1144 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1145 value: hint_label.to_string(),
1146 location: Some(lsp::Location {
1147 uri: params.text_document.uri,
1148 range: target_range,
1149 }),
1150 ..Default::default()
1151 }]),
1152 kind: Some(lsp::InlayHintKind::TYPE),
1153 text_edits: None,
1154 tooltip: None,
1155 padding_left: Some(false),
1156 padding_right: Some(false),
1157 data: None,
1158 }]))
1159 }
1160 })
1161 .next()
1162 .await;
1163 cx.background_executor.run_until_parked();
1164 cx.update_editor(|editor, _window, cx| {
1165 let expected_layers = vec![hint_label.to_string()];
1166 assert_eq!(expected_layers, cached_hint_labels(editor, cx));
1167 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1168 });
1169
1170 let inlay_range = cx
1171 .ranges(indoc! {"
1172 struct TestStruct;
1173
1174 fn main() {
1175 let variable« »= TestStruct;
1176 }
1177 "})
1178 .first()
1179 .cloned()
1180 .unwrap();
1181 let midpoint = cx.update_editor(|editor, window, cx| {
1182 let snapshot = editor.snapshot(window, cx);
1183 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
1184 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
1185 assert_eq!(previous_valid.row(), next_valid.row());
1186 assert!(previous_valid.column() < next_valid.column());
1187 DisplayPoint::new(
1188 previous_valid.row(),
1189 previous_valid.column() + (hint_label.len() / 2) as u32,
1190 )
1191 });
1192 // Press cmd to trigger highlight
1193 let hover_point = cx.pixel_position_for(midpoint);
1194 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1195 cx.background_executor.run_until_parked();
1196 cx.update_editor(|editor, window, cx| {
1197 let snapshot = editor.snapshot(window, cx);
1198 let actual_highlights = snapshot
1199 .inlay_highlights::<HoveredLinkState>()
1200 .into_iter()
1201 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1202 .collect::<Vec<_>>();
1203
1204 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1205 let expected_highlight = InlayHighlight {
1206 inlay: InlayId::Hint(0),
1207 inlay_position: buffer_snapshot.anchor_after(MultiBufferOffset(inlay_range.start)),
1208 range: 0..hint_label.len(),
1209 };
1210 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1211 });
1212
1213 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1214 // Assert no link highlights
1215 cx.update_editor(|editor, window, cx| {
1216 let snapshot = editor.snapshot(window, cx);
1217 let actual_ranges = snapshot
1218 .text_highlight_ranges::<HoveredLinkState>()
1219 .map(|ranges| ranges.as_ref().clone().1)
1220 .unwrap_or_default();
1221
1222 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1223 });
1224
1225 cx.simulate_modifiers_change(Modifiers::secondary_key());
1226 cx.background_executor.run_until_parked();
1227 cx.simulate_click(hover_point, Modifiers::secondary_key());
1228 cx.background_executor.run_until_parked();
1229 cx.assert_editor_state(indoc! {"
1230 struct «TestStructˇ»;
1231
1232 fn main() {
1233 let variable = TestStruct;
1234 }
1235 "});
1236 }
1237
1238 #[gpui::test]
1239 async fn test_urls(cx: &mut gpui::TestAppContext) {
1240 init_test(cx, |_| {});
1241 let mut cx = EditorLspTestContext::new_rust(
1242 lsp::ServerCapabilities {
1243 ..Default::default()
1244 },
1245 cx,
1246 )
1247 .await;
1248
1249 cx.set_state(indoc! {"
1250 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1251 "});
1252
1253 let screen_coord = cx.pixel_position(indoc! {"
1254 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1255 "});
1256
1257 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1258 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1259 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1260 "});
1261
1262 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1263 assert_eq!(
1264 cx.opened_url(),
1265 Some("https://zed.dev/channel/had-(oops)".into())
1266 );
1267 }
1268
1269 #[gpui::test]
1270 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1271 init_test(cx, |_| {});
1272 let mut cx = EditorLspTestContext::new_rust(
1273 lsp::ServerCapabilities {
1274 ..Default::default()
1275 },
1276 cx,
1277 )
1278 .await;
1279
1280 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1281
1282 let screen_coord =
1283 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1284
1285 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1286 cx.assert_editor_text_highlights::<HoveredLinkState>(
1287 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1288 );
1289
1290 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1291 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1292 }
1293
1294 #[gpui::test]
1295 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1296 init_test(cx, |_| {});
1297 let mut cx = EditorLspTestContext::new_rust(
1298 lsp::ServerCapabilities {
1299 ..Default::default()
1300 },
1301 cx,
1302 )
1303 .await;
1304
1305 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1306
1307 let screen_coord =
1308 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1309
1310 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1311 cx.assert_editor_text_highlights::<HoveredLinkState>(
1312 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1313 );
1314
1315 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1316 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1317 }
1318
1319 #[gpui::test]
1320 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1321 init_test(cx, |_| {});
1322 let mut cx = EditorLspTestContext::new_rust(
1323 lsp::ServerCapabilities {
1324 ..Default::default()
1325 },
1326 cx,
1327 )
1328 .await;
1329
1330 let test_cases = [
1331 ("file ˇ name", None),
1332 ("ˇfile name", Some("file")),
1333 ("file ˇname", Some("name")),
1334 ("fiˇle name", Some("file")),
1335 ("filenˇame", Some("filename")),
1336 // Absolute path
1337 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1338 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1339 // Windows
1340 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1341 // Whitespace
1342 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1343 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1344 // Tilde
1345 ("ˇ~/file.txt", Some("~/file.txt")),
1346 ("~/fiˇle.txt", Some("~/file.txt")),
1347 // Double quotes
1348 ("\"fˇile.txt\"", Some("file.txt")),
1349 ("ˇ\"file.txt\"", Some("file.txt")),
1350 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1351 // Single quotes
1352 ("'fˇile.txt'", Some("file.txt")),
1353 ("ˇ'file.txt'", Some("file.txt")),
1354 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1355 // Quoted multibyte characters
1356 (" ˇ\"常\"", Some("常")),
1357 (" \"ˇ常\"", Some("常")),
1358 ("ˇ\"常\"", Some("常")),
1359 ];
1360
1361 for (input, expected) in test_cases {
1362 cx.set_state(input);
1363
1364 let (position, snapshot) = cx.editor(|editor, _, cx| {
1365 let positions = editor.selections.newest_anchor().head().text_anchor;
1366 let snapshot = editor
1367 .buffer()
1368 .clone()
1369 .read(cx)
1370 .as_singleton()
1371 .unwrap()
1372 .read(cx)
1373 .snapshot();
1374 (positions, snapshot)
1375 });
1376
1377 let result = surrounding_filename(snapshot, position);
1378
1379 if let Some(expected) = expected {
1380 assert!(result.is_some(), "Failed to find file path: {}", input);
1381 let (_, path) = result.unwrap();
1382 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1383 } else {
1384 assert!(
1385 result.is_none(),
1386 "Expected no result, but got one: {:?}",
1387 result
1388 );
1389 }
1390 }
1391 }
1392
1393 #[gpui::test]
1394 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1395 init_test(cx, |_| {});
1396 let mut cx = EditorLspTestContext::new_rust(
1397 lsp::ServerCapabilities {
1398 ..Default::default()
1399 },
1400 cx,
1401 )
1402 .await;
1403
1404 // Insert a new file
1405 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1406 fs.as_fake()
1407 .insert_file(
1408 path!("/root/dir/file2.rs"),
1409 "This is file2.rs".as_bytes().to_vec(),
1410 )
1411 .await;
1412
1413 #[cfg(not(target_os = "windows"))]
1414 cx.set_state(indoc! {"
1415 You can't go to a file that does_not_exist.txt.
1416 Go to file2.rs if you want.
1417 Or go to ../dir/file2.rs if you want.
1418 Or go to /root/dir/file2.rs if project is local.
1419 Or go to /root/dir/file2 if this is a Rust file.ˇ
1420 "});
1421 #[cfg(target_os = "windows")]
1422 cx.set_state(indoc! {"
1423 You can't go to a file that does_not_exist.txt.
1424 Go to file2.rs if you want.
1425 Or go to ../dir/file2.rs if you want.
1426 Or go to C:/root/dir/file2.rs if project is local.
1427 Or go to C:/root/dir/file2 if this is a Rust file.ˇ
1428 "});
1429
1430 // File does not exist
1431 #[cfg(not(target_os = "windows"))]
1432 let screen_coord = cx.pixel_position(indoc! {"
1433 You can't go to a file that dˇoes_not_exist.txt.
1434 Go to file2.rs if you want.
1435 Or go to ../dir/file2.rs if you want.
1436 Or go to /root/dir/file2.rs if project is local.
1437 Or go to /root/dir/file2 if this is a Rust file.
1438 "});
1439 #[cfg(target_os = "windows")]
1440 let screen_coord = cx.pixel_position(indoc! {"
1441 You can't go to a file that dˇoes_not_exist.txt.
1442 Go to file2.rs if you want.
1443 Or go to ../dir/file2.rs if you want.
1444 Or go to C:/root/dir/file2.rs if project is local.
1445 Or go to C:/root/dir/file2 if this is a Rust file.
1446 "});
1447 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1448 // No highlight
1449 cx.update_editor(|editor, window, cx| {
1450 assert!(
1451 editor
1452 .snapshot(window, cx)
1453 .text_highlight_ranges::<HoveredLinkState>()
1454 .unwrap_or_default()
1455 .1
1456 .is_empty()
1457 );
1458 });
1459
1460 // Moving the mouse over a file that does exist should highlight it.
1461 #[cfg(not(target_os = "windows"))]
1462 let screen_coord = cx.pixel_position(indoc! {"
1463 You can't go to a file that does_not_exist.txt.
1464 Go to fˇile2.rs if you want.
1465 Or go to ../dir/file2.rs if you want.
1466 Or go to /root/dir/file2.rs if project is local.
1467 Or go to /root/dir/file2 if this is a Rust file.
1468 "});
1469 #[cfg(target_os = "windows")]
1470 let screen_coord = cx.pixel_position(indoc! {"
1471 You can't go to a file that does_not_exist.txt.
1472 Go to fˇile2.rs if you want.
1473 Or go to ../dir/file2.rs if you want.
1474 Or go to C:/root/dir/file2.rs if project is local.
1475 Or go to C:/root/dir/file2 if this is a Rust file.
1476 "});
1477
1478 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1479 #[cfg(not(target_os = "windows"))]
1480 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1481 You can't go to a file that does_not_exist.txt.
1482 Go to «file2.rsˇ» if you want.
1483 Or go to ../dir/file2.rs if you want.
1484 Or go to /root/dir/file2.rs if project is local.
1485 Or go to /root/dir/file2 if this is a Rust file.
1486 "});
1487 #[cfg(target_os = "windows")]
1488 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1489 You can't go to a file that does_not_exist.txt.
1490 Go to «file2.rsˇ» if you want.
1491 Or go to ../dir/file2.rs if you want.
1492 Or go to C:/root/dir/file2.rs if project is local.
1493 Or go to C:/root/dir/file2 if this is a Rust file.
1494 "});
1495
1496 // Moving the mouse over a relative path that does exist should highlight it
1497 #[cfg(not(target_os = "windows"))]
1498 let screen_coord = cx.pixel_position(indoc! {"
1499 You can't go to a file that does_not_exist.txt.
1500 Go to file2.rs if you want.
1501 Or go to ../dir/fˇile2.rs if you want.
1502 Or go to /root/dir/file2.rs if project is local.
1503 Or go to /root/dir/file2 if this is a Rust file.
1504 "});
1505 #[cfg(target_os = "windows")]
1506 let screen_coord = cx.pixel_position(indoc! {"
1507 You can't go to a file that does_not_exist.txt.
1508 Go to file2.rs if you want.
1509 Or go to ../dir/fˇile2.rs if you want.
1510 Or go to C:/root/dir/file2.rs if project is local.
1511 Or go to C:/root/dir/file2 if this is a Rust file.
1512 "});
1513
1514 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1515 #[cfg(not(target_os = "windows"))]
1516 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1517 You can't go to a file that does_not_exist.txt.
1518 Go to file2.rs if you want.
1519 Or go to «../dir/file2.rsˇ» if you want.
1520 Or go to /root/dir/file2.rs if project is local.
1521 Or go to /root/dir/file2 if this is a Rust file.
1522 "});
1523 #[cfg(target_os = "windows")]
1524 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1525 You can't go to a file that does_not_exist.txt.
1526 Go to file2.rs if you want.
1527 Or go to «../dir/file2.rsˇ» if you want.
1528 Or go to C:/root/dir/file2.rs if project is local.
1529 Or go to C:/root/dir/file2 if this is a Rust file.
1530 "});
1531
1532 // Moving the mouse over an absolute path that does exist should highlight it
1533 #[cfg(not(target_os = "windows"))]
1534 let screen_coord = cx.pixel_position(indoc! {"
1535 You can't go to a file that does_not_exist.txt.
1536 Go to file2.rs if you want.
1537 Or go to ../dir/file2.rs if you want.
1538 Or go to /root/diˇr/file2.rs if project is local.
1539 Or go to /root/dir/file2 if this is a Rust file.
1540 "});
1541
1542 #[cfg(target_os = "windows")]
1543 let screen_coord = cx.pixel_position(indoc! {"
1544 You can't go to a file that does_not_exist.txt.
1545 Go to file2.rs if you want.
1546 Or go to ../dir/file2.rs if you want.
1547 Or go to C:/root/diˇr/file2.rs if project is local.
1548 Or go to C:/root/dir/file2 if this is a Rust file.
1549 "});
1550
1551 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1552 #[cfg(not(target_os = "windows"))]
1553 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1554 You can't go to a file that does_not_exist.txt.
1555 Go to file2.rs if you want.
1556 Or go to ../dir/file2.rs if you want.
1557 Or go to «/root/dir/file2.rsˇ» if project is local.
1558 Or go to /root/dir/file2 if this is a Rust file.
1559 "});
1560 #[cfg(target_os = "windows")]
1561 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1562 You can't go to a file that does_not_exist.txt.
1563 Go to file2.rs if you want.
1564 Or go to ../dir/file2.rs if you want.
1565 Or go to «C:/root/dir/file2.rsˇ» if project is local.
1566 Or go to C:/root/dir/file2 if this is a Rust file.
1567 "});
1568
1569 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1570 #[cfg(not(target_os = "windows"))]
1571 let screen_coord = cx.pixel_position(indoc! {"
1572 You can't go to a file that does_not_exist.txt.
1573 Go to file2.rs if you want.
1574 Or go to ../dir/file2.rs if you want.
1575 Or go to /root/dir/file2.rs if project is local.
1576 Or go to /root/diˇr/file2 if this is a Rust file.
1577 "});
1578 #[cfg(target_os = "windows")]
1579 let screen_coord = cx.pixel_position(indoc! {"
1580 You can't go to a file that does_not_exist.txt.
1581 Go to file2.rs if you want.
1582 Or go to ../dir/file2.rs if you want.
1583 Or go to C:/root/dir/file2.rs if project is local.
1584 Or go to C:/root/diˇr/file2 if this is a Rust file.
1585 "});
1586
1587 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1588 #[cfg(not(target_os = "windows"))]
1589 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1590 You can't go to a file that does_not_exist.txt.
1591 Go to file2.rs if you want.
1592 Or go to ../dir/file2.rs if you want.
1593 Or go to /root/dir/file2.rs if project is local.
1594 Or go to «/root/dir/file2ˇ» if this is a Rust file.
1595 "});
1596 #[cfg(target_os = "windows")]
1597 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1598 You can't go to a file that does_not_exist.txt.
1599 Go to file2.rs if you want.
1600 Or go to ../dir/file2.rs if you want.
1601 Or go to C:/root/dir/file2.rs if project is local.
1602 Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
1603 "});
1604
1605 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1606
1607 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1608 cx.update_workspace(|workspace, _, cx| {
1609 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1610
1611 let buffer = active_editor
1612 .read(cx)
1613 .buffer()
1614 .read(cx)
1615 .as_singleton()
1616 .unwrap();
1617
1618 let file = buffer.read(cx).file().unwrap();
1619 let file_path = file.as_local().unwrap().abs_path(cx);
1620
1621 assert_eq!(
1622 file_path,
1623 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
1624 );
1625 });
1626 }
1627
1628 #[gpui::test]
1629 async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
1630 init_test(cx, |_| {});
1631 let mut cx = EditorLspTestContext::new_rust(
1632 lsp::ServerCapabilities {
1633 ..Default::default()
1634 },
1635 cx,
1636 )
1637 .await;
1638
1639 // Insert a new file
1640 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1641 fs.as_fake()
1642 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1643 .await;
1644
1645 cx.set_state(indoc! {"
1646 You can't open ../diˇr because it's a directory.
1647 "});
1648
1649 // File does not exist
1650 let screen_coord = cx.pixel_position(indoc! {"
1651 You can't open ../diˇr because it's a directory.
1652 "});
1653 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1654
1655 // No highlight
1656 cx.update_editor(|editor, window, cx| {
1657 assert!(
1658 editor
1659 .snapshot(window, cx)
1660 .text_highlight_ranges::<HoveredLinkState>()
1661 .unwrap_or_default()
1662 .1
1663 .is_empty()
1664 );
1665 });
1666
1667 // Does not open the directory
1668 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1669 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1670 }
1671
1672 #[gpui::test]
1673 async fn test_hover_unicode(cx: &mut gpui::TestAppContext) {
1674 init_test(cx, |_| {});
1675 let mut cx = EditorLspTestContext::new_rust(
1676 lsp::ServerCapabilities {
1677 ..Default::default()
1678 },
1679 cx,
1680 )
1681 .await;
1682
1683 cx.set_state(indoc! {"
1684 You can't open ˇ\"🤩\" because it's an emoji.
1685 "});
1686
1687 // File does not exist
1688 let screen_coord = cx.pixel_position(indoc! {"
1689 You can't open ˇ\"🤩\" because it's an emoji.
1690 "});
1691 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1692
1693 // No highlight, does not panic...
1694 cx.update_editor(|editor, window, cx| {
1695 assert!(
1696 editor
1697 .snapshot(window, cx)
1698 .text_highlight_ranges::<HoveredLinkState>()
1699 .unwrap_or_default()
1700 .1
1701 .is_empty()
1702 );
1703 });
1704
1705 // Does not open the directory
1706 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1707 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1708 }
1709}