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 multi_buffer::MultiBufferOffset;
 742    use settings::InlayHintSettingsContent;
 743    use util::{assert_set_eq, path};
 744    use workspace::item::Item;
 745
 746    #[gpui::test]
 747    async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
 748        init_test(cx, |_| {});
 749
 750        let mut cx = EditorLspTestContext::new_rust(
 751            lsp::ServerCapabilities {
 752                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
 753                type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)),
 754                ..Default::default()
 755            },
 756            cx,
 757        )
 758        .await;
 759
 760        cx.set_state(indoc! {"
 761            struct A;
 762            let vˇariable = A;
 763        "});
 764        let screen_coord = cx.editor(|editor, _, cx| editor.pixel_position_of_cursor(cx));
 765
 766        // Basic hold cmd+shift, expect highlight in region if response contains type definition
 767        let symbol_range = cx.lsp_range(indoc! {"
 768            struct A;
 769            let «variable» = A;
 770        "});
 771        let target_range = cx.lsp_range(indoc! {"
 772            struct «A»;
 773            let variable = A;
 774        "});
 775
 776        cx.run_until_parked();
 777
 778        let mut requests =
 779            cx.set_request_handler::<GotoTypeDefinition, _, _>(move |url, _, _| async move {
 780                Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![
 781                    lsp::LocationLink {
 782                        origin_selection_range: Some(symbol_range),
 783                        target_uri: url.clone(),
 784                        target_range,
 785                        target_selection_range: target_range,
 786                    },
 787                ])))
 788            });
 789
 790        let modifiers = if cfg!(target_os = "macos") {
 791            Modifiers::command_shift()
 792        } else {
 793            Modifiers::control_shift()
 794        };
 795
 796        cx.simulate_mouse_move(screen_coord.unwrap(), None, modifiers);
 797
 798        requests.next().await;
 799        cx.run_until_parked();
 800        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 801            struct A;
 802            let «variable» = A;
 803        "});
 804
 805        cx.simulate_modifiers_change(Modifiers::secondary_key());
 806        cx.run_until_parked();
 807        // Assert no link highlights
 808        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 809            struct A;
 810            let variable = A;
 811        "});
 812
 813        cx.simulate_click(screen_coord.unwrap(), modifiers);
 814
 815        cx.assert_editor_state(indoc! {"
 816            struct «Aˇ»;
 817            let variable = A;
 818        "});
 819    }
 820
 821    #[gpui::test]
 822    async fn test_hover_links(cx: &mut gpui::TestAppContext) {
 823        init_test(cx, |_| {});
 824
 825        let mut cx = EditorLspTestContext::new_rust(
 826            lsp::ServerCapabilities {
 827                hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
 828                definition_provider: Some(lsp::OneOf::Left(true)),
 829                ..Default::default()
 830            },
 831            cx,
 832        )
 833        .await;
 834
 835        cx.set_state(indoc! {"
 836                fn ˇtest() { do_work(); }
 837                fn do_work() { test(); }
 838            "});
 839
 840        // Basic hold cmd, expect highlight in region if response contains definition
 841        let hover_point = cx.pixel_position(indoc! {"
 842                fn test() { do_wˇork(); }
 843                fn do_work() { test(); }
 844            "});
 845        let symbol_range = cx.lsp_range(indoc! {"
 846                fn test() { «do_work»(); }
 847                fn do_work() { test(); }
 848            "});
 849        let target_range = cx.lsp_range(indoc! {"
 850                fn test() { do_work(); }
 851                fn «do_work»() { test(); }
 852            "});
 853
 854        let mut requests =
 855            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
 856                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
 857                    lsp::LocationLink {
 858                        origin_selection_range: Some(symbol_range),
 859                        target_uri: url.clone(),
 860                        target_range,
 861                        target_selection_range: target_range,
 862                    },
 863                ])))
 864            });
 865
 866        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
 867        requests.next().await;
 868        cx.background_executor.run_until_parked();
 869        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 870                fn test() { «do_work»(); }
 871                fn do_work() { test(); }
 872            "});
 873
 874        // Unpress cmd causes highlight to go away
 875        cx.simulate_modifiers_change(Modifiers::none());
 876        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 877                fn test() { do_work(); }
 878                fn do_work() { test(); }
 879            "});
 880
 881        let mut requests =
 882            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
 883                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
 884                    lsp::LocationLink {
 885                        origin_selection_range: Some(symbol_range),
 886                        target_uri: url.clone(),
 887                        target_range,
 888                        target_selection_range: target_range,
 889                    },
 890                ])))
 891            });
 892
 893        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
 894        requests.next().await;
 895        cx.background_executor.run_until_parked();
 896        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 897                fn test() { «do_work»(); }
 898                fn do_work() { test(); }
 899            "});
 900
 901        // Moving mouse to location with no response dismisses highlight
 902        let hover_point = cx.pixel_position(indoc! {"
 903                fˇn test() { do_work(); }
 904                fn do_work() { test(); }
 905            "});
 906        let mut requests =
 907            cx.lsp
 908                .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
 909                    // No definitions returned
 910                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
 911                });
 912        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
 913
 914        requests.next().await;
 915        cx.background_executor.run_until_parked();
 916
 917        // Assert no link highlights
 918        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 919                fn test() { do_work(); }
 920                fn do_work() { test(); }
 921            "});
 922
 923        // // Move mouse without cmd and then pressing cmd triggers highlight
 924        let hover_point = cx.pixel_position(indoc! {"
 925                fn test() { do_work(); }
 926                fn do_work() { teˇst(); }
 927            "});
 928        cx.simulate_mouse_move(hover_point, None, Modifiers::none());
 929
 930        // Assert no link highlights
 931        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 932                fn test() { do_work(); }
 933                fn do_work() { test(); }
 934            "});
 935
 936        let symbol_range = cx.lsp_range(indoc! {"
 937                fn test() { do_work(); }
 938                fn do_work() { «test»(); }
 939            "});
 940        let target_range = cx.lsp_range(indoc! {"
 941                fn «test»() { do_work(); }
 942                fn do_work() { test(); }
 943            "});
 944
 945        let mut requests =
 946            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
 947                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
 948                    lsp::LocationLink {
 949                        origin_selection_range: Some(symbol_range),
 950                        target_uri: url,
 951                        target_range,
 952                        target_selection_range: target_range,
 953                    },
 954                ])))
 955            });
 956
 957        cx.simulate_modifiers_change(Modifiers::secondary_key());
 958
 959        requests.next().await;
 960        cx.background_executor.run_until_parked();
 961
 962        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 963                fn test() { do_work(); }
 964                fn do_work() { «test»(); }
 965            "});
 966
 967        cx.deactivate_window();
 968        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 969                fn test() { do_work(); }
 970                fn do_work() { test(); }
 971            "});
 972
 973        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
 974        cx.background_executor.run_until_parked();
 975        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 976                fn test() { do_work(); }
 977                fn do_work() { «test»(); }
 978            "});
 979
 980        // Moving again within the same symbol range doesn't re-request
 981        let hover_point = cx.pixel_position(indoc! {"
 982                fn test() { do_work(); }
 983                fn do_work() { tesˇt(); }
 984            "});
 985        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
 986        cx.background_executor.run_until_parked();
 987        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
 988                fn test() { do_work(); }
 989                fn do_work() { «test»(); }
 990            "});
 991
 992        // Cmd click with existing definition doesn't re-request and dismisses highlight
 993        cx.simulate_click(hover_point, Modifiers::secondary_key());
 994        cx.lsp
 995            .set_request_handler::<GotoDefinition, _, _>(move |_, _| async move {
 996                // Empty definition response to make sure we aren't hitting the lsp and using
 997                // the cached location instead
 998                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
 999            });
1000        cx.background_executor.run_until_parked();
1001        cx.assert_editor_state(indoc! {"
1002                fn «testˇ»() { do_work(); }
1003                fn do_work() { test(); }
1004            "});
1005
1006        // Assert no link highlights after jump
1007        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1008                fn test() { do_work(); }
1009                fn do_work() { test(); }
1010            "});
1011
1012        // Cmd click without existing definition requests and jumps
1013        let hover_point = cx.pixel_position(indoc! {"
1014                fn test() { do_wˇork(); }
1015                fn do_work() { test(); }
1016            "});
1017        let target_range = cx.lsp_range(indoc! {"
1018                fn test() { do_work(); }
1019                fn «do_work»() { test(); }
1020            "});
1021
1022        let mut requests =
1023            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1024                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1025                    lsp::LocationLink {
1026                        origin_selection_range: None,
1027                        target_uri: url,
1028                        target_range,
1029                        target_selection_range: target_range,
1030                    },
1031                ])))
1032            });
1033        cx.simulate_click(hover_point, Modifiers::secondary_key());
1034        requests.next().await;
1035        cx.background_executor.run_until_parked();
1036        cx.assert_editor_state(indoc! {"
1037                fn test() { do_work(); }
1038                fn «do_workˇ»() { test(); }
1039            "});
1040
1041        // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens
1042        // 2. Selection is completed, hovering
1043        let hover_point = cx.pixel_position(indoc! {"
1044                fn test() { do_wˇork(); }
1045                fn do_work() { test(); }
1046            "});
1047        let target_range = cx.lsp_range(indoc! {"
1048                fn test() { do_work(); }
1049                fn «do_work»() { test(); }
1050            "});
1051        let mut requests =
1052            cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
1053                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
1054                    lsp::LocationLink {
1055                        origin_selection_range: None,
1056                        target_uri: url,
1057                        target_range,
1058                        target_selection_range: target_range,
1059                    },
1060                ])))
1061            });
1062
1063        // create a pending selection
1064        let selection_range = cx.ranges(indoc! {"
1065                fn «test() { do_w»ork(); }
1066                fn do_work() { test(); }
1067            "})[0]
1068            .clone();
1069        cx.update_editor(|editor, window, cx| {
1070            let snapshot = editor.buffer().read(cx).snapshot(cx);
1071            let anchor_range = snapshot.anchor_before(MultiBufferOffset(selection_range.start))
1072                ..snapshot.anchor_after(MultiBufferOffset(selection_range.end));
1073            editor.change_selections(Default::default(), window, cx, |s| {
1074                s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character)
1075            });
1076        });
1077        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1078        cx.background_executor.run_until_parked();
1079        assert!(requests.try_next().is_err());
1080        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1081                fn test() { do_work(); }
1082                fn do_work() { test(); }
1083            "});
1084        cx.background_executor.run_until_parked();
1085    }
1086
1087    #[gpui::test]
1088    async fn test_inlay_hover_links(cx: &mut gpui::TestAppContext) {
1089        init_test(cx, |settings| {
1090            settings.defaults.inlay_hints = Some(InlayHintSettingsContent {
1091                enabled: Some(true),
1092                show_value_hints: Some(false),
1093                edit_debounce_ms: Some(0),
1094                scroll_debounce_ms: Some(0),
1095                show_type_hints: Some(true),
1096                show_parameter_hints: Some(true),
1097                show_other_hints: Some(true),
1098                show_background: Some(false),
1099                toggle_on_modifiers_press: None,
1100            })
1101        });
1102
1103        let mut cx = EditorLspTestContext::new_rust(
1104            lsp::ServerCapabilities {
1105                inlay_hint_provider: Some(lsp::OneOf::Left(true)),
1106                ..Default::default()
1107            },
1108            cx,
1109        )
1110        .await;
1111        cx.set_state(indoc! {"
1112                struct TestStruct;
1113
1114                fn main() {
1115                    let variableˇ = TestStruct;
1116                }
1117            "});
1118        let hint_start_offset = cx.ranges(indoc! {"
1119                struct TestStruct;
1120
1121                fn main() {
1122                    let variableˇ = TestStruct;
1123                }
1124            "})[0]
1125            .start;
1126        let hint_position = cx.to_lsp(MultiBufferOffset(hint_start_offset));
1127        let target_range = cx.lsp_range(indoc! {"
1128                struct «TestStruct»;
1129
1130                fn main() {
1131                    let variable = TestStruct;
1132                }
1133            "});
1134
1135        let expected_uri = cx.buffer_lsp_url.clone();
1136        let hint_label = ": TestStruct";
1137        cx.lsp
1138            .set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
1139                let expected_uri = expected_uri.clone();
1140                async move {
1141                    assert_eq!(params.text_document.uri, expected_uri);
1142                    Ok(Some(vec![lsp::InlayHint {
1143                        position: hint_position,
1144                        label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart {
1145                            value: hint_label.to_string(),
1146                            location: Some(lsp::Location {
1147                                uri: params.text_document.uri,
1148                                range: target_range,
1149                            }),
1150                            ..Default::default()
1151                        }]),
1152                        kind: Some(lsp::InlayHintKind::TYPE),
1153                        text_edits: None,
1154                        tooltip: None,
1155                        padding_left: Some(false),
1156                        padding_right: Some(false),
1157                        data: None,
1158                    }]))
1159                }
1160            })
1161            .next()
1162            .await;
1163        cx.background_executor.run_until_parked();
1164        cx.update_editor(|editor, _window, cx| {
1165            let expected_layers = vec![hint_label.to_string()];
1166            assert_eq!(expected_layers, cached_hint_labels(editor, cx));
1167            assert_eq!(expected_layers, visible_hint_labels(editor, cx));
1168        });
1169
1170        let inlay_range = cx
1171            .ranges(indoc! {"
1172                struct TestStruct;
1173
1174                fn main() {
1175                    let variable« »= TestStruct;
1176                }
1177            "})
1178            .first()
1179            .cloned()
1180            .unwrap();
1181        let midpoint = cx.update_editor(|editor, window, cx| {
1182            let snapshot = editor.snapshot(window, cx);
1183            let previous_valid = MultiBufferOffset(inlay_range.start).to_display_point(&snapshot);
1184            let next_valid = MultiBufferOffset(inlay_range.end).to_display_point(&snapshot);
1185            assert_eq!(previous_valid.row(), next_valid.row());
1186            assert!(previous_valid.column() < next_valid.column());
1187            DisplayPoint::new(
1188                previous_valid.row(),
1189                previous_valid.column() + (hint_label.len() / 2) as u32,
1190            )
1191        });
1192        // Press cmd to trigger highlight
1193        let hover_point = cx.pixel_position_for(midpoint);
1194        cx.simulate_mouse_move(hover_point, None, Modifiers::secondary_key());
1195        cx.background_executor.run_until_parked();
1196        cx.update_editor(|editor, window, cx| {
1197            let snapshot = editor.snapshot(window, cx);
1198            let actual_highlights = snapshot
1199                .inlay_highlights::<HoveredLinkState>()
1200                .into_iter()
1201                .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight))
1202                .collect::<Vec<_>>();
1203
1204            let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx));
1205            let expected_highlight = InlayHighlight {
1206                inlay: InlayId::Hint(0),
1207                inlay_position: buffer_snapshot.anchor_after(MultiBufferOffset(inlay_range.start)),
1208                range: 0..hint_label.len(),
1209            };
1210            assert_set_eq!(actual_highlights, vec![&expected_highlight]);
1211        });
1212
1213        cx.simulate_mouse_move(hover_point, None, Modifiers::none());
1214        // Assert no link highlights
1215        cx.update_editor(|editor, window, cx| {
1216                let snapshot = editor.snapshot(window, cx);
1217                let actual_ranges = snapshot
1218                    .text_highlight_ranges::<HoveredLinkState>()
1219                    .map(|ranges| ranges.as_ref().clone().1)
1220                    .unwrap_or_default();
1221
1222                assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}");
1223            });
1224
1225        cx.simulate_modifiers_change(Modifiers::secondary_key());
1226        cx.background_executor.run_until_parked();
1227        cx.simulate_click(hover_point, Modifiers::secondary_key());
1228        cx.background_executor.run_until_parked();
1229        cx.assert_editor_state(indoc! {"
1230                struct «TestStructˇ»;
1231
1232                fn main() {
1233                    let variable = TestStruct;
1234                }
1235            "});
1236    }
1237
1238    #[gpui::test]
1239    async fn test_urls(cx: &mut gpui::TestAppContext) {
1240        init_test(cx, |_| {});
1241        let mut cx = EditorLspTestContext::new_rust(
1242            lsp::ServerCapabilities {
1243                ..Default::default()
1244            },
1245            cx,
1246        )
1247        .await;
1248
1249        cx.set_state(indoc! {"
1250            Let's test a [complex](https://zed.dev/channel/had-(oops)) caseˇ.
1251        "});
1252
1253        let screen_coord = cx.pixel_position(indoc! {"
1254            Let's test a [complex](https://zed.dev/channel/had-(ˇoops)) case.
1255            "});
1256
1257        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1258        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1259            Let's test a [complex](«https://zed.dev/channel/had-(oops)ˇ») case.
1260        "});
1261
1262        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1263        assert_eq!(
1264            cx.opened_url(),
1265            Some("https://zed.dev/channel/had-(oops)".into())
1266        );
1267    }
1268
1269    #[gpui::test]
1270    async fn test_urls_at_beginning_of_buffer(cx: &mut gpui::TestAppContext) {
1271        init_test(cx, |_| {});
1272        let mut cx = EditorLspTestContext::new_rust(
1273            lsp::ServerCapabilities {
1274                ..Default::default()
1275            },
1276            cx,
1277        )
1278        .await;
1279
1280        cx.set_state(indoc! {"https://zed.dev/releases is a cool ˇwebpage."});
1281
1282        let screen_coord =
1283            cx.pixel_position(indoc! {"https://zed.dev/relˇeases is a cool webpage."});
1284
1285        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1286        cx.assert_editor_text_highlights::<HoveredLinkState>(
1287            indoc! {"«https://zed.dev/releasesˇ» is a cool webpage."},
1288        );
1289
1290        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1291        assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1292    }
1293
1294    #[gpui::test]
1295    async fn test_urls_at_end_of_buffer(cx: &mut gpui::TestAppContext) {
1296        init_test(cx, |_| {});
1297        let mut cx = EditorLspTestContext::new_rust(
1298            lsp::ServerCapabilities {
1299                ..Default::default()
1300            },
1301            cx,
1302        )
1303        .await;
1304
1305        cx.set_state(indoc! {"A cool ˇwebpage is https://zed.dev/releases"});
1306
1307        let screen_coord =
1308            cx.pixel_position(indoc! {"A cool webpage is https://zed.dev/releˇases"});
1309
1310        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1311        cx.assert_editor_text_highlights::<HoveredLinkState>(
1312            indoc! {"A cool webpage is «https://zed.dev/releasesˇ»"},
1313        );
1314
1315        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1316        assert_eq!(cx.opened_url(), Some("https://zed.dev/releases".into()));
1317    }
1318
1319    #[gpui::test]
1320    async fn test_surrounding_filename(cx: &mut gpui::TestAppContext) {
1321        init_test(cx, |_| {});
1322        let mut cx = EditorLspTestContext::new_rust(
1323            lsp::ServerCapabilities {
1324                ..Default::default()
1325            },
1326            cx,
1327        )
1328        .await;
1329
1330        let test_cases = [
1331            ("file ˇ name", None),
1332            ("ˇfile name", Some("file")),
1333            ("file ˇname", Some("name")),
1334            ("fiˇle name", Some("file")),
1335            ("filenˇame", Some("filename")),
1336            // Absolute path
1337            ("foobar ˇ/home/user/f.txt", Some("/home/user/f.txt")),
1338            ("foobar /home/useˇr/f.txt", Some("/home/user/f.txt")),
1339            // Windows
1340            ("C:\\Useˇrs\\user\\f.txt", Some("C:\\Users\\user\\f.txt")),
1341            // Whitespace
1342            ("ˇfile\\ -\\ name.txt", Some("file - name.txt")),
1343            ("file\\ -\\ naˇme.txt", Some("file - name.txt")),
1344            // Tilde
1345            ("ˇ~/file.txt", Some("~/file.txt")),
1346            ("~/fiˇle.txt", Some("~/file.txt")),
1347            // Double quotes
1348            ("\"fˇile.txt\"", Some("file.txt")),
1349            ("ˇ\"file.txt\"", Some("file.txt")),
1350            ("ˇ\"fi\\ le.txt\"", Some("fi le.txt")),
1351            // Single quotes
1352            ("'fˇile.txt'", Some("file.txt")),
1353            ("ˇ'file.txt'", Some("file.txt")),
1354            ("ˇ'fi\\ le.txt'", Some("fi le.txt")),
1355            // Quoted multibyte characters
1356            (" ˇ\"\"", Some("")),
1357            (" \"ˇ常\"", Some("")),
1358            ("ˇ\"\"", Some("")),
1359        ];
1360
1361        for (input, expected) in test_cases {
1362            cx.set_state(input);
1363
1364            let (position, snapshot) = cx.editor(|editor, _, cx| {
1365                let positions = editor.selections.newest_anchor().head().text_anchor;
1366                let snapshot = editor
1367                    .buffer()
1368                    .clone()
1369                    .read(cx)
1370                    .as_singleton()
1371                    .unwrap()
1372                    .read(cx)
1373                    .snapshot();
1374                (positions, snapshot)
1375            });
1376
1377            let result = surrounding_filename(snapshot, position);
1378
1379            if let Some(expected) = expected {
1380                assert!(result.is_some(), "Failed to find file path: {}", input);
1381                let (_, path) = result.unwrap();
1382                assert_eq!(&path, expected, "Incorrect file path for input: {}", input);
1383            } else {
1384                assert!(
1385                    result.is_none(),
1386                    "Expected no result, but got one: {:?}",
1387                    result
1388                );
1389            }
1390        }
1391    }
1392
1393    #[gpui::test]
1394    async fn test_hover_filenames(cx: &mut gpui::TestAppContext) {
1395        init_test(cx, |_| {});
1396        let mut cx = EditorLspTestContext::new_rust(
1397            lsp::ServerCapabilities {
1398                ..Default::default()
1399            },
1400            cx,
1401        )
1402        .await;
1403
1404        // Insert a new file
1405        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1406        fs.as_fake()
1407            .insert_file(
1408                path!("/root/dir/file2.rs"),
1409                "This is file2.rs".as_bytes().to_vec(),
1410            )
1411            .await;
1412
1413        #[cfg(not(target_os = "windows"))]
1414        cx.set_state(indoc! {"
1415            You can't go to a file that does_not_exist.txt.
1416            Go to file2.rs if you want.
1417            Or go to ../dir/file2.rs if you want.
1418            Or go to /root/dir/file2.rs if project is local.
1419            Or go to /root/dir/file2 if this is a Rust file.ˇ
1420            "});
1421        #[cfg(target_os = "windows")]
1422        cx.set_state(indoc! {"
1423            You can't go to a file that does_not_exist.txt.
1424            Go to file2.rs if you want.
1425            Or go to ../dir/file2.rs if you want.
1426            Or go to C:/root/dir/file2.rs if project is local.
1427            Or go to C:/root/dir/file2 if this is a Rust file.ˇ
1428        "});
1429
1430        // File does not exist
1431        #[cfg(not(target_os = "windows"))]
1432        let screen_coord = cx.pixel_position(indoc! {"
1433            You can't go to a file that dˇoes_not_exist.txt.
1434            Go to file2.rs if you want.
1435            Or go to ../dir/file2.rs if you want.
1436            Or go to /root/dir/file2.rs if project is local.
1437            Or go to /root/dir/file2 if this is a Rust file.
1438        "});
1439        #[cfg(target_os = "windows")]
1440        let screen_coord = cx.pixel_position(indoc! {"
1441            You can't go to a file that dˇoes_not_exist.txt.
1442            Go to file2.rs if you want.
1443            Or go to ../dir/file2.rs if you want.
1444            Or go to C:/root/dir/file2.rs if project is local.
1445            Or go to C:/root/dir/file2 if this is a Rust file.
1446        "});
1447        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1448        // No highlight
1449        cx.update_editor(|editor, window, cx| {
1450            assert!(
1451                editor
1452                    .snapshot(window, cx)
1453                    .text_highlight_ranges::<HoveredLinkState>()
1454                    .unwrap_or_default()
1455                    .1
1456                    .is_empty()
1457            );
1458        });
1459
1460        // Moving the mouse over a file that does exist should highlight it.
1461        #[cfg(not(target_os = "windows"))]
1462        let screen_coord = cx.pixel_position(indoc! {"
1463            You can't go to a file that does_not_exist.txt.
1464            Go to fˇile2.rs if you want.
1465            Or go to ../dir/file2.rs if you want.
1466            Or go to /root/dir/file2.rs if project is local.
1467            Or go to /root/dir/file2 if this is a Rust file.
1468        "});
1469        #[cfg(target_os = "windows")]
1470        let screen_coord = cx.pixel_position(indoc! {"
1471            You can't go to a file that does_not_exist.txt.
1472            Go to fˇile2.rs if you want.
1473            Or go to ../dir/file2.rs if you want.
1474            Or go to C:/root/dir/file2.rs if project is local.
1475            Or go to C:/root/dir/file2 if this is a Rust file.
1476        "});
1477
1478        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1479        #[cfg(not(target_os = "windows"))]
1480        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1481            You can't go to a file that does_not_exist.txt.
1482            Go to «file2.rsˇ» if you want.
1483            Or go to ../dir/file2.rs if you want.
1484            Or go to /root/dir/file2.rs if project is local.
1485            Or go to /root/dir/file2 if this is a Rust file.
1486        "});
1487        #[cfg(target_os = "windows")]
1488        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1489            You can't go to a file that does_not_exist.txt.
1490            Go to «file2.rsˇ» if you want.
1491            Or go to ../dir/file2.rs if you want.
1492            Or go to C:/root/dir/file2.rs if project is local.
1493            Or go to C:/root/dir/file2 if this is a Rust file.
1494        "});
1495
1496        // Moving the mouse over a relative path that does exist should highlight it
1497        #[cfg(not(target_os = "windows"))]
1498        let screen_coord = cx.pixel_position(indoc! {"
1499            You can't go to a file that does_not_exist.txt.
1500            Go to file2.rs if you want.
1501            Or go to ../dir/fˇile2.rs if you want.
1502            Or go to /root/dir/file2.rs if project is local.
1503            Or go to /root/dir/file2 if this is a Rust file.
1504        "});
1505        #[cfg(target_os = "windows")]
1506        let screen_coord = cx.pixel_position(indoc! {"
1507            You can't go to a file that does_not_exist.txt.
1508            Go to file2.rs if you want.
1509            Or go to ../dir/fˇile2.rs if you want.
1510            Or go to C:/root/dir/file2.rs if project is local.
1511            Or go to C:/root/dir/file2 if this is a Rust file.
1512        "});
1513
1514        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1515        #[cfg(not(target_os = "windows"))]
1516        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1517            You can't go to a file that does_not_exist.txt.
1518            Go to file2.rs if you want.
1519            Or go to «../dir/file2.rsˇ» if you want.
1520            Or go to /root/dir/file2.rs if project is local.
1521            Or go to /root/dir/file2 if this is a Rust file.
1522        "});
1523        #[cfg(target_os = "windows")]
1524        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1525            You can't go to a file that does_not_exist.txt.
1526            Go to file2.rs if you want.
1527            Or go to «../dir/file2.rsˇ» if you want.
1528            Or go to C:/root/dir/file2.rs if project is local.
1529            Or go to C:/root/dir/file2 if this is a Rust file.
1530        "});
1531
1532        // Moving the mouse over an absolute path that does exist should highlight it
1533        #[cfg(not(target_os = "windows"))]
1534        let screen_coord = cx.pixel_position(indoc! {"
1535            You can't go to a file that does_not_exist.txt.
1536            Go to file2.rs if you want.
1537            Or go to ../dir/file2.rs if you want.
1538            Or go to /root/diˇr/file2.rs if project is local.
1539            Or go to /root/dir/file2 if this is a Rust file.
1540        "});
1541
1542        #[cfg(target_os = "windows")]
1543        let screen_coord = cx.pixel_position(indoc! {"
1544            You can't go to a file that does_not_exist.txt.
1545            Go to file2.rs if you want.
1546            Or go to ../dir/file2.rs if you want.
1547            Or go to C:/root/diˇr/file2.rs if project is local.
1548            Or go to C:/root/dir/file2 if this is a Rust file.
1549        "});
1550
1551        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1552        #[cfg(not(target_os = "windows"))]
1553        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1554            You can't go to a file that does_not_exist.txt.
1555            Go to file2.rs if you want.
1556            Or go to ../dir/file2.rs if you want.
1557            Or go to «/root/dir/file2.rsˇ» if project is local.
1558            Or go to /root/dir/file2 if this is a Rust file.
1559        "});
1560        #[cfg(target_os = "windows")]
1561        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1562            You can't go to a file that does_not_exist.txt.
1563            Go to file2.rs if you want.
1564            Or go to ../dir/file2.rs if you want.
1565            Or go to «C:/root/dir/file2.rsˇ» if project is local.
1566            Or go to C:/root/dir/file2 if this is a Rust file.
1567        "});
1568
1569        // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
1570        #[cfg(not(target_os = "windows"))]
1571        let screen_coord = cx.pixel_position(indoc! {"
1572            You can't go to a file that does_not_exist.txt.
1573            Go to file2.rs if you want.
1574            Or go to ../dir/file2.rs if you want.
1575            Or go to /root/dir/file2.rs if project is local.
1576            Or go to /root/diˇr/file2 if this is a Rust file.
1577        "});
1578        #[cfg(target_os = "windows")]
1579        let screen_coord = cx.pixel_position(indoc! {"
1580            You can't go to a file that does_not_exist.txt.
1581            Go to file2.rs if you want.
1582            Or go to ../dir/file2.rs if you want.
1583            Or go to C:/root/dir/file2.rs if project is local.
1584            Or go to C:/root/diˇr/file2 if this is a Rust file.
1585        "});
1586
1587        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1588        #[cfg(not(target_os = "windows"))]
1589        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1590            You can't go to a file that does_not_exist.txt.
1591            Go to file2.rs if you want.
1592            Or go to ../dir/file2.rs if you want.
1593            Or go to /root/dir/file2.rs if project is local.
1594            Or go to «/root/dir/file2ˇ» if this is a Rust file.
1595        "});
1596        #[cfg(target_os = "windows")]
1597        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
1598            You can't go to a file that does_not_exist.txt.
1599            Go to file2.rs if you want.
1600            Or go to ../dir/file2.rs if you want.
1601            Or go to C:/root/dir/file2.rs if project is local.
1602            Or go to «C:/root/dir/file2ˇ» if this is a Rust file.
1603        "});
1604
1605        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1606
1607        cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
1608        cx.update_workspace(|workspace, _, cx| {
1609            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
1610
1611            let buffer = active_editor
1612                .read(cx)
1613                .buffer()
1614                .read(cx)
1615                .as_singleton()
1616                .unwrap();
1617
1618            let file = buffer.read(cx).file().unwrap();
1619            let file_path = file.as_local().unwrap().abs_path(cx);
1620
1621            assert_eq!(
1622                file_path,
1623                std::path::PathBuf::from(path!("/root/dir/file2.rs"))
1624            );
1625        });
1626    }
1627
1628    #[gpui::test]
1629    async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
1630        init_test(cx, |_| {});
1631        let mut cx = EditorLspTestContext::new_rust(
1632            lsp::ServerCapabilities {
1633                ..Default::default()
1634            },
1635            cx,
1636        )
1637        .await;
1638
1639        // Insert a new file
1640        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
1641        fs.as_fake()
1642            .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
1643            .await;
1644
1645        cx.set_state(indoc! {"
1646            You can't open ../diˇr because it's a directory.
1647        "});
1648
1649        // File does not exist
1650        let screen_coord = cx.pixel_position(indoc! {"
1651            You can't open ../diˇr because it's a directory.
1652        "});
1653        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1654
1655        // No highlight
1656        cx.update_editor(|editor, window, cx| {
1657            assert!(
1658                editor
1659                    .snapshot(window, cx)
1660                    .text_highlight_ranges::<HoveredLinkState>()
1661                    .unwrap_or_default()
1662                    .1
1663                    .is_empty()
1664            );
1665        });
1666
1667        // Does not open the directory
1668        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1669        cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1670    }
1671
1672    #[gpui::test]
1673    async fn test_hover_unicode(cx: &mut gpui::TestAppContext) {
1674        init_test(cx, |_| {});
1675        let mut cx = EditorLspTestContext::new_rust(
1676            lsp::ServerCapabilities {
1677                ..Default::default()
1678            },
1679            cx,
1680        )
1681        .await;
1682
1683        cx.set_state(indoc! {"
1684            You can't open ˇ\"🤩\" because it's an emoji.
1685        "});
1686
1687        // File does not exist
1688        let screen_coord = cx.pixel_position(indoc! {"
1689            You can't open ˇ\"🤩\" because it's an emoji.
1690        "});
1691        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
1692
1693        // No highlight, does not panic...
1694        cx.update_editor(|editor, window, cx| {
1695            assert!(
1696                editor
1697                    .snapshot(window, cx)
1698                    .text_highlight_ranges::<HoveredLinkState>()
1699                    .unwrap_or_default()
1700                    .1
1701                    .is_empty()
1702            );
1703        });
1704
1705        // Does not open the directory
1706        cx.simulate_click(screen_coord, Modifiers::secondary_key());
1707        cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 1));
1708    }
1709}