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