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 let scope = snapshot.language_scope_at(position);
717 let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
718
719 async fn check_path(
720 candidate_file_path: &str,
721 project: &Model<Project>,
722 buffer: &Model<language::Buffer>,
723 cx: &mut AsyncWindowContext,
724 ) -> Option<ResolvedPath> {
725 project
726 .update(cx, |project, cx| {
727 project.resolve_existing_file_path(&candidate_file_path, buffer, cx)
728 })
729 .ok()?
730 .await
731 }
732
733 if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
734 return Some((range, existing_path));
735 }
736
737 if let Some(scope) = scope {
738 for suffix in scope.path_suffixes() {
739 if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
740 continue;
741 }
742
743 let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
744 if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
745 {
746 return Some((range, existing_path));
747 }
748 }
749 }
750
751 None
752}
753
754fn surrounding_filename(
755 snapshot: language::BufferSnapshot,
756 position: text::Anchor,
757) -> Option<(Range<text::Anchor>, String)> {
758 const LIMIT: usize = 2048;
759
760 let offset = position.to_offset(&snapshot);
761 let mut token_start = offset;
762 let mut token_end = offset;
763 let mut found_start = false;
764 let mut found_end = false;
765 let mut inside_quotes = false;
766
767 let mut filename = String::new();
768
769 let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
770 while let Some(ch) = backwards.next() {
771 // Escaped whitespace
772 if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
773 filename.push(ch);
774 token_start -= ch.len_utf8();
775 backwards.next();
776 token_start -= '\\'.len_utf8();
777 continue;
778 }
779 if ch.is_whitespace() {
780 found_start = true;
781 break;
782 }
783 if (ch == '"' || ch == '\'') && !inside_quotes {
784 found_start = true;
785 inside_quotes = true;
786 break;
787 }
788
789 filename.push(ch);
790 token_start -= ch.len_utf8();
791 }
792 if !found_start && token_start != 0 {
793 return None;
794 }
795
796 filename = filename.chars().rev().collect();
797
798 let mut forwards = snapshot
799 .chars_at(offset)
800 .take(LIMIT - (offset - token_start))
801 .peekable();
802 while let Some(ch) = forwards.next() {
803 // Skip escaped whitespace
804 if ch == '\\' && forwards.peek().map_or(false, |ch| ch.is_whitespace()) {
805 token_end += ch.len_utf8();
806 let whitespace = forwards.next().unwrap();
807 token_end += whitespace.len_utf8();
808 filename.push(whitespace);
809 continue;
810 }
811
812 if ch.is_whitespace() {
813 found_end = true;
814 break;
815 }
816 if ch == '"' || ch == '\'' {
817 // If we're inside quotes, we stop when we come across the next quote
818 if inside_quotes {
819 found_end = true;
820 break;
821 } else {
822 // Otherwise, we skip the quote
823 inside_quotes = true;
824 continue;
825 }
826 }
827 filename.push(ch);
828 token_end += ch.len_utf8();
829 }
830
831 if !found_end && (token_end - token_start >= LIMIT) {
832 return None;
833 }
834
835 if filename.is_empty() {
836 return None;
837 }
838
839 let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
840
841 Some((range, filename))
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use crate::{
848 display_map::ToDisplayPoint,
849 editor_tests::init_test,
850 inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels},
851 test::editor_lsp_test_context::EditorLspTestContext,
852 DisplayPoint,
853 };
854 use futures::StreamExt;
855 use gpui::Modifiers;
856 use indoc::indoc;
857 use language::language_settings::InlayHintSettings;
858 use lsp::request::{GotoDefinition, GotoTypeDefinition};
859 use util::assert_set_eq;
860 use workspace::item::Item;
861
862 #[gpui::test]
863 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
864 init_test(cx, |_| {});
865
866 let mut cx = EditorLspTestContext::new_rust(
867 lsp::ServerCapabilities {
868 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
869 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
870 ..Default::default()
871 },
872 cx,
873 )
874 .await;
875
876 cx.set_state(indoc! {"
877 struct A;
878 let vˇariable = A;
879 "});
880 let screen_coord = cx.editor(|editor, cx| editor.pixel_position_of_cursor(cx));
881
882 // Basic hold cmd+shift, expect highlight in region if response contains type definition
883 let symbol_range = cx.lsp_range(indoc! {"
884 struct A;
885 let «variable» = A;
886 "});
887 let target_range = cx.lsp_range(indoc! {"
888 struct «A»;
889 let variable = A;
890 "});
891
892 cx.run_until_parked();
893
894 let mut requests =
895 cx.handle_request::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
896 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
897 lsp::LocationLink {
898 origin_selection_range: Some(symbol_range),
899 target_uri: url.clone(),
900 target_range,
901 target_selection_range: target_range,
902 },
903 ])))
904 });
905
906 let modifiers = if cfg!(target_os = "macos") {
907 Modifiers::command_shift()
908 } else {
909 Modifiers::control_shift()
910 };
911
912 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
913
914 requests.next().await;
915 cx.run_until_parked();
916 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
917 struct A;
918 let «variable» = A;
919 "});
920
921 cx.simulate_modifiers_change(Modifiers::secondary_key());
922 cx.run_until_parked();
923 // Assert no link highlights
924 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
925 struct A;
926 let variable = A;
927 "});
928
929 cx.simulate_click(screen_coord.unwrap(), modifiers);
930
931 cx.assert_editor_state(indoc! {"
932 struct «Aˇ»;
933 let variable = A;
934 "});
935 }
936
937 #[gpui::test]
938 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
939 init_test(cx, |_| {});
940
941 let mut cx = EditorLspTestContext::new_rust(
942 lsp::ServerCapabilities {
943 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
944 definition_provider: Some(lsp::OneOf::Left(true)),
945 ..Default::default()
946 },
947 cx,
948 )
949 .await;
950
951 cx.set_state(indoc! {"
952 fn ˇtest() { do_work(); }
953 fn do_work() { test(); }
954 "});
955
956 // Basic hold cmd, expect highlight in region if response contains definition
957 let hover_point = cx.pixel_position(indoc! {"
958 fn test() { do_wˇork(); }
959 fn do_work() { test(); }
960 "});
961 let symbol_range = cx.lsp_range(indoc! {"
962 fn test() { «do_work»(); }
963 fn do_work() { test(); }
964 "});
965 let target_range = cx.lsp_range(indoc! {"
966 fn test() { do_work(); }
967 fn «do_work»() { test(); }
968 "});
969
970 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
971 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
972 lsp::LocationLink {
973 origin_selection_range: Some(symbol_range),
974 target_uri: url.clone(),
975 target_range,
976 target_selection_range: target_range,
977 },
978 ])))
979 });
980
981 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
982 requests.next().await;
983 cx.background_executor.run_until_parked();
984 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
985 fn test() { «do_work»(); }
986 fn do_work() { test(); }
987 "});
988
989 // Unpress cmd causes highlight to go away
990 cx.simulate_modifiers_change(Modifiers::none());
991 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
992 fn test() { do_work(); }
993 fn do_work() { test(); }
994 "});
995
996 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
997 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
998 lsp::LocationLink {
999 origin_selection_range: Some(symbol_range),
1000 target_uri: url.clone(),
1001 target_range,
1002 target_selection_range: target_range,
1003 },
1004 ])))
1005 });
1006
1007 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1008 requests.next().await;
1009 cx.background_executor.run_until_parked();
1010 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1011 fn test() { «do_work»(); }
1012 fn do_work() { test(); }
1013 "});
1014
1015 // Moving mouse to location with no response dismisses highlight
1016 let hover_point = cx.pixel_position(indoc! {"
1017 fˇn test() { do_work(); }
1018 fn do_work() { test(); }
1019 "});
1020 let mut requests = cx
1021 .lsp
1022 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
1023 // No definitions returned
1024 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1025 });
1026 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1027
1028 requests.next().await;
1029 cx.background_executor.run_until_parked();
1030
1031 // Assert no link highlights
1032 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1033 fn test() { do_work(); }
1034 fn do_work() { test(); }
1035 "});
1036
1037 // // Move mouse without cmd and then pressing cmd triggers highlight
1038 let hover_point = cx.pixel_position(indoc! {"
1039 fn test() { do_work(); }
1040 fn do_work() { teˇst(); }
1041 "});
1042 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1043
1044 // Assert no link highlights
1045 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1046 fn test() { do_work(); }
1047 fn do_work() { test(); }
1048 "});
1049
1050 let symbol_range = cx.lsp_range(indoc! {"
1051 fn test() { do_work(); }
1052 fn do_work() { «test»(); }
1053 "});
1054 let target_range = cx.lsp_range(indoc! {"
1055 fn «test»() { do_work(); }
1056 fn do_work() { test(); }
1057 "});
1058
1059 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1060 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1061 lsp::LocationLink {
1062 origin_selection_range: Some(symbol_range),
1063 target_uri: url,
1064 target_range,
1065 target_selection_range: target_range,
1066 },
1067 ])))
1068 });
1069
1070 cx.simulate_modifiers_change(Modifiers::secondary_key());
1071
1072 requests.next().await;
1073 cx.background_executor.run_until_parked();
1074
1075 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1076 fn test() { do_work(); }
1077 fn do_work() { «test»(); }
1078 "});
1079
1080 cx.deactivate_window();
1081 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1082 fn test() { do_work(); }
1083 fn do_work() { test(); }
1084 "});
1085
1086 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1087 cx.background_executor.run_until_parked();
1088 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1089 fn test() { do_work(); }
1090 fn do_work() { «test»(); }
1091 "});
1092
1093 // Moving again within the same symbol range doesn't re-request
1094 let hover_point = cx.pixel_position(indoc! {"
1095 fn test() { do_work(); }
1096 fn do_work() { tesˇt(); }
1097 "});
1098 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1099 cx.background_executor.run_until_parked();
1100 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1101 fn test() { do_work(); }
1102 fn do_work() { «test»(); }
1103 "});
1104
1105 // Cmd click with existing definition doesn't re-request and dismisses highlight
1106 cx.simulate_click(hover_point, Modifiers::secondary_key());
1107 cx.lsp
1108 .handle_request::<GotoDefinition, _, _>(move |_, _| async move {
1109 // Empty definition response to make sure we aren't hitting the lsp and using
1110 // the cached location instead
1111 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1112 });
1113 cx.background_executor.run_until_parked();
1114 cx.assert_editor_state(indoc! {"
1115 fn «testˇ»() { do_work(); }
1116 fn do_work() { test(); }
1117 "});
1118
1119 // Assert no link highlights after jump
1120 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1121 fn test() { do_work(); }
1122 fn do_work() { test(); }
1123 "});
1124
1125 // Cmd click without existing definition requests and jumps
1126 let hover_point = cx.pixel_position(indoc! {"
1127 fn test() { do_wˇork(); }
1128 fn do_work() { test(); }
1129 "});
1130 let target_range = cx.lsp_range(indoc! {"
1131 fn test() { do_work(); }
1132 fn «do_work»() { test(); }
1133 "});
1134
1135 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1136 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1137 lsp::LocationLink {
1138 origin_selection_range: None,
1139 target_uri: url,
1140 target_range,
1141 target_selection_range: target_range,
1142 },
1143 ])))
1144 });
1145 cx.simulate_click(hover_point, Modifiers::secondary_key());
1146 requests.next().await;
1147 cx.background_executor.run_until_parked();
1148 cx.assert_editor_state(indoc! {"
1149 fn test() { do_work(); }
1150 fn «do_workˇ»() { test(); }
1151 "});
1152
1153 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1154 // 2. Selection is completed, hovering
1155 let hover_point = cx.pixel_position(indoc! {"
1156 fn test() { do_wˇork(); }
1157 fn do_work() { test(); }
1158 "});
1159 let target_range = cx.lsp_range(indoc! {"
1160 fn test() { do_work(); }
1161 fn «do_work»() { test(); }
1162 "});
1163 let mut requests = cx.handle_request::<GotoDefinition, _, _>(move |url, _, _| async move {
1164 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1165 lsp::LocationLink {
1166 origin_selection_range: None,
1167 target_uri: url,
1168 target_range,
1169 target_selection_range: target_range,
1170 },
1171 ])))
1172 });
1173
1174 // create a pending selection
1175 let selection_range = cx.ranges(indoc! {"
1176 fn «test() { do_w»ork(); }
1177 fn do_work() { test(); }
1178 "})[0]
1179 .clone();
1180 cx.update_editor(|editor, cx| {
1181 let snapshot = editor.buffer().read(cx).snapshot(cx);
1182 let anchor_range = snapshot.anchor_before(selection_range.start)
1183 ..snapshot.anchor_after(selection_range.end);
1184 editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| {
1185 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1186 });
1187 });
1188 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1189 cx.background_executor.run_until_parked();
1190 assert!(requests.try_next().is_err());
1191 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1192 fn test() { do_work(); }
1193 fn do_work() { test(); }
1194 "});
1195 cx.background_executor.run_until_parked();
1196 }
1197
1198 #[gpui::test]
1199 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1200 init_test(cx, |settings| {
1201 settings.defaults.inlay_hints = Some(InlayHintSettings {
1202 enabled: true,
1203 edit_debounce_ms: 0,
1204 scroll_debounce_ms: 0,
1205 show_type_hints: true,
1206 show_parameter_hints: true,
1207 show_other_hints: true,
1208 show_background: false,
1209 })
1210 });
1211
1212 let mut cx = EditorLspTestContext::new_rust(
1213 lsp::ServerCapabilities {
1214 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1215 ..Default::default()
1216 },
1217 cx,
1218 )
1219 .await;
1220 cx.set_state(indoc! {"
1221 struct TestStruct;
1222
1223 fn main() {
1224 let variableˇ = TestStruct;
1225 }
1226 "});
1227 let hint_start_offset = cx.ranges(indoc! {"
1228 struct TestStruct;
1229
1230 fn main() {
1231 let variableˇ = TestStruct;
1232 }
1233 "})[0]
1234 .start;
1235 let hint_position = cx.to_lsp(hint_start_offset);
1236 let target_range = cx.lsp_range(indoc! {"
1237 struct «TestStruct»;
1238
1239 fn main() {
1240 let variable = TestStruct;
1241 }
1242 "});
1243
1244 let expected_uri = cx.buffer_lsp_url.clone();
1245 let hint_label = ": TestStruct";
1246 cx.lsp
1247 .handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1248 let expected_uri = expected_uri.clone();
1249 async move {
1250 assert_eq!(params.text_document.uri, expected_uri);
1251 Ok(Some(vec![lsp::InlayHint {
1252 position: hint_position,
1253 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1254 value: hint_label.to_string(),
1255 location: Some(lsp::Location {
1256 uri: params.text_document.uri,
1257 range: target_range,
1258 }),
1259 ..Default::default()
1260 }]),
1261 kind: Some(lsp::InlayHintKind::TYPE),
1262 text_edits: None,
1263 tooltip: None,
1264 padding_left: Some(false),
1265 padding_right: Some(false),
1266 data: None,
1267 }]))
1268 }
1269 })
1270 .next()
1271 .await;
1272 cx.background_executor.run_until_parked();
1273 cx.update_editor(|editor, cx| {
1274 let expected_layers = vec![hint_label.to_string()];
1275 assert_eq!(expected_layers, cached_hint_labels(editor));
1276 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1277 });
1278
1279 let inlay_range = cx
1280 .ranges(indoc! {"
1281 struct TestStruct;
1282
1283 fn main() {
1284 let variable« »= TestStruct;
1285 }
1286 "})
1287 .first()
1288 .cloned()
1289 .unwrap();
1290 let midpoint = cx.update_editor(|editor, cx| {
1291 let snapshot = editor.snapshot(cx);
1292 let previous_valid = inlay_range.start.to_display_point(&snapshot);
1293 let next_valid = inlay_range.end.to_display_point(&snapshot);
1294 assert_eq!(previous_valid.row(), next_valid.row());
1295 assert!(previous_valid.column() < next_valid.column());
1296 DisplayPoint::new(
1297 previous_valid.row(),
1298 previous_valid.column() + (hint_label.len() / 2) as u32,
1299 )
1300 });
1301 // Press cmd to trigger highlight
1302 let hover_point = cx.pixel_position_for(midpoint);
1303 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1304 cx.background_executor.run_until_parked();
1305 cx.update_editor(|editor, cx| {
1306 let snapshot = editor.snapshot(cx);
1307 let actual_highlights = snapshot
1308 .inlay_highlights::<HoveredLinkState>()
1309 .into_iter()
1310 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1311 .collect::<Vec<_>>();
1312
1313 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1314 let expected_highlight = InlayHighlight {
1315 inlay: InlayId::Hint(0),
1316 inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right),
1317 range: 0..hint_label.len(),
1318 };
1319 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1320 });
1321
1322 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1323 // Assert no link highlights
1324 cx.update_editor(|editor, cx| {
1325 let snapshot = editor.snapshot(cx);
1326 let actual_ranges = snapshot
1327 .text_highlight_ranges::<HoveredLinkState>()
1328 .map(|ranges| ranges.as_ref().clone().1)
1329 .unwrap_or_default();
1330
1331 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1332 });
1333
1334 cx.simulate_modifiers_change(Modifiers::secondary_key());
1335 cx.background_executor.run_until_parked();
1336 cx.simulate_click(hover_point, Modifiers::secondary_key());
1337 cx.background_executor.run_until_parked();
1338 cx.assert_editor_state(indoc! {"
1339 struct «TestStructˇ»;
1340
1341 fn main() {
1342 let variable = TestStruct;
1343 }
1344 "});
1345 }
1346
1347 #[gpui::test]
1348 async fn test_urls(cx: &mut gpui::TestAppContext) {
1349 init_test(cx, |_| {});
1350 let mut cx = EditorLspTestContext::new_rust(
1351 lsp::ServerCapabilities {
1352 ..Default::default()
1353 },
1354 cx,
1355 )
1356 .await;
1357
1358 cx.set_state(indoc! {"
1359 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1360 "});
1361
1362 let screen_coord = cx.pixel_position(indoc! {"
1363 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1364 "});
1365
1366 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1367 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1368 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1369 "});
1370
1371 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1372 assert_eq!(
1373 cx.opened_url(),
1374 Some("https://zed.dev/channel/had-(oops)".into())
1375 );
1376 }
1377
1378 #[gpui::test]
1379 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1380 init_test(cx, |_| {});
1381 let mut cx = EditorLspTestContext::new_rust(
1382 lsp::ServerCapabilities {
1383 ..Default::default()
1384 },
1385 cx,
1386 )
1387 .await;
1388
1389 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1390
1391 let screen_coord =
1392 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1393
1394 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1395 cx.assert_editor_text_highlights::<HoveredLinkState>(
1396 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1397 );
1398
1399 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1400 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1401 }
1402
1403 #[gpui::test]
1404 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1405 init_test(cx, |_| {});
1406 let mut cx = EditorLspTestContext::new_rust(
1407 lsp::ServerCapabilities {
1408 ..Default::default()
1409 },
1410 cx,
1411 )
1412 .await;
1413
1414 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1415
1416 let screen_coord =
1417 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1418
1419 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1420 cx.assert_editor_text_highlights::<HoveredLinkState>(
1421 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1422 );
1423
1424 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1425 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1426 }
1427
1428 #[gpui::test]
1429 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1430 init_test(cx, |_| {});
1431 let mut cx = EditorLspTestContext::new_rust(
1432 lsp::ServerCapabilities {
1433 ..Default::default()
1434 },
1435 cx,
1436 )
1437 .await;
1438
1439 let test_cases = [
1440 ("file ˇ name", None),
1441 ("ˇfile name", Some("file")),
1442 ("file ˇname", Some("name")),
1443 ("fiˇle name", Some("file")),
1444 ("filenˇame", Some("filename")),
1445 // Absolute path
1446 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1447 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1448 // Windows
1449 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1450 // Whitespace
1451 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1452 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1453 // Tilde
1454 ("ˇ~/file.txt", Some("~/file.txt")),
1455 ("~/fiˇle.txt", Some("~/file.txt")),
1456 // Double quotes
1457 ("\"fˇile.txt\"", Some("file.txt")),
1458 ("ˇ\"file.txt\"", Some("file.txt")),
1459 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1460 // Single quotes
1461 ("'fˇile.txt'", Some("file.txt")),
1462 ("ˇ'file.txt'", Some("file.txt")),
1463 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1464 ];
1465
1466 for (input, expected) in test_cases {
1467 cx.set_state(input);
1468
1469 let (position, snapshot) = cx.editor(|editor, cx| {
1470 let positions = editor.selections.newest_anchor().head().text_anchor;
1471 let snapshot = editor
1472 .buffer()
1473 .clone()
1474 .read(cx)
1475 .as_singleton()
1476 .unwrap()
1477 .read(cx)
1478 .snapshot();
1479 (positions, snapshot)
1480 });
1481
1482 let result = surrounding_filename(snapshot, position);
1483
1484 if let Some(expected) = expected {
1485 assert!(result.is_some(), "Failed to find file path: {}", input);
1486 let (_, path) = result.unwrap();
1487 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1488 } else {
1489 assert!(
1490 result.is_none(),
1491 "Expected no result, but got one: {:?}",
1492 result
1493 );
1494 }
1495 }
1496 }
1497
1498 #[gpui::test]
1499 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1500 init_test(cx, |_| {});
1501 let mut cx = EditorLspTestContext::new_rust(
1502 lsp::ServerCapabilities {
1503 ..Default::default()
1504 },
1505 cx,
1506 )
1507 .await;
1508
1509 // Insert a new file
1510 let fs = cx.update_workspace(|workspace, cx| workspace.project().read(cx).fs().clone());
1511 fs.as_fake()
1512 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1513 .await;
1514
1515 cx.set_state(indoc! {"
1516 You can't go to a file that does_not_exist.txt.
1517 Go to file2.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 Or go to /root/dir/file2 if this is a Rust file.ˇ
1521 "});
1522
1523 // File does not exist
1524 let screen_coord = cx.pixel_position(indoc! {"
1525 You can't go to a file that dˇoes_not_exist.txt.
1526 Go to file2.rs if you want.
1527 Or go to ../dir/file2.rs if you want.
1528 Or go to /root/dir/file2.rs if project is local.
1529 Or go to /root/dir/file2 if this is a Rust file.
1530 "});
1531 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1532 // No highlight
1533 cx.update_editor(|editor, cx| {
1534 assert!(editor
1535 .snapshot(cx)
1536 .text_highlight_ranges::<HoveredLinkState>()
1537 .unwrap_or_default()
1538 .1
1539 .is_empty());
1540 });
1541
1542 // Moving the mouse over a file 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 fˇile2.rs if you want.
1546 Or go to ../dir/file2.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 a relative 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/fˇile2.rs if you want.
1565 Or go to /root/dir/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 an absolute path that does exist 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/diˇr/file2.rs if project is local.
1584 Or go to /root/dir/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 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1597 let screen_coord = cx.pixel_position(indoc! {"
1598 You can't go to a file that does_not_exist.txt.
1599 Go to file2.rs if you want.
1600 Or go to ../dir/file2.rs if you want.
1601 Or go to /root/dir/file2.rs if project is local.
1602 Or go to /root/diˇr/file2 if this is a Rust file.
1603 "});
1604
1605 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1606 cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1607 You can't go to a file that does_not_exist.txt.
1608 Go to file2.rs if you want.
1609 Or go to ../dir/file2.rs if you want.
1610 Or go to /root/dir/file2.rs if project is local.
1611 Or go to «/root/dir/file2ˇ» if this is a Rust file.
1612 "});
1613
1614 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1615
1616 cx.update_workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 2));
1617 cx.update_workspace(|workspace, cx| {
1618 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1619
1620 let buffer = active_editor
1621 .read(cx)
1622 .buffer()
1623 .read(cx)
1624 .as_singleton()
1625 .unwrap();
1626
1627 let file = buffer.read(cx).file().unwrap();
1628 let file_path = file.as_local().unwrap().abs_path(cx);
1629
1630 assert_eq!(file_path.to_str().unwrap(), "/root/dir/file2.rs");
1631 });
1632 }
1633}