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