hover_links.rs

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