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