editor: In OpenFile check if file with path_suffix exists (#17805)

Thorsten Ball , Abdelhakim Qbaich , and Pete LeVasseur created

Demo:


https://github.com/user-attachments/assets/6acb6c1e-bb15-4205-9dcb-2aa4bb99dcf9



Release Notes:

- When using `OpenFile` (`gf` in Vim mode) and the word under the cursor
is not an existing file path, we now fall back and additionally check
whether a file called
`<word-under-cursor>.<language-specific-path-suffixes>` exists. That's
similar to Vim's `suffixesadd` option.

---------

Co-authored-by: Abdelhakim Qbaich <abdelhakim@qbaich.com>
Co-authored-by: Pete LeVasseur <plevasseur@gmail.com>

Change summary

crates/editor/src/hover_links.rs | 69 +++++++++++++++++++++++++++++----
crates/language/src/language.rs  |  4 +
crates/vim/src/command.rs        | 19 +++++++++
3 files changed, 83 insertions(+), 9 deletions(-)

Detailed changes

crates/editor/src/hover_links.rs 🔗

@@ -713,17 +713,42 @@ pub(crate) async fn find_file(
     cx: &mut AsyncWindowContext,
 ) -> Option<(Range<text::Anchor>, ResolvedPath)> {
     let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()).ok()?;
-
+    let scope = snapshot.language_scope_at(position);
     let (range, candidate_file_path) = surrounding_filename(snapshot, position)?;
 
-    let existing_path = project
-        .update(cx, |project, cx| {
-            project.resolve_existing_file_path(&candidate_file_path, buffer, cx)
-        })
-        .ok()?
-        .await?;
+    async fn check_path(
+        candidate_file_path: &str,
+        project: &Model<Project>,
+        buffer: &Model<language::Buffer>,
+        cx: &mut AsyncWindowContext,
+    ) -> Option<ResolvedPath> {
+        project
+            .update(cx, |project, cx| {
+                project.resolve_existing_file_path(&candidate_file_path, buffer, cx)
+            })
+            .ok()?
+            .await
+    }
 
-    Some((range, existing_path))
+    if let Some(existing_path) = check_path(&candidate_file_path, &project, buffer, cx).await {
+        return Some((range, existing_path));
+    }
+
+    if let Some(scope) = scope {
+        for suffix in scope.path_suffixes() {
+            if candidate_file_path.ends_with(format!(".{suffix}").as_str()) {
+                continue;
+            }
+
+            let suffixed_candidate = format!("{candidate_file_path}.{suffix}");
+            if let Some(existing_path) = check_path(&suffixed_candidate, &project, buffer, cx).await
+            {
+                return Some((range, existing_path));
+            }
+        }
+    }
+
+    None
 }
 
 fn surrounding_filename(
@@ -1490,7 +1515,8 @@ mod tests {
             You can't go to a file that does_not_exist.txt.
             Go to file2.rs if you want.
             Or go to ../dir/file2.rs if you want.
-            Or go to /root/dir/file2.rs if project is local.ˇ
+            Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.ˇ
         "});
 
         // File does not exist
@@ -1499,6 +1525,7 @@ mod tests {
             Go to file2.rs if you want.
             Or go to ../dir/file2.rs if you want.
             Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
         cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
         // No highlight
@@ -1517,6 +1544,7 @@ mod tests {
             Go to fˇile2.rs if you want.
             Or go to ../dir/file2.rs if you want.
             Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
 
         cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
@@ -1525,6 +1553,7 @@ mod tests {
             Go to «file2.rsˇ» if you want.
             Or go to ../dir/file2.rs if you want.
             Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
 
         // Moving the mouse over a relative path that does exist should highlight it
@@ -1533,6 +1562,7 @@ mod tests {
             Go to file2.rs if you want.
             Or go to ../dir/fˇile2.rs if you want.
             Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
 
         cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
@@ -1541,6 +1571,7 @@ mod tests {
             Go to file2.rs if you want.
             Or go to «../dir/file2.rsˇ» if you want.
             Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
 
         // Moving the mouse over an absolute path that does exist should highlight it
@@ -1549,6 +1580,7 @@ mod tests {
             Go to file2.rs if you want.
             Or go to ../dir/file2.rs if you want.
             Or go to /root/diˇr/file2.rs if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
         "});
 
         cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
@@ -1557,6 +1589,25 @@ mod tests {
             Go to file2.rs if you want.
             Or go to ../dir/file2.rs if you want.
             Or go to «/root/dir/file2.rsˇ» if project is local.
+            Or go to /root/dir/file2 if this is a Rust file.
+        "});
+
+        // Moving the mouse over a path that exists, if we add the language-specific suffix, it should highlight it
+        let screen_coord = cx.pixel_position(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+            Or go to /root/diˇr/file2 if this is a Rust file.
+        "});
+
+        cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
+        cx.assert_editor_text_highlights::<HoveredLinkState>(indoc! {"
+            You can't go to a file that does_not_exist.txt.
+            Go to file2.rs if you want.
+            Or go to ../dir/file2.rs if you want.
+            Or go to /root/dir/file2.rs if project is local.
+            Or go to «/root/dir/file2ˇ» if this is a Rust file.
         "});
 
         cx.simulate_click(screen_coord, Modifiers::secondary_key());

crates/language/src/language.rs 🔗

@@ -1410,6 +1410,10 @@ impl Language {
 }
 
 impl LanguageScope {
+    pub fn path_suffixes(&self) -> &[String] {
+        &self.language.path_suffixes()
+    }
+
     pub fn language_name(&self) -> LanguageName {
         self.language.config.name.clone()
     }

crates/vim/src/command.rs 🔗

@@ -969,6 +969,9 @@ mod test {
         fs.as_fake()
             .insert_file("/root/dir/file2.rs", "This is file2.rs".as_bytes().to_vec())
             .await;
+        fs.as_fake()
+            .insert_file("/root/dir/file3.rs", "go to file3".as_bytes().to_vec())
+            .await;
 
         // Put the path to the second file into the currently open buffer
         cx.set_state(indoc! {"go to fiˇle2.rs"}, Mode::Normal);
@@ -981,5 +984,21 @@ mod test {
         cx.workspace(|workspace, cx| {
             assert_active_item(workspace, "/root/dir/file2.rs", "This is file2.rs", cx);
         });
+
+        // Update editor to point to `file2.rs`
+        cx.editor = cx.workspace(|workspace, cx| workspace.active_item_as::<Editor>(cx).unwrap());
+
+        // Put the path to the third file into the currently open buffer,
+        // but remove its suffix, because we want that lookup to happen automatically.
+        cx.set_state(indoc! {"go to fiˇle3"}, Mode::Normal);
+
+        // Go to file3.rs
+        cx.simulate_keystrokes("g f");
+
+        // We now have three items
+        cx.workspace(|workspace, cx| assert_eq!(workspace.items(cx).count(), 3));
+        cx.workspace(|workspace, cx| {
+            assert_active_item(workspace, "/root/dir/file3.rs", "go to file3", cx);
+        });
     }
 }