Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:33:04.272Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

hover_links.rs

3189 lines · 117.3 KB · rust
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![
1924                "LinkTitle](link_file.txt",
1925                "link_file.txt",
1926                "[LinkTitle](link_file.txt)"
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![
1938                "LinkTitle](link_file.txt",
1939                "link_file.txt",
1940                "LinkTitle](link_file.txt)"
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![
1952                "LinkTitle](link\\ _file.txt",
1953                "link\\ _file.txt",
1954                "LinkTitle](link\\ _file.txt)"
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            vec![
2002                "LinkTitle](link_(link_file)file.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
Served at tenant.openagents/omega Member data and write actions are omitted.