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