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