editor: Support file:line:col navigation from hover links (#55877)

Jona Abdinghoff created

I added navigation for file:line:col hover links e.g. `file.rs:83`,
similar to what the cli and terminal already do. I also added backticks
as file delimiters so that you can open file paths in markdown
documents, see:


https://github.com/user-attachments/assets/e31fca8e-6a22-4b5c-97c5-b8ddf8982e72

Self-Review Checklist

  - [x] I've reviewed my own diff for quality, security, and reliability
  - [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
  - [x] Tests cover the new/changed behavior
  - [x] Performance impact has been considered and is acceptable

Btw the Zed docs don't mention this yet, but on native ARM64 Windows the
build fails with `error: instruction requires: fullfp16` from the
`gemm-f16` crate. I fixed this by adding `+fp16` to the target feature
flags:

```toml
[target.'cfg(target_os = "windows")']
rustflags = [
    "--cfg", "windows_slim_errors",
    "-C", "target-feature=+crt-static,+fp16",
]
```

CI cross-compiles from x86_64 so it doesn't hit this.

Closes https://github.com/zed-industries/zed/discussions/41123

Release Notes:

- Added file:line:col navigation from ctrl+click hover links in the
editor

Change summary

crates/editor/src/editor.rs      |  49 +++
crates/editor/src/hover_links.rs | 413 ++++++++++++++++++++++++++++++++-
2 files changed, 433 insertions(+), 29 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -17766,6 +17766,28 @@ impl Editor {
         range: Range<Point>,
         window: &mut Window,
         cx: &mut Context<Self>,
+    ) {
+        self.go_to_singleton_buffer_range_impl(range, true, window, cx);
+    }
+
+    /// Like `go_to_singleton_buffer_point`, but does not push a navigation
+    /// history entry. Useful when the caller already recorded one (e.g. when
+    /// a file was just opened and we only need to move the cursor).
+    pub fn go_to_singleton_buffer_point_silently(
+        &mut self,
+        point: Point,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.go_to_singleton_buffer_range_impl(point..point, false, window, cx);
+    }
+
+    fn go_to_singleton_buffer_range_impl(
+        &mut self,
+        range: Range<Point>,
+        record_nav_history: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
     ) {
         let multibuffer = self.buffer().read(cx);
         if !multibuffer.is_singleton() {
@@ -17777,7 +17799,7 @@ impl Editor {
                 self.cursor_top_offset(cx),
                 cx,
             ))
-            .nav_history(true),
+            .nav_history(record_nav_history),
             window,
             cx,
             |s| s.select_anchor_ranges([anchor_range]),
@@ -18240,12 +18262,14 @@ impl Editor {
         cx.spawn_in(window, async move |_, cx| {
             let result = find_file(&buffer, project, buffer_position, cx).await;
 
-            if let Some((_, path)) = result {
-                workspace
+            if let Some((_, file_target)) = result {
+                let item = workspace
                     .update_in(cx, |workspace, window, cx| {
-                        workspace.open_resolved_path(path, window, cx)
+                        workspace.open_resolved_path(file_target.resolved_path.clone(), window, cx)
                     })?
                     .await?;
+
+                file_target.navigate_item_to_position(item, cx);
             }
             anyhow::Ok(())
         })
@@ -18276,8 +18300,8 @@ impl Editor {
                     first_url_or_file = Some(Either::Left(url));
                     None
                 }
-                HoverLink::File(path) => {
-                    first_url_or_file = Some(Either::Right(path));
+                HoverLink::File(file_target) => {
+                    first_url_or_file = Some(Either::Right(file_target));
                     None
                 }
             })
@@ -18411,18 +18435,25 @@ impl Editor {
                         })?;
                         Ok(Navigated::Yes)
                     }
-                    Some(Either::Right(path)) => {
+                    Some(Either::Right(file_target)) => {
                         // TODO(andrew): respect preview tab settings
                         //               `enable_keep_preview_on_code_navigation` and
                         //               `enable_preview_file_from_code_navigation`
                         let Some(workspace) = workspace else {
                             return Ok(Navigated::No);
                         };
-                        workspace
+                        let item = workspace
                             .update_in(cx, |workspace, window, cx| {
-                                workspace.open_resolved_path(path, window, cx)
+                                workspace.open_resolved_path(
+                                    file_target.resolved_path.clone(),
+                                    window,
+                                    cx,
+                                )
                             })?
                             .await?;
+
+                        file_target.navigate_item_to_position(item, cx);
+
                         Ok(Navigated::Yes)
                     }
                     None => Ok(Navigated::No),

crates/editor/src/hover_links.rs 🔗

@@ -14,7 +14,7 @@ use settings::Settings;
 use std::{ops::Range, sync::LazyLock};
 use text::OffsetRangeExt;
 use theme::ActiveTheme as _;
-use util::{ResultExt, TryFutureExt as _, maybe};
+use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition};
 
 #[derive(Debug)]
 pub struct HoveredLinkState {
@@ -63,7 +63,7 @@ impl RangeInEditor {
 #[derive(Debug, Clone)]
 pub enum HoverLink {
     Url(String),
-    File(ResolvedPath),
+    File(ResolvedFileTarget),
     Text(LocationLink),
     InlayHint(lsp::Location, LanguageServerId),
 }
@@ -376,7 +376,7 @@ pub fn show_link_definition(
                             (range, vec![HoverLink::Url(url)])
                         })
                         .ok()
-                    } else if let Some((filename_range, filename)) =
+                    } else if let Some((filename_range, file_target)) =
                         find_file(&buffer, project.clone(), anchor, cx).await
                     {
                         let range = maybe!({
@@ -385,7 +385,7 @@ pub fn show_link_definition(
                             Some(RangeInEditor::Text(range))
                         });
 
-                        Some((range, vec![HoverLink::File(filename)]))
+                        Some((range, vec![HoverLink::File(file_target)]))
                     } else if let Some(provider) = provider {
                         let task = cx.update(|_, cx| {
                             provider.definitions(&buffer, anchor, preferred_kind, cx)
@@ -608,12 +608,49 @@ pub(crate) fn find_url_from_range(
     None
 }
 
+#[derive(Debug, Clone)]
+pub(crate) struct ResolvedFileTarget {
+    pub resolved_path: ResolvedPath,
+    pub row: Option<u32>,
+    pub column: Option<u32>,
+}
+
+impl ResolvedFileTarget {
+    /// After opening a file, navigate the editor to the row/column position if present.
+    pub fn navigate_item_to_position(
+        &self,
+        item: Box<dyn crate::ItemHandle>,
+        cx: &mut AsyncWindowContext,
+    ) {
+        if let Some(row) = self.row {
+            let col = self.column.unwrap_or(0);
+            if let Some(active_editor) = item.downcast::<crate::Editor>() {
+                active_editor
+                    .downgrade()
+                    .update_in(cx, |editor, window, cx| {
+                        let row = row.saturating_sub(1);
+                        let col = col.saturating_sub(1);
+                        let Some(buffer) = editor.buffer().read(cx).as_singleton() else {
+                            return;
+                        };
+                        let point = buffer
+                            .read(cx)
+                            .snapshot()
+                            .point_from_external_input(row, col);
+                        editor.go_to_singleton_buffer_point_silently(point, window, cx);
+                    })
+                    .log_err();
+            }
+        }
+    }
+}
+
 pub(crate) async fn find_file(
     buffer: &Entity<language::Buffer>,
     project: Option<Entity<Project>>,
     position: text::Anchor,
     cx: &mut AsyncWindowContext,
-) -> Option<(Range<text::Anchor>, ResolvedPath)> {
+) -> Option<(Range<text::Anchor>, ResolvedFileTarget)> {
     let project = project?;
     let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
     let scope = snapshot.language_scope_at(position);
@@ -636,19 +673,53 @@ pub(crate) async fn find_file(
 
     let pattern_candidates = link_pattern_file_candidates(&candidate_file_path);
 
+    // Compute the highlight range for a pattern_range within the candidate string.
+    let make_range = |pattern_range: &Range<usize>| -> Range<text::Anchor> {
+        let offset_range = range.to_offset(&snapshot);
+        let actual_start = offset_range.start + pattern_range.start;
+        let actual_end = offset_range.end - (candidate_len - pattern_range.end);
+        snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end)
+    };
+
+    // For each candidate extracted by link_pattern_file_candidates, try resolving in order:
+    // 1. The raw candidate string
+    // 2. The path portion after stripping `:row:col` suffix
+    // 3. With language-specific file extensions appended to raw candidate
+    // 4. With language-specific file extensions appended to stripped path
     for (pattern_candidate, pattern_range) in &pattern_candidates {
+        // Try the raw candidate first.
         if let Some(existing_path) = check_path(&pattern_candidate, &project, buffer, cx).await {
-            let offset_range = range.to_offset(&snapshot);
-            let actual_start = offset_range.start + pattern_range.start;
-            let actual_end = offset_range.end - (candidate_len - pattern_range.end);
             return Some((
-                snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end),
-                existing_path,
+                make_range(pattern_range),
+                ResolvedFileTarget {
+                    resolved_path: existing_path,
+                    row: None,
+                    column: None,
+                },
             ));
         }
-    }
-    if let Some(scope) = scope {
-        for (pattern_candidate, pattern_range) in pattern_candidates {
+
+        // Parse row:col suffix once per candidate for use in fallback attempts.
+        // This handles patterns like `file.rs:83:1`, `file.rs:83`, and `file.rs:20:in`.
+        let parsed = PathWithPosition::parse_str(pattern_candidate);
+        let parsed_path = parsed.path.to_string_lossy();
+
+        // Try resolving just the path portion (without :row:col).
+        if parsed.row.is_some() {
+            if let Some(existing_path) = check_path(&parsed_path, &project, buffer, cx).await {
+                return Some((
+                    make_range(pattern_range),
+                    ResolvedFileTarget {
+                        resolved_path: existing_path,
+                        row: parsed.row,
+                        column: parsed.column,
+                    },
+                ));
+            }
+        }
+
+        // Try with language-specific suffixes.
+        if let Some(scope) = &scope {
             for suffix in scope.path_suffixes() {
                 if pattern_candidate.ends_with(format!(".{suffix}").as_str()) {
                     continue;
@@ -658,15 +729,39 @@ pub(crate) async fn find_file(
                 if let Some(existing_path) =
                     check_path(&suffixed_candidate, &project, buffer, cx).await
                 {
-                    let offset_range = range.to_offset(&snapshot);
-                    let actual_start = offset_range.start + pattern_range.start;
-                    let actual_end = offset_range.end - (candidate_len - pattern_range.end);
                     return Some((
-                        snapshot.anchor_before(actual_start)..snapshot.anchor_after(actual_end),
-                        existing_path,
+                        make_range(pattern_range),
+                        ResolvedFileTarget {
+                            resolved_path: existing_path,
+                            row: None,
+                            column: None,
+                        },
                     ));
                 }
             }
+
+            // Try with language-specific suffixes on the stripped path.
+            if parsed.row.is_some() {
+                for suffix in scope.path_suffixes() {
+                    if parsed_path.ends_with(&format!(".{suffix}")) {
+                        continue;
+                    }
+
+                    let suffixed_candidate = format!("{parsed_path}.{suffix}");
+                    if let Some(existing_path) =
+                        check_path(&suffixed_candidate, &project, buffer, cx).await
+                    {
+                        return Some((
+                            make_range(pattern_range),
+                            ResolvedFileTarget {
+                                resolved_path: existing_path,
+                                row: parsed.row,
+                                column: parsed.column,
+                            },
+                        ));
+                    }
+                }
+            }
         }
     }
     None
@@ -721,7 +816,7 @@ fn surrounding_filename(
             found_start = true;
             break;
         }
-        if (ch == '"' || ch == '\'') && !inside_quotes {
+        if (ch == '"' || ch == '\'' || ch == '`') && !inside_quotes {
             found_start = true;
             inside_quotes = true;
             break;
@@ -754,7 +849,7 @@ fn surrounding_filename(
             found_end = true;
             break;
         }
-        if ch == '"' || ch == '\'' {
+        if ch == '"' || ch == '\'' || ch == '`' {
             // If we're inside quotes, we stop when we come across the next quote
             if inside_quotes {
                 found_end = true;
@@ -1576,6 +1671,16 @@ mod tests {
             (" ˇ\"常\"", Some("常")),
             (" \"ˇ常\"", Some("常")),
             ("ˇ\"常\"", Some("常")),
+            // Path with row:column suffix
+            ("fiˇle.rs:83:1", Some("file.rs:83:1")),
+            ("file.rs:83ˇ:1 foo", Some("file.rs:83:1")),
+            ("file.rs:20ˇ:in bar", Some("file.rs:20:in")),
+            // Backtick delimiters
+            ("`fˇile.txt`", Some("file.txt")),
+            ("ˇ`file.txt`", Some("file.txt")),
+            ("`fˇile.txt` and more", Some("file.txt")),
+            // Backtick with row:col
+            ("`fiˇle.rs:83:1`", Some("file.rs:83:1")),
         ];
 
         for (input, expected) in test_cases {
@@ -1873,6 +1978,274 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_hover_filename_with_row_column(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        // Insert a new file with multiple lines
+        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file(
+                path!("/root/dir/file2.rs"),
+                "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n"
+                    .as_bytes()
+                    .to_vec(),
+            )
+            .await;
+
+        // file2.rs:5:3 should be highlighted and clickable
+        cx.set_state(indoc! {"
+            Go to file2.rs:5:3 for the fix.ˇ
+        "});
+
+        let screen_coord = cx.pixel_position(indoc! {"
+            Go to filˇe2.rs:5:3 for the fix.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            Go to «file2.rs:5:3ˇ» for the fix.
+        "},
+        );
+
+        cx.simulate_click(screen_coord, Modifiers::secondary_key());
+
+        cx.update_workspace(|workspace, _, cx| assert_eq!(workspace.items(cx).count(), 2));
+        cx.update_workspace(|workspace, window, cx| {
+            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+            {
+                let editor = active_editor.read(cx);
+                let buffer = editor.buffer().read(cx).as_singleton().unwrap();
+                let file = buffer.read(cx).file().unwrap();
+                let file_path = file.as_local().unwrap().abs_path(cx);
+                assert_eq!(
+                    file_path,
+                    std::path::PathBuf::from(path!("/root/dir/file2.rs"))
+                );
+            }
+
+            // Check that the cursor is at row 5, column 3 (0-indexed: row 4, col 2)
+            let (count, snapshot) = active_editor.update(cx, |editor, cx| {
+                (editor.selections.count(), editor.snapshot(window, cx))
+            });
+            assert_eq!(count, 1);
+            let selections = active_editor
+                .read(cx)
+                .selections
+                .newest::<language::Point>(&snapshot.display_snapshot);
+            assert_eq!(
+                selections.head().row,
+                4,
+                "Expected cursor on row 5 (0-indexed: 4)"
+            );
+            assert_eq!(
+                selections.head().column,
+                2,
+                "Expected cursor on column 3 (0-indexed: 2)"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hover_filename_with_row_only(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file(
+                path!("/root/dir/file2.rs"),
+                "line 1\nline 2\nline 3\nline 4\nline 5\n"
+                    .as_bytes()
+                    .to_vec(),
+            )
+            .await;
+
+        // file2.rs:3 should be highlighted and clickable
+        cx.set_state(indoc! {"
+            Go to file2.rs:3 please.ˇ
+        "});
+
+        let screen_coord = cx.pixel_position(indoc! {"
+            Go to filˇe2.rs:3 please.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            Go to «file2.rs:3ˇ» please.
+        "},
+        );
+
+        cx.simulate_click(screen_coord, Modifiers::secondary_key());
+
+        cx.update_workspace(|workspace, window, cx| {
+            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+            let (count, snapshot) = active_editor.update(cx, |editor, cx| {
+                (editor.selections.count(), editor.snapshot(window, cx))
+            });
+            assert_eq!(count, 1);
+            let selections = active_editor
+                .read(cx)
+                .selections
+                .newest::<language::Point>(&snapshot.display_snapshot);
+            assert_eq!(
+                selections.head().row,
+                2,
+                "Expected cursor on row 3 (0-indexed: 2)"
+            );
+            assert_eq!(selections.head().column, 0, "Expected cursor on column 0");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hover_filename_with_non_numeric_suffix(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file(
+                path!("/root/dir/file2.rs"),
+                "line 1\nline 2\nline 3\n".as_bytes().to_vec(),
+            )
+            .await;
+
+        // file2.rs:2:in should resolve to file2.rs line 2 (like Ruby backtraces)
+        cx.set_state(indoc! {"
+            Error at file2.rs:2:in 'method'ˇ
+        "});
+
+        let screen_coord = cx.pixel_position(indoc! {"
+            Error at filˇe2.rs:2:in 'method'
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            Error at «file2.rs:2:inˇ» 'method'
+        "},
+        );
+
+        cx.simulate_click(screen_coord, Modifiers::secondary_key());
+
+        cx.update_workspace(|workspace, window, cx| {
+            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+            let (count, snapshot) = active_editor.update(cx, |editor, cx| {
+                (editor.selections.count(), editor.snapshot(window, cx))
+            });
+            assert_eq!(count, 1);
+            let selections = active_editor
+                .read(cx)
+                .selections
+                .newest::<language::Point>(&snapshot.display_snapshot);
+            assert_eq!(
+                selections.head().row,
+                1,
+                "Expected cursor on row 2 (0-indexed: 1)"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_hover_markdown_link_with_row_column(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                ..Default::default()
+            },
+            cx,
+        )
+        .await;
+
+        let fs = cx.update_workspace(|workspace, _, cx| workspace.project().read(cx).fs().clone());
+        fs.as_fake()
+            .insert_file(
+                path!("/root/dir/file2.rs"),
+                "line 1\nline 2\nline 3\nline 4\nline 5\n"
+                    .as_bytes()
+                    .to_vec(),
+            )
+            .await;
+
+        // Markdown link [text](file2.rs:3:2) should highlight only the inner link,
+        // not the surrounding markdown syntax.
+        cx.set_state(indoc! {"
+            See [here](file2.rs:3:2) for details.ˇ
+        "});
+
+        let screen_coord = cx.pixel_position(indoc! {"
+            See [here](filˇe2.rs:3:2) for details.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights(
+            HighlightKey::HoveredLinkState,
+            indoc! {"
+            See [here](«file2.rs:3:2ˇ») for details.
+        "},
+        );
+
+        cx.simulate_click(screen_coord, Modifiers::secondary_key());
+
+        cx.update_workspace(|workspace, window, cx| {
+            let active_editor = workspace.active_item_as::<Editor>(cx).unwrap();
+            {
+                let editor = active_editor.read(cx);
+                let buffer = editor.buffer().read(cx).as_singleton().unwrap();
+                let file = buffer.read(cx).file().unwrap();
+                let file_path = file.as_local().unwrap().abs_path(cx);
+                assert_eq!(
+                    file_path,
+                    std::path::PathBuf::from(path!("/root/dir/file2.rs"))
+                );
+            }
+
+            // Check cursor is at row 3, column 2 (0-indexed: row 2, col 1)
+            let (count, snapshot) = active_editor.update(cx, |editor, cx| {
+                (editor.selections.count(), editor.snapshot(window, cx))
+            });
+            assert_eq!(count, 1);
+            let selections = active_editor
+                .read(cx)
+                .selections
+                .newest::<language::Point>(&snapshot.display_snapshot);
+            assert_eq!(
+                selections.head().row,
+                2,
+                "Expected cursor on row 3 (0-indexed: 2)"
+            );
+            assert_eq!(
+                selections.head().column,
+                1,
+                "Expected cursor on column 2 (0-indexed: 1)"
+            );
+        });
+    }
+
     #[gpui::test]
     async fn test_hover_directories(cx: &mut gpui::TestAppContext) {
         init_test(cx, |_| {});