Stop canonicalizing LSP locations before worktree lookup (#63754)

Closes
https://github.com/zed-industries/zed/pull/61392#issuecomment-5538772188
Follow-up to https://github.com/zed-industries/zed/pull/61392



https://github.com/user-attachments/assets/6c745474-6b6e-4cfa-b1b6-87e9631973bf


Not every language server wants to deal with the canonicalized paths,
some (e.g. gopls) can resolve them themselves, and keep sending
symlinked paths over the LSP — canonicalizing them means we will break
out of the current worktree and open odd singleton files instead.

Release Notes:

- Fixed goToDefinition opening out-of-worktree entries
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-09-04 12:15:49 +00:00
1 parent 5889a27ff2
commit 5d1147a6e4
2 files changed
+282 -20

No files matched your search

+106 -15
View File
@@ -147,7 +147,7 @@ use util::{
paths::{PathStyle, SanitizedPath, UrlExt},
post_inc,
redact::redact_command,
rel_path::RelPath,
rel_path::{RelPath, RelPathBuf},
union_json_value_into,
};
@@ -10272,15 +10272,6 @@ impl LspStore {
let abs_path = abs_path
.to_file_path_ext(path_style)
.map_err(|()| anyhow!("can't convert URI to path"))?;
let fs = lsp_store.update(cx, |lsp_store, _cx| {
lsp_store.as_local().map(|local| local.fs.clone())
})?;
let abs_path = if let Some(fs) = fs {
fs.canonicalize(&abs_path).await.unwrap_or(abs_path)
} else {
abs_path
};
let p = abs_path.clone();
let yarn_worktree = lsp_store
.update(cx, move |lsp_store, cx| match lsp_store.as_local() {
@@ -10301,11 +10292,16 @@ impl LspStore {
} else {
(Arc::<Path>::from(abs_path.as_path()), None)
};
let worktree = lsp_store.update(cx, |lsp_store, cx| {
lsp_store.worktree_store.update(cx, |worktree_store, cx| {
worktree_store.find_worktree(&worktree_root_target, cx)
})
})?;
let worktree = if known_relative_path.is_some() {
lsp_store.read_with(cx, |lsp_store, cx| {
lsp_store
.worktree_store
.read(cx)
.find_worktree(&worktree_root_target, cx)
})?
} else {
find_worktree_for_lsp_path(&lsp_store, &abs_path, cx).await?
};
let (worktree, relative_path, source_ws) = if let Some(result) = worktree {
let relative_path = known_relative_path.unwrap_or_else(|| result.1.clone());
(result.0, relative_path, None)
@@ -14948,6 +14944,101 @@ fn lsp_ranges_overlap(a: &lsp::Range, b: &lsp::Range) -> bool {
a.start < b.end && b.start < a.end
}
async fn find_worktree_for_lsp_path(
lsp_store: &WeakEntity<LspStore>,
abs_path: &Path,
cx: &mut AsyncApp,
) -> Result<Option<(Entity<Worktree>, Arc<RelPath>)>> {
let (fs, worktree) = lsp_store.read_with(cx, |lsp_store, cx| {
(
lsp_store.as_local().map(|local| local.fs.clone()),
lsp_store
.worktree_store
.read(cx)
.find_worktree(abs_path, cx),
)
})?;
match worktree {
Some((worktree, relative_path)) => {
let relative_path =
normalize_lsp_relative_path(&worktree, abs_path, relative_path, cx).await?;
Ok(Some((worktree, relative_path)))
}
None => {
let Some(fs) = fs else {
return Ok(None);
};
let Ok(canonical_path) = fs.canonicalize(abs_path).await else {
return Ok(None);
};
lsp_store.read_with(cx, |lsp_store, cx| {
lsp_store
.worktree_store
.read(cx)
.find_worktree(&canonical_path, cx)
})
}
}
}
async fn normalize_lsp_relative_path(
worktree: &Entity<Worktree>,
abs_path: &Path,
relative_path: Arc<RelPath>,
cx: &mut AsyncApp,
) -> Result<Arc<RelPath>> {
let Some((fs, snapshot, worktree_root)) = worktree.read_with(cx, |worktree, _| {
let local_worktree = worktree.as_local()?;
if local_worktree.fs_is_case_sensitive() {
return None;
}
Some((
local_worktree.fs().clone(),
worktree.snapshot(),
worktree.abs_path(),
))
}) else {
return Ok(relative_path);
};
if snapshot.entry_for_path(&relative_path).is_some() {
return Ok(relative_path);
}
let mut normalized_path = RelPathBuf::new();
let mut components = relative_path.components();
while let Some(component) = components.next() {
let mut exact_path = normalized_path.clone();
exact_path.push_component(component)?;
if snapshot.entry_for_path(&exact_path).is_some() {
normalized_path = exact_path;
continue;
}
let case_insensitive_match = snapshot
.child_entries(&normalized_path)
.find(|entry| {
entry
.path
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case(component))
})
.map(|entry| entry.path.to_rel_path_buf());
let Some(matched_path) = case_insensitive_match else {
if let Ok(canonical_path) = fs.canonicalize(abs_path).await
&& let Ok(canonical_root) = fs.canonicalize(&worktree_root).await
&& let Ok(canonical_relative_path) = canonical_path.strip_prefix(&canonical_root)
&& let Ok(canonical_relative_path) =
RelPath::new(canonical_relative_path, PathStyle::local())
{
return Ok(canonical_relative_path.into_arc());
}
exact_path.push(components.rest());
return Ok(Arc::from(exact_path));
};
normalized_path = matched_path;
}
Ok(Arc::from(normalized_path))
}
fn subscribe_to_binary_statuses(
languages: &Arc<LanguageRegistry>,
cx: &mut Context<'_, LspStore>,
+176 -5
View File
@@ -1,15 +1,15 @@
use std::{
path::Path,
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use collections::HashMap;
use fs::FakeFs;
use fs::{FakeFs, Fs};
use futures::StreamExt;
use gpui::TestAppContext;
use language::{CodeLabel, FakeLspAdapter, HighlightId, rust_lang};
use lsp::{LanguageServerName, Uri};
use gpui::{Entity, TestAppContext};
use language::{Buffer, CodeLabel, FakeLspAdapter, HighlightId, LocalFile, rust_lang};
use lsp::{LanguageServerId, LanguageServerName, Uri};
use parking_lot::Mutex;
use project::{
Project,
@@ -163,6 +163,117 @@ async fn test_open_buffer_via_lsp_case_variant_no_duplicate(cx: &mut TestAppCont
});
}
#[gpui::test]
async fn test_open_buffer_via_lsp_preserves_external_symlink_path(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/shared"),
json!({ "pkg": { "def.rs": "pub fn def() {}" } }),
)
.await;
fs.insert_tree(
path!("/project"),
json!({ "src": { "main.rs": "fn main() {}" } }),
)
.await;
fs.create_symlink(
path!("/project/pkg").as_ref(),
PathBuf::from(path!("/shared/pkg")),
)
.await
.unwrap();
let (project, server_id) =
project_with_rust_server(fs, path!("/project"), path!("/project/src/main.rs"), cx).await;
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer_via_lsp(
Uri::from_file_path(path!("/project/pkg/def.rs")).unwrap(),
server_id,
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
buffer_paths(&buffer, cx),
(
"pkg/def.rs".to_string(),
PathBuf::from(path!("/project/pkg/def.rs"))
)
);
assert_eq!(
worktree_roots(&project, cx),
vec![PathBuf::from(path!("/project"))]
);
}
#[gpui::test]
async fn test_open_buffer_via_lsp_case_variant_in_unscanned_dir(cx: &mut TestAppContext) {
init_test(cx);
cx.executor().allow_parking();
let fs = FakeFs::new(cx.executor());
fs.set_case_sensitive(false);
fs.insert_tree(
path!("/root"),
json!({
".gitignore": "ignored\n",
"src": { "main.rs": "fn main() {}" },
"ignored": { "lib.rs": "pub fn lib() {}" },
}),
)
.await;
let (project, server_id) =
project_with_rust_server(fs, path!("/root"), path!("/root/src/main.rs"), cx).await;
assert_eq!(
worktree_entries(&project, cx),
vec!["", ".gitignore", "ignored", "src", "src/main.rs"]
);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer_via_lsp(
Uri::from_file_path(path!("/root/IGNORED/LIB.rs")).unwrap(),
server_id,
cx,
)
})
.await
.unwrap();
cx.run_until_parked();
assert_eq!(
buffer_paths(&buffer, cx),
(
"ignored/lib.rs".to_string(),
PathBuf::from(path!("/root/ignored/lib.rs"))
)
);
assert_eq!(
worktree_roots(&project, cx),
vec![PathBuf::from(path!("/root"))]
);
assert_eq!(
worktree_entries(&project, cx),
vec![
"",
".gitignore",
"ignored",
"ignored/lib.rs",
"src",
"src/main.rs"
]
);
}
#[test]
fn test_rpc_log_grouping_separates_timed_messages() {
for (received, direction) in [(false, "Send"), (true, "Receive")] {
@@ -673,3 +784,63 @@ async fn test_initialization_options_contributions_without_own_options(cx: &mut
Some(Some(contribution)),
);
}
async fn project_with_rust_server(
fs: Arc<FakeFs>,
root: &str,
first_file: &str,
cx: &mut TestAppContext,
) -> (Entity<Project>, LanguageServerId) {
let project = Project::test(fs, [root.as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default());
project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp(first_file, cx)
})
.await
.unwrap();
fake_servers.next().await.unwrap();
cx.run_until_parked();
let server_id = project.read_with(cx, |project, cx| {
project
.lsp_store()
.read(cx)
.language_server_statuses()
.next()
.unwrap()
.0
});
(project, server_id)
}
fn buffer_paths(buffer: &Entity<Buffer>, cx: &TestAppContext) -> (String, PathBuf) {
buffer.read_with(cx, |buffer, cx| {
let file = File::from_dyn(buffer.file()).unwrap();
(file.path.as_unix_str().to_string(), file.abs_path(cx))
})
}
fn worktree_roots(project: &Entity<Project>, cx: &TestAppContext) -> Vec<PathBuf> {
project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.map(|worktree| worktree.read(cx).abs_path().to_path_buf())
.collect()
})
}
fn worktree_entries(project: &Entity<Project>, cx: &TestAppContext) -> Vec<String> {
project.read_with(cx, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap();
worktree
.read(cx)
.snapshot()
.entries(true, 0)
.map(|entry| entry.path.as_unix_str().to_string())
.collect()
})
}