1use crate::{
2 hover_popover::{self, InlayHover},
3 scroll::ScrollAmount,
4 Anchor, Editor, EditorSnapshot, FindAllReferences, GoToDefinition, GoToTypeDefinition,
5 GotoDefinitionKind, InlayId, 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 as _};
18
19#[derive(Debug)]
20pub struct HoveredLinkState {
21 pub last_trigger_point: TriggerPoint,
22 pub preferred_kind: GotoDefinitionKind,
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
431pub fn show_link_definition(
432 shift_held: bool,
433 editor: &mut Editor,
434 trigger_point: TriggerPoint,
435 snapshot: &EditorSnapshot,
436 cx: &mut ViewContext<Editor>,
437) {
438 let preferred_kind = match trigger_point {
439 TriggerPoint::Text(_) if !shift_held => GotoDefinitionKind::Symbol,
440 _ => GotoDefinitionKind::Type,
441 };
442
443 let (mut hovered_link_state, is_cached) =
444 if let Some(existing) = editor.hovered_link_state.take() {
445 (existing, true)
446 } else {
447 (
448 HoveredLinkState {
449 last_trigger_point: trigger_point.clone(),
450 symbol_range: None,
451 preferred_kind,
452 links: vec![],
453 task: None,
454 },
455 false,
456 )
457 };
458
459 if editor.pending_rename.is_some() {
460 return;
461 }
462
463 let trigger_anchor = trigger_point.anchor();
464 let Some((buffer, buffer_position)) = editor
465 .buffer
466 .read(cx)
467 .text_anchor_for_position(*trigger_anchor, cx)
468 else {
469 return;
470 };
471
472 let Some((excerpt_id, _, _)) = editor
473 .buffer()
474 .read(cx)
475 .excerpt_containing(*trigger_anchor, cx)
476 else {
477 return;
478 };
479
480 let same_kind = hovered_link_state.preferred_kind == preferred_kind
481 || hovered_link_state
482 .links
483 .first()
484 .is_some_and(|d| matches!(d, HoverLink::Url(_)));
485
486 if same_kind {
487 if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
488 || hovered_link_state
489 .symbol_range
490 .as_ref()
491 .is_some_and(|symbol_range| {
492 symbol_range.point_within_range(&trigger_point, snapshot)
493 })
494 {
495 editor.hovered_link_state = Some(hovered_link_state);
496 return;
497 }
498 } else {
499 editor.hide_hovered_link(cx)
500 }
501 let project = editor.project.clone();
502 let provider = editor.semantics_provider.clone();
503
504 let snapshot = snapshot.buffer_snapshot.clone();
505 hovered_link_state.task = Some(cx.spawn(|this, mut cx| {
506 async move {
507 let result = match &trigger_point {
508 TriggerPoint::Text(_) => {
509 if let Some((url_range, url)) = find_url(&buffer, buffer_position, cx.clone()) {
510 this.update(&mut cx, |_, _| {
511 let range = maybe!({
512 let start =
513 snapshot.anchor_in_excerpt(excerpt_id, url_range.start)?;
514 let end = snapshot.anchor_in_excerpt(excerpt_id, url_range.end)?;
515 Some(RangeInEditor::Text(start..end))
516 });
517 (range, vec![HoverLink::Url(url)])
518 })
519 .ok()
520 } else if let Some((filename_range, filename)) =
521 find_file(&buffer, project.clone(), buffer_position, &mut cx).await
522 {
523 let range = maybe!({
524 let start =
525 snapshot.anchor_in_excerpt(excerpt_id, filename_range.start)?;
526 let end = snapshot.anchor_in_excerpt(excerpt_id, filename_range.end)?;
527 Some(RangeInEditor::Text(start..end))
528 });
529
530 Some((range, vec![HoverLink::File(filename)]))
531 } else if let Some(provider) = provider {
532 let task = cx.update(|cx| {
533 provider.definitions(&buffer, buffer_position, preferred_kind, cx)
534 })?;
535 if let Some(task) = task {
536 task.await.ok().map(|definition_result| {
537 (
538 definition_result.iter().find_map(|link| {
539 link.origin.as_ref().and_then(|origin| {
540 let start = snapshot.anchor_in_excerpt(
541 excerpt_id,
542 origin.range.start,
543 )?;
544 let end = snapshot
545 .anchor_in_excerpt(excerpt_id, origin.range.end)?;
546 Some(RangeInEditor::Text(start..end))
547 })
548 }),
549 definition_result.into_iter().map(HoverLink::Text).collect(),
550 )
551 })
552 } else {
553 None
554 }
555 } else {
556 None
557 }
558 }
559 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
560 Some(RangeInEditor::Inlay(highlight.clone())),
561 vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
562 )),
563 };
564
565 this.update(&mut cx, |editor, cx| {
566 // Clear any existing highlights
567 editor.clear_highlights::<HoveredLinkState>(cx);
568 let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
569 editor.hide_hovered_link(cx);
570 return;
571 };
572 hovered_link_state.preferred_kind = preferred_kind;
573 hovered_link_state.symbol_range = result
574 .as_ref()
575 .and_then(|(symbol_range, _)| symbol_range.clone());
576
577 if let Some((symbol_range, definitions)) = result {
578 hovered_link_state.links = definitions;
579
580 let underline_hovered_link = !hovered_link_state.links.is_empty()
581 || hovered_link_state.symbol_range.is_some();
582
583 if underline_hovered_link {
584 let style = gpui::HighlightStyle {
585 underline: Some(gpui::UnderlineStyle {
586 thickness: px(1.),
587 ..Default::default()
588 }),
589 color: Some(cx.theme().colors().link_text_hover),
590 ..Default::default()
591 };
592 let highlight_range =
593 symbol_range.unwrap_or_else(|| match &trigger_point {
594 TriggerPoint::Text(trigger_anchor) => {
595 // If no symbol range returned from language server, use the surrounding word.
596 let (offset_range, _) =
597 snapshot.surrounding_word(*trigger_anchor, false);
598 RangeInEditor::Text(
599 snapshot.anchor_before(offset_range.start)
600 ..snapshot.anchor_after(offset_range.end),
601 )
602 }
603 TriggerPoint::InlayHint(highlight, _, _) => {
604 RangeInEditor::Inlay(highlight.clone())
605 }
606 });
607
608 match highlight_range {
609 RangeInEditor::Text(text_range) => editor
610 .highlight_text::<HoveredLinkState>(vec![text_range], style, cx),
611 RangeInEditor::Inlay(highlight) => editor
612 .highlight_inlays::<HoveredLinkState>(vec![highlight], style, cx),
613 }
614 }
615 } else {
616 editor.hide_hovered_link(cx);
617 }
618 })?;
619
620 Ok::<_, anyhow::Error>(())
621 }
622 .log_err()
623 }));
624
625 editor.hovered_link_state = Some(hovered_link_state);
626}
627
628pub(crate) fn find_url(
629 buffer: &Model<language::Buffer>,
630 position: text::Anchor,
631 mut cx: AsyncWindowContext,
632) -> Option<(Range<text::Anchor>, String)> {
633 const LIMIT: usize = 2048;
634
635 let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
636 return None;
637 };
638
639 let offset = position.to_offset(&snapshot);
640 let mut token_start = offset;
641 let mut token_end = offset;
642 let mut found_start = false;
643 let mut found_end = false;
644
645 for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
646 if ch.is_whitespace() {
647 found_start = true;
648 break;
649 }
650 token_start -= ch.len_utf8();
651 }
652 // Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
653 if !found_start && token_start != 0 {
654 return None;
655 }
656
657 for ch in snapshot
658 .chars_at(offset)
659 .take(LIMIT - (offset - token_start))
660 {
661 if ch.is_whitespace() {
662 found_end = true;
663 break;
664 }
665 token_end += ch.len_utf8();
666 }
667 // Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
668 // which at this point would happen only if we reached the end of buffer
669 if !found_end && (token_end - token_start >= LIMIT) {
670 return None;
671 }
672
673 let mut finder = LinkFinder::new();
674 finder.kinds(&[LinkKind::Url]);
675 let input = snapshot
676 .text_for_range(token_start..token_end)
677 .collect::<String>();
678
679 let relative_offset = offset - token_start;
680 for link in finder.links(&input) {
681 if link.start() <= relative_offset && link.end() >= relative_offset {
682 let range = snapshot.anchor_before(token_start + link.start())
683 ..snapshot.anchor_after(token_start + link.end());
684 return Some((range, link.as_str().to_string()));
685 }
686 }
687 None
688}
689
690pub(crate) async fn find_file(
691 buffer: &Model<language::Buffer>,
692 project: Option<Model<Project>>,
693 position: text::Anchor,
694 cx: &mut AsyncWindowContext,
695) -> Option<(Range<text::Anchor>, ResolvedPath)> {
696 let project = project?;
697 let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
698 let scope = snapshot.language_scope_at(position);
699 let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
700
701 async fn check_path(
702 candidate_file_path: &str,
703 project: &Model<Project>,
704 buffer: &Model<language::Buffer>,
705 cx: &mut AsyncWindowContext,
706 ) -> Option<ResolvedPath> {
707 project
708 .update(cx, |project, cx| {
709 project.resolve_existing_file_path(&candidate_file_path, buffer, cx)
710 })
711 .ok()?
712 .await
713 }
714
715 if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
716 return Some((range, existing_path));
717 }
718
719 if let Some(scope) = scope {
720 for suffix in scope.path_suffixes() {
721 if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
722 continue;
723 }
724
725 let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
726 if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
727 {
728 return Some((range, existing_path));
729 }
730 }
731 }
732
733 None
734}
735
736fn surrounding_filename(
737 snapshot: language::BufferSnapshot,
738 position: text::Anchor,
739) -> Option<(Range<text::Anchor>, String)> {
740 const LIMIT: usize = 2048;
741
742 let offset = position.to_offset(&snapshot);
743 let mut token_start = offset;
744 let mut token_end = offset;
745 let mut found_start = false;
746 let mut found_end = false;
747 let mut inside_quotes = false;
748
749 let mut filename = String::new();
750
751 let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
752 while let Some(ch) = backwards.next() {
753 // Escaped whitespace
754 if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
755 filename.push(ch);
756 token_start -= ch.len_utf8();
757 backwards.next();
758 token_start -= '\\'.len_utf8();
759 continue;
760 }
761 if ch.is_whitespace() {
762 found_start = true;
763 break;
764 }
765 if (ch == '"' || ch == '\'') && !inside_quotes {
766 found_start = true;
767 inside_quotes = true;
768 break;
769 }
770
771 filename.push(ch);
772 token_start -= ch.len_utf8();
773 }
774 if !found_start && token_start != 0 {
775 return None;
776 }
777
778 filename = filename.chars().rev().collect();
779
780 let mut forwards = snapshot
781 .chars_at(offset)
782 .take(LIMIT - (offset - token_start))
783 .peekable();
784 while let Some(ch) = forwards.next() {
785 // Skip escaped whitespace
786 if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
787 token_end += ch.len_utf8();
788 let whitespace = forwards.next().unwrap();
789 token_end += whitespace.len_utf8();
790 filename.push(whitespace);
791 continue;
792 }
793
794 if ch.is_whitespace() {
795 found_end = true;
796 break;
797 }
798 if ch == '"' || ch == '\'' {
799 // If we're inside quotes, we stop when we come across the next quote
800 if inside_quotes {
801 found_end = true;
802 break;
803 } else {
804 // Otherwise, we skip the quote
805 inside_quotes = true;
806 continue;
807 }
808 }
809 filename.push(ch);
810 token_end += ch.len_utf8();
811 }
812
813 if !found_end && (token_end - token_start >= LIMIT) {
814 return None;
815 }
816
817 if filename.is_empty() {
818 return None;
819 }
820
821 let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
822
823 Some((range, filename))
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use crate::{
830 display_map::ToDisplayPoint,
831 editor_tests::init_test,
832 inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
833 test::editor_lsp_test_context::EditorLspTestContext,
834 DisplayPoint,
835 };
836 use futures::StreamExt;
837 use gpui::Modifiers;
838 use indoc::indoc;
839 use language::language_settings::InlayHintSettings;
840 use lsp::request::{GotoDefinition, GotoTypeDefinition};
841 use util::assert_set_eq;
842 use workspace::item::Item;
843
844 #[gpui::test]
845 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
846 init_test(cx, |_| {});
847
848 let mut cx = EditorLspTestContext::new_rust(
849 lsp::ServerCapabilities {
850 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
851 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
852 ..Default::default()
853 },
854 cx,
855 )
856 .await;
857
858 cx.set_state(indoc! {"
859 struct A;
860 let vˇariable = A;
861 "});
862 let screen_coord = cx.editor(|editor, cx| editor.pixel_position_of_cursor(cx));
863
864 // Basic hold cmd+shift, expect highlight in region if response contains type definition
865 let symbol_range = cx.lsp_range(indoc! {"
866 struct A;
867 let «variable» = A;
868 "});
869 let target_range = cx.lsp_range(indoc! {"
870 struct «A»;
871 let variable = A;
872 "});
873
874 cx.run_until_parked();
875
876 let mut requests =
877 cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
878 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
879 lsp::LocationLink {
880 origin_selection_range: Some(symbol_range),
881 target_uri: url.clone(),
882 target_range,
883 target_selection_range: target_range,
884 },
885 ])))
886 });
887
888 let modifiers = if cfg!(target_os = "macos") {
889 Modifiers::command_shift()
890 } else {
891 Modifiers::control_shift()
892 };
893
894 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
895
896 requests.next().await;
897 cx.run_until_parked();
898 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
899 struct A;
900 let «variable» = A;
901 "});
902
903 cx.simulate_modifiers_change(Modifiers::secondary_key());
904 cx.run_until_parked();
905 // Assert no link highlights
906 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
907 struct A;
908 let variable = A;
909 "});
910
911 cx.simulate_click(screen_coord.unwrap(), modifiers);
912
913 cx.assert_editor_state(indoc! {"
914 struct «Aˇ»;
915 let variable = A;
916 "});
917 }
918
919 #[gpui::test]
920 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
921 init_test(cx, |_| {});
922
923 let mut cx = EditorLspTestContext::new_rust(
924 lsp::ServerCapabilities {
925 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
926 definition_provider: Some(lsp::OneOf::Left(true)),
927 ..Default::default()
928 },
929 cx,
930 )
931 .await;
932
933 cx.set_state(indoc! {"
934 fn ˇtest() { do_work(); }
935 fn do_work() { test(); }
936 "});
937
938 // Basic hold cmd, expect highlight in region if response contains definition
939 let hover_point = cx.pixel_position(indoc! {"
940 fn test() { do_wˇork(); }
941 fn do_work() { test(); }
942 "});
943 let symbol_range = cx.lsp_range(indoc! {"
944 fn test() { «do_work»(); }
945 fn do_work() { test(); }
946 "});
947 let target_range = cx.lsp_range(indoc! {"
948 fn test() { do_work(); }
949 fn «do_work»() { test(); }
950 "});
951
952 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
953 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
954 lsp::LocationLink {
955 origin_selection_range: Some(symbol_range),
956 target_uri: url.clone(),
957 target_range,
958 target_selection_range: target_range,
959 },
960 ])))
961 });
962
963 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
964 requests.next().await;
965 cx.background_executor.run_until_parked();
966 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
967 fn test() { «do_work»(); }
968 fn do_work() { test(); }
969 "});
970
971 // Unpress cmd causes highlight to go away
972 cx.simulate_modifiers_change(Modifiers::none());
973 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
974 fn test() { do_work(); }
975 fn do_work() { test(); }
976 "});
977
978 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
979 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
980 lsp::LocationLink {
981 origin_selection_range: Some(symbol_range),
982 target_uri: url.clone(),
983 target_range,
984 target_selection_range: target_range,
985 },
986 ])))
987 });
988
989 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
990 requests.next().await;
991 cx.background_executor.run_until_parked();
992 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
993 fn test() { «do_work»(); }
994 fn do_work() { test(); }
995 "});
996
997 // Moving mouse to location with no response dismisses highlight
998 let hover_point = cx.pixel_position(indoc! {"
999 fˇn test() { do_work(); }
1000 fn do_work() { test(); }
1001 "});
1002 let mut requests = cx
1003 .lsp
1004 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
1005 // No definitions returned
1006 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1007 });
1008 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1009
1010 requests.next().await;
1011 cx.background_executor.run_until_parked();
1012
1013 // Assert no link highlights
1014 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1015 fn test() { do_work(); }
1016 fn do_work() { test(); }
1017 "});
1018
1019 // // Move mouse without cmd and then pressing cmd triggers highlight
1020 let hover_point = cx.pixel_position(indoc! {"
1021 fn test() { do_work(); }
1022 fn do_work() { teˇst(); }
1023 "});
1024 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1025
1026 // Assert no link highlights
1027 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1028 fn test() { do_work(); }
1029 fn do_work() { test(); }
1030 "});
1031
1032 let symbol_range = cx.lsp_range(indoc! {"
1033 fn test() { do_work(); }
1034 fn do_work() { «test»(); }
1035 "});
1036 let target_range = cx.lsp_range(indoc! {"
1037 fn «test»() { do_work(); }
1038 fn do_work() { test(); }
1039 "});
1040
1041 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1042 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1043 lsp::LocationLink {
1044 origin_selection_range: Some(symbol_range),
1045 target_uri: url,
1046 target_range,
1047 target_selection_range: target_range,
1048 },
1049 ])))
1050 });
1051
1052 cx.simulate_modifiers_change(Modifiers::secondary_key());
1053
1054 requests.next().await;
1055 cx.background_executor.run_until_parked();
1056
1057 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1058 fn test() { do_work(); }
1059 fn do_work() { «test»(); }
1060 "});
1061
1062 cx.deactivate_window();
1063 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1064 fn test() { do_work(); }
1065 fn do_work() { test(); }
1066 "});
1067
1068 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1069 cx.background_executor.run_until_parked();
1070 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1071 fn test() { do_work(); }
1072 fn do_work() { «test»(); }
1073 "});
1074
1075 // Moving again within the same symbol range doesn't re-request
1076 let hover_point = cx.pixel_position(indoc! {"
1077 fn test() { do_work(); }
1078 fn do_work() { tesˇt(); }
1079 "});
1080 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1081 cx.background_executor.run_until_parked();
1082 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1083 fn test() { do_work(); }
1084 fn do_work() { «test»(); }
1085 "});
1086
1087 // Cmd click with existing definition doesn't re-request and dismisses highlight
1088 cx.simulate_click(hover_point, Modifiers::secondary_key());
1089 cx.lsp
1090 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
1091 // Empty definition response to make sure we aren't hitting the lsp and using
1092 // the cached location instead
1093 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1094 });
1095 cx.background_executor.run_until_parked();
1096 cx.assert_editor_state(indoc! {"
1097 fn «testˇ»() { do_work(); }
1098 fn do_work() { test(); }
1099 "});
1100
1101 // Assert no link highlights after jump
1102 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1103 fn test() { do_work(); }
1104 fn do_work() { test(); }
1105 "});
1106
1107 // Cmd click without existing definition requests and jumps
1108 let hover_point = cx.pixel_position(indoc! {"
1109 fn test() { do_wˇork(); }
1110 fn do_work() { test(); }
1111 "});
1112 let target_range = cx.lsp_range(indoc! {"
1113 fn test() { do_work(); }
1114 fn «do_work»() { test(); }
1115 "});
1116
1117 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1118 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1119 lsp::LocationLink {
1120 origin_selection_range: None,
1121 target_uri: url,
1122 target_range,
1123 target_selection_range: target_range,
1124 },
1125 ])))
1126 });
1127 cx.simulate_click(hover_point, Modifiers::secondary_key());
1128 requests.next().await;
1129 cx.background_executor.run_until_parked();
1130 cx.assert_editor_state(indoc! {"
1131 fn test() { do_work(); }
1132 fn «do_workˇ»() { test(); }
1133 "});
1134
1135 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1136 // 2. Selection is completed, hovering
1137 let hover_point = cx.pixel_position(indoc! {"
1138 fn test() { do_wˇork(); }
1139 fn do_work() { test(); }
1140 "});
1141 let target_range = cx.lsp_range(indoc! {"
1142 fn test() { do_work(); }
1143 fn «do_work»() { test(); }
1144 "});
1145 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1146 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1147 lsp::LocationLink {
1148 origin_selection_range: None,
1149 target_uri: url,
1150 target_range,
1151 target_selection_range: target_range,
1152 },
1153 ])))
1154 });
1155
1156 // create a pending selection
1157 let selection_range = cx.ranges(indoc! {"
1158 fn «test() { do_w»ork(); }
1159 fn do_work() { test(); }
1160 "})[0]
1161 .clone();
1162 cx.update_editor(|editor, cx| {
1163 let snapshot = editor.buffer().read(cx).snapshot(cx);
1164 let anchor_range = snapshot.anchor_before(selection_range.start)
1165 ..snapshot.anchor_after(selection_range.end);
1166 editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| {
1167 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1168 });
1169 });
1170 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1171 cx.background_executor.run_until_parked();
1172 assert!(requests.try_next().is_err());
1173 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1174 fn test() { do_work(); }
1175 fn do_work() { test(); }
1176 "});
1177 cx.background_executor.run_until_parked();
1178 }
1179
1180 #[gpui::test]
1181 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1182 init_test(cx, |settings| {
1183 settings.defaults.inlay_hints = Some(InlayHintSettings {
1184 enabled: true,
1185 edit_debounce_ms: 0,
1186 scroll_debounce_ms: 0,
1187 show_type_hints: true,
1188 show_parameter_hints: true,
1189 show_other_hints: true,
1190 show_background: false,
1191 })
1192 });
1193
1194 let mut cx = EditorLspTestContext::new_rust(
1195 lsp::ServerCapabilities {
1196 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1197 ..Default::default()
1198 },
1199 cx,
1200 )
1201 .await;
1202 cx.set_state(indoc! {"
1203 struct TestStruct;
1204
1205 fn main() {
1206 let variableˇ = TestStruct;
1207 }
1208 "});
1209 let hint_start_offset = cx.ranges(indoc! {"
1210 struct TestStruct;
1211
1212 fn main() {
1213 let variableˇ = TestStruct;
1214 }
1215 "})[0]
1216 .start;
1217 let hint_position = cx.to_lsp(hint_start_offset);
1218 let target_range = cx.lsp_range(indoc! {"
1219 struct «TestStruct»;
1220
1221 fn main() {
1222 let variable = TestStruct;
1223 }
1224 "});
1225
1226 let expected_uri = cx.buffer_lsp_url.clone();
1227 let hint_label = ": TestStruct";
1228 cx.lsp
1229 .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1230 let expected_uri = expected_uri.clone();
1231 async move {
1232 assert_eq!(params.text_document.uri, expected_uri);
1233 Ok(Some(vec![lsp::InlayHint {
1234 position: hint_position,
1235 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1236 value: hint_label.to_string(),
1237 location: Some(lsp::Location {
1238 uri: params.text_document.uri,
1239 range: target_range,
1240 }),
1241 ..Default::default()
1242 }]),
1243 kind: Some(lsp::InlayHintKind::TYPE),
1244 text_edits: None,
1245 tooltip: None,
1246 padding_left: Some(false),
1247 padding_right: Some(false),
1248 data: None,
1249 }]))
1250 }
1251 })
1252 .next()
1253 .await;
1254 cx.background_executor.run_until_parked();
1255 cx.update_editor(|editor, cx| {
1256 let expected_layers = vec![hint_label.to_string()];
1257 assert_eq!(expected_layers, cached_hint_labels(editor));
1258 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1259 });
1260
1261 let inlay_range = cx
1262 .ranges(indoc! {"
1263 struct TestStruct;
1264
1265 fn main() {
1266 let variable« »= TestStruct;
1267 }
1268 "})
1269 .first()
1270 .cloned()
1271 .unwrap();
1272 let midpoint = cx.update_editor(|editor, cx| {
1273 let snapshot = editor.snapshot(cx);
1274 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1275 let next_valid = inlay_range.end.to_display_point(&snapshot);
1276 assert_eq!(previous_valid.row(), next_valid.row());
1277 assert!(previous_valid.column() < next_valid.column());
1278 DisplayPoint::new(
1279 previous_valid.row(),
1280 previous_valid.column() + (hint_label.len() / 2) as u32,
1281 )
1282 });
1283 // Press cmd to trigger highlight
1284 let hover_point = cx.pixel_position_for(midpoint);
1285 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1286 cx.background_executor.run_until_parked();
1287 cx.update_editor(|editor, cx| {
1288 let snapshot = editor.snapshot(cx);
1289 let actual_highlights = snapshot
1290 .inlay_highlights::<HoveredLinkState>()
1291 .into_iter()
1292 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1293 .collect::<Vec<_>>();
1294
1295 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1296 let expected_highlight = InlayHighlight {
1297 inlay: InlayId::Hint(0),
1298 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
1299 range: 0..hint_label.len(),
1300 };
1301 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1302 });
1303
1304 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1305 // Assert no link highlights
1306 cx.update_editor(|editor, cx| {
1307 let snapshot = editor.snapshot(cx);
1308 let actual_ranges = snapshot
1309 .text_highlight_ranges::<HoveredLinkState>()
1310 .map(|ranges| ranges.as_ref().clone().1)
1311 .unwrap_or_default();
1312
1313 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1314 });
1315
1316 cx.simulate_modifiers_change(Modifiers::secondary_key());
1317 cx.background_executor.run_until_parked();
1318 cx.simulate_click(hover_point, Modifiers::secondary_key());
1319 cx.background_executor.run_until_parked();
1320 cx.assert_editor_state(indoc! {"
1321 struct «TestStructˇ»;
1322
1323 fn main() {
1324 let variable = TestStruct;
1325 }
1326 "});
1327 }
1328
1329 #[gpui::test]
1330 async fn test_urls(cx: &mut gpui::TestAppContext) {
1331 init_test(cx, |_| {});
1332 let mut cx = EditorLspTestContext::new_rust(
1333 lsp::ServerCapabilities {
1334 ..Default::default()
1335 },
1336 cx,
1337 )
1338 .await;
1339
1340 cx.set_state(indoc! {"
1341 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1342 "});
1343
1344 let screen_coord = cx.pixel_position(indoc! {"
1345 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1346 "});
1347
1348 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1349 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1350 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1351 "});
1352
1353 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1354 assert_eq!(
1355 cx.opened_url(),
1356 Some("https://zed.dev/channel/had-(oops)".into())
1357 );
1358 }
1359
1360 #[gpui::test]
1361 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1362 init_test(cx, |_| {});
1363 let mut cx = EditorLspTestContext::new_rust(
1364 lsp::ServerCapabilities {
1365 ..Default::default()
1366 },
1367 cx,
1368 )
1369 .await;
1370
1371 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1372
1373 let screen_coord =
1374 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1375
1376 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1377 cx.assert_editor_text_highlights::<HoveredLinkState>(
1378 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1379 );
1380
1381 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1382 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1383 }
1384
1385 #[gpui::test]
1386 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1387 init_test(cx, |_| {});
1388 let mut cx = EditorLspTestContext::new_rust(
1389 lsp::ServerCapabilities {
1390 ..Default::default()
1391 },
1392 cx,
1393 )
1394 .await;
1395
1396 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1397
1398 let screen_coord =
1399 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1400
1401 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1402 cx.assert_editor_text_highlights::<HoveredLinkState>(
1403 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1404 );
1405
1406 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1407 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1408 }
1409
1410 #[gpui::test]
1411 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1412 init_test(cx, |_| {});
1413 let mut cx = EditorLspTestContext::new_rust(
1414 lsp::ServerCapabilities {
1415 ..Default::default()
1416 },
1417 cx,
1418 )
1419 .await;
1420
1421 let test_cases = [
1422 ("file ˇ name", None),
1423 ("ˇfile name", Some("file")),
1424 ("file ˇname", Some("name")),
1425 ("fiˇle name", Some("file")),
1426 ("filenˇame", Some("filename")),
1427 // Absolute path
1428 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1429 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1430 // Windows
1431 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1432 // Whitespace
1433 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1434 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1435 // Tilde
1436 ("ˇ~/file.txt", Some("~/file.txt")),
1437 ("~/fiˇle.txt", Some("~/file.txt")),
1438 // Double quotes
1439 ("\"fˇile.txt\"", Some("file.txt")),
1440 ("ˇ\"file.txt\"", Some("file.txt")),
1441 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1442 // Single quotes
1443 ("'fˇile.txt'", Some("file.txt")),
1444 ("ˇ'file.txt'", Some("file.txt")),
1445 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1446 ];
1447
1448 for (input, expected) in test_cases {
1449 cx.set_state(input);
1450
1451 let (position, snapshot) = cx.editor(|editor, cx| {
1452 let positions = editor.selections.newest_anchor().head().text_anchor;
1453 let snapshot = editor
1454 .buffer()
1455 .clone()
1456 .read(cx)
1457 .as_singleton()
1458 .unwrap()
1459 .read(cx)
1460 .snapshot();
1461 (positions, snapshot)
1462 });
1463
1464 let result = surrounding_filename(snapshot, position);
1465
1466 if let Some(expected) = expected {
1467 assert!(result.is_some(), "Failed to find file path: {}", input);
1468 let (_, path) = result.unwrap();
1469 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1470 } else {
1471 assert!(
1472 result.is_none(),
1473 "Expected no result, but got one: {:?}",
1474 result
1475 );
1476 }
1477 }
1478 }
1479
1480 #[gpui::test]
1481 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1482 init_test(cx, |_| {});
1483 let mut cx = EditorLspTestContext::new_rust(
1484 lsp::ServerCapabilities {
1485 ..Default::default()
1486 },
1487 cx,
1488 )
1489 .await;
1490
1491 // Insert a new file
1492 let fs = cx.update_workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
1493 fs.as_fake()
1494 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1495 .await;
1496
1497 cx.set_state(indoc! {"
1498 You can't go to a file that does_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 Or go to /root/dir/file2 if this is a Rust file.ˇ
1503 "});
1504
1505 // File does not exist
1506 let screen_coord = cx.pixel_position(indoc! {"
1507 You can't go to a file that dˇoes_not_exist.txt.
1508 Go to file2.rs if you want.
1509 Or go to ../dir/file2.rs if you want.
1510 Or go to /root/dir/file2.rs if project is local.
1511 Or go to /root/dir/file2 if this is a Rust file.
1512 "});
1513 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1514 // No highlight
1515 cx.update_editor(|editor, cx| {
1516 assert!(editor
1517 .snapshot(cx)
1518 .text_highlight_ranges::<HoveredLinkState>()
1519 .unwrap_or_default()
1520 .1
1521 .is_empty());
1522 });
1523
1524 // Moving the mouse over a file that does exist should highlight it.
1525 let screen_coord = cx.pixel_position(indoc! {"
1526 You can't go to a file that does_not_exist.txt.
1527 Go to fˇile2.rs if you want.
1528 Or go to ../dir/file2.rs if you want.
1529 Or go to /root/dir/file2.rs if project is local.
1530 Or go to /root/dir/file2 if this is a Rust file.
1531 "});
1532
1533 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1534 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1535 You can't go to a file that does_not_exist.txt.
1536 Go to «file2.rsˇ» if you want.
1537 Or go to ../dir/file2.rs if you want.
1538 Or go to /root/dir/file2.rs if project is local.
1539 Or go to /root/dir/file2 if this is a Rust file.
1540 "});
1541
1542 // Moving the mouse over a relative path that does exist should highlight it
1543 let screen_coord = cx.pixel_position(indoc! {"
1544 You can't go to a file that does_not_exist.txt.
1545 Go to file2.rs if you want.
1546 Or go to ../dir/fˇile2.rs if you want.
1547 Or go to /root/dir/file2.rs if project is local.
1548 Or go to /root/dir/file2 if this is a Rust file.
1549 "});
1550
1551 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1552 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1553 You can't go to a file that does_not_exist.txt.
1554 Go to file2.rs if you want.
1555 Or go to «../dir/file2.rsˇ» if you want.
1556 Or go to /root/dir/file2.rs if project is local.
1557 Or go to /root/dir/file2 if this is a Rust file.
1558 "});
1559
1560 // Moving the mouse over an absolute path that does exist should highlight it
1561 let screen_coord = cx.pixel_position(indoc! {"
1562 You can't go to a file that does_not_exist.txt.
1563 Go to file2.rs if you want.
1564 Or go to ../dir/file2.rs if you want.
1565 Or go to /root/diˇr/file2.rs if project is local.
1566 Or go to /root/dir/file2 if this is a Rust file.
1567 "});
1568
1569 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1570 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1571 You can't go to a file that does_not_exist.txt.
1572 Go to file2.rs if you want.
1573 Or go to ../dir/file2.rs if you want.
1574 Or go to «/root/dir/file2.rsˇ» if project is local.
1575 Or go to /root/dir/file2 if this is a Rust file.
1576 "});
1577
1578 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1579 let screen_coord = cx.pixel_position(indoc! {"
1580 You can't go to a file that does_not_exist.txt.
1581 Go to file2.rs if you want.
1582 Or go to ../dir/file2.rs if you want.
1583 Or go to /root/dir/file2.rs if project is local.
1584 Or go to /root/diˇr/file2 if this is a Rust file.
1585 "});
1586
1587 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1588 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1589 You can't go to a file that does_not_exist.txt.
1590 Go to file2.rs if you want.
1591 Or go to ../dir/file2.rs if you want.
1592 Or go to /root/dir/file2.rs if project is local.
1593 Or go to «/root/dir/file2ˇ» if this is a Rust file.
1594 "});
1595
1596 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1597
1598 cx.update_workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
1599 cx.update_workspace(|workspace, cx| {
1600 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1601
1602 let buffer = active_editor
1603 .read(cx)
1604 .buffer()
1605 .read(cx)
1606 .as_singleton()
1607 .unwrap();
1608
1609 let file = buffer.read(cx).file().unwrap();
1610 let file_path = file.as_local().unwrap().abs_path(cx);
1611
1612 assert_eq!(file_path.to_str().unwrap(), "/root/dir/file2.rs");
1613 });
1614 }
1615}