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 anchor = snapshot.buffer_snapshot().anchor_before(*trigger_anchor);
497 let Some(buffer) = editor.buffer().read(cx).buffer_for_anchor(anchor, cx) else {
498 return;
499 };
500 let Anchor {
501 excerpt_id,
502 text_anchor,
503 ..
504 } = anchor;
505 let same_kind = hovered_link_state.preferred_kind == preferred_kind
506 || hovered_link_state
507 .links
508 .first()
509 .is_some_and(|d| matches!(d, HoverLink::Url(_)));
510
511 if same_kind {
512 if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
513 || hovered_link_state
514 .symbol_range
515 .as_ref()
516 .is_some_and(|symbol_range| {
517 symbol_range.point_within_range(&trigger_point, snapshot)
518 })
519 {
520 editor.hovered_link_state = Some(hovered_link_state);
521 return;
522 }
523 } else {
524 editor.hide_hovered_link(cx)
525 }
526 let project = editor.project.clone();
527 let provider = editor.semantics_provider.clone();
528
529 let snapshot = snapshot.buffer_snapshot().clone();
530 hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| {
531 async move {
532 let result = match &trigger_point {
533 TriggerPoint::Text(_) => {
534 if let Some((url_range, url)) = find_url(&buffer, text_anchor, cx.clone()) {
535 this.read_with(cx, |_, _| {
536 let range = maybe!({
537 let start =
538 snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
539 let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?;
540 Some(RangeInEditor::Text(start..end))
541 });
542 (range, vec![HoverLink::Url(url)])
543 })
544 .ok()
545 } else if let Some((filename_range, filename)) =
546 find_file(&buffer, project.clone(), text_anchor, cx).await
547 {
548 let range = maybe!({
549 let start =
550 snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?;
551 let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?;
552 Some(RangeInEditor::Text(start..end))
553 });
554
555 Some((range, vec![HoverLink::File(filename)]))
556 } else if let Some(provider) = provider {
557 let task = cx.update(|_, cx| {
558 provider.definitions(&buffer, text_anchor, preferred_kind, cx)
559 })?;
560 if let Some(task) = task {
561 task.await.ok().flatten().map(|definition_result| {
562 (
563 definition_result.iter().find_map(|link| {
564 link.origin.as_ref().and_then(|origin| {
565 let start = snapshot.anchor_in_excerpt(
566 excerpt_id,
567 origin.range.start,
568 )?;
569 let end = snapshot
570 .anchor_in_excerpt(excerpt_id, origin.range.end)?;
571 Some(RangeInEditor::Text(start..end))
572 })
573 }),
574 definition_result.into_iter().map(HoverLink::Text).collect(),
575 )
576 })
577 } else {
578 None
579 }
580 } else {
581 None
582 }
583 }
584 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
585 Some(RangeInEditor::Inlay(highlight.clone())),
586 vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
587 )),
588 };
589
590 this.update(cx, |editor, cx| {
591 // Clear any existing highlights
592 editor.clear_highlights::<HoveredLinkState>(cx);
593 let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
594 editor.hide_hovered_link(cx);
595 return;
596 };
597 hovered_link_state.preferred_kind = preferred_kind;
598 hovered_link_state.symbol_range = result
599 .as_ref()
600 .and_then(|(symbol_range, _)| symbol_range.clone());
601
602 if let Some((symbol_range, definitions)) = result {
603 hovered_link_state.links = definitions;
604
605 let underline_hovered_link = !hovered_link_state.links.is_empty()
606 || hovered_link_state.symbol_range.is_some();
607
608 if underline_hovered_link {
609 let style = gpui::HighlightStyle {
610 underline: Some(gpui::UnderlineStyle {
611 thickness: px(1.),
612 ..Default::default()
613 }),
614 color: Some(cx.theme().colors().link_text_hover),
615 ..Default::default()
616 };
617 let highlight_range =
618 symbol_range.unwrap_or_else(|| match &trigger_point {
619 TriggerPoint::Text(trigger_anchor) => {
620 // If no symbol range returned from language server, use the surrounding word.
621 let (offset_range, _) =
622 snapshot.surrounding_word(*trigger_anchor, None);
623 RangeInEditor::Text(
624 snapshot.anchor_before(offset_range.start)
625 ..snapshot.anchor_after(offset_range.end),
626 )
627 }
628 TriggerPoint::InlayHint(highlight, _, _) => {
629 RangeInEditor::Inlay(highlight.clone())
630 }
631 });
632
633 match highlight_range {
634 RangeInEditor::Text(text_range) => editor
635 .highlight_text::<HoveredLinkState>(vec![text_range], style, cx),
636 RangeInEditor::Inlay(highlight) => editor
637 .highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
638 }
639 }
640 } else {
641 editor.hide_hovered_link(cx);
642 }
643 })?;
644
645 anyhow::Ok(())
646 }
647 .log_err()
648 .await
649 }));
650
651 editor.hovered_link_state = Some(hovered_link_state);
652}
653
654pub(crate) fn find_url(
655 buffer: &Entity<language::Buffer>,
656 position: text::Anchor,
657 cx: AsyncWindowContext,
658) -> Option<(Range<text::Anchor>, String)> {
659 const LIMIT: usize = 2048;
660
661 let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot()).ok()?;
662
663 let offset = position.to_offset(&snapshot);
664 let mut token_start = offset;
665 let mut token_end = offset;
666 let mut found_start = false;
667 let mut found_end = false;
668
669 for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
670 if ch.is_whitespace() {
671 found_start = true;
672 break;
673 }
674 token_start -= ch.len_utf8();
675 }
676 // Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
677 if !found_start && token_start != 0 {
678 return None;
679 }
680
681 for ch in snapshot
682 .chars_at(offset)
683 .take(LIMIT - (offset - token_start))
684 {
685 if ch.is_whitespace() {
686 found_end = true;
687 break;
688 }
689 token_end += ch.len_utf8();
690 }
691 // Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
692 // which at this point would happen only if we reached the end of buffer
693 if !found_end && (token_end - token_start >= LIMIT) {
694 return None;
695 }
696
697 let mut finder = LinkFinder::new();
698 finder.kinds(&[LinkKind::Url]);
699 let input = snapshot
700 .text_for_range(token_start..token_end)
701 .collect::<String>();
702
703 let relative_offset = offset - token_start;
704 for link in finder.links(&input) {
705 if link.start() <= relative_offset && link.end() >= relative_offset {
706 let range = snapshot.anchor_before(token_start + link.start())
707 ..snapshot.anchor_after(token_start + link.end());
708 return Some((range, link.as_str().to_string()));
709 }
710 }
711 None
712}
713
714pub(crate) fn find_url_from_range(
715 buffer: &Entity<language::Buffer>,
716 range: Range<text::Anchor>,
717 cx: AsyncWindowContext,
718) -> Option<String> {
719 const LIMIT: usize = 2048;
720
721 let Ok(snapshot) = buffer.read_with(&cx, |buffer, _| buffer.snapshot()) else {
722 return None;
723 };
724
725 let start_offset = range.start.to_offset(&snapshot);
726 let end_offset = range.end.to_offset(&snapshot);
727
728 let mut token_start = start_offset.min(end_offset);
729 let mut token_end = start_offset.max(end_offset);
730
731 let range_len = token_end - token_start;
732
733 if range_len >= LIMIT {
734 return None;
735 }
736
737 // Skip leading whitespace
738 for ch in snapshot.chars_at(token_start).take(range_len) {
739 if !ch.is_whitespace() {
740 break;
741 }
742 token_start += ch.len_utf8();
743 }
744
745 // Skip trailing whitespace
746 for ch in snapshot.reversed_chars_at(token_end).take(range_len) {
747 if !ch.is_whitespace() {
748 break;
749 }
750 token_end -= ch.len_utf8();
751 }
752
753 if token_start >= token_end {
754 return None;
755 }
756
757 let text = snapshot
758 .text_for_range(token_start..token_end)
759 .collect::<String>();
760
761 let mut finder = LinkFinder::new();
762 finder.kinds(&[LinkKind::Url]);
763
764 if let Some(link) = finder.links(&text).next()
765 && link.start() == 0
766 && link.end() == text.len()
767 {
768 return Some(link.as_str().to_string());
769 }
770
771 None
772}
773
774pub(crate) async fn find_file(
775 buffer: &Entity<language::Buffer>,
776 project: Option<Entity<Project>>,
777 position: text::Anchor,
778 cx: &mut AsyncWindowContext,
779) -> Option<(Range<text::Anchor>, ResolvedPath)> {
780 let project = project?;
781 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()).ok()?;
782 let scope = snapshot.language_scope_at(position);
783 let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
784
785 async fn check_path(
786 candidate_file_path: &str,
787 project: &Entity<Project>,
788 buffer: &Entity<language::Buffer>,
789 cx: &mut AsyncWindowContext,
790 ) -> Option<ResolvedPath> {
791 project
792 .update(cx, |project, cx| {
793 project.resolve_path_in_buffer(candidate_file_path, buffer, cx)
794 })
795 .ok()?
796 .await
797 .filter(|s| s.is_file())
798 }
799
800 if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
801 return Some((range, existing_path));
802 }
803
804 if let Some(scope) = scope {
805 for suffix in scope.path_suffixes() {
806 if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
807 continue;
808 }
809
810 let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
811 if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
812 {
813 return Some((range, existing_path));
814 }
815 }
816 }
817
818 None
819}
820
821fn surrounding_filename(
822 snapshot: language::BufferSnapshot,
823 position: text::Anchor,
824) -> Option<(Range<text::Anchor>, String)> {
825 const LIMIT: usize = 2048;
826
827 let offset = position.to_offset(&snapshot);
828 let mut token_start = offset;
829 let mut token_end = offset;
830 let mut found_start = false;
831 let mut found_end = false;
832 let mut inside_quotes = false;
833
834 let mut filename = String::new();
835
836 let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
837 while let Some(ch) = backwards.next() {
838 // Escaped whitespace
839 if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
840 filename.push(ch);
841 token_start -= ch.len_utf8();
842 backwards.next();
843 token_start -= '\\'.len_utf8();
844 continue;
845 }
846 if ch.is_whitespace() {
847 found_start = true;
848 break;
849 }
850 if (ch == '"' || ch == '\'') && !inside_quotes {
851 found_start = true;
852 inside_quotes = true;
853 break;
854 }
855
856 filename.push(ch);
857 token_start -= ch.len_utf8();
858 }
859 if !found_start && token_start != 0 {
860 return None;
861 }
862
863 filename = filename.chars().rev().collect();
864
865 let mut forwards = snapshot
866 .chars_at(offset)
867 .take(LIMIT - (offset - token_start))
868 .peekable();
869 while let Some(ch) = forwards.next() {
870 // Skip escaped whitespace
871 if ch == '\\' && forwards.peek().is_some_and(|ch| ch.is_whitespace()) {
872 token_end += ch.len_utf8();
873 let whitespace = forwards.next().unwrap();
874 token_end += whitespace.len_utf8();
875 filename.push(whitespace);
876 continue;
877 }
878
879 if ch.is_whitespace() {
880 found_end = true;
881 break;
882 }
883 if ch == '"' || ch == '\'' {
884 // If we're inside quotes, we stop when we come across the next quote
885 if inside_quotes {
886 found_end = true;
887 break;
888 } else {
889 // Otherwise, we skip the quote
890 inside_quotes = true;
891 token_end += ch.len_utf8();
892 continue;
893 }
894 }
895 filename.push(ch);
896 token_end += ch.len_utf8();
897 }
898
899 if !found_end && (token_end - token_start >= LIMIT) {
900 return None;
901 }
902
903 if filename.is_empty() {
904 return None;
905 }
906
907 let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
908
909 Some((range, filename))
910}
911
912#[cfg(test)]
913mod tests {
914 use super::*;
915 use crate::{
916 DisplayPoint,
917 display_map::ToDisplayPoint,
918 editor_tests::init_test,
919 inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
920 test::editor_lsp_test_context::EditorLspTestContext,
921 };
922 use futures::StreamExt;
923 use gpui::Modifiers;
924 use indoc::indoc;
925 use lsp::request::{GotoDefinition, GotoTypeDefinition};
926 use settings::InlayHintSettingsContent;
927 use util::{assert_set_eq, path};
928 use workspace::item::Item;
929
930 #[gpui::test]
931 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
932 init_test(cx, |_| {});
933
934 let mut cx = EditorLspTestContext::new_rust(
935 lsp::ServerCapabilities {
936 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
937 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
938 ..Default::default()
939 },
940 cx,
941 )
942 .await;
943
944 cx.set_state(indoc! {"
945 struct A;
946 let vˇariable = A;
947 "});
948 let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
949
950 // Basic hold cmd+shift, expect highlight in region if response contains type definition
951 let symbol_range = cx.lsp_range(indoc! {"
952 struct A;
953 let «variable» = A;
954 "});
955 let target_range = cx.lsp_range(indoc! {"
956 struct «A»;
957 let variable = A;
958 "});
959
960 cx.run_until_parked();
961
962 let mut requests =
963 cx.set_request_handler::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
964 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
965 lsp::LocationLink {
966 origin_selection_range: Some(symbol_range),
967 target_uri: url.clone(),
968 target_range,
969 target_selection_range: target_range,
970 },
971 ])))
972 });
973
974 let modifiers = if cfg!(target_os = "macos") {
975 Modifiers::command_shift()
976 } else {
977 Modifiers::control_shift()
978 };
979
980 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
981
982 requests.next().await;
983 cx.run_until_parked();
984 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
985 struct A;
986 let «variable» = A;
987 "});
988
989 cx.simulate_modifiers_change(Modifiers::secondary_key());
990 cx.run_until_parked();
991 // Assert no link highlights
992 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
993 struct A;
994 let variable = A;
995 "});
996
997 cx.simulate_click(screen_coord.unwrap(), modifiers);
998
999 cx.assert_editor_state(indoc! {"
1000 struct «Aˇ»;
1001 let variable = A;
1002 "});
1003 }
1004
1005 #[gpui::test]
1006 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
1007 init_test(cx, |_| {});
1008
1009 let mut cx = EditorLspTestContext::new_rust(
1010 lsp::ServerCapabilities {
1011 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1012 definition_provider: Some(lsp::OneOf::Left(true)),
1013 ..Default::default()
1014 },
1015 cx,
1016 )
1017 .await;
1018
1019 cx.set_state(indoc! {"
1020 fn ˇtest() { do_work(); }
1021 fn do_work() { test(); }
1022 "});
1023
1024 // Basic hold cmd, expect highlight in region if response contains definition
1025 let hover_point = cx.pixel_position(indoc! {"
1026 fn test() { do_wˇork(); }
1027 fn do_work() { test(); }
1028 "});
1029 let symbol_range = cx.lsp_range(indoc! {"
1030 fn test() { «do_work»(); }
1031 fn do_work() { test(); }
1032 "});
1033 let target_range = cx.lsp_range(indoc! {"
1034 fn test() { do_work(); }
1035 fn «do_work»() { test(); }
1036 "});
1037
1038 let mut requests =
1039 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1040 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1041 lsp::LocationLink {
1042 origin_selection_range: Some(symbol_range),
1043 target_uri: url.clone(),
1044 target_range,
1045 target_selection_range: target_range,
1046 },
1047 ])))
1048 });
1049
1050 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1051 requests.next().await;
1052 cx.background_executor.run_until_parked();
1053 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1054 fn test() { «do_work»(); }
1055 fn do_work() { test(); }
1056 "});
1057
1058 // Unpress cmd causes highlight to go away
1059 cx.simulate_modifiers_change(Modifiers::none());
1060 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1061 fn test() { do_work(); }
1062 fn do_work() { test(); }
1063 "});
1064
1065 let mut requests =
1066 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1067 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1068 lsp::LocationLink {
1069 origin_selection_range: Some(symbol_range),
1070 target_uri: url.clone(),
1071 target_range,
1072 target_selection_range: target_range,
1073 },
1074 ])))
1075 });
1076
1077 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1078 requests.next().await;
1079 cx.background_executor.run_until_parked();
1080 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1081 fn test() { «do_work»(); }
1082 fn do_work() { test(); }
1083 "});
1084
1085 // Moving mouse to location with no response dismisses highlight
1086 let hover_point = cx.pixel_position(indoc! {"
1087 fˇn test() { do_work(); }
1088 fn do_work() { test(); }
1089 "});
1090 let mut requests =
1091 cx.lsp
1092 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1093 // No definitions returned
1094 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1095 });
1096 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1097
1098 requests.next().await;
1099 cx.background_executor.run_until_parked();
1100
1101 // Assert no link highlights
1102 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1103 fn test() { do_work(); }
1104 fn do_work() { test(); }
1105 "});
1106
1107 // // Move mouse without cmd and then pressing cmd triggers highlight
1108 let hover_point = cx.pixel_position(indoc! {"
1109 fn test() { do_work(); }
1110 fn do_work() { teˇst(); }
1111 "});
1112 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1113
1114 // Assert no link highlights
1115 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1116 fn test() { do_work(); }
1117 fn do_work() { test(); }
1118 "});
1119
1120 let symbol_range = cx.lsp_range(indoc! {"
1121 fn test() { do_work(); }
1122 fn do_work() { «test»(); }
1123 "});
1124 let target_range = cx.lsp_range(indoc! {"
1125 fn «test»() { do_work(); }
1126 fn do_work() { test(); }
1127 "});
1128
1129 let mut requests =
1130 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1131 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1132 lsp::LocationLink {
1133 origin_selection_range: Some(symbol_range),
1134 target_uri: url,
1135 target_range,
1136 target_selection_range: target_range,
1137 },
1138 ])))
1139 });
1140
1141 cx.simulate_modifiers_change(Modifiers::secondary_key());
1142
1143 requests.next().await;
1144 cx.background_executor.run_until_parked();
1145
1146 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1147 fn test() { do_work(); }
1148 fn do_work() { «test»(); }
1149 "});
1150
1151 cx.deactivate_window();
1152 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1153 fn test() { do_work(); }
1154 fn do_work() { test(); }
1155 "});
1156
1157 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1158 cx.background_executor.run_until_parked();
1159 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1160 fn test() { do_work(); }
1161 fn do_work() { «test»(); }
1162 "});
1163
1164 // Moving again within the same symbol range doesn't re-request
1165 let hover_point = cx.pixel_position(indoc! {"
1166 fn test() { do_work(); }
1167 fn do_work() { tesˇt(); }
1168 "});
1169 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1170 cx.background_executor.run_until_parked();
1171 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1172 fn test() { do_work(); }
1173 fn do_work() { «test»(); }
1174 "});
1175
1176 // Cmd click with existing definition doesn't re-request and dismisses highlight
1177 cx.simulate_click(hover_point, Modifiers::secondary_key());
1178 cx.lsp
1179 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1180 // Empty definition response to make sure we aren't hitting the lsp and using
1181 // the cached location instead
1182 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1183 });
1184 cx.background_executor.run_until_parked();
1185 cx.assert_editor_state(indoc! {"
1186 fn «testˇ»() { do_work(); }
1187 fn do_work() { test(); }
1188 "});
1189
1190 // Assert no link highlights after jump
1191 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1192 fn test() { do_work(); }
1193 fn do_work() { test(); }
1194 "});
1195
1196 // Cmd click without existing definition requests and jumps
1197 let hover_point = cx.pixel_position(indoc! {"
1198 fn test() { do_wˇork(); }
1199 fn do_work() { test(); }
1200 "});
1201 let target_range = cx.lsp_range(indoc! {"
1202 fn test() { do_work(); }
1203 fn «do_work»() { test(); }
1204 "});
1205
1206 let mut requests =
1207 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1208 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1209 lsp::LocationLink {
1210 origin_selection_range: None,
1211 target_uri: url,
1212 target_range,
1213 target_selection_range: target_range,
1214 },
1215 ])))
1216 });
1217 cx.simulate_click(hover_point, Modifiers::secondary_key());
1218 requests.next().await;
1219 cx.background_executor.run_until_parked();
1220 cx.assert_editor_state(indoc! {"
1221 fn test() { do_work(); }
1222 fn «do_workˇ»() { test(); }
1223 "});
1224
1225 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1226 // 2. Selection is completed, hovering
1227 let hover_point = cx.pixel_position(indoc! {"
1228 fn test() { do_wˇork(); }
1229 fn do_work() { test(); }
1230 "});
1231 let target_range = cx.lsp_range(indoc! {"
1232 fn test() { do_work(); }
1233 fn «do_work»() { test(); }
1234 "});
1235 let mut requests =
1236 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1237 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1238 lsp::LocationLink {
1239 origin_selection_range: None,
1240 target_uri: url,
1241 target_range,
1242 target_selection_range: target_range,
1243 },
1244 ])))
1245 });
1246
1247 // create a pending selection
1248 let selection_range = cx.ranges(indoc! {"
1249 fn «test() { do_w»ork(); }
1250 fn do_work() { test(); }
1251 "})[0]
1252 .clone();
1253 cx.update_editor(|editor, window, cx| {
1254 let snapshot = editor.buffer().read(cx).snapshot(cx);
1255 let anchor_range = snapshot.anchor_before(selection_range.start)
1256 ..snapshot.anchor_after(selection_range.end);
1257 editor.change_selections(Default::default(), window, cx, |s| {
1258 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1259 });
1260 });
1261 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1262 cx.background_executor.run_until_parked();
1263 assert!(requests.try_next().is_err());
1264 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1265 fn test() { do_work(); }
1266 fn do_work() { test(); }
1267 "});
1268 cx.background_executor.run_until_parked();
1269 }
1270
1271 #[gpui::test]
1272 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1273 init_test(cx, |settings| {
1274 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1275 enabled: Some(true),
1276 show_value_hints: Some(false),
1277 edit_debounce_ms: Some(0),
1278 scroll_debounce_ms: Some(0),
1279 show_type_hints: Some(true),
1280 show_parameter_hints: Some(true),
1281 show_other_hints: Some(true),
1282 show_background: Some(false),
1283 toggle_on_modifiers_press: None,
1284 })
1285 });
1286
1287 let mut cx = EditorLspTestContext::new_rust(
1288 lsp::ServerCapabilities {
1289 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1290 ..Default::default()
1291 },
1292 cx,
1293 )
1294 .await;
1295 cx.set_state(indoc! {"
1296 struct TestStruct;
1297
1298 fn main() {
1299 let variableˇ = TestStruct;
1300 }
1301 "});
1302 let hint_start_offset = cx.ranges(indoc! {"
1303 struct TestStruct;
1304
1305 fn main() {
1306 let variableˇ = TestStruct;
1307 }
1308 "})[0]
1309 .start;
1310 let hint_position = cx.to_lsp(hint_start_offset);
1311 let target_range = cx.lsp_range(indoc! {"
1312 struct «TestStruct»;
1313
1314 fn main() {
1315 let variable = TestStruct;
1316 }
1317 "});
1318
1319 let expected_uri = cx.buffer_lsp_url.clone();
1320 let hint_label = ": TestStruct";
1321 cx.lsp
1322 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1323 let expected_uri = expected_uri.clone();
1324 async move {
1325 assert_eq!(params.text_document.uri, expected_uri);
1326 Ok(Some(vec![lsp::InlayHint {
1327 position: hint_position,
1328 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1329 value: hint_label.to_string(),
1330 location: Some(lsp::Location {
1331 uri: params.text_document.uri,
1332 range: target_range,
1333 }),
1334 ..Default::default()
1335 }]),
1336 kind: Some(lsp::InlayHintKind::TYPE),
1337 text_edits: None,
1338 tooltip: None,
1339 padding_left: Some(false),
1340 padding_right: Some(false),
1341 data: None,
1342 }]))
1343 }
1344 })
1345 .next()
1346 .await;
1347 cx.background_executor.run_until_parked();
1348 cx.update_editor(|editor, _window, cx| {
1349 let expected_layers = vec![hint_label.to_string()];
1350 assert_eq!(expected_layers, cached_hint_labels(editor));
1351 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1352 });
1353
1354 let inlay_range = cx
1355 .ranges(indoc! {"
1356 struct TestStruct;
1357
1358 fn main() {
1359 let variable« »= TestStruct;
1360 }
1361 "})
1362 .first()
1363 .cloned()
1364 .unwrap();
1365 let midpoint = cx.update_editor(|editor, window, cx| {
1366 let snapshot = editor.snapshot(window, cx);
1367 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1368 let next_valid = inlay_range.end.to_display_point(&snapshot);
1369 assert_eq!(previous_valid.row(), next_valid.row());
1370 assert!(previous_valid.column() < next_valid.column());
1371 DisplayPoint::new(
1372 previous_valid.row(),
1373 previous_valid.column() + (hint_label.len() / 2) as u32,
1374 )
1375 });
1376 // Press cmd to trigger highlight
1377 let hover_point = cx.pixel_position_for(midpoint);
1378 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1379 cx.background_executor.run_until_parked();
1380 cx.update_editor(|editor, window, cx| {
1381 let snapshot = editor.snapshot(window, cx);
1382 let actual_highlights = snapshot
1383 .inlay_highlights::<HoveredLinkState>()
1384 .into_iter()
1385 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1386 .collect::<Vec<_>>();
1387
1388 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1389 let expected_highlight = InlayHighlight {
1390 inlay: InlayId::Hint(0),
1391 inlay_position: buffer_snapshot.anchor_after(inlay_range.start),
1392 range: 0..hint_label.len(),
1393 };
1394 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1395 });
1396
1397 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1398 // Assert no link highlights
1399 cx.update_editor(|editor, window, cx| {
1400 let snapshot = editor.snapshot(window, cx);
1401 let actual_ranges = snapshot
1402 .text_highlight_ranges::<HoveredLinkState>()
1403 .map(|ranges| ranges.as_ref().clone().1)
1404 .unwrap_or_default();
1405
1406 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1407 });
1408
1409 cx.simulate_modifiers_change(Modifiers::secondary_key());
1410 cx.background_executor.run_until_parked();
1411 cx.simulate_click(hover_point, Modifiers::secondary_key());
1412 cx.background_executor.run_until_parked();
1413 cx.assert_editor_state(indoc! {"
1414 struct «TestStructˇ»;
1415
1416 fn main() {
1417 let variable = TestStruct;
1418 }
1419 "});
1420 }
1421
1422 #[gpui::test]
1423 async fn test_urls(cx: &mut gpui::TestAppContext) {
1424 init_test(cx, |_| {});
1425 let mut cx = EditorLspTestContext::new_rust(
1426 lsp::ServerCapabilities {
1427 ..Default::default()
1428 },
1429 cx,
1430 )
1431 .await;
1432
1433 cx.set_state(indoc! {"
1434 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1435 "});
1436
1437 let screen_coord = cx.pixel_position(indoc! {"
1438 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1439 "});
1440
1441 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1442 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1443 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1444 "});
1445
1446 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1447 assert_eq!(
1448 cx.opened_url(),
1449 Some("https://zed.dev/channel/had-(oops)".into())
1450 );
1451 }
1452
1453 #[gpui::test]
1454 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1455 init_test(cx, |_| {});
1456 let mut cx = EditorLspTestContext::new_rust(
1457 lsp::ServerCapabilities {
1458 ..Default::default()
1459 },
1460 cx,
1461 )
1462 .await;
1463
1464 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1465
1466 let screen_coord =
1467 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1468
1469 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1470 cx.assert_editor_text_highlights::<HoveredLinkState>(
1471 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1472 );
1473
1474 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1475 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1476 }
1477
1478 #[gpui::test]
1479 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1480 init_test(cx, |_| {});
1481 let mut cx = EditorLspTestContext::new_rust(
1482 lsp::ServerCapabilities {
1483 ..Default::default()
1484 },
1485 cx,
1486 )
1487 .await;
1488
1489 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1490
1491 let screen_coord =
1492 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1493
1494 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1495 cx.assert_editor_text_highlights::<HoveredLinkState>(
1496 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1497 );
1498
1499 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1500 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1501 }
1502
1503 #[gpui::test]
1504 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1505 init_test(cx, |_| {});
1506 let mut cx = EditorLspTestContext::new_rust(
1507 lsp::ServerCapabilities {
1508 ..Default::default()
1509 },
1510 cx,
1511 )
1512 .await;
1513
1514 let test_cases = [
1515 ("file ˇ name", None),
1516 ("ˇfile name", Some("file")),
1517 ("file ˇname", Some("name")),
1518 ("fiˇle name", Some("file")),
1519 ("filenˇame", Some("filename")),
1520 // Absolute path
1521 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1522 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1523 // Windows
1524 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1525 // Whitespace
1526 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1527 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1528 // Tilde
1529 ("ˇ~/file.txt", Some("~/file.txt")),
1530 ("~/fiˇle.txt", Some("~/file.txt")),
1531 // Double quotes
1532 ("\"fˇile.txt\"", Some("file.txt")),
1533 ("ˇ\"file.txt\"", Some("file.txt")),
1534 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1535 // Single quotes
1536 ("'fˇile.txt'", Some("file.txt")),
1537 ("ˇ'file.txt'", Some("file.txt")),
1538 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1539 // Quoted multibyte characters
1540 (" ˇ\"常\"", Some("常")),
1541 (" \"ˇ常\"", Some("常")),
1542 ("ˇ\"常\"", Some("常")),
1543 ];
1544
1545 for (input, expected) in test_cases {
1546 cx.set_state(input);
1547
1548 let (position, snapshot) = cx.editor(|editor, _, cx| {
1549 let positions = editor.selections.newest_anchor().head().text_anchor;
1550 let snapshot = editor
1551 .buffer()
1552 .clone()
1553 .read(cx)
1554 .as_singleton()
1555 .unwrap()
1556 .read(cx)
1557 .snapshot();
1558 (positions, snapshot)
1559 });
1560
1561 let result = surrounding_filename(snapshot, position);
1562
1563 if let Some(expected) = expected {
1564 assert!(result.is_some(), "Failed to find file path: {}", input);
1565 let (_, path) = result.unwrap();
1566 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1567 } else {
1568 assert!(
1569 result.is_none(),
1570 "Expected no result, but got one: {:?}",
1571 result
1572 );
1573 }
1574 }
1575 }
1576
1577 #[gpui::test]
1578 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1579 init_test(cx, |_| {});
1580 let mut cx = EditorLspTestContext::new_rust(
1581 lsp::ServerCapabilities {
1582 ..Default::default()
1583 },
1584 cx,
1585 )
1586 .await;
1587
1588 // Insert a new file
1589 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1590 fs.as_fake()
1591 .insert_file(
1592 path!("/root/dir/file2.rs"),
1593 "This is file2.rs".as_bytes().to_vec(),
1594 )
1595 .await;
1596
1597 #[cfg(not(target_os = "windows"))]
1598 cx.set_state(indoc! {"
1599 You can't go to a file that does_not_exist.txt.
1600 Go to file2.rs if you want.
1601 Or go to ../dir/file2.rs if you want.
1602 Or go to /root/dir/file2.rs if project is local.
1603 Or go to /root/dir/file2 if this is a Rust file.ˇ
1604 "});
1605 #[cfg(target_os = "windows")]
1606 cx.set_state(indoc! {"
1607 You can't go to a file that does_not_exist.txt.
1608 Go to file2.rs if you want.
1609 Or go to ../dir/file2.rs if you want.
1610 Or go to C:/root/dir/file2.rs if project is local.
1611 Or go to C:/root/dir/file2 if this is a Rust file.ˇ
1612 "});
1613
1614 // File does not exist
1615 #[cfg(not(target_os = "windows"))]
1616 let screen_coord = cx.pixel_position(indoc! {"
1617 You can't go to a file that dˇoes_not_exist.txt.
1618 Go to file2.rs if you want.
1619 Or go to ../dir/file2.rs if you want.
1620 Or go to /root/dir/file2.rs if project is local.
1621 Or go to /root/dir/file2 if this is a Rust file.
1622 "});
1623 #[cfg(target_os = "windows")]
1624 let screen_coord = cx.pixel_position(indoc! {"
1625 You can't go to a file that dˇoes_not_exist.txt.
1626 Go to file2.rs if you want.
1627 Or go to ../dir/file2.rs if you want.
1628 Or go to C:/root/dir/file2.rs if project is local.
1629 Or go to C:/root/dir/file2 if this is a Rust file.
1630 "});
1631 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1632 // No highlight
1633 cx.update_editor(|editor, window, cx| {
1634 assert!(
1635 editor
1636 .snapshot(window, cx)
1637 .text_highlight_ranges::<HoveredLinkState>()
1638 .unwrap_or_default()
1639 .1
1640 .is_empty()
1641 );
1642 });
1643
1644 // Moving the mouse over a file that does exist should highlight it.
1645 #[cfg(not(target_os = "windows"))]
1646 let screen_coord = cx.pixel_position(indoc! {"
1647 You can't go to a file that does_not_exist.txt.
1648 Go to fˇile2.rs if you want.
1649 Or go to ../dir/file2.rs if you want.
1650 Or go to /root/dir/file2.rs if project is local.
1651 Or go to /root/dir/file2 if this is a Rust file.
1652 "});
1653 #[cfg(target_os = "windows")]
1654 let screen_coord = cx.pixel_position(indoc! {"
1655 You can't go to a file that does_not_exist.txt.
1656 Go to fˇile2.rs if you want.
1657 Or go to ../dir/file2.rs if you want.
1658 Or go to C:/root/dir/file2.rs if project is local.
1659 Or go to C:/root/dir/file2 if this is a Rust file.
1660 "});
1661
1662 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1663 #[cfg(not(target_os = "windows"))]
1664 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1665 You can't go to a file that does_not_exist.txt.
1666 Go to «file2.rsˇ» if you want.
1667 Or go to ../dir/file2.rs if you want.
1668 Or go to /root/dir/file2.rs if project is local.
1669 Or go to /root/dir/file2 if this is a Rust file.
1670 "});
1671 #[cfg(target_os = "windows")]
1672 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1673 You can't go to a file that does_not_exist.txt.
1674 Go to «file2.rsˇ» if you want.
1675 Or go to ../dir/file2.rs if you want.
1676 Or go to C:/root/dir/file2.rs if project is local.
1677 Or go to C:/root/dir/file2 if this is a Rust file.
1678 "});
1679
1680 // Moving the mouse over a relative path that does exist should highlight it
1681 #[cfg(not(target_os = "windows"))]
1682 let screen_coord = cx.pixel_position(indoc! {"
1683 You can't go to a file that does_not_exist.txt.
1684 Go to file2.rs if you want.
1685 Or go to ../dir/fˇile2.rs if you want.
1686 Or go to /root/dir/file2.rs if project is local.
1687 Or go to /root/dir/file2 if this is a Rust file.
1688 "});
1689 #[cfg(target_os = "windows")]
1690 let screen_coord = cx.pixel_position(indoc! {"
1691 You can't go to a file that does_not_exist.txt.
1692 Go to file2.rs if you want.
1693 Or go to ../dir/fˇile2.rs if you want.
1694 Or go to C:/root/dir/file2.rs if project is local.
1695 Or go to C:/root/dir/file2 if this is a Rust file.
1696 "});
1697
1698 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1699 #[cfg(not(target_os = "windows"))]
1700 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1701 You can't go to a file that does_not_exist.txt.
1702 Go to file2.rs if you want.
1703 Or go to «../dir/file2.rsˇ» if you want.
1704 Or go to /root/dir/file2.rs if project is local.
1705 Or go to /root/dir/file2 if this is a Rust file.
1706 "});
1707 #[cfg(target_os = "windows")]
1708 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1709 You can't go to a file that does_not_exist.txt.
1710 Go to file2.rs if you want.
1711 Or go to «../dir/file2.rsˇ» if you want.
1712 Or go to C:/root/dir/file2.rs if project is local.
1713 Or go to C:/root/dir/file2 if this is a Rust file.
1714 "});
1715
1716 // Moving the mouse over an absolute path that does exist should highlight it
1717 #[cfg(not(target_os = "windows"))]
1718 let screen_coord = cx.pixel_position(indoc! {"
1719 You can't go to a file that does_not_exist.txt.
1720 Go to file2.rs if you want.
1721 Or go to ../dir/file2.rs if you want.
1722 Or go to /root/diˇr/file2.rs if project is local.
1723 Or go to /root/dir/file2 if this is a Rust file.
1724 "});
1725
1726 #[cfg(target_os = "windows")]
1727 let screen_coord = cx.pixel_position(indoc! {"
1728 You can't go to a file that does_not_exist.txt.
1729 Go to file2.rs if you want.
1730 Or go to ../dir/file2.rs if you want.
1731 Or go to C:/root/diˇr/file2.rs if project is local.
1732 Or go to C:/root/dir/file2 if this is a Rust file.
1733 "});
1734
1735 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1736 #[cfg(not(target_os = "windows"))]
1737 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1738 You can't go to a file that does_not_exist.txt.
1739 Go to file2.rs if you want.
1740 Or go to ../dir/file2.rs if you want.
1741 Or go to «/root/dir/file2.rsˇ» if project is local.
1742 Or go to /root/dir/file2 if this is a Rust file.
1743 "});
1744 #[cfg(target_os = "windows")]
1745 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1746 You can't go to a file that does_not_exist.txt.
1747 Go to file2.rs if you want.
1748 Or go to ../dir/file2.rs if you want.
1749 Or go to «C:/root/dir/file2.rsˇ» if project is local.
1750 Or go to C:/root/dir/file2 if this is a Rust file.
1751 "});
1752
1753 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1754 #[cfg(not(target_os = "windows"))]
1755 let screen_coord = cx.pixel_position(indoc! {"
1756 You can't go to a file that does_not_exist.txt.
1757 Go to file2.rs if you want.
1758 Or go to ../dir/file2.rs if you want.
1759 Or go to /root/dir/file2.rs if project is local.
1760 Or go to /root/diˇr/file2 if this is a Rust file.
1761 "});
1762 #[cfg(target_os = "windows")]
1763 let screen_coord = cx.pixel_position(indoc! {"
1764 You can't go to a file that does_not_exist.txt.
1765 Go to file2.rs if you want.
1766 Or go to ../dir/file2.rs if you want.
1767 Or go to C:/root/dir/file2.rs if project is local.
1768 Or go to C:/root/diˇr/file2 if this is a Rust file.
1769 "});
1770
1771 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1772 #[cfg(not(target_os = "windows"))]
1773 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1774 You can't go to a file that does_not_exist.txt.
1775 Go to file2.rs if you want.
1776 Or go to ../dir/file2.rs if you want.
1777 Or go to /root/dir/file2.rs if project is local.
1778 Or go to «/root/dir/file2ˇ» if this is a Rust file.
1779 "});
1780 #[cfg(target_os = "windows")]
1781 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1782 You can't go to a file that does_not_exist.txt.
1783 Go to file2.rs if you want.
1784 Or go to ../dir/file2.rs if you want.
1785 Or go to C:/root/dir/file2.rs if project is local.
1786 Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
1787 "});
1788
1789 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1790
1791 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1792 cx.update_workspace(|workspace, _, cx| {
1793 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1794
1795 let buffer = active_editor
1796 .read(cx)
1797 .buffer()
1798 .read(cx)
1799 .as_singleton()
1800 .unwrap();
1801
1802 let file = buffer.read(cx).file().unwrap();
1803 let file_path = file.as_local().unwrap().abs_path(cx);
1804
1805 assert_eq!(
1806 file_path,
1807 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
1808 );
1809 });
1810 }
1811
1812 #[gpui::test]
1813 async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
1814 init_test(cx, |_| {});
1815 let mut cx = EditorLspTestContext::new_rust(
1816 lsp::ServerCapabilities {
1817 ..Default::default()
1818 },
1819 cx,
1820 )
1821 .await;
1822
1823 // Insert a new file
1824 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1825 fs.as_fake()
1826 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1827 .await;
1828
1829 cx.set_state(indoc! {"
1830 You can't open ../diˇr because it's a directory.
1831 "});
1832
1833 // File does not exist
1834 let screen_coord = cx.pixel_position(indoc! {"
1835 You can't open ../diˇr because it's a directory.
1836 "});
1837 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1838
1839 // No highlight
1840 cx.update_editor(|editor, window, cx| {
1841 assert!(
1842 editor
1843 .snapshot(window, cx)
1844 .text_highlight_ranges::<HoveredLinkState>()
1845 .unwrap_or_default()
1846 .1
1847 .is_empty()
1848 );
1849 });
1850
1851 // Does not open the directory
1852 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1853 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1854 }
1855
1856 #[gpui::test]
1857 async fn test_hover_unicode(cx: &mut gpui::TestAppContext) {
1858 init_test(cx, |_| {});
1859 let mut cx = EditorLspTestContext::new_rust(
1860 lsp::ServerCapabilities {
1861 ..Default::default()
1862 },
1863 cx,
1864 )
1865 .await;
1866
1867 cx.set_state(indoc! {"
1868 You can't open ˇ\"🤩\" because it's an emoji.
1869 "});
1870
1871 // File does not exist
1872 let screen_coord = cx.pixel_position(indoc! {"
1873 You can't open ˇ\"🤩\" because it's an emoji.
1874 "});
1875 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1876
1877 // No highlight, does not panic...
1878 cx.update_editor(|editor, window, cx| {
1879 assert!(
1880 editor
1881 .snapshot(window, cx)
1882 .text_highlight_ranges::<HoveredLinkState>()
1883 .unwrap_or_default()
1884 .1
1885 .is_empty()
1886 );
1887 });
1888
1889 // Does not open the directory
1890 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1891 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1892 }
1893}