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