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