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