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