Skip to repository content3189 lines · 117.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:33:04.272Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
hover_links.rs
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::{
8 App, AsyncWindowContext, Context, Entity, HighlightStyle, Modifiers, Pixels, Task,
9 UnderlineStyle, Window, px,
10};
11use language::{Bias, ToOffset};
12use linkify::{LinkFinder, LinkKind};
13use lsp::LanguageServerId;
14use project::{InlayId, LocationLink, Project, ResolvedPath};
15use regex::Regex;
16use settings::Settings;
17use std::{ops::Range, str::FromStr as _, sync::LazyLock};
18use text::OffsetRangeExt;
19use theme::ActiveTheme as _;
20use util::{
21 ResultExt, TryFutureExt as _, markdown::source_position_from_fragment, paths::PathWithPosition,
22};
23
24#[derive(Debug)]
25pub struct HoveredLinkState {
26 pub last_trigger_point: TriggerPoint,
27 pub preferred_kind: GotoDefinitionKind,
28 pub symbol_range: Option<RangeInEditor>,
29 pub links: Vec<HoverLink>,
30 pub task: Option<Task<Option<()>>>,
31}
32
33#[derive(Debug, Eq, PartialEq, Clone)]
34pub enum RangeInEditor {
35 Text(Range<Anchor>),
36 Inlay(InlayHighlight),
37}
38
39impl RangeInEditor {
40 pub fn as_text_range(&self) -> Option<Range<Anchor>> {
41 match self {
42 Self::Text(range) => Some(range.clone()),
43 Self::Inlay(_) => None,
44 }
45 }
46
47 pub fn point_within_range(
48 &self,
49 trigger_point: &TriggerPoint,
50 snapshot: &EditorSnapshot,
51 ) -> bool {
52 match (self, trigger_point) {
53 (Self::Text(range), TriggerPoint::Text(point)) => {
54 let point_after_start = range.start.cmp(point, &snapshot.buffer_snapshot()).is_le();
55 point_after_start && range.end.cmp(point, &snapshot.buffer_snapshot()).is_ge()
56 }
57 (Self::Inlay(highlight), TriggerPoint::InlayHint(point, _, _)) => {
58 highlight.inlay == point.inlay
59 && highlight.range.contains(&point.range.start)
60 && highlight.range.contains(&point.range.end)
61 }
62 (Self::Inlay(_), TriggerPoint::Text(_))
63 | (Self::Text(_), TriggerPoint::InlayHint(_, _, _)) => false,
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
69pub enum HoverLink {
70 Url(String),
71 File(ResolvedFileTarget),
72 Text(LocationLink),
73 /// Navigate to an LSP-given location whose buffer may not be loaded yet.
74 /// Used by inlay-hint hover, code-lens references, and document-link
75 /// targets that point inside a workspace file (e.g. `file:///foo#9,16`).
76 LspLocation(lsp::Location, LanguageServerId),
77}
78
79/// Convert a `documentLink` target URI into a [`HoverLink`], reusing the
80/// existing navigation paths: `file://` URIs go through the LSP location
81/// pipeline (so an optional `#line[,column]` fragment is honored), while
82/// any other scheme is opened as a regular URL.
83pub fn document_link_target_to_hover_link(target: &str, server_id: LanguageServerId) -> HoverLink {
84 if let Ok(url) = url::Url::parse(target)
85 && url.scheme() == "file"
86 && let Ok(uri) = lsp::Uri::from_str(target)
87 {
88 let position = url
89 .fragment()
90 .and_then(source_position_from_fragment)
91 .map(|(line, character)| lsp::Position { line, character })
92 .unwrap_or_default();
93 return HoverLink::LspLocation(
94 lsp::Location {
95 uri,
96 range: lsp::Range::new(position, position),
97 },
98 server_id,
99 );
100 }
101 HoverLink::Url(target.to_string())
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct InlayHighlight {
106 pub inlay: InlayId,
107 pub inlay_position: Anchor,
108 pub range: Range<usize>,
109}
110
111#[derive(Debug, Clone, PartialEq)]
112pub enum TriggerPoint {
113 Text(Anchor),
114 InlayHint(InlayHighlight, lsp::Location, LanguageServerId),
115}
116
117impl TriggerPoint {
118 fn anchor(&self) -> &Anchor {
119 match self {
120 TriggerPoint::Text(anchor) => anchor,
121 TriggerPoint::InlayHint(inlay_range, _, _) => &inlay_range.inlay_position,
122 }
123 }
124}
125
126pub fn exclude_link_to_position(
127 buffer: &Entity<language::Buffer>,
128 current_position: &text::Anchor,
129 location: &LocationLink,
130 cx: &App,
131) -> bool {
132 // Exclude definition links that points back to cursor position.
133 // (i.e., currently cursor upon definition).
134 let snapshot = buffer.read(cx).snapshot();
135 !(buffer == &location.target.buffer
136 && current_position
137 .bias_right(&snapshot)
138 .cmp(&location.target.range.start, &snapshot)
139 .is_ge()
140 && current_position
141 .cmp(&location.target.range.end, &snapshot)
142 .is_le())
143}
144
145impl Editor {
146 pub(crate) fn update_hovered_link(
147 &mut self,
148 point_for_position: PointForPosition,
149 mouse_position: Option<gpui::Point<Pixels>>,
150 snapshot: &EditorSnapshot,
151 modifiers: Modifiers,
152 window: &mut Window,
153 cx: &mut Context<Self>,
154 ) {
155 let hovered_link_modifier = Editor::is_cmd_or_ctrl_pressed(&modifiers, cx);
156 if !hovered_link_modifier || self.has_pending_selection() {
157 self.hide_hovered_link(cx);
158 return;
159 }
160
161 if !cx.is_cursor_visible() {
162 self.hide_hovered_link(cx);
163 return;
164 }
165
166 match point_for_position.as_valid() {
167 Some(point) => {
168 let trigger_point = TriggerPoint::Text(
169 snapshot
170 .buffer_snapshot()
171 .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left)),
172 );
173
174 show_link_definition(modifiers.shift, self, trigger_point, snapshot, window, cx);
175 }
176 None => {
177 self.update_inlay_link_and_hover_points(
178 snapshot,
179 point_for_position,
180 mouse_position,
181 hovered_link_modifier,
182 modifiers.shift,
183 window,
184 cx,
185 );
186 }
187 }
188 }
189
190 pub(crate) fn hide_hovered_link(&mut self, cx: &mut Context<Self>) {
191 self.hovered_link_state.take();
192 self.clear_highlights(HighlightKey::HoveredLinkState, cx);
193 }
194
195 pub(crate) fn handle_click_hovered_link(
196 &mut self,
197 point: PointForPosition,
198 modifiers: Modifiers,
199 window: &mut Window,
200 cx: &mut Context<Editor>,
201 ) {
202 let reveal_task = self.cmd_click_reveal_task(point, modifiers, window, cx);
203 cx.spawn_in(window, async move |editor, cx| {
204 let definition_revealed = reveal_task.await.log_err().unwrap_or(Navigated::No);
205 let find_references = editor
206 .update_in(cx, |editor, window, cx| {
207 if definition_revealed == Navigated::Yes {
208 return None;
209 }
210 match EditorSettings::get_global(cx).go_to_definition_fallback {
211 GoToDefinitionFallback::None => None,
212 GoToDefinitionFallback::FindAllReferences => {
213 editor.find_all_references(&FindAllReferences::default(), window, cx)
214 }
215 }
216 })
217 .ok()
218 .flatten();
219 if let Some(find_references) = find_references {
220 find_references.await.log_err();
221 }
222 })
223 .detach();
224 }
225
226 pub fn scroll_hover(
227 &mut self,
228 amount: ScrollAmount,
229 window: &mut Window,
230 cx: &mut Context<Self>,
231 ) -> bool {
232 let selection = self.selections.newest_anchor().head();
233 let snapshot = self.snapshot(window, cx);
234
235 if let Some(popover) = self.hover_state.info_popovers.iter().find(|popover| {
236 popover
237 .symbol_range
238 .point_within_range(&TriggerPoint::Text(selection), &snapshot)
239 }) {
240 popover.scroll(amount, window, cx);
241 true
242 } else if let Some(context_menu) = self.context_menu.borrow_mut().as_mut() {
243 context_menu.scroll_aside(amount, window, cx);
244 true
245 } else {
246 false
247 }
248 }
249
250 fn cmd_click_reveal_task(
251 &mut self,
252 point: PointForPosition,
253 modifiers: Modifiers,
254 window: &mut Window,
255 cx: &mut Context<Editor>,
256 ) -> Task<anyhow::Result<Navigated>> {
257 if let Some(hovered_link_state) = self.hovered_link_state.take() {
258 self.hide_hovered_link(cx);
259 if !hovered_link_state.links.is_empty() {
260 if !self.focus_handle.is_focused(window) {
261 window.focus(&self.focus_handle, cx);
262 }
263
264 // exclude links pointing back to the current anchor
265 let current_position = point
266 .next_valid
267 .to_point(&self.snapshot(window, cx).display_snapshot);
268 let Some((buffer, anchor)) = self
269 .buffer()
270 .read(cx)
271 .text_anchor_for_position(current_position, cx)
272 else {
273 return Task::ready(Ok(Navigated::No));
274 };
275 let Some(multi_buffer_anchor) = self
276 .buffer()
277 .read(cx)
278 .snapshot(cx)
279 .anchor_in_excerpt(anchor)
280 else {
281 return Task::ready(Ok(Navigated::No));
282 };
283 let links = hovered_link_state
284 .links
285 .into_iter()
286 .filter(|link| {
287 if let HoverLink::Text(location) = link {
288 exclude_link_to_position(&buffer, &anchor, location, cx)
289 } else {
290 true
291 }
292 })
293 .collect();
294 let nav_entry = self.navigation_entry(multi_buffer_anchor, cx);
295 let split = Self::is_alt_pressed(&modifiers, cx);
296 let navigate_task =
297 self.navigate_to_hover_links(None, links, nav_entry, split, window, cx);
298 self.select(SelectPhase::End, window, cx);
299 return navigate_task;
300 }
301 }
302
303 // We don't have the correct kind of link cached, set the selection on
304 // click and immediately trigger GoToDefinition.
305 self.select(
306 SelectPhase::Begin {
307 position: point.next_valid,
308 add: false,
309 click_count: 1,
310 },
311 window,
312 cx,
313 );
314
315 let navigate_task = if point.as_valid().is_some() {
316 let split = Self::is_alt_pressed(&modifiers, cx);
317 match (modifiers.shift, split) {
318 (true, true) => {
319 self.go_to_type_definition_split(&GoToTypeDefinitionSplit, window, cx)
320 }
321 (true, false) => self.go_to_type_definition(&GoToTypeDefinition, window, cx),
322 (false, true) => self.go_to_definition_split(&GoToDefinitionSplit, window, cx),
323 (false, false) => self.go_to_definition(&GoToDefinition::default(), window, cx),
324 }
325 } else {
326 Task::ready(Ok(Navigated::No))
327 };
328 self.select(SelectPhase::End, window, cx);
329 navigate_task
330 }
331}
332
333pub fn show_link_definition(
334 shift_held: bool,
335 editor: &mut Editor,
336 trigger_point: TriggerPoint,
337 snapshot: &EditorSnapshot,
338 window: &mut Window,
339 cx: &mut Context<Editor>,
340) {
341 let preferred_kind = match trigger_point {
342 TriggerPoint::Text(_) if !shift_held => GotoDefinitionKind::Symbol,
343 _ => GotoDefinitionKind::Type,
344 };
345
346 let (mut hovered_link_state, is_cached) =
347 if let Some(existing) = editor.hovered_link_state.take() {
348 (existing, true)
349 } else {
350 (
351 HoveredLinkState {
352 last_trigger_point: trigger_point.clone(),
353 symbol_range: None,
354 preferred_kind,
355 links: vec![],
356 task: None,
357 },
358 false,
359 )
360 };
361
362 if editor.pending_rename.is_some() {
363 return;
364 }
365
366 let anchor = trigger_point.anchor().bias_left(snapshot.buffer_snapshot());
367 let Some((anchor, _)) = snapshot.buffer_snapshot().anchor_to_buffer_anchor(anchor) else {
368 return;
369 };
370 let Some(buffer) = editor.buffer.read(cx).buffer(anchor.buffer_id) else {
371 return;
372 };
373 let same_kind = hovered_link_state.preferred_kind == preferred_kind
374 || hovered_link_state
375 .links
376 .first()
377 .is_some_and(|d| matches!(d, HoverLink::Url(_) | HoverLink::LspLocation(_, _)));
378
379 if same_kind {
380 if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
381 || hovered_link_state
382 .symbol_range
383 .as_ref()
384 .is_some_and(|symbol_range| {
385 symbol_range.point_within_range(&trigger_point, snapshot)
386 })
387 {
388 editor.hovered_link_state = Some(hovered_link_state);
389 return;
390 }
391 } else {
392 editor.hide_hovered_link(cx)
393 }
394 let project = editor.project.clone();
395 let provider = editor.semantics_provider.clone();
396
397 // Record the requested position so a mouse move on the same point short-circuits
398 // instead of re-querying, even when the server returns no `originSelectionRange`
399 // (which would otherwise leave `symbol_range` empty).
400 hovered_link_state.last_trigger_point = trigger_point.clone();
401
402 hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| {
403 async move {
404 // LSP document links take priority: the server explicitly
405 // declares which ranges are clickable, so they are more
406 // accurate than the heuristic-based URL/file detection.
407 //
408 // Resolution is deduplicated by `LspStore`; awaiting here only
409 // blocks until either the cached resolved entry is returned or
410 // the in-flight `Shared` task completes.
411 let resolved_document_links = this
412 .update(cx, |editor, cx| {
413 editor.document_links_at(buffer.clone(), anchor, cx)
414 })
415 .ok()
416 .flatten();
417 let resolved_document_links = match resolved_document_links {
418 Some(task) => task.await,
419 None => Vec::new(),
420 };
421 let snapshot = this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
422 let detected_document_link =
423 resolved_document_links
424 .into_iter()
425 .find_map(|(server_id, link)| {
426 let multi_buffer_range =
427 snapshot.buffer_anchor_range_to_anchor_range(link.range.clone())?;
428 Some((link.range, multi_buffer_range, link.target, server_id))
429 });
430 drop(snapshot);
431
432 let result = match &trigger_point {
433 TriggerPoint::Text(_) => {
434 let mut links = Vec::new();
435 let mut symbol_range = None;
436
437 // LSP-provided document link wins over heuristic URL/file
438 // detection at the same position: the server tells us the
439 // exact range and target, while `find_url`/`find_file` are
440 // best-effort text matches.
441 if let Some((_, multi_buffer_range, Some(target), server_id)) =
442 detected_document_link.clone()
443 {
444 symbol_range = Some(RangeInEditor::Text(multi_buffer_range));
445 links.push(document_link_target_to_hover_link(&target, server_id));
446 } else if let Some((url_range, url)) = find_url(&buffer, anchor, cx) {
447 let snapshot =
448 this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
449 if let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(url_range)
450 {
451 symbol_range = Some(RangeInEditor::Text(range));
452 }
453 links.push(HoverLink::Url(url));
454 } else if let Some((filename_range, file_target)) =
455 find_file(&buffer, project.clone(), anchor, cx).await
456 {
457 let snapshot =
458 this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
459 if let Some(range) =
460 snapshot.buffer_anchor_range_to_anchor_range(filename_range)
461 {
462 symbol_range = Some(RangeInEditor::Text(range));
463 }
464 links.push(HoverLink::File(file_target));
465 }
466
467 // Always also collect LSP definitions so that cmd-click
468 // reveals every applicable target (e.g. a position that
469 // carries both a document link and a definition).
470 if let Some(provider) = provider {
471 let task = cx.update(|_, cx| {
472 provider.definitions(&buffer, anchor, preferred_kind, cx)
473 })?;
474 if let Some(task) = task
475 && let Some(definition_result) = task.await.ok().flatten()
476 {
477 if symbol_range.is_none() {
478 let snapshot = this.read_with(cx, |editor, cx| {
479 editor.buffer.read(cx).snapshot(cx)
480 })?;
481 symbol_range = definition_result.iter().find_map(|link| {
482 link.origin.as_ref().and_then(|origin| {
483 let range = snapshot.buffer_anchor_range_to_anchor_range(
484 origin.range.clone(),
485 )?;
486 Some(RangeInEditor::Text(range))
487 })
488 });
489 }
490 links.extend(definition_result.into_iter().map(HoverLink::Text));
491 }
492 }
493
494 if links.is_empty() {
495 None
496 } else {
497 Some((symbol_range, links))
498 }
499 }
500 TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
501 Some(RangeInEditor::Inlay(highlight.clone())),
502 vec![HoverLink::LspLocation(lsp_location.clone(), *server_id)],
503 )),
504 };
505
506 this.update(cx, |editor, cx| {
507 // Clear any existing highlights
508 editor.clear_highlights(HighlightKey::HoveredLinkState, cx);
509 let Some(hovered_link_state) = editor.hovered_link_state.as_mut() else {
510 editor.hide_hovered_link(cx);
511 return;
512 };
513 hovered_link_state.preferred_kind = preferred_kind;
514 hovered_link_state.symbol_range = result
515 .as_ref()
516 .and_then(|(symbol_range, _)| symbol_range.clone())
517 .or_else(|| {
518 // Even if we have no click target yet (e.g. an
519 // unresolved document link), record the link's range
520 // so subsequent mouse moves on the same link
521 // short-circuit in `show_link_definition`.
522 detected_document_link
523 .as_ref()
524 .map(|(_, multi_buffer_range, _, _)| {
525 RangeInEditor::Text(multi_buffer_range.clone())
526 })
527 });
528
529 if let Some((symbol_range, definitions)) = result {
530 hovered_link_state.links = definitions;
531
532 let underline_hovered_link = !hovered_link_state.links.is_empty()
533 || hovered_link_state.symbol_range.is_some();
534
535 if underline_hovered_link {
536 let style = HighlightStyle {
537 underline: Some(UnderlineStyle {
538 thickness: px(1.),
539 ..UnderlineStyle::default()
540 }),
541 color: Some(cx.theme().colors().link_text_hover),
542 ..HighlightStyle::default()
543 };
544 let highlight_range =
545 symbol_range.unwrap_or_else(|| match &trigger_point {
546 TriggerPoint::Text(trigger_anchor) => {
547 let snapshot = editor.buffer.read(cx).snapshot(cx);
548 // If no symbol range returned from language server, use the surrounding word.
549 let (offset_range, _) =
550 snapshot.surrounding_word(*trigger_anchor, None);
551 RangeInEditor::Text(
552 snapshot.anchor_before(offset_range.start)
553 ..snapshot.anchor_after(offset_range.end),
554 )
555 }
556 TriggerPoint::InlayHint(highlight, _, _) => {
557 RangeInEditor::Inlay(highlight.clone())
558 }
559 });
560
561 // When the server reports no `originSelectionRange`, fall back
562 // to the highlighted word as the symbol range so that hovering
563 // elsewhere within the same symbol reuses this result instead
564 // of issuing another request.
565 if let Some(hovered_link_state) = editor.hovered_link_state.as_mut()
566 && hovered_link_state.symbol_range.is_none()
567 {
568 hovered_link_state.symbol_range = Some(highlight_range.clone());
569 }
570
571 match highlight_range {
572 RangeInEditor::Text(text_range) => editor.highlight_text(
573 HighlightKey::HoveredLinkState,
574 vec![text_range],
575 style,
576 cx,
577 ),
578 RangeInEditor::Inlay(highlight) => editor.highlight_inlays(
579 HighlightKey::HoveredLinkState,
580 vec![highlight],
581 style,
582 cx,
583 ),
584 }
585 }
586 } else if let Some((_, multi_buffer_range, _, _)) = detected_document_link.as_ref()
587 {
588 let style = HighlightStyle {
589 underline: Some(UnderlineStyle {
590 thickness: px(1.),
591 ..UnderlineStyle::default()
592 }),
593 color: Some(cx.theme().colors().link_text_hover),
594 ..HighlightStyle::default()
595 };
596 editor.highlight_text(
597 HighlightKey::HoveredLinkState,
598 vec![multi_buffer_range.clone()],
599 style,
600 cx,
601 );
602 } else {
603 // When no links are found, we don't want to completely
604 // throw away the `HoveredLinkState`, we'll want to at least
605 // keep the `trigger_point` around in order to avoid sending
606 // multiple requests for the same point.
607 hovered_link_state.links.clear();
608 }
609 })?;
610
611 anyhow::Ok(())
612 }
613 .log_err()
614 .await
615 }));
616
617 editor.hovered_link_state = Some(hovered_link_state);
618}
619
620pub(crate) fn find_url(
621 buffer: &Entity<language::Buffer>,
622 position: text::Anchor,
623 cx: &AsyncWindowContext,
624) -> Option<(Range<text::Anchor>, String)> {
625 const LIMIT: usize = 2048;
626
627 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
628
629 let offset = position.to_offset(&snapshot);
630 let mut token_start = offset;
631 let mut token_end = offset;
632 let mut found_start = false;
633 let mut found_end = false;
634
635 for ch in snapshot.reversed_chars_at(offset).take(LIMIT) {
636 if ch.is_whitespace() {
637 found_start = true;
638 break;
639 }
640 token_start -= ch.len_utf8();
641 }
642 // Check if we didn't find the starting whitespace or if we didn't reach the start of the buffer
643 if !found_start && token_start != 0 {
644 return None;
645 }
646
647 for ch in snapshot
648 .chars_at(offset)
649 .take(LIMIT - (offset - token_start))
650 {
651 if ch.is_whitespace() {
652 found_end = true;
653 break;
654 }
655 token_end += ch.len_utf8();
656 }
657 // Check if we didn't find the ending whitespace or if we read more or equal than LIMIT
658 // which at this point would happen only if we reached the end of buffer
659 if !found_end && (token_end - token_start >= LIMIT) {
660 return None;
661 }
662
663 let mut finder = LinkFinder::new();
664 finder.kinds(&[LinkKind::Url]);
665 let input = snapshot
666 .text_for_range(token_start..token_end)
667 .collect::<String>();
668
669 let relative_offset = offset - token_start;
670 for link in finder.links(&input) {
671 if link.start() <= relative_offset && link.end() >= relative_offset {
672 let range = snapshot.anchor_before(token_start + link.start())
673 ..snapshot.anchor_after(token_start + link.end());
674 return Some((range, link.as_str().to_string()));
675 }
676 }
677 None
678}
679
680pub(crate) fn find_url_from_range(
681 buffer: &Entity<language::Buffer>,
682 range: Range<text::Anchor>,
683 cx: &AsyncWindowContext,
684) -> Option<String> {
685 const LIMIT: usize = 2048;
686
687 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
688
689 let start_offset = range.start.to_offset(&snapshot);
690 let end_offset = range.end.to_offset(&snapshot);
691
692 let mut token_start = start_offset.min(end_offset);
693 let mut token_end = start_offset.max(end_offset);
694
695 let range_len = token_end - token_start;
696
697 if range_len >= LIMIT {
698 return None;
699 }
700
701 // Skip leading whitespace
702 for ch in snapshot.chars_at(token_start).take(range_len) {
703 if !ch.is_whitespace() {
704 break;
705 }
706 token_start += ch.len_utf8();
707 }
708
709 // Skip trailing whitespace
710 for ch in snapshot.reversed_chars_at(token_end).take(range_len) {
711 if !ch.is_whitespace() {
712 break;
713 }
714 token_end -= ch.len_utf8();
715 }
716
717 if token_start >= token_end {
718 return None;
719 }
720
721 let text = snapshot
722 .text_for_range(token_start..token_end)
723 .collect::<String>();
724
725 let mut finder = LinkFinder::new();
726 finder.kinds(&[LinkKind::Url]);
727
728 if let Some(link) = finder.links(&text).next()
729 && link.start() == 0
730 && link.end() == text.len()
731 {
732 return Some(link.as_str().to_string());
733 }
734
735 None
736}
737
738#[derive(Debug, Clone)]
739pub struct ResolvedFileTarget {
740 pub resolved_path: ResolvedPath,
741 pub row: Option<u32>,
742 pub column: Option<u32>,
743}
744
745impl ResolvedFileTarget {
746 /// After opening a file, navigate the editor to the row/column position if present.
747 pub fn navigate_item_to_position(
748 &self,
749 item: Box<dyn crate::ItemHandle>,
750 cx: &mut AsyncWindowContext,
751 ) {
752 if let Some(row) = self.row {
753 let col = self.column.unwrap_or(0);
754 if let Some(active_editor) = item.downcast::<crate::Editor>() {
755 active_editor
756 .downgrade()
757 .update_in(cx, |editor, window, cx| {
758 let row = row.saturating_sub(1);
759 let col = col.saturating_sub(1);
760 let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
761 return;
762 };
763 let point = buffer
764 .read(cx)
765 .snapshot()
766 .point_from_external_input(row, col);
767 editor.go_to_singleton_buffer_point_silently(point, window, cx);
768 })
769 .log_err();
770 }
771 }
772 }
773}
774
775pub(crate) async fn find_file(
776 buffer: &Entity<language::Buffer>,
777 project: Option<Entity<Project>>,
778 position: text::Anchor,
779 cx: &mut AsyncWindowContext,
780) -> Option<(Range<text::Anchor>, ResolvedFileTarget)> {
781 let project = project?;
782 let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
783 let scope = snapshot.language_scope_at(position);
784 let (range, candidate_file_path) = surrounding_filename(&snapshot, position)?;
785 let candidate_len = candidate_file_path.len();
786
787 async fn check_path(
788 candidate_file_path: &str,
789 project: &Entity<Project>,
790 buffer: &Entity<language::Buffer>,
791 cx: &mut AsyncWindowContext,
792 ) -> Option<ResolvedPath> {
793 project
794 .update(cx, |project, cx| {
795 project.resolve_path_in_buffer(candidate_file_path, buffer, cx)
796 })
797 .await
798 .filter(|s| s.is_file())
799 }
800
801 let pattern_candidates = link_pattern_file_candidates(&candidate_file_path);
802
803 // Compute the highlight range for a pattern_range within the candidate string.
804 let make_range = |pattern_range: &Range<usize>| -> Range<text::Anchor> {
805 let offset_range = range.to_offset(&snapshot);
806 let actual_start = offset_range.start + pattern_range.start;
807 let actual_end = offset_range.end - (candidate_len - pattern_range.end);
808 snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end)
809 };
810
811 // For each candidate extracted by link_pattern_file_candidates, try resolving in order:
812 // 1. The raw candidate string
813 // 2. The path portion after stripping `:row:col` suffix
814 // 3. With language-specific file extensions appended to raw candidate
815 // 4. With language-specific file extensions appended to stripped path
816 for (pattern_candidate, pattern_range) in &pattern_candidates {
817 // Try the raw candidate first.
818 if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await {
819 return Some((
820 make_range(pattern_range),
821 ResolvedFileTarget {
822 resolved_path: existing_path,
823 row: None,
824 column: None,
825 },
826 ));
827 }
828
829 // Parse row:col suffix once per candidate for use in fallback attempts.
830 // This handles patterns like `file.rs:83:1`, `file.rs:83`, and `file.rs:20:in`.
831 let parsed = PathWithPosition::parse_str(pattern_candidate);
832 let parsed_path = parsed.path.to_string_lossy();
833
834 // Try resolving just the path portion (without :row:col).
835 if parsed.row.is_some() {
836 if let Some(existing_path) = check_path(&parsed_path, &project, buffer, cx).await {
837 return Some((
838 make_range(pattern_range),
839 ResolvedFileTarget {
840 resolved_path: existing_path,
841 row: parsed.row,
842 column: parsed.column,
843 },
844 ));
845 }
846 }
847
848 // Try with language-specific suffixes.
849 if let Some(scope) = &scope {
850 for suffix in scope.path_suffixes() {
851 if pattern_candidate.ends_with(format!(".{suffix}").as_str()) {
852 continue;
853 }
854
855 let suffixed_candidate = format!("{pattern_candidate}.{suffix}");
856 if let Some(existing_path) =
857 check_path(&suffixed_candidate, &project, buffer, cx).await
858 {
859 return Some((
860 make_range(pattern_range),
861 ResolvedFileTarget {
862 resolved_path: existing_path,
863 row: None,
864 column: None,
865 },
866 ));
867 }
868 }
869
870 // Try with language-specific suffixes on the stripped path.
871 if parsed.row.is_some() {
872 for suffix in scope.path_suffixes() {
873 if parsed_path.ends_with(&format!(".{suffix}")) {
874 continue;
875 }
876
877 let suffixed_candidate = format!("{parsed_path}.{suffix}");
878 if let Some(existing_path) =
879 check_path(&suffixed_candidate, &project, buffer, cx).await
880 {
881 return Some((
882 make_range(pattern_range),
883 ResolvedFileTarget {
884 resolved_path: existing_path,
885 row: parsed.row,
886 column: parsed.column,
887 },
888 ));
889 }
890 }
891 }
892 }
893 }
894 None
895}
896
897// Generates candidate file paths by stripping common punctuation wrappers.
898// Handles markdown patterns like [title](path), `path`, (path), as well as
899// partial wrappers where punctuation only appears on one side (e.g. path) or path`).
900// Returns candidates ordered from most-specific (most trimmed) to least-specific (raw).
901fn link_pattern_file_candidates(candidate: &str) -> Vec<(String, Range<usize>)> {
902 static MD_LINK_REGEX: LazyLock<Regex> =
903 LazyLock::new(|| Regex::new(r"]\(([^)]*)\)").expect("Failed to create REGEX"));
904
905 // Punctuation that commonly wraps file paths in prose/markdown
906 const LEADING_PUNCTUATION: &[char] = &['`', '(', '[', '{', '<', '"', '\''];
907 const TRAILING_PUNCTUATION: &[char] = &[
908 '`', ')', ']', '}', '>', '"', '\'', '.', ',', ':', ';', '!', '?',
909 ];
910
911 let candidate_len = candidate.len();
912 let mut candidates = Vec::new();
913
914 // Trim leading and trailing punctuation iteratively
915 let mut start = 0;
916 let mut end = candidate_len;
917
918 // Trim leading punctuation
919 for ch in candidate.chars() {
920 if LEADING_PUNCTUATION.contains(&ch) {
921 start += ch.len_utf8();
922 } else {
923 break;
924 }
925 }
926
927 // Trim trailing punctuation
928 for ch in candidate.chars().rev() {
929 if TRAILING_PUNCTUATION.contains(&ch) {
930 end -= ch.len_utf8();
931 } else {
932 break;
933 }
934 }
935
936 // Add trimmed candidate first (highest priority) if it differs from original
937 if start < end && (start > 0 || end < candidate_len) {
938 candidates.push((candidate[start..end].to_string(), start..end));
939 }
940
941 // Extract markdown link destination: [title](path) or ](path) -> path
942 // This also handles bare (path) wrapping.
943 if let Some(captures) = MD_LINK_REGEX.captures(candidate) {
944 if let Some(link) = captures.get(1) {
945 let link_str = link.as_str().to_string();
946 let link_range = link.range();
947 // Avoid duplicate if punctuation trimming already found this
948 if !candidates.iter().any(|(s, _)| s == &link_str) {
949 candidates.push((link_str, link_range));
950 }
951 }
952 }
953
954 // Always include the raw candidate as fallback (lowest priority)
955 candidates.push((candidate.to_string(), 0..candidate_len));
956
957 candidates
958}
959
960fn surrounding_filename(
961 snapshot: &language::BufferSnapshot,
962 position: text::Anchor,
963) -> Option<(Range<text::Anchor>, String)> {
964 const LIMIT: usize = 2048;
965
966 let offset = position.to_offset(&snapshot);
967 let mut token_start = offset;
968 let mut token_end = offset;
969 let mut found_start = false;
970 let mut found_end = false;
971 let mut inside_quotes = false;
972
973 let mut filename = String::new();
974
975 let mut backwards = snapshot.reversed_chars_at(offset).take(LIMIT).peekable();
976 while let Some(ch) = backwards.next() {
977 // Escaped whitespace
978 if ch.is_whitespace() && backwards.peek() == Some(&'\\') {
979 filename.push(ch);
980 token_start -= ch.len_utf8();
981 backwards.next();
982 token_start -= '\\'.len_utf8();
983 continue;
984 }
985 if ch.is_whitespace() {
986 found_start = true;
987 break;
988 }
989 // Quote characters open a quoted region that is stripped from the
990 // returned filename. Backticks and parens are NOT treated this way —
991 // they are kept as part of the token so that downstream candidate
992 // generation (link_pattern_file_candidates) can trim them and produce
993 // a tight highlight range via make_range.
994 if (ch == '"' || ch == '\'') && !inside_quotes {
995 found_start = true;
996 inside_quotes = true;
997 break;
998 }
999
1000 filename.push(ch);
1001 token_start -= ch.len_utf8();
1002 }
1003 if !found_start && token_start != 0 {
1004 return None;
1005 }
1006
1007 filename = filename.chars().rev().collect();
1008
1009 let mut forwards = snapshot
1010 .chars_at(offset)
1011 .take(LIMIT - (offset - token_start))
1012 .peekable();
1013 while let Some(ch) = forwards.next() {
1014 // Skip escaped whitespace
1015 if ch == '\\' && forwards.peek().is_some_and(|ch| ch.is_whitespace()) {
1016 token_end += ch.len_utf8();
1017 let whitespace = forwards.next().unwrap();
1018 token_end += whitespace.len_utf8();
1019 filename.push(whitespace);
1020 continue;
1021 }
1022
1023 if ch.is_whitespace() {
1024 found_end = true;
1025 break;
1026 }
1027 if ch == '"' || ch == '\'' {
1028 // If we're inside quotes, we stop when we come across the next quote
1029 if inside_quotes {
1030 found_end = true;
1031 break;
1032 } else {
1033 // Otherwise, we skip the quote
1034 inside_quotes = true;
1035 token_end += ch.len_utf8();
1036 continue;
1037 }
1038 }
1039 filename.push(ch);
1040 token_end += ch.len_utf8();
1041 }
1042
1043 if !found_end && (token_end - token_start >= LIMIT) {
1044 return None;
1045 }
1046
1047 if filename.is_empty() {
1048 return None;
1049 }
1050
1051 let range = snapshot.anchor_before(token_start)..snapshot.anchor_after(token_end);
1052
1053 Some((range, filename))
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059 use crate::{
1060 DisplayPoint,
1061 display_map::ToDisplayPoint,
1062 editor_tests::init_test,
1063 inlays::inlay_hints::tests::{cached_hint_labels, visible_hint_labels},
1064 test::editor_lsp_test_context::EditorLspTestContext,
1065 };
1066 use futures::StreamExt;
1067 use gpui::{Modifiers, MousePressureEvent, PressureStage};
1068 use indoc::indoc;
1069 use lsp::request::{GotoDefinition, GotoTypeDefinition};
1070 use multi_buffer::MultiBufferOffset;
1071 use settings::InlayHintSettingsContent;
1072 use std::str::FromStr;
1073 use std::sync::Arc;
1074 use std::sync::atomic::{AtomicUsize, Ordering};
1075 use util::{assert_set_eq, path};
1076 use workspace::item::Item;
1077
1078 #[test]
1079 fn test_document_link_target_to_hover_link_file_uri_with_fragment() {
1080 let server_id = LanguageServerId(0);
1081 let target = "file:///Users/me/work/local_test/document-links-test.json#9,16";
1082 match document_link_target_to_hover_link(target, server_id) {
1083 HoverLink::LspLocation(location, returned_id) => {
1084 assert_eq!(returned_id, server_id);
1085 assert_eq!(
1086 location.uri.as_str(),
1087 "file:///Users/me/work/local_test/document-links-test.json#9,16",
1088 );
1089 assert_eq!(
1090 location.range,
1091 lsp::Range {
1092 start: lsp::Position {
1093 line: 8,
1094 character: 15,
1095 },
1096 end: lsp::Position {
1097 line: 8,
1098 character: 15,
1099 },
1100 }
1101 );
1102 }
1103 other => panic!("expected LspLocation variant, got {other:?}"),
1104 }
1105 }
1106
1107 #[test]
1108 fn test_document_link_target_to_hover_link_http_url() {
1109 let server_id = LanguageServerId(0);
1110 let target = "https://opensource.org/licenses/MIT";
1111 match document_link_target_to_hover_link(target, server_id) {
1112 HoverLink::Url(url) => assert_eq!(url, target),
1113 other => panic!("expected Url variant, got {other:?}"),
1114 }
1115 }
1116
1117 #[gpui::test]
1118 async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
1119 init_test(cx, |_| {});
1120
1121 let mut cx = EditorLspTestContext::new_rust(
1122 lsp::ServerCapabilities {
1123 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1124 type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
1125 ..Default::default()
1126 },
1127 cx,
1128 )
1129 .await;
1130
1131 cx.set_state(indoc! {"
1132 struct A;
1133 let vˇariable = A;
1134 "});
1135 let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
1136
1137 // Basic hold cmd+shift, expect highlight in region if response contains type definition
1138 let symbol_range = cx.lsp_range(indoc! {"
1139 struct A;
1140 let «variable» = A;
1141 "});
1142 let target_range = cx.lsp_range(indoc! {"
1143 struct «A»;
1144 let variable = A;
1145 "});
1146
1147 cx.run_until_parked();
1148
1149 let mut requests =
1150 cx.set_request_handler::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
1151 Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
1152 lsp::LocationLink {
1153 origin_selection_range: Some(symbol_range),
1154 target_uri: url.clone(),
1155 target_range,
1156 target_selection_range: target_range,
1157 },
1158 ])))
1159 });
1160
1161 let modifiers = if cfg!(target_os = "macos") {
1162 Modifiers::command_shift()
1163 } else {
1164 Modifiers::control_shift()
1165 };
1166
1167 cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
1168
1169 requests.next().await;
1170 cx.run_until_parked();
1171 cx.assert_editor_text_highlights(
1172 HighlightKey::HoveredLinkState,
1173 indoc! {"
1174 struct A;
1175 let «variable» = A;
1176 "},
1177 );
1178
1179 cx.simulate_modifiers_change(Modifiers::secondary_key());
1180 cx.run_until_parked();
1181 // Assert no link highlights
1182 cx.assert_editor_text_highlights(
1183 HighlightKey::HoveredLinkState,
1184 indoc! {"
1185 struct A;
1186 let variable = A;
1187 "},
1188 );
1189
1190 cx.simulate_click(screen_coord.unwrap(), modifiers);
1191
1192 cx.assert_editor_state(indoc! {"
1193 struct «Aˇ»;
1194 let variable = A;
1195 "});
1196 }
1197
1198 #[gpui::test]
1199 async fn test_go_to_definition_link_dedup(cx: &mut gpui::TestAppContext) {
1200 init_test(cx, |_| {});
1201
1202 let mut cx = EditorLspTestContext::new_rust(
1203 lsp::ServerCapabilities {
1204 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1205 definition_provider: Some(lsp::OneOf::Left(true)),
1206 ..Default::default()
1207 },
1208 cx,
1209 )
1210 .await;
1211
1212 cx.set_state(indoc! {"
1213 fn ˇtest() { do_work(); }
1214 fn do_work() { test(); }
1215 "});
1216
1217 let request_count = Arc::new(AtomicUsize::new(0));
1218 let _requests = cx.set_request_handler::<GotoDefinition, _, _>({
1219 let request_count = request_count.clone();
1220 move |url, _, _| {
1221 request_count.fetch_add(1, Ordering::SeqCst);
1222 async move {
1223 // Return a bare `Location`, not an `originSelectionRange`
1224 // so we can confirm that jiggling the mouse within the same
1225 // symbol range does not trigger a second request, even
1226 // though `originSelectionRange` was not returned.
1227 Ok(Some(lsp::GotoDefinitionResponse::Scalar(lsp::Location {
1228 uri: url,
1229 range: lsp::Range::default(),
1230 })))
1231 }
1232 }
1233 });
1234
1235 let symbol_start = cx.pixel_position(indoc! {"
1236 fn test() { ˇdo_work(); }
1237 fn do_work() { test(); }
1238 "});
1239 let symbol_end = cx.pixel_position(indoc! {"
1240 fn test() { do_worˇk(); }
1241 fn do_work() { test(); }
1242 "});
1243 let other_symbol = cx.pixel_position(indoc! {"
1244 fn test() { do_work(); }
1245 fn do_work() { teˇst(); }
1246 "});
1247
1248 cx.simulate_mouse_move(symbol_start, None, Modifiers::secondary_key());
1249 cx.run_until_parked();
1250
1251 cx.simulate_mouse_move(symbol_end, None, Modifiers::secondary_key());
1252 cx.run_until_parked();
1253
1254 cx.simulate_mouse_move(other_symbol, None, Modifiers::secondary_key());
1255 cx.run_until_parked();
1256
1257 assert_eq!(
1258 request_count.load(Ordering::SeqCst),
1259 2,
1260 "expected one request per symbol, reused within a symbol"
1261 );
1262 }
1263
1264 #[gpui::test]
1265 async fn test_go_to_definition_link_dedup_no_link(cx: &mut gpui::TestAppContext) {
1266 init_test(cx, |_| {});
1267
1268 let mut cx = EditorLspTestContext::new_rust(
1269 lsp::ServerCapabilities {
1270 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1271 definition_provider: Some(lsp::OneOf::Left(true)),
1272 ..Default::default()
1273 },
1274 cx,
1275 )
1276 .await;
1277
1278 cx.set_state(indoc! {"
1279 fn ˇtest() { do_work(); }
1280 fn do_work() { test(); }
1281 "});
1282
1283 let request_count = Arc::new(AtomicUsize::new(0));
1284 let _requests = cx.set_request_handler::<GotoDefinition, _, _>({
1285 let request_count = request_count.clone();
1286
1287 move |_, _, _| {
1288 request_count.fetch_add(1, Ordering::SeqCst);
1289
1290 // Simulate response from the language server, reporting
1291 // that no link was found.
1292 async move { Ok(None) }
1293 }
1294 });
1295
1296 let first_point = cx.pixel_position(indoc! {"
1297 fn test() { do_wˇork(); }
1298 fn do_work() { test(); }
1299 "});
1300 let second_point = cx.pixel_position(indoc! {"
1301 fn test() { do_woˇrk(); }
1302 fn do_work() { test(); }
1303 "});
1304
1305 cx.simulate_mouse_move(first_point, None, Modifiers::secondary_key());
1306 cx.run_until_parked();
1307
1308 cx.simulate_mouse_move(second_point, None, Modifiers::secondary_key());
1309 cx.run_until_parked();
1310
1311 // Jiggle within the same character should not produce a new request,
1312 // even though the previous response was empty and produced no link to
1313 // highlight.
1314 cx.simulate_mouse_move(second_point, None, Modifiers::secondary_key());
1315 cx.run_until_parked();
1316
1317 assert_eq!(
1318 request_count.load(Ordering::SeqCst),
1319 2,
1320 "expected one definition request per distinct position"
1321 );
1322 }
1323
1324 #[gpui::test]
1325 async fn test_hover_links(cx: &mut gpui::TestAppContext) {
1326 init_test(cx, |_| {});
1327
1328 let mut cx = EditorLspTestContext::new_rust(
1329 lsp::ServerCapabilities {
1330 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
1331 definition_provider: Some(lsp::OneOf::Left(true)),
1332 ..Default::default()
1333 },
1334 cx,
1335 )
1336 .await;
1337
1338 cx.set_state(indoc! {"
1339 fn ˇtest() { do_work(); }
1340 fn do_work() { test(); }
1341 "});
1342
1343 // Basic hold cmd, expect highlight in region if response contains definition
1344 let hover_point = cx.pixel_position(indoc! {"
1345 fn test() { do_wˇork(); }
1346 fn do_work() { test(); }
1347 "});
1348 let symbol_range = cx.lsp_range(indoc! {"
1349 fn test() { «do_work»(); }
1350 fn do_work() { test(); }
1351 "});
1352 let target_range = cx.lsp_range(indoc! {"
1353 fn test() { do_work(); }
1354 fn «do_work»() { test(); }
1355 "});
1356
1357 let mut requests =
1358 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1359 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1360 lsp::LocationLink {
1361 origin_selection_range: Some(symbol_range),
1362 target_uri: url.clone(),
1363 target_range,
1364 target_selection_range: target_range,
1365 },
1366 ])))
1367 });
1368
1369 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1370 requests.next().await;
1371 cx.background_executor.run_until_parked();
1372 cx.assert_editor_text_highlights(
1373 HighlightKey::HoveredLinkState,
1374 indoc! {"
1375 fn test() { «do_work»(); }
1376 fn do_work() { test(); }
1377 "},
1378 );
1379
1380 // Unpress cmd causes highlight to go away
1381 cx.simulate_modifiers_change(Modifiers::none());
1382 cx.assert_editor_text_highlights(
1383 HighlightKey::HoveredLinkState,
1384 indoc! {"
1385 fn test() { do_work(); }
1386 fn do_work() { test(); }
1387 "},
1388 );
1389
1390 let mut requests =
1391 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1392 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1393 lsp::LocationLink {
1394 origin_selection_range: Some(symbol_range),
1395 target_uri: url.clone(),
1396 target_range,
1397 target_selection_range: target_range,
1398 },
1399 ])))
1400 });
1401
1402 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1403 requests.next().await;
1404 cx.background_executor.run_until_parked();
1405 cx.assert_editor_text_highlights(
1406 HighlightKey::HoveredLinkState,
1407 indoc! {"
1408 fn test() { «do_work»(); }
1409 fn do_work() { test(); }
1410 "},
1411 );
1412
1413 // Moving mouse to location with no response dismisses highlight
1414 let hover_point = cx.pixel_position(indoc! {"
1415 fˇn test() { do_work(); }
1416 fn do_work() { test(); }
1417 "});
1418 let mut requests =
1419 cx.lsp
1420 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1421 // No definitions returned
1422 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1423 });
1424 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1425
1426 requests.next().await;
1427 cx.background_executor.run_until_parked();
1428
1429 // Assert no link highlights
1430 cx.assert_editor_text_highlights(
1431 HighlightKey::HoveredLinkState,
1432 indoc! {"
1433 fn test() { do_work(); }
1434 fn do_work() { test(); }
1435 "},
1436 );
1437
1438 // // Move mouse without cmd and then pressing cmd triggers highlight
1439 let hover_point = cx.pixel_position(indoc! {"
1440 fn test() { do_work(); }
1441 fn do_work() { teˇst(); }
1442 "});
1443 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1444
1445 // Assert no link highlights
1446 cx.assert_editor_text_highlights(
1447 HighlightKey::HoveredLinkState,
1448 indoc! {"
1449 fn test() { do_work(); }
1450 fn do_work() { test(); }
1451 "},
1452 );
1453
1454 let symbol_range = cx.lsp_range(indoc! {"
1455 fn test() { do_work(); }
1456 fn do_work() { «test»(); }
1457 "});
1458 let target_range = cx.lsp_range(indoc! {"
1459 fn «test»() { do_work(); }
1460 fn do_work() { test(); }
1461 "});
1462
1463 let mut requests =
1464 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1465 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1466 lsp::LocationLink {
1467 origin_selection_range: Some(symbol_range),
1468 target_uri: url,
1469 target_range,
1470 target_selection_range: target_range,
1471 },
1472 ])))
1473 });
1474
1475 cx.simulate_modifiers_change(Modifiers::secondary_key());
1476
1477 requests.next().await;
1478 cx.background_executor.run_until_parked();
1479
1480 cx.assert_editor_text_highlights(
1481 HighlightKey::HoveredLinkState,
1482 indoc! {"
1483 fn test() { do_work(); }
1484 fn do_work() { «test»(); }
1485 "},
1486 );
1487
1488 cx.deactivate_window();
1489 cx.assert_editor_text_highlights(
1490 HighlightKey::HoveredLinkState,
1491 indoc! {"
1492 fn test() { do_work(); }
1493 fn do_work() { test(); }
1494 "},
1495 );
1496
1497 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1498 cx.background_executor.run_until_parked();
1499 cx.assert_editor_text_highlights(
1500 HighlightKey::HoveredLinkState,
1501 indoc! {"
1502 fn test() { do_work(); }
1503 fn do_work() { «test»(); }
1504 "},
1505 );
1506
1507 // Moving again within the same symbol range doesn't re-request
1508 let hover_point = cx.pixel_position(indoc! {"
1509 fn test() { do_work(); }
1510 fn do_work() { tesˇt(); }
1511 "});
1512 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1513 cx.background_executor.run_until_parked();
1514 cx.assert_editor_text_highlights(
1515 HighlightKey::HoveredLinkState,
1516 indoc! {"
1517 fn test() { do_work(); }
1518 fn do_work() { «test»(); }
1519 "},
1520 );
1521
1522 // Cmd click with existing definition doesn't re-request and dismisses highlight
1523 cx.simulate_click(hover_point, Modifiers::secondary_key());
1524 cx.lsp
1525 .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
1526 // Empty definition response to make sure we aren't hitting the lsp and using
1527 // the cached location instead
1528 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
1529 });
1530 cx.background_executor.run_until_parked();
1531 cx.assert_editor_state(indoc! {"
1532 fn «testˇ»() { do_work(); }
1533 fn do_work() { test(); }
1534 "});
1535
1536 // Assert no link highlights after jump
1537 cx.assert_editor_text_highlights(
1538 HighlightKey::HoveredLinkState,
1539 indoc! {"
1540 fn test() { do_work(); }
1541 fn do_work() { test(); }
1542 "},
1543 );
1544
1545 // Cmd click without existing definition requests and jumps
1546 let hover_point = cx.pixel_position(indoc! {"
1547 fn test() { do_wˇork(); }
1548 fn do_work() { test(); }
1549 "});
1550 let target_range = cx.lsp_range(indoc! {"
1551 fn test() { do_work(); }
1552 fn «do_work»() { test(); }
1553 "});
1554
1555 let mut requests =
1556 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1557 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1558 lsp::LocationLink {
1559 origin_selection_range: None,
1560 target_uri: url,
1561 target_range,
1562 target_selection_range: target_range,
1563 },
1564 ])))
1565 });
1566 cx.simulate_click(hover_point, Modifiers::secondary_key());
1567 requests.next().await;
1568 cx.background_executor.run_until_parked();
1569 cx.assert_editor_state(indoc! {"
1570 fn test() { do_work(); }
1571 fn «do_workˇ»() { test(); }
1572 "});
1573
1574 // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1575 // 2. Selection is completed, hovering
1576 let hover_point = cx.pixel_position(indoc! {"
1577 fn test() { do_wˇork(); }
1578 fn do_work() { test(); }
1579 "});
1580 let target_range = cx.lsp_range(indoc! {"
1581 fn test() { do_work(); }
1582 fn «do_work»() { test(); }
1583 "});
1584 let mut requests =
1585 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1586 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1587 lsp::LocationLink {
1588 origin_selection_range: None,
1589 target_uri: url,
1590 target_range,
1591 target_selection_range: target_range,
1592 },
1593 ])))
1594 });
1595
1596 // create a pending selection
1597 let selection_range = cx.ranges(indoc! {"
1598 fn «test() { do_w»ork(); }
1599 fn do_work() { test(); }
1600 "})[0]
1601 .clone();
1602 cx.update_editor(|editor, window, cx| {
1603 let snapshot = editor.buffer().read(cx).snapshot(cx);
1604 let anchor_range = snapshot.anchor_before(MultiBufferOffset(selection_range.start))
1605 ..snapshot.anchor_after(MultiBufferOffset(selection_range.end));
1606 editor.change_selections(Default::default(), window, cx, |s| {
1607 s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1608 });
1609 });
1610 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1611 cx.background_executor.run_until_parked();
1612 assert!(requests.try_recv().is_err());
1613 cx.assert_editor_text_highlights(
1614 HighlightKey::HoveredLinkState,
1615 indoc! {"
1616 fn test() { do_work(); }
1617 fn do_work() { test(); }
1618 "},
1619 );
1620 cx.background_executor.run_until_parked();
1621 }
1622
1623 #[gpui::test]
1624 async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1625 init_test(cx, |settings| {
1626 settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1627 enabled: Some(true),
1628 show_value_hints: Some(false),
1629 edit_debounce_ms: Some(0),
1630 scroll_debounce_ms: Some(0),
1631 show_type_hints: Some(true),
1632 show_parameter_hints: Some(true),
1633 show_other_hints: Some(true),
1634 show_background: Some(false),
1635 toggle_on_modifiers_press: None,
1636 })
1637 });
1638
1639 let mut cx = EditorLspTestContext::new_rust(
1640 lsp::ServerCapabilities {
1641 inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1642 ..Default::default()
1643 },
1644 cx,
1645 )
1646 .await;
1647 cx.set_state(indoc! {"
1648 struct TestStruct;
1649
1650 fn main() {
1651 let variableˇ = TestStruct;
1652 }
1653 "});
1654 let hint_start_offset = cx.ranges(indoc! {"
1655 struct TestStruct;
1656
1657 fn main() {
1658 let variableˇ = TestStruct;
1659 }
1660 "})[0]
1661 .start;
1662 let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1663 let target_range = cx.lsp_range(indoc! {"
1664 struct «TestStruct»;
1665
1666 fn main() {
1667 let variable = TestStruct;
1668 }
1669 "});
1670
1671 let expected_uri = cx.buffer_lsp_url.clone();
1672 let hint_label = ": TestStruct";
1673 cx.lsp
1674 .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1675 let expected_uri = expected_uri.clone();
1676 async move {
1677 assert_eq!(params.text_document.uri, expected_uri);
1678 Ok(Some(vec![lsp::InlayHint {
1679 position: hint_position,
1680 label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1681 value: hint_label.to_string(),
1682 location: Some(lsp::Location {
1683 uri: params.text_document.uri,
1684 range: target_range,
1685 }),
1686 ..Default::default()
1687 }]),
1688 kind: Some(lsp::InlayHintKind::TYPE),
1689 text_edits: None,
1690 tooltip: None,
1691 padding_left: Some(false),
1692 padding_right: Some(false),
1693 data: None,
1694 }]))
1695 }
1696 })
1697 .next()
1698 .await;
1699 cx.background_executor.run_until_parked();
1700 cx.update_editor(|editor, _window, cx| {
1701 let expected_layers = vec![hint_label.to_string()];
1702 assert_eq!(expected_layers, cached_hint_labels(editor, cx));
1703 assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1704 });
1705
1706 let inlay_range = cx
1707 .ranges(indoc! {"
1708 struct TestStruct;
1709
1710 fn main() {
1711 let variable« »= TestStruct;
1712 }
1713 "})
1714 .first()
1715 .cloned()
1716 .unwrap();
1717 let midpoint = cx.update_editor(|editor, window, cx| {
1718 let snapshot = editor.snapshot(window, cx);
1719 let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
1720 let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
1721 assert_eq!(previous_valid.row(), next_valid.row());
1722 assert!(previous_valid.column() < next_valid.column());
1723 DisplayPoint::new(
1724 previous_valid.row(),
1725 previous_valid.column() + (hint_label.len() / 2) as u32,
1726 )
1727 });
1728 // Press cmd to trigger highlight
1729 let hover_point = cx.pixel_position_for(midpoint);
1730 cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1731 cx.background_executor.run_until_parked();
1732 cx.update_editor(|editor, window, cx| {
1733 let snapshot = editor.snapshot(window, cx);
1734 let actual_highlights = snapshot
1735 .inlay_highlights(HighlightKey::HoveredLinkState)
1736 .into_iter()
1737 .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1738 .collect::<Vec<_>>();
1739
1740 let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1741 let expected_highlight = InlayHighlight {
1742 inlay: InlayId::Hint(0),
1743 inlay_position: buffer_snapshot.anchor_after(MultiBufferOffset(inlay_range.start)),
1744 range: 0..hint_label.len(),
1745 };
1746 assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1747 });
1748
1749 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1750 // Assert no link highlights
1751 cx.update_editor(|editor, window, cx| {
1752 let snapshot = editor.snapshot(window, cx);
1753 let actual_ranges = snapshot
1754 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1755 .map(|ranges| ranges.as_ref().clone().1)
1756 .unwrap_or_default();
1757
1758 assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1759 });
1760
1761 cx.simulate_modifiers_change(Modifiers::secondary_key());
1762 cx.background_executor.run_until_parked();
1763 cx.simulate_click(hover_point, Modifiers::secondary_key());
1764 cx.background_executor.run_until_parked();
1765 cx.assert_editor_state(indoc! {"
1766 struct «TestStructˇ»;
1767
1768 fn main() {
1769 let variable = TestStruct;
1770 }
1771 "});
1772 }
1773
1774 #[gpui::test]
1775 async fn test_urls(cx: &mut gpui::TestAppContext) {
1776 init_test(cx, |_| {});
1777 let mut cx = EditorLspTestContext::new_rust(
1778 lsp::ServerCapabilities {
1779 ..Default::default()
1780 },
1781 cx,
1782 )
1783 .await;
1784
1785 cx.set_state(indoc! {"
1786 Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1787 "});
1788
1789 let screen_coord = cx.pixel_position(indoc! {"
1790 Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1791 "});
1792
1793 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1794 cx.assert_editor_text_highlights(
1795 HighlightKey::HoveredLinkState,
1796 indoc! {"
1797 Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1798 "},
1799 );
1800
1801 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1802 assert_eq!(
1803 cx.opened_url(),
1804 Some("https://zed.dev/channel/had-(oops)".into())
1805 );
1806 }
1807
1808 #[gpui::test]
1809 async fn test_hover_preconditions(cx: &mut gpui::TestAppContext) {
1810 init_test(cx, |_| {});
1811 let mut cx = EditorLspTestContext::new_rust(
1812 lsp::ServerCapabilities {
1813 ..Default::default()
1814 },
1815 cx,
1816 )
1817 .await;
1818
1819 macro_rules! assert_no_highlight {
1820 ($cx:expr) => {
1821 // No highlight
1822 $cx.update_editor(|editor, window, cx| {
1823 assert!(
1824 editor
1825 .snapshot(window, cx)
1826 .text_highlight_ranges(HighlightKey::HoveredLinkState)
1827 .unwrap_or_default()
1828 .1
1829 .is_empty()
1830 );
1831 });
1832 };
1833 }
1834
1835 // No link
1836 cx.set_state(indoc! {"
1837 Let's test a [complex](https://zed.dev/channel/) caseˇ.
1838 "});
1839 assert_no_highlight!(cx);
1840
1841 // No modifier
1842 let screen_coord = cx.pixel_position(indoc! {"
1843 Let's test a [complex](https://zed.dev/channel/ˇ) case.
1844 "});
1845 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
1846 assert_no_highlight!(cx);
1847
1848 // Modifier active
1849 let screen_coord = cx.pixel_position(indoc! {"
1850 Let's test a [complex](https://zed.dev/channeˇl/) case.
1851 "});
1852 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1853 cx.assert_editor_text_highlights(
1854 HighlightKey::HoveredLinkState,
1855 indoc! {"
1856 Let's test a [complex](«https://zed.dev/channel/ˇ») case.
1857 "},
1858 );
1859 }
1860
1861 #[gpui::test]
1862 async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1863 init_test(cx, |_| {});
1864 let mut cx = EditorLspTestContext::new_rust(
1865 lsp::ServerCapabilities {
1866 ..Default::default()
1867 },
1868 cx,
1869 )
1870 .await;
1871
1872 cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1873
1874 let screen_coord =
1875 cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1876
1877 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1878 cx.assert_editor_text_highlights(
1879 HighlightKey::HoveredLinkState,
1880 indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1881 );
1882
1883 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1884 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1885 }
1886
1887 #[gpui::test]
1888 async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1889 init_test(cx, |_| {});
1890 let mut cx = EditorLspTestContext::new_rust(
1891 lsp::ServerCapabilities {
1892 ..Default::default()
1893 },
1894 cx,
1895 )
1896 .await;
1897
1898 cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1899
1900 let screen_coord =
1901 cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1902
1903 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1904 cx.assert_editor_text_highlights(
1905 HighlightKey::HoveredLinkState,
1906 indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1907 );
1908
1909 cx.simulate_click(screen_coord, Modifiers::secondary_key());
1910 assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1911 }
1912
1913 #[test]
1914 fn test_link_pattern_file_candidates() {
1915 // Full markdown link: [LinkTitle](link_file.txt)
1916 // Trimmed strips [ and ), regex extracts link destination, raw is fallback
1917 let candidates: Vec<String> = link_pattern_file_candidates("[LinkTitle](link_file.txt)")
1918 .into_iter()
1919 .map(|(c, _)| c)
1920 .collect();
1921 assert_eq!(
1922 candidates,
1923 vec"
1927 ]
1928 );
1929
1930 // Link title with spaces (token starts mid-link)
1931 let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link_file.txt)")
1932 .into_iter()
1933 .map(|(c, _)| c)
1934 .collect();
1935 assert_eq!(
1936 candidates,
1937 vec"
1941 ]
1942 );
1943
1944 // Link with escaped spaces
1945 let candidates: Vec<String> = link_pattern_file_candidates("LinkTitle](link\\ _file.txt)")
1946 .into_iter()
1947 .map(|(c, _)| c)
1948 .collect();
1949 assert_eq!(
1950 candidates,
1951 vec"
1955 ]
1956 );
1957
1958 // Bare parentheses: (link_file.txt)
1959 let candidates: Vec<String> = link_pattern_file_candidates("(link_file.txt)")
1960 .into_iter()
1961 .map(|(c, _)| c)
1962 .collect();
1963 assert_eq!(candidates, vec!["link_file.txt", "(link_file.txt)"]);
1964
1965 // Trailing paren only: link_file.txt)
1966 let candidates: Vec<String> = link_pattern_file_candidates("link_file.txt)")
1967 .into_iter()
1968 .map(|(c, _)| c)
1969 .collect();
1970 assert_eq!(candidates, vec!["link_file.txt", "link_file.txt)"]);
1971
1972 // Trailing backtick only: link_file.txt`
1973 let candidates: Vec<String> = link_pattern_file_candidates("link_file.txt`")
1974 .into_iter()
1975 .map(|(c, _)| c)
1976 .collect();
1977 assert_eq!(candidates, vec!["link_file.txt", "link_file.txt`"]);
1978
1979 // Wrapped in backticks: `link_file.txt`
1980 let candidates: Vec<String> = link_pattern_file_candidates("`link_file.txt`")
1981 .into_iter()
1982 .map(|(c, _)| c)
1983 .collect();
1984 assert_eq!(candidates, vec!["link_file.txt", "`link_file.txt`"]);
1985
1986 // Trailing period (sentence ending): link_file.txt.
1987 let candidates: Vec<String> = link_pattern_file_candidates("link_file.txt.")
1988 .into_iter()
1989 .map(|(c, _)| c)
1990 .collect();
1991 assert_eq!(candidates, vec!["link_file.txt", "link_file.txt."]);
1992
1993 // Nested parens - regex finds first (...) capturing inner content
1994 let candidates: Vec<String> =
1995 link_pattern_file_candidates("LinkTitle](link_(link_file)file.txt)")
1996 .into_iter()
1997 .map(|(c, _)| c)
1998 .collect();
1999 assert_eq!(
2000 candidates,
2001 vecfile.txt",
2003 "link_(link_file",
2004 "LinkTitle](link_(link_file)file.txt)"
2005 ]
2006 );
2007 }
2008
2009 #[gpui::test]
2010 async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
2011 init_test(cx, |_| {});
2012 let mut cx = EditorLspTestContext::new_rust(
2013 lsp::ServerCapabilities {
2014 ..Default::default()
2015 },
2016 cx,
2017 )
2018 .await;
2019
2020 let test_cases = [
2021 ("file ˇ name", None),
2022 ("ˇfile name", Some("file")),
2023 ("file ˇname", Some("name")),
2024 ("fiˇle name", Some("file")),
2025 ("filenˇame", Some("filename")),
2026 // Absolute path
2027 ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
2028 ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
2029 // Windows
2030 ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
2031 // Whitespace
2032 ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
2033 ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
2034 // Tilde
2035 ("ˇ~/file.txt", Some("~/file.txt")),
2036 ("~/fiˇle.txt", Some("~/file.txt")),
2037 // Double quotes
2038 ("\"fˇile.txt\"", Some("file.txt")),
2039 ("ˇ\"file.txt\"", Some("file.txt")),
2040 ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
2041 // Single quotes
2042 ("'fˇile.txt'", Some("file.txt")),
2043 ("ˇ'file.txt'", Some("file.txt")),
2044 ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
2045 // Quoted multibyte characters
2046 (" ˇ\"常\"", Some("常")),
2047 (" \"ˇ常\"", Some("常")),
2048 ("ˇ\"常\"", Some("常")),
2049 // Backticks (surrounding_filename returns the full token including backticks)
2050 ("`fiˇle.txt`", Some("`file.txt`")),
2051 ("open `fiˇle.txt` please", Some("`file.txt`")),
2052 // Parentheses (surrounding_filename returns the full token including parens)
2053 ("(fiˇle.txt)", Some("(file.txt)")),
2054 ("open (fiˇle.txt) please", Some("(file.txt)")),
2055 ];
2056
2057 for (input, expected) in test_cases {
2058 cx.set_state(input);
2059
2060 let (position, snapshot) = cx.editor(|editor, _, cx| {
2061 let positions = editor
2062 .selections
2063 .newest_anchor()
2064 .head()
2065 .expect_text_anchor();
2066 let snapshot = editor
2067 .buffer()
2068 .clone()
2069 .read(cx)
2070 .as_singleton()
2071 .unwrap()
2072 .read(cx)
2073 .snapshot();
2074 (positions, snapshot)
2075 });
2076
2077 let result = surrounding_filename(&snapshot, position);
2078
2079 if let Some(expected) = expected {
2080 assert!(result.is_some(), "Failed to find file path: {}", input);
2081 let (_, path) = result.unwrap();
2082 assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
2083 } else {
2084 assert!(
2085 result.is_none(),
2086 "Expected no result, but got one: {:?}",
2087 result
2088 );
2089 }
2090 }
2091 }
2092
2093 #[gpui::test]
2094 async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
2095 init_test(cx, |_| {});
2096 let mut cx = EditorLspTestContext::new_rust(
2097 lsp::ServerCapabilities {
2098 ..Default::default()
2099 },
2100 cx,
2101 )
2102 .await;
2103
2104 // Insert a new file
2105 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2106 fs.as_fake()
2107 .insert_file(
2108 path!("/root/dir/file2.rs"),
2109 "This is file2.rs".as_bytes().to_vec(),
2110 )
2111 .await;
2112
2113 // Base document with {ABS} placeholder for absolute path prefix.
2114 // Each test case replaces a specific line to add cursor (ˇ) or highlight («»ˇ) markers.
2115 #[cfg(not(target_os = "windows"))]
2116 const ABS: &str = "/root/dir";
2117 #[cfg(target_os = "windows")]
2118 const ABS: &str = "C:/root/dir";
2119
2120 let base = format!(
2121 "\
2122You can't go to a file that does_not_exist.txt.
2123Go to file2.rs if you want.
2124Or go to ../dir/file2.rs if you want.
2125Or go to {ABS}/file2.rs if project is local.
2126Or go to {ABS}/file2 if this is a Rust file.
2127Or `file2.rs` in backticks.
2128Or (file2.rs) in parens.
2129Or [link](file2.rs) markdown style.
2130A file (named file2.rs) in prose.
2131Read with `cat file2.rs` command.
2132Sentence ending file2.rs.
2133"
2134 );
2135
2136 cx.set_state(&format!("{base}ˇ"));
2137
2138 // Test cases: (original_line, cursor_line, highlight_line)
2139 // - cursor_line: the line with ˇ to position the mouse
2140 // - highlight_line: None = expect no highlight, Some(...) = expect this highlight
2141 let test_cases: &[(&str, &str, Option<&str>)] = &[
2142 // File does not exist - no highlight
2143 ("does_not_exist.txt", "dˇoes_not_exist.txt", None),
2144 // Simple filename
2145 (
2146 "Go to file2.rs if",
2147 "Go to fˇile2.rs if",
2148 Some("Go to «file2.rsˇ» if"),
2149 ),
2150 // Relative path
2151 (
2152 "Or go to ../dir/file2.rs if",
2153 "Or go to ../dir/fˇile2.rs if",
2154 Some("Or go to «../dir/file2.rsˇ» if"),
2155 ),
2156 // Absolute path
2157 (
2158 &format!("Or go to {ABS}/file2.rs if"),
2159 &format!("Or go to {ABS}/fiˇle2.rs if"),
2160 Some(&format!("Or go to «{ABS}/file2.rsˇ» if")),
2161 ),
2162 // Path without extension (language suffix added)
2163 (
2164 &format!("Or go to {ABS}/file2 if"),
2165 &format!("Or go to {ABS}/fiˇle2 if"),
2166 Some(&format!("Or go to «{ABS}/file2ˇ» if")),
2167 ),
2168 // Backticks
2169 (
2170 "Or `file2.rs` in backticks",
2171 "Or `fiˇle2.rs` in backticks",
2172 Some("Or `«file2.rsˇ»` in backticks"),
2173 ),
2174 // Parentheses
2175 (
2176 "Or (file2.rs) in parens",
2177 "Or (fiˇle2.rs) in parens",
2178 Some("Or («file2.rsˇ») in parens"),
2179 ),
2180 // Markdown link
2181 (
2182 "Or [link](file2.rs) markdown",
2183 "Or [link](fiˇle2.rs) markdown",
2184 Some("Or [link](«file2.rsˇ») markdown"),
2185 ),
2186 // Partial wrapper: trailing paren in prose like "(named file2.rs)"
2187 (
2188 "A file (named file2.rs) in",
2189 "A file (named fiˇle2.rs) in",
2190 Some("A file (named «file2.rsˇ») in"),
2191 ),
2192 // Partial wrapper: inside code span like "`cat file2.rs`"
2193 (
2194 "Read with `cat file2.rs` command",
2195 "Read with `cat fiˇle2.rs` command",
2196 Some("Read with `cat «file2.rsˇ»` command"),
2197 ),
2198 // Trailing period at end of sentence
2199 (
2200 "Sentence ending file2.rs.",
2201 "Sentence ending fiˇle2.rs.",
2202 Some("Sentence ending «file2.rsˇ»."),
2203 ),
2204 ];
2205
2206 for (original, cursor_version, highlight_version) in test_cases {
2207 let position_text = base.replace(original, cursor_version);
2208 let screen_coord = cx.pixel_position(&position_text);
2209 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2210
2211 if let Some(highlight) = highlight_version {
2212 let expected = base.replace(original, highlight);
2213 cx.assert_editor_text_highlights(HighlightKey::HoveredLinkState, &expected);
2214 } else {
2215 // Expect no highlight
2216 cx.update_editor(|editor, window, cx| {
2217 assert!(
2218 editor
2219 .snapshot(window, cx)
2220 .text_highlight_ranges(HighlightKey::HoveredLinkState)
2221 .unwrap_or_default()
2222 .1
2223 .is_empty(),
2224 "Expected no highlight for cursor at: {}",
2225 cursor_version
2226 );
2227 });
2228 }
2229 }
2230
2231 // Test click navigation on markdown link
2232 let position_text = base.replace(
2233 "Or [link](file2.rs) markdown",
2234 "Or [link](fiˇle2.rs) markdown",
2235 );
2236 let screen_coord = cx.pixel_position(&position_text);
2237 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2238
2239 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2240 cx.update_workspace(|workspace, _, cx| {
2241 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2242
2243 let buffer = active_editor
2244 .read(cx)
2245 .buffer()
2246 .read(cx)
2247 .as_singleton()
2248 .unwrap();
2249
2250 let file = buffer.read(cx).file().unwrap();
2251 let file_path = file.as_local().unwrap().abs_path(cx);
2252
2253 assert_eq!(
2254 file_path,
2255 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
2256 );
2257 });
2258 }
2259
2260 #[gpui::test]
2261 async fn test_hover_filename_with_row_column(cx: &mut gpui::TestAppContext) {
2262 init_test(cx, |_| {});
2263 let mut cx = EditorLspTestContext::new_rust(
2264 lsp::ServerCapabilities {
2265 ..Default::default()
2266 },
2267 cx,
2268 )
2269 .await;
2270
2271 // Insert a new file with multiple lines
2272 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2273 fs.as_fake()
2274 .insert_file(
2275 path!("/root/dir/file2.rs"),
2276 "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n"
2277 .as_bytes()
2278 .to_vec(),
2279 )
2280 .await;
2281
2282 // file2.rs:5:3 should be highlighted and clickable
2283 cx.set_state(indoc! {"
2284 Go to file2.rs:5:3 for the fix.ˇ
2285 "});
2286
2287 let screen_coord = cx.pixel_position(indoc! {"
2288 Go to filˇe2.rs:5:3 for the fix.
2289 "});
2290
2291 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2292 cx.assert_editor_text_highlights(
2293 HighlightKey::HoveredLinkState,
2294 indoc! {"
2295 Go to «file2.rs:5:3ˇ» for the fix.
2296 "},
2297 );
2298
2299 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2300
2301 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
2302 cx.update_workspace(|workspace, window, cx| {
2303 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2304 {
2305 let editor = active_editor.read(cx);
2306 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
2307 let file = buffer.read(cx).file().unwrap();
2308 let file_path = file.as_local().unwrap().abs_path(cx);
2309 assert_eq!(
2310 file_path,
2311 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
2312 );
2313 }
2314
2315 // Check that the cursor is at row 5, column 3 (0-indexed: row 4, col 2)
2316 let (count, snapshot) = active_editor.update(cx, |editor, cx| {
2317 (editor.selections.count(), editor.snapshot(window, cx))
2318 });
2319 assert_eq!(count, 1);
2320 let selections = active_editor
2321 .read(cx)
2322 .selections
2323 .newest::<language::Point>(&snapshot.display_snapshot);
2324 assert_eq!(
2325 selections.head().row,
2326 4,
2327 "Expected cursor on row 5 (0-indexed: 4)"
2328 );
2329 assert_eq!(
2330 selections.head().column,
2331 2,
2332 "Expected cursor on column 3 (0-indexed: 2)"
2333 );
2334 });
2335 }
2336
2337 #[gpui::test]
2338 async fn test_hover_filename_with_row_only(cx: &mut gpui::TestAppContext) {
2339 init_test(cx, |_| {});
2340 let mut cx = EditorLspTestContext::new_rust(
2341 lsp::ServerCapabilities {
2342 ..Default::default()
2343 },
2344 cx,
2345 )
2346 .await;
2347
2348 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2349 fs.as_fake()
2350 .insert_file(
2351 path!("/root/dir/file2.rs"),
2352 "line 1\nline 2\nline 3\nline 4\nline 5\n"
2353 .as_bytes()
2354 .to_vec(),
2355 )
2356 .await;
2357
2358 // file2.rs:3 should be highlighted and clickable
2359 cx.set_state(indoc! {"
2360 Go to file2.rs:3 please.ˇ
2361 "});
2362
2363 let screen_coord = cx.pixel_position(indoc! {"
2364 Go to filˇe2.rs:3 please.
2365 "});
2366
2367 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2368 cx.assert_editor_text_highlights(
2369 HighlightKey::HoveredLinkState,
2370 indoc! {"
2371 Go to «file2.rs:3ˇ» please.
2372 "},
2373 );
2374
2375 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2376
2377 cx.update_workspace(|workspace, window, cx| {
2378 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2379 let (count, snapshot) = active_editor.update(cx, |editor, cx| {
2380 (editor.selections.count(), editor.snapshot(window, cx))
2381 });
2382 assert_eq!(count, 1);
2383 let selections = active_editor
2384 .read(cx)
2385 .selections
2386 .newest::<language::Point>(&snapshot.display_snapshot);
2387 assert_eq!(
2388 selections.head().row,
2389 2,
2390 "Expected cursor on row 3 (0-indexed: 2)"
2391 );
2392 assert_eq!(selections.head().column, 0, "Expected cursor on column 0");
2393 });
2394 }
2395
2396 #[gpui::test]
2397 async fn test_hover_filename_with_non_numeric_suffix(cx: &mut gpui::TestAppContext) {
2398 init_test(cx, |_| {});
2399 let mut cx = EditorLspTestContext::new_rust(
2400 lsp::ServerCapabilities {
2401 ..Default::default()
2402 },
2403 cx,
2404 )
2405 .await;
2406
2407 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2408 fs.as_fake()
2409 .insert_file(
2410 path!("/root/dir/file2.rs"),
2411 "line 1\nline 2\nline 3\n".as_bytes().to_vec(),
2412 )
2413 .await;
2414
2415 // file2.rs:2:in should resolve to file2.rs line 2 (like Ruby backtraces)
2416 cx.set_state(indoc! {"
2417 Error at file2.rs:2:in 'method'ˇ
2418 "});
2419
2420 let screen_coord = cx.pixel_position(indoc! {"
2421 Error at filˇe2.rs:2:in 'method'
2422 "});
2423
2424 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2425 cx.assert_editor_text_highlights(
2426 HighlightKey::HoveredLinkState,
2427 indoc! {"
2428 Error at «file2.rs:2:inˇ» 'method'
2429 "},
2430 );
2431
2432 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2433
2434 cx.update_workspace(|workspace, window, cx| {
2435 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2436 let (count, snapshot) = active_editor.update(cx, |editor, cx| {
2437 (editor.selections.count(), editor.snapshot(window, cx))
2438 });
2439 assert_eq!(count, 1);
2440 let selections = active_editor
2441 .read(cx)
2442 .selections
2443 .newest::<language::Point>(&snapshot.display_snapshot);
2444 assert_eq!(
2445 selections.head().row,
2446 1,
2447 "Expected cursor on row 2 (0-indexed: 1)"
2448 );
2449 });
2450 }
2451
2452 #[gpui::test]
2453 async fn test_hover_markdown_link_with_row_column(cx: &mut gpui::TestAppContext) {
2454 init_test(cx, |_| {});
2455 let mut cx = EditorLspTestContext::new_rust(
2456 lsp::ServerCapabilities {
2457 ..Default::default()
2458 },
2459 cx,
2460 )
2461 .await;
2462
2463 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2464 fs.as_fake()
2465 .insert_file(
2466 path!("/root/dir/file2.rs"),
2467 "line 1\nline 2\nline 3\nline 4\nline 5\n"
2468 .as_bytes()
2469 .to_vec(),
2470 )
2471 .await;
2472
2473 // Markdown link [text](file2.rs:3:2) should highlight only the inner link,
2474 // not the surrounding markdown syntax.
2475 cx.set_state(indoc! {"
2476 See [here](file2.rs:3:2) for details.ˇ
2477 "});
2478
2479 let screen_coord = cx.pixel_position(indoc! {"
2480 See [here](filˇe2.rs:3:2) for details.
2481 "});
2482
2483 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2484 cx.assert_editor_text_highlights(
2485 HighlightKey::HoveredLinkState,
2486 indoc! {"
2487 See [here](«file2.rs:3:2ˇ») for details.
2488 "},
2489 );
2490
2491 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2492
2493 cx.update_workspace(|workspace, window, cx| {
2494 let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
2495 {
2496 let editor = active_editor.read(cx);
2497 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
2498 let file = buffer.read(cx).file().unwrap();
2499 let file_path = file.as_local().unwrap().abs_path(cx);
2500 assert_eq!(
2501 file_path,
2502 std::path::PathBuf::from(path!("/root/dir/file2.rs"))
2503 );
2504 }
2505
2506 // Check cursor is at row 3, column 2 (0-indexed: row 2, col 1)
2507 let (count, snapshot) = active_editor.update(cx, |editor, cx| {
2508 (editor.selections.count(), editor.snapshot(window, cx))
2509 });
2510 assert_eq!(count, 1);
2511 let selections = active_editor
2512 .read(cx)
2513 .selections
2514 .newest::<language::Point>(&snapshot.display_snapshot);
2515 assert_eq!(
2516 selections.head().row,
2517 2,
2518 "Expected cursor on row 3 (0-indexed: 2)"
2519 );
2520 assert_eq!(
2521 selections.head().column,
2522 1,
2523 "Expected cursor on column 2 (0-indexed: 1)"
2524 );
2525 });
2526 }
2527
2528 #[gpui::test]
2529 async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
2530 init_test(cx, |_| {});
2531 let mut cx = EditorLspTestContext::new_rust(
2532 lsp::ServerCapabilities {
2533 ..Default::default()
2534 },
2535 cx,
2536 )
2537 .await;
2538
2539 // Insert a new file
2540 let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
2541 fs.as_fake()
2542 .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
2543 .await;
2544
2545 cx.set_state(indoc! {"
2546 You can't open ../diˇr because it's a directory.
2547 "});
2548
2549 // File does not exist
2550 let screen_coord = cx.pixel_position(indoc! {"
2551 You can't open ../diˇr because it's a directory.
2552 "});
2553 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2554
2555 // No highlight
2556 cx.update_editor(|editor, window, cx| {
2557 assert!(
2558 editor
2559 .snapshot(window, cx)
2560 .text_highlight_ranges(HighlightKey::HoveredLinkState)
2561 .unwrap_or_default()
2562 .1
2563 .is_empty()
2564 );
2565 });
2566
2567 // Does not open the directory
2568 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2569 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2570 }
2571
2572 #[gpui::test]
2573 async fn test_hover_unicode(cx: &mut gpui::TestAppContext) {
2574 init_test(cx, |_| {});
2575 let mut cx = EditorLspTestContext::new_rust(
2576 lsp::ServerCapabilities {
2577 ..Default::default()
2578 },
2579 cx,
2580 )
2581 .await;
2582
2583 cx.set_state(indoc! {"
2584 You can't open ˇ\"🤩\" because it's an emoji.
2585 "});
2586
2587 // File does not exist
2588 let screen_coord = cx.pixel_position(indoc! {"
2589 You can't open ˇ\"🤩\" because it's an emoji.
2590 "});
2591 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2592
2593 // No highlight, does not panic...
2594 cx.update_editor(|editor, window, cx| {
2595 assert!(
2596 editor
2597 .snapshot(window, cx)
2598 .text_highlight_ranges(HighlightKey::HoveredLinkState)
2599 .unwrap_or_default()
2600 .1
2601 .is_empty()
2602 );
2603 });
2604
2605 // Does not open the directory
2606 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2607 cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
2608 }
2609
2610 #[gpui::test]
2611 async fn test_pressure_links(cx: &mut gpui::TestAppContext) {
2612 init_test(cx, |_| {});
2613
2614 let mut cx = EditorLspTestContext::new_rust(
2615 lsp::ServerCapabilities {
2616 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
2617 definition_provider: Some(lsp::OneOf::Left(true)),
2618 ..Default::default()
2619 },
2620 cx,
2621 )
2622 .await;
2623
2624 cx.set_state(indoc! {"
2625 fn ˇtest() { do_work(); }
2626 fn do_work() { test(); }
2627 "});
2628
2629 // Position the mouse over a symbol that has a definition
2630 let hover_point = cx.pixel_position(indoc! {"
2631 fn test() { do_wˇork(); }
2632 fn do_work() { test(); }
2633 "});
2634 let symbol_range = cx.lsp_range(indoc! {"
2635 fn test() { «do_work»(); }
2636 fn do_work() { test(); }
2637 "});
2638 let target_range = cx.lsp_range(indoc! {"
2639 fn test() { do_work(); }
2640 fn «do_work»() { test(); }
2641 "});
2642
2643 let mut requests =
2644 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
2645 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
2646 lsp::LocationLink {
2647 origin_selection_range: Some(symbol_range),
2648 target_uri: url.clone(),
2649 target_range,
2650 target_selection_range: target_range,
2651 },
2652 ])))
2653 });
2654
2655 cx.simulate_mouse_move(hover_point, None, Modifiers::none());
2656
2657 // First simulate Normal pressure to set up the previous stage
2658 cx.simulate_event(MousePressureEvent {
2659 pressure: 0.5,
2660 stage: PressureStage::Normal,
2661 position: hover_point,
2662 modifiers: Modifiers::none(),
2663 });
2664 cx.background_executor.run_until_parked();
2665
2666 // Now simulate Force pressure to trigger the force click and go-to definition
2667 cx.simulate_event(MousePressureEvent {
2668 pressure: 1.0,
2669 stage: PressureStage::Force,
2670 position: hover_point,
2671 modifiers: Modifiers::none(),
2672 });
2673 requests.next().await;
2674 cx.background_executor.run_until_parked();
2675
2676 // Assert that we navigated to the definition
2677 cx.assert_editor_state(indoc! {"
2678 fn test() { do_work(); }
2679 fn «do_workˇ»() { test(); }
2680 "});
2681 }
2682
2683 #[gpui::test]
2684 async fn test_document_links(cx: &mut gpui::TestAppContext) {
2685 init_test(cx, |_| {});
2686
2687 let mut cx = EditorLspTestContext::new_rust(
2688 lsp::ServerCapabilities {
2689 document_link_provider: Some(lsp::DocumentLinkOptions {
2690 resolve_provider: Some(false),
2691 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
2692 }),
2693 ..lsp::ServerCapabilities::default()
2694 },
2695 cx,
2696 )
2697 .await;
2698
2699 cx.set_state(indoc! {"
2700 // See LICENSE for details
2701 fn main() {
2702 println!(\"hello\");
2703 }ˇ
2704 "});
2705
2706 let link_range = cx.lsp_range(indoc! {"
2707 // See «LICENSE» for details
2708 fn main() {
2709 println!(\"hello\");
2710 }
2711 "});
2712
2713 let mut requests = cx
2714 .lsp
2715 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
2716 move |_, _| async move {
2717 Ok(Some(vec![lsp::DocumentLink {
2718 range: link_range,
2719 target: Some(
2720 lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
2721 ),
2722 tooltip: Some("Open license".to_string()),
2723 data: None,
2724 }]))
2725 },
2726 );
2727
2728 // Trigger document link fetch via LSP data refresh
2729 cx.run_until_parked();
2730 requests.next().await;
2731 cx.run_until_parked();
2732
2733 // Cmd-hover over "LICENSE" should highlight it as a link
2734 let screen_coord = cx.pixel_position(indoc! {"
2735 // See LICˇENSE for details
2736 fn main() {
2737 println!(\"hello\");
2738 }
2739 "});
2740
2741 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2742 cx.run_until_parked();
2743
2744 cx.assert_editor_text_highlights(
2745 HighlightKey::HoveredLinkState,
2746 indoc! {"
2747 // See «LICENSEˇ» for details
2748 fn main() {
2749 println!(\"hello\");
2750 }
2751 "},
2752 );
2753
2754 // Clicking opens the URL
2755 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2756 assert_eq!(
2757 cx.opened_url(),
2758 Some("https://opensource.org/licenses/MIT".into())
2759 );
2760 }
2761
2762 #[gpui::test]
2763 async fn test_document_links_take_priority_over_url_detection(cx: &mut gpui::TestAppContext) {
2764 init_test(cx, |_| {});
2765
2766 let mut cx = EditorLspTestContext::new_rust(
2767 lsp::ServerCapabilities {
2768 document_link_provider: Some(lsp::DocumentLinkOptions {
2769 resolve_provider: Some(false),
2770 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
2771 }),
2772 ..lsp::ServerCapabilities::default()
2773 },
2774 cx,
2775 )
2776 .await;
2777
2778 // Text contains a URL, but the LSP provides a document link that
2779 // covers a broader range and points to a different target.
2780 cx.set_state(indoc! {"
2781 // See https://example.com for more infoˇ
2782 "});
2783
2784 let link_range = cx.lsp_range(indoc! {"
2785 // «See https://example.com for more info»
2786 "});
2787
2788 let mut requests = cx
2789 .lsp
2790 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
2791 move |_, _| async move {
2792 Ok(Some(vec![lsp::DocumentLink {
2793 range: link_range,
2794 target: Some(
2795 lsp::Uri::from_str("https://lsp-provided.example.com").unwrap(),
2796 ),
2797 tooltip: None,
2798 data: None,
2799 }]))
2800 },
2801 );
2802
2803 cx.run_until_parked();
2804 requests.next().await;
2805 cx.run_until_parked();
2806
2807 let screen_coord = cx.pixel_position(indoc! {"
2808 // See https://examˇple.com for more info
2809 "});
2810
2811 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2812 cx.run_until_parked();
2813
2814 // LSP document link range is highlighted, not just the URL portion
2815 cx.assert_editor_text_highlights(
2816 HighlightKey::HoveredLinkState,
2817 indoc! {"
2818 // «See https://example.com for more infoˇ»
2819 "},
2820 );
2821
2822 // Clicking navigates to the LSP-provided target, not the detected URL.
2823 // (Uri::to_string normalizes "https://host" to "https://host/")
2824 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2825 assert_eq!(
2826 cx.opened_url(),
2827 Some("https://lsp-provided.example.com/".into())
2828 );
2829 }
2830
2831 #[gpui::test]
2832 async fn test_cmd_hover_aggregates_document_link_and_definition(cx: &mut gpui::TestAppContext) {
2833 // VSCode behavior: when a position carries multiple link sources
2834 // (LSP document link, go-to-definition, ...), cmd-click should reveal
2835 // every applicable target. We assert this by inspecting the
2836 // aggregated `hovered_link_state.links` after a cmd-hover.
2837 init_test(cx, |_| {});
2838
2839 let mut cx = EditorLspTestContext::new_rust(
2840 lsp::ServerCapabilities {
2841 document_link_provider: Some(lsp::DocumentLinkOptions {
2842 resolve_provider: Some(false),
2843 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
2844 }),
2845 definition_provider: Some(lsp::OneOf::Left(true)),
2846 ..lsp::ServerCapabilities::default()
2847 },
2848 cx,
2849 )
2850 .await;
2851
2852 cx.set_state(indoc! {"
2853 // See LICENSE for details
2854 fn definition() {}ˇ
2855 "});
2856
2857 let link_range = cx.lsp_range(indoc! {"
2858 // See «LICENSE» for details
2859 fn definition() {}
2860 "});
2861 let definition_target_range = cx.lsp_range(indoc! {"
2862 // See LICENSE for details
2863 fn «definition»() {}
2864 "});
2865
2866 let mut document_link_requests = cx
2867 .lsp
2868 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
2869 move |_, _| async move {
2870 Ok(Some(vec![lsp::DocumentLink {
2871 range: link_range,
2872 target: Some(
2873 lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
2874 ),
2875 tooltip: Some("Open license".to_string()),
2876 data: None,
2877 }]))
2878 },
2879 );
2880
2881 let mut definition_requests =
2882 cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
2883 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
2884 lsp::LocationLink {
2885 origin_selection_range: Some(link_range),
2886 target_uri: url.clone(),
2887 target_range: definition_target_range,
2888 target_selection_range: definition_target_range,
2889 },
2890 ])))
2891 });
2892
2893 cx.run_until_parked();
2894 document_link_requests.next().await;
2895 cx.run_until_parked();
2896
2897 let screen_coord = cx.pixel_position(indoc! {"
2898 // See LICˇENSE for details
2899 fn definition() {}
2900 "});
2901 cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
2902 definition_requests.next().await;
2903 cx.run_until_parked();
2904
2905 cx.update_editor(|editor, _, _| {
2906 let links = &editor
2907 .hovered_link_state
2908 .as_ref()
2909 .expect("cmd-hover should populate `hovered_link_state`")
2910 .links;
2911 let url_count = links
2912 .iter()
2913 .filter(|link| matches!(link, HoverLink::Url(_)))
2914 .count();
2915 let text_count = links
2916 .iter()
2917 .filter(|link| matches!(link, HoverLink::Text(_)))
2918 .count();
2919 assert_eq!(
2920 url_count, 1,
2921 "document link should contribute exactly one Url hover link, got {links:?}"
2922 );
2923 assert_eq!(
2924 text_count, 1,
2925 "go-to-definition should contribute exactly one Text hover link, got {links:?}"
2926 );
2927 });
2928
2929 // Cmd-click resolves the in-buffer location (definition) since the
2930 // mixed Url + Text case lets `navigate_to_hover_links` prefer the
2931 // location target over the external URL.
2932 cx.simulate_click(screen_coord, Modifiers::secondary_key());
2933 cx.run_until_parked();
2934 cx.assert_editor_state(indoc! {"
2935 // See LICENSE for details
2936 fn «definitionˇ»() {}
2937 "});
2938 }
2939
2940 #[gpui::test]
2941 async fn test_document_link_tooltip_popover(cx: &mut gpui::TestAppContext) {
2942 init_test(cx, |_| {});
2943
2944 let mut cx = EditorLspTestContext::new_rust(
2945 lsp::ServerCapabilities {
2946 document_link_provider: Some(lsp::DocumentLinkOptions {
2947 resolve_provider: Some(false),
2948 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
2949 }),
2950 ..lsp::ServerCapabilities::default()
2951 },
2952 cx,
2953 )
2954 .await;
2955
2956 cx.set_state(indoc! {"
2957 // See LICENSE for detailsˇ
2958 "});
2959
2960 let link_range = cx.lsp_range(indoc! {"
2961 // See «LICENSE» for details
2962 "});
2963
2964 let mut requests = cx
2965 .lsp
2966 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
2967 move |_, _| async move {
2968 Ok(Some(vec![lsp::DocumentLink {
2969 range: link_range,
2970 target: Some(
2971 lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
2972 ),
2973 tooltip: Some("Open license".to_string()),
2974 data: None,
2975 }]))
2976 },
2977 );
2978
2979 cx.run_until_parked();
2980 requests.next().await;
2981 cx.run_until_parked();
2982
2983 let screen_coord = cx.pixel_position(indoc! {"
2984 // See LICˇENSE for details
2985 "});
2986 // Plain hover (no modifier) is enough; the doc-link tooltip stacks
2987 // alongside the regular LSP hover popovers.
2988 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
2989 let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
2990 cx.background_executor
2991 .advance_clock(std::time::Duration::from_millis(delay_ms + 100));
2992 cx.run_until_parked();
2993
2994 cx.update_editor(|editor, _, cx| {
2995 let tooltip_text = editor
2996 .hover_state
2997 .info_popovers
2998 .iter()
2999 .find_map(|popover| {
3000 let parsed = popover.parsed_content.as_ref()?;
3001 let text = parsed.read(cx).parsed_markdown().source().to_string();
3002 (text == "Open license").then_some(text)
3003 })
3004 .expect("doc-link tooltip should appear in info_popovers on plain hover");
3005 assert_eq!(tooltip_text, "Open license");
3006 });
3007
3008 // Move the mouse off the link; `show_hover` re-fires for the new
3009 // position and rebuilds `info_popovers` without the tooltip.
3010 let off_link = cx.pixel_position(indoc! {"
3011 // ˇSee LICENSE for details
3012 "});
3013 cx.simulate_mouse_move(off_link, None, Modifiers::none());
3014 cx.background_executor
3015 .advance_clock(std::time::Duration::from_millis(delay_ms + 100));
3016 cx.run_until_parked();
3017 cx.update_editor(|editor, _, cx| {
3018 let still_present = editor.hover_state.info_popovers.iter().any(|popover| {
3019 popover
3020 .parsed_content
3021 .as_ref()
3022 .map(|parsed| *parsed.read(cx).parsed_markdown().source() == "Open license")
3023 .unwrap_or(false)
3024 });
3025 assert!(
3026 !still_present,
3027 "doc-link tooltip should be cleared once the mouse leaves the link"
3028 );
3029 });
3030 }
3031
3032 #[gpui::test]
3033 async fn test_document_link_resolve_on_hover(cx: &mut gpui::TestAppContext) {
3034 init_test(cx, |_| {});
3035
3036 let mut cx = EditorLspTestContext::new_rust(
3037 lsp::ServerCapabilities {
3038 document_link_provider: Some(lsp::DocumentLinkOptions {
3039 resolve_provider: Some(true),
3040 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
3041 }),
3042 ..lsp::ServerCapabilities::default()
3043 },
3044 cx,
3045 )
3046 .await;
3047
3048 cx.set_state(indoc! {"
3049 // See LICENSE for detailsˇ
3050 "});
3051
3052 let link_range = cx.lsp_range(indoc! {"
3053 // See «LICENSE» for details
3054 "});
3055 let resolve_data = serde_json::json!({"id": 42});
3056
3057 let mut document_link_requests = {
3058 let resolve_data = resolve_data.clone();
3059 cx.lsp
3060 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |_, _| {
3061 let resolve_data = resolve_data.clone();
3062 async move {
3063 Ok(Some(vec![lsp::DocumentLink {
3064 range: link_range,
3065 target: None,
3066 tooltip: None,
3067 data: Some(resolve_data),
3068 }]))
3069 }
3070 })
3071 };
3072
3073 let mut resolve_requests = cx
3074 .lsp
3075 .set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(
3076 move |req, _| async move {
3077 Ok(lsp::DocumentLink {
3078 range: req.range,
3079 target: Some(
3080 lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
3081 ),
3082 tooltip: Some("Resolved tooltip".to_string()),
3083 data: None,
3084 })
3085 },
3086 );
3087
3088 cx.run_until_parked();
3089 document_link_requests.next().await;
3090 cx.run_until_parked();
3091
3092 let screen_coord = cx.pixel_position(indoc! {"
3093 // See LICˇENSE for details
3094 "});
3095 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
3096 let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
3097 cx.background_executor
3098 .advance_clock(std::time::Duration::from_millis(delay_ms + 100));
3099 cx.run_until_parked();
3100 // Hover triggers resolve, not a viewport sweep.
3101 resolve_requests.next().await;
3102 cx.run_until_parked();
3103
3104 cx.update_editor(|editor, _, cx| {
3105 let tooltip_text = editor
3106 .hover_state
3107 .info_popovers
3108 .iter()
3109 .find_map(|popover| {
3110 let parsed = popover.parsed_content.as_ref()?;
3111 let text = parsed.read(cx).parsed_markdown().source().to_string();
3112 (text == "Resolved tooltip").then_some(text)
3113 })
3114 .expect("resolved doc-link tooltip should appear in info_popovers");
3115 assert_eq!(tooltip_text, "Resolved tooltip");
3116 });
3117 }
3118
3119 #[gpui::test]
3120 async fn test_document_link_tooltip_respects_hover_popover_enabled(
3121 cx: &mut gpui::TestAppContext,
3122 ) {
3123 init_test(cx, |_| {});
3124
3125 cx.update(|cx| {
3126 use gpui::BorrowAppContext as _;
3127 cx.update_global::<settings::SettingsStore, _>(|settings, cx| {
3128 settings.update_user_settings(cx, |settings| {
3129 settings.editor.hover_popover_enabled = Some(false);
3130 });
3131 });
3132 });
3133
3134 let mut cx = EditorLspTestContext::new_rust(
3135 lsp::ServerCapabilities {
3136 document_link_provider: Some(lsp::DocumentLinkOptions {
3137 resolve_provider: Some(false),
3138 work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
3139 }),
3140 ..lsp::ServerCapabilities::default()
3141 },
3142 cx,
3143 )
3144 .await;
3145
3146 cx.set_state(indoc! {"
3147 // See LICENSE for detailsˇ
3148 "});
3149
3150 let link_range = cx.lsp_range(indoc! {"
3151 // See «LICENSE» for details
3152 "});
3153
3154 let mut requests = cx
3155 .lsp
3156 .set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
3157 move |_, _| async move {
3158 Ok(Some(vec![lsp::DocumentLink {
3159 range: link_range,
3160 target: Some(
3161 lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
3162 ),
3163 tooltip: Some("Open license".to_string()),
3164 data: None,
3165 }]))
3166 },
3167 );
3168
3169 cx.run_until_parked();
3170 requests.next().await;
3171 cx.run_until_parked();
3172
3173 let screen_coord = cx.pixel_position(indoc! {"
3174 // See LICˇENSE for details
3175 "});
3176 cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
3177 cx.background_executor
3178 .advance_clock(std::time::Duration::from_millis(2000));
3179 cx.run_until_parked();
3180
3181 cx.update_editor(|editor, _, _| {
3182 assert!(
3183 editor.hover_state.info_popovers.is_empty(),
3184 "no popovers should appear when hover_popover_enabled is false"
3185 );
3186 });
3187 }
3188}
3189