hover_links.rs

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