hover_links.rs

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