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