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