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