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