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