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