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 .snapshot(cx)
241 .anchor_in_excerpt(anchor)
242 else {
243 return Task::ready(Ok(Navigated::No));
244 };
245 let links = hovered_link_state
246 .links
247 .into_iter()
248 .filter(|link| {
249 if let HoverLink::Text(location) = link {
250 exclude_link_to_position(&buffer, &anchor, location, cx)
251 } else {
252 true
253 }
254 })
255 .collect();
256 let nav_entry = self.navigation_entry(mb_anchor, cx);
257 let split = Self::is_alt_pressed(&modifiers, cx);
258 let navigate_task =
259 self.navigate_to_hover_links(None, links, nav_entry, split, window, cx);
260 self.select(SelectPhase::End, window, cx);
261 return navigate_task;
262 }
263 }
264
265 // We don't have the correct kind of link cached, set the selection on
266 // click and immediately trigger GoToDefinition.
267 self.select(
268 SelectPhase::Begin {
269 position: point.next_valid,
270 add: false,
271 click_count: 1,
272 },
273 window,
274 cx,
275 );
276
277 let navigate_task = if point.as_valid().is_some() {
278 let split = Self::is_alt_pressed(&modifiers, cx);
279 match (modifiers.shift, split) {
280 (true, true) => {
281 self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx)
282 }
283 (true, false) => self.go_to_type_definition(&GoToTypeDefinition, window, cx),
284 (false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx),
285 (false, false) => self.go_to_definition(&GoToDefinition, window, cx),
286 }
287 } else {
288 Task::ready(Ok(Navigated::No))
289 };
290 self.select(SelectPhase::End, window, cx);
291 navigate_task
292 }
293}
294
295pub fn show_link_definition(
296 shift_held: bool,
297 editor: &mut Editor,
298 trigger_point: TriggerPoint,
299 snapshot: &EditorSnapshot,
300 window: &mut Window,
301 cx: &mut Context<Editor>,
302) {
303 let preferred_kind = match trigger_point {
304 TriggerPoint::Text(_) if !shift_held => GotoDefinitionKind::Symbol,
305 _ => GotoDefinitionKind::Type,
306 };
307
308 let (mut hovered_link_state, is_cached) =
309 if let Some(existing) = editor.hovered_link_state.take() {
310 (existing, true)
311 } else {
312 (
313 HoveredLinkState {
314 last_trigger_point: trigger_point.clone(),
315 symbol_range: None,
316 preferred_kind,
317 links: vec![],
318 task: None,
319 },
320 false,
321 )
322 };
323
324 if editor.pending_rename.is_some() {
325 return;
326 }
327
328 let anchor = trigger_point.anchor().bias_left(snapshot.buffer_snapshot());
329 let Some((anchor, _)) = snapshot.buffer_snapshot().anchor_to_buffer_anchor(anchor) else {
330 return;
331 };
332 let Some(buffer) = editor.buffer.read(cx).buffer(anchor.buffer_id) else {
333 return;
334 };
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, anchor, cx.clone()) {
365 this.read_with(cx, |_, _| {
366 let range = maybe!({
367 let range =
368 snapshot.buffer_anchor_range_to_anchor_range(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(), anchor, cx).await
376 {
377 let range = maybe!({
378 let range =
379 snapshot.buffer_anchor_range_to_anchor_range(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, 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
394 .buffer_anchor_range_to_anchor_range(
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, HideMouseCursorOrigin,
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_recv().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_hover_preconditions(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 macro_rules! assert_no_highlight {
1377 ($cx:expr) => {
1378 // No highlight
1379 $cx.update_editor(|editor, window, cx| {
1380 assert!(
1381 editor
1382 .snapshot(window, cx)
1383 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1384 .unwrap_or_default()
1385 .1
1386 .is_empty()
1387 );
1388 });
1389 };
1390 }
1391
1392 // No link
1393 cx.set_state(indoc! {"
1394 Let's test a [complex](https://zed.dev/channel/) caseˇ.
1395 "});
1396 assert_no_highlight!(cx);
1397
1398 // No modifier
1399 let screen_coord = cx.pixel_position(indoc! {"
1400 Let's test a [complex](https://zed.dev/channel/ˇ) case.
1401 "});
1402 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
1403 assert_no_highlight!(cx);
1404
1405 // Modifier active
1406 let screen_coord = cx.pixel_position(indoc! {"
1407 Let's test a [complex](https://zed.dev/channeˇl/) case.
1408 "});
1409 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1410 cx.assert_editor_text_highlights(
1411 HighlightKey::HoveredLinkState,
1412 indoc! {"
1413 Let's test a [complex](«https://zed.dev/channel/ˇ») case.
1414 "},
1415 );
1416
1417 // Cursor hidden with secondary key
1418 let screen_coord = cx.pixel_position(indoc! {"
1419 Let's test a [complex](https://zed.dev/ˇchannel/) case.
1420 "});
1421 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
1422 cx.update_editor(|editor, _, cx| {
1423 editor.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx);
1424 });
1425 cx.simulate_modifiers_change(Modifiers::secondary_key());
1426 assert_no_highlight!(cx);
1427
1428 // Cursor active again
1429 let screen_coord = cx.pixel_position(indoc! {"
1430 Let's test a [complex](https://ˇzed.dev/channel/) case.
1431 "});
1432 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1433 cx.assert_editor_text_highlights(
1434 HighlightKey::HoveredLinkState,
1435 indoc! {"
1436 Let's test a [complex](«https://zed.dev/channel/ˇ») case.
1437 "},
1438 );
1439 }
1440
1441 #[gpui::test]
1442 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1443 init_test(cx, |_| {});
1444 let mut cx = EditorLspTestContext::new_rust(
1445 lsp::ServerCapabilities {
1446 ..Default::default()
1447 },
1448 cx,
1449 )
1450 .await;
1451
1452 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1453
1454 let screen_coord =
1455 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1456
1457 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1458 cx.assert_editor_text_highlights(
1459 HighlightKey::HoveredLinkState,
1460 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1461 );
1462
1463 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1464 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1465 }
1466
1467 #[gpui::test]
1468 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1469 init_test(cx, |_| {});
1470 let mut cx = EditorLspTestContext::new_rust(
1471 lsp::ServerCapabilities {
1472 ..Default::default()
1473 },
1474 cx,
1475 )
1476 .await;
1477
1478 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1479
1480 let screen_coord =
1481 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1482
1483 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1484 cx.assert_editor_text_highlights(
1485 HighlightKey::HoveredLinkState,
1486 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1487 );
1488
1489 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1490 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1491 }
1492
1493 #[test]
1494 fn test_link_pattern_file_candidates() {
1495 let candidates: Vec<String> = link_pattern_file_candidates("[LinkTitle](link_file.txt)")
1496 .into_iter()
1497 .map(|(c, _)| c)
1498 .collect();
1499 assert_eq!(
1500 candidates,
1501 vec", "link_file.txt",]
1502 );
1503 // Link title with spaces in it
1504 let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link_file.txt)")
1505 .into_iter()
1506 .map(|(c, _)| c)
1507 .collect();
1508 assert_eq!(
1509 candidates,
1510 vec", "link_file.txt",]
1511 );
1512
1513 // Link with spaces
1514 let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)")
1515 .into_iter()
1516 .map(|(c, _)| c)
1517 .collect();
1518
1519 assert_eq!(
1520 candidates,
1521 vec", "link\\ _file.txt",]
1522 );
1523 // Parentheses without preceding `]` should not extract inner content,
1524 // to avoid matching function calls like `do_work(file2)` as file paths.
1525 let candidates: Vec<String> = link_pattern_file_candidates("(link_file.txt)")
1526 .into_iter()
1527 .map(|(c, _)| c)
1528 .collect();
1529 assert_eq!(candidates, vec!["(link_file.txt)"]);
1530
1531 let candidates: Vec<String> = link_pattern_file_candidates("do_work(file2);")
1532 .into_iter()
1533 .map(|(c, _)| c)
1534 .collect();
1535 assert_eq!(candidates, vec!["do_work(file2);"]);
1536
1537 // Markdown links should still extract the path
1538 let candidates: Vec<String> = link_pattern_file_candidates("](readme.md)")
1539 .into_iter()
1540 .map(|(c, _)| c)
1541 .collect();
1542 assert_eq!(candidates, vec", "readme.md"]);
1543
1544 // No nesting
1545 let candidates: Vec<String> =
1546 link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)")
1547 .into_iter()
1548 .map(|(c, _)| c)
1549 .collect();
1550
1551 assert_eq!(
1552 candidates,
1553 vecfile.txt)", "link_(link_file",]
1554 )
1555 }
1556
1557 #[gpui::test]
1558 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1559 init_test(cx, |_| {});
1560 let mut cx = EditorLspTestContext::new_rust(
1561 lsp::ServerCapabilities {
1562 ..Default::default()
1563 },
1564 cx,
1565 )
1566 .await;
1567
1568 let test_cases = [
1569 ("file ˇ name", None),
1570 ("ˇfile name", Some("file")),
1571 ("file ˇname", Some("name")),
1572 ("fiˇle name", Some("file")),
1573 ("filenˇame", Some("filename")),
1574 // Absolute path
1575 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1576 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1577 // Windows
1578 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1579 // Whitespace
1580 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1581 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1582 // Tilde
1583 ("ˇ~/file.txt", Some("~/file.txt")),
1584 ("~/fiˇle.txt", Some("~/file.txt")),
1585 // Double quotes
1586 ("\"fˇile.txt\"", Some("file.txt")),
1587 ("ˇ\"file.txt\"", Some("file.txt")),
1588 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1589 // Single quotes
1590 ("'fˇile.txt'", Some("file.txt")),
1591 ("ˇ'file.txt'", Some("file.txt")),
1592 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1593 // Quoted multibyte characters
1594 (" ˇ\"常\"", Some("常")),
1595 (" \"ˇ常\"", Some("常")),
1596 ("ˇ\"常\"", Some("常")),
1597 ];
1598
1599 for (input, expected) in test_cases {
1600 cx.set_state(input);
1601
1602 let (position, snapshot) = cx.editor(|editor, _, cx| {
1603 let positions = editor
1604 .selections
1605 .newest_anchor()
1606 .head()
1607 .expect_text_anchor();
1608 let snapshot = editor
1609 .buffer()
1610 .clone()
1611 .read(cx)
1612 .as_singleton()
1613 .unwrap()
1614 .read(cx)
1615 .snapshot();
1616 (positions, snapshot)
1617 });
1618
1619 let result = surrounding_filename(&snapshot, position);
1620
1621 if let Some(expected) = expected {
1622 assert!(result.is_some(), "Failed to find file path: {}", input);
1623 let (_, path) = result.unwrap();
1624 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1625 } else {
1626 assert!(
1627 result.is_none(),
1628 "Expected no result, but got one: {:?}",
1629 result
1630 );
1631 }
1632 }
1633 }
1634
1635 #[gpui::test]
1636 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1637 init_test(cx, |_| {});
1638 let mut cx = EditorLspTestContext::new_rust(
1639 lsp::ServerCapabilities {
1640 ..Default::default()
1641 },
1642 cx,
1643 )
1644 .await;
1645
1646 // Insert a new file
1647 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1648 fs.as_fake()
1649 .insert_file(
1650 path!("/root/dir/file2.rs"),
1651 "This is file2.rs".as_bytes().to_vec(),
1652 )
1653 .await;
1654
1655 #[cfg(not(target_os = "windows"))]
1656 cx.set_state(indoc! {"
1657 You can't go to a file that does_not_exist.txt.
1658 Go to file2.rs if you want.
1659 Or go to ../dir/file2.rs if you want.
1660 Or go to /root/dir/file2.rs if project is local.
1661 Or go to /root/dir/file2 if this is a Rust file.ˇ
1662 "});
1663 #[cfg(target_os = "windows")]
1664 cx.set_state(indoc! {"
1665 You can't go to a file that does_not_exist.txt.
1666 Go to file2.rs if you want.
1667 Or go to ../dir/file2.rs if you want.
1668 Or go to C:/root/dir/file2.rs if project is local.
1669 Or go to C:/root/dir/file2 if this is a Rust file.ˇ
1670 "});
1671
1672 // File does not exist
1673 #[cfg(not(target_os = "windows"))]
1674 let screen_coord = cx.pixel_position(indoc! {"
1675 You can't go to a file that dˇoes_not_exist.txt.
1676 Go to file2.rs if you want.
1677 Or go to ../dir/file2.rs if you want.
1678 Or go to /root/dir/file2.rs if project is local.
1679 Or go to /root/dir/file2 if this is a Rust file.
1680 "});
1681 #[cfg(target_os = "windows")]
1682 let screen_coord = cx.pixel_position(indoc! {"
1683 You can't go to a file that dˇoes_not_exist.txt.
1684 Go to file2.rs if you want.
1685 Or go to ../dir/file2.rs if you want.
1686 Or go to C:/root/dir/file2.rs if project is local.
1687 Or go to C:/root/dir/file2 if this is a Rust file.
1688 "});
1689 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1690 // No highlight
1691 cx.update_editor(|editor, window, cx| {
1692 assert!(
1693 editor
1694 .snapshot(window, cx)
1695 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1696 .unwrap_or_default()
1697 .1
1698 .is_empty()
1699 );
1700 });
1701
1702 // Moving the mouse over a file that does exist should highlight it.
1703 #[cfg(not(target_os = "windows"))]
1704 let screen_coord = cx.pixel_position(indoc! {"
1705 You can't go to a file that does_not_exist.txt.
1706 Go to fˇile2.rs if you want.
1707 Or go to ../dir/file2.rs if you want.
1708 Or go to /root/dir/file2.rs if project is local.
1709 Or go to /root/dir/file2 if this is a Rust file.
1710 "});
1711 #[cfg(target_os = "windows")]
1712 let screen_coord = cx.pixel_position(indoc! {"
1713 You can't go to a file that does_not_exist.txt.
1714 Go to fˇile2.rs if you want.
1715 Or go to ../dir/file2.rs if you want.
1716 Or go to C:/root/dir/file2.rs if project is local.
1717 Or go to C:/root/dir/file2 if this is a Rust file.
1718 "});
1719
1720 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1721 #[cfg(not(target_os = "windows"))]
1722 cx.assert_editor_text_highlights(
1723 HighlightKey::HoveredLinkState,
1724 indoc! {"
1725 You can't go to a file that does_not_exist.txt.
1726 Go to «file2.rsˇ» if you want.
1727 Or go to ../dir/file2.rs if you want.
1728 Or go to /root/dir/file2.rs if project is local.
1729 Or go to /root/dir/file2 if this is a Rust file.
1730 "},
1731 );
1732 #[cfg(target_os = "windows")]
1733 cx.assert_editor_text_highlights(
1734 HighlightKey::HoveredLinkState,
1735 indoc! {"
1736 You can't go to a file that does_not_exist.txt.
1737 Go to «file2.rsˇ» if you want.
1738 Or go to ../dir/file2.rs if you want.
1739 Or go to C:/root/dir/file2.rs if project is local.
1740 Or go to C:/root/dir/file2 if this is a Rust file.
1741 "},
1742 );
1743
1744 // Moving the mouse over a relative path that does exist should highlight it
1745 #[cfg(not(target_os = "windows"))]
1746 let screen_coord = cx.pixel_position(indoc! {"
1747 You can't go to a file that does_not_exist.txt.
1748 Go to file2.rs if you want.
1749 Or go to ../dir/fˇile2.rs if you want.
1750 Or go to /root/dir/file2.rs if project is local.
1751 Or go to /root/dir/file2 if this is a Rust file.
1752 "});
1753 #[cfg(target_os = "windows")]
1754 let screen_coord = cx.pixel_position(indoc! {"
1755 You can't go to a file that does_not_exist.txt.
1756 Go to file2.rs if you want.
1757 Or go to ../dir/fˇile2.rs if you want.
1758 Or go to C:/root/dir/file2.rs if project is local.
1759 Or go to C:/root/dir/file2 if this is a Rust file.
1760 "});
1761
1762 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1763 #[cfg(not(target_os = "windows"))]
1764 cx.assert_editor_text_highlights(
1765 HighlightKey::HoveredLinkState,
1766 indoc! {"
1767 You can't go to a file that does_not_exist.txt.
1768 Go to file2.rs if you want.
1769 Or go to «../dir/file2.rsˇ» if you want.
1770 Or go to /root/dir/file2.rs if project is local.
1771 Or go to /root/dir/file2 if this is a Rust file.
1772 "},
1773 );
1774 #[cfg(target_os = "windows")]
1775 cx.assert_editor_text_highlights(
1776 HighlightKey::HoveredLinkState,
1777 indoc! {"
1778 You can't go to a file that does_not_exist.txt.
1779 Go to file2.rs if you want.
1780 Or go to «../dir/file2.rsˇ» if you want.
1781 Or go to C:/root/dir/file2.rs if project is local.
1782 Or go to C:/root/dir/file2 if this is a Rust file.
1783 "},
1784 );
1785
1786 // Moving the mouse over an absolute path that does exist should highlight it
1787 #[cfg(not(target_os = "windows"))]
1788 let screen_coord = cx.pixel_position(indoc! {"
1789 You can't go to a file that does_not_exist.txt.
1790 Go to file2.rs if you want.
1791 Or go to ../dir/file2.rs if you want.
1792 Or go to /root/diˇr/file2.rs if project is local.
1793 Or go to /root/dir/file2 if this is a Rust file.
1794 "});
1795
1796 #[cfg(target_os = "windows")]
1797 let screen_coord = cx.pixel_position(indoc! {"
1798 You can't go to a file that does_not_exist.txt.
1799 Go to file2.rs if you want.
1800 Or go to ../dir/file2.rs if you want.
1801 Or go to C:/root/diˇr/file2.rs if project is local.
1802 Or go to C:/root/dir/file2 if this is a Rust file.
1803 "});
1804
1805 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1806 #[cfg(not(target_os = "windows"))]
1807 cx.assert_editor_text_highlights(
1808 HighlightKey::HoveredLinkState,
1809 indoc! {"
1810 You can't go to a file that does_not_exist.txt.
1811 Go to file2.rs if you want.
1812 Or go to ../dir/file2.rs if you want.
1813 Or go to «/root/dir/file2.rsˇ» if project is local.
1814 Or go to /root/dir/file2 if this is a Rust file.
1815 "},
1816 );
1817 #[cfg(target_os = "windows")]
1818 cx.assert_editor_text_highlights(
1819 HighlightKey::HoveredLinkState,
1820 indoc! {"
1821 You can't go to a file that does_not_exist.txt.
1822 Go to file2.rs if you want.
1823 Or go to ../dir/file2.rs if you want.
1824 Or go to «C:/root/dir/file2.rsˇ» if project is local.
1825 Or go to C:/root/dir/file2 if this is a Rust file.
1826 "},
1827 );
1828
1829 // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1830 #[cfg(not(target_os = "windows"))]
1831 let screen_coord = cx.pixel_position(indoc! {"
1832 You can't go to a file that does_not_exist.txt.
1833 Go to file2.rs if you want.
1834 Or go to ../dir/file2.rs if you want.
1835 Or go to /root/dir/file2.rs if project is local.
1836 Or go to /root/diˇr/file2 if this is a Rust file.
1837 "});
1838 #[cfg(target_os = "windows")]
1839 let screen_coord = cx.pixel_position(indoc! {"
1840 You can't go to a file that does_not_exist.txt.
1841 Go to file2.rs if you want.
1842 Or go to ../dir/file2.rs if you want.
1843 Or go to C:/root/dir/file2.rs if project is local.
1844 Or go to C:/root/diˇr/file2 if this is a Rust file.
1845 "});
1846
1847 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1848 #[cfg(not(target_os = "windows"))]
1849 cx.assert_editor_text_highlights(
1850 HighlightKey::HoveredLinkState,
1851 indoc! {"
1852 You can't go to a file that does_not_exist.txt.
1853 Go to file2.rs if you want.
1854 Or go to ../dir/file2.rs if you want.
1855 Or go to /root/dir/file2.rs if project is local.
1856 Or go to «/root/dir/file2ˇ» if this is a Rust file.
1857 "},
1858 );
1859 #[cfg(target_os = "windows")]
1860 cx.assert_editor_text_highlights(
1861 HighlightKey::HoveredLinkState,
1862 indoc! {"
1863 You can't go to a file that does_not_exist.txt.
1864 Go to file2.rs if you want.
1865 Or go to ../dir/file2.rs if you want.
1866 Or go to C:/root/dir/file2.rs if project is local.
1867 Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
1868 "},
1869 );
1870
1871 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1872
1873 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1874 cx.update_workspace(|workspace, _, cx| {
1875 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1876
1877 let buffer = active_editor
1878 .read(cx)
1879 .buffer()
1880 .read(cx)
1881 .as_singleton()
1882 .unwrap();
1883
1884 let file = buffer.read(cx).file().unwrap();
1885 let file_path = file.as_local().unwrap().abs_path(cx);
1886
1887 assert_eq!(
1888 file_path,
1889 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
1890 );
1891 });
1892 }
1893
1894 #[gpui::test]
1895 async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
1896 init_test(cx, |_| {});
1897 let mut cx = EditorLspTestContext::new_rust(
1898 lsp::ServerCapabilities {
1899 ..Default::default()
1900 },
1901 cx,
1902 )
1903 .await;
1904
1905 // Insert a new file
1906 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1907 fs.as_fake()
1908 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1909 .await;
1910
1911 cx.set_state(indoc! {"
1912 You can't open ../diˇr because it's a directory.
1913 "});
1914
1915 // File does not exist
1916 let screen_coord = cx.pixel_position(indoc! {"
1917 You can't open ../diˇr because it's a directory.
1918 "});
1919 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1920
1921 // No highlight
1922 cx.update_editor(|editor, window, cx| {
1923 assert!(
1924 editor
1925 .snapshot(window, cx)
1926 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1927 .unwrap_or_default()
1928 .1
1929 .is_empty()
1930 );
1931 });
1932
1933 // Does not open the directory
1934 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1935 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1936 }
1937
1938 #[gpui::test]
1939 async fn test_hover_unicode(cx: &mut gpui::TestAppContext) {
1940 init_test(cx, |_| {});
1941 let mut cx = EditorLspTestContext::new_rust(
1942 lsp::ServerCapabilities {
1943 ..Default::default()
1944 },
1945 cx,
1946 )
1947 .await;
1948
1949 cx.set_state(indoc! {"
1950 You can't open ˇ\"🤩\" because it's an emoji.
1951 "});
1952
1953 // File does not exist
1954 let screen_coord = cx.pixel_position(indoc! {"
1955 You can't open ˇ\"🤩\" because it's an emoji.
1956 "});
1957 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1958
1959 // No highlight, does not panic...
1960 cx.update_editor(|editor, window, cx| {
1961 assert!(
1962 editor
1963 .snapshot(window, cx)
1964 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1965 .unwrap_or_default()
1966 .1
1967 .is_empty()
1968 );
1969 });
1970
1971 // Does not open the directory
1972 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1973 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1974 }
1975
1976 #[gpui::test]
1977 async fn test_pressure_links(cx: &mut gpui::TestAppContext) {
1978 init_test(cx, |_| {});
1979
1980 let mut cx = EditorLspTestContext::new_rust(
1981 lsp::ServerCapabilities {
1982 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1983 definition_provider: Some(lsp::OneOf::Left(true)),
1984 ..Default::default()
1985 },
1986 cx,
1987 )
1988 .await;
1989
1990 cx.set_state(indoc! {"
1991 fn ˇtest() { do_work(); }
1992 fn do_work() { test(); }
1993 "});
1994
1995 // Position the mouse over a symbol that has a definition
1996 let hover_point = cx.pixel_position(indoc! {"
1997 fn test() { do_wˇork(); }
1998 fn do_work() { test(); }
1999 "});
2000 let symbol_range = cx.lsp_range(indoc! {"
2001 fn test() { «do_work»(); }
2002 fn do_work() { test(); }
2003 "});
2004 let target_range = cx.lsp_range(indoc! {"
2005 fn test() { do_work(); }
2006 fn «do_work»() { test(); }
2007 "});
2008
2009 let mut requests =
2010 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
2011 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
2012 lsp::LocationLink {
2013 origin_selection_range: Some(symbol_range),
2014 target_uri: url.clone(),
2015 target_range,
2016 target_selection_range: target_range,
2017 },
2018 ])))
2019 });
2020
2021 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
2022
2023 // First simulate Normal pressure to set up the previous stage
2024 cx.simulate_event(MousePressureEvent {
2025 pressure: 0.5,
2026 stage: PressureStage::Normal,
2027 position: hover_point,
2028 modifiers: Modifiers::none(),
2029 });
2030 cx.background_executor.run_until_parked();
2031
2032 // Now simulate Force pressure to trigger the force click and go-to definition
2033 cx.simulate_event(MousePressureEvent {
2034 pressure: 1.0,
2035 stage: PressureStage::Force,
2036 position: hover_point,
2037 modifiers: Modifiers::none(),
2038 });
2039 requests.next().await;
2040 cx.background_executor.run_until_parked();
2041
2042 // Assert that we navigated to the definition
2043 cx.assert_editor_state(indoc! {"
2044 fn test() { do_work(); }
2045 fn «do_workˇ»() { test(); }
2046 "});
2047 }
2048}