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