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