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