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