hover_links.rs

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