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