hover_links.rs

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