hover_links.rs

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