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