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