hover_links.rs

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