Support LSP document links (#56011)

Closes https://github.com/zed-industries/zed/issues/33587


https://github.com/user-attachments/assets/bbaea8a9-402e-485b-800e-2f4486142956

Release Notes:

- Supported LSP document links (enabled by default, use
`"lsp_document_links": false` to turn it off)
This commit is contained in:
Kirill Bulatov 2026-05-26 10:09:47 +03:00 committed by GitHub
parent a5457029cc
commit 3e77442f2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2427 additions and 127 deletions

View file

@ -1500,6 +1500,10 @@
// 4. Draw a background behind the color text..
// "lsp_document_colors": "background",
"lsp_document_colors": "inlay",
// Whether to query and display LSP `textDocument/documentLink` links in the editor.
//
// Default: true
"lsp_document_links": true,
// Diagnostics configuration.
"diagnostics": {
// Whether to show the project diagnostics button in the status bar.

View file

@ -364,6 +364,7 @@ impl Server {
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferById>)
.add_request_handler(forward_read_only_project_request::<proto::SynchronizeBuffers>)
.add_request_handler(forward_read_only_project_request::<proto::ResolveInlayHint>)
.add_request_handler(forward_read_only_project_request::<proto::ResolveDocumentLink>)
.add_request_handler(forward_read_only_project_request::<proto::GetColorPresentation>)
.add_request_handler(forward_read_only_project_request::<proto::OpenBufferByPath>)
.add_request_handler(forward_read_only_project_request::<proto::OpenImageByPath>)

View file

@ -44,6 +44,7 @@ use std::{
num::NonZeroU32,
ops::{Deref as _, Range},
path::{Path, PathBuf},
str::FromStr as _,
sync::{
Arc,
atomic::{self, AtomicBool, AtomicUsize},
@ -2709,6 +2710,317 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
});
}
#[gpui::test]
async fn test_lsp_document_links(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
for cx in [&mut *cx_a, &mut *cx_b] {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_links = Some(true);
});
});
});
}
let capabilities = lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(true),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
let other_contents = concat!(
"fn first() {}\n",
"fn second() {}\n",
"fn third(x: i32) {}\n",
"fn fourth() {}\n",
"fn fifth() {}\n",
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "// see LICENSE for details\nfn main() {}",
"other.rs": other_contents,
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let _editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
let link_range = lsp::Range {
start: lsp::Position {
line: 0,
character: 7,
},
end: lsp::Position {
line: 0,
character: 14,
},
};
let other_uri = lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap();
// The server points at line 3, column 5 (1-based) of `other.rs` using the
// json-language-server fragment convention.
let other_uri_with_fragment =
lsp::Uri::from_str(&format!("{}#3,5", other_uri.as_str())).unwrap();
let other_target = other_uri_with_fragment.to_string();
let tooltip = "Open other.rs";
let resolve_marker = serde_json::json!({"id": 42});
let document_link_requests = Arc::new(AtomicUsize::new(0));
let document_link_count = Arc::clone(&document_link_requests);
let resolve_marker_for_links = resolve_marker.clone();
let mut document_link_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |params, _| {
let document_link_count = Arc::clone(&document_link_count);
let resolve_marker = resolve_marker_for_links.clone();
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
document_link_count.fetch_add(1, atomic::Ordering::Release);
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: None,
tooltip: None,
data: Some(resolve_marker),
}]))
}
});
let resolve_requests = Arc::new(AtomicUsize::new(0));
let resolve_count = Arc::clone(&resolve_requests);
let other_uri_for_resolve = other_uri_with_fragment.clone();
let resolve_marker_for_resolve = resolve_marker.clone();
let _resolve_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(move |link, _| {
let resolve_count = Arc::clone(&resolve_count);
let other_uri = other_uri_for_resolve.clone();
let expected_marker = resolve_marker_for_resolve.clone();
async move {
assert_eq!(link.range, link_range);
assert_eq!(link.data.as_ref(), Some(&expected_marker));
resolve_count.fetch_add(1, atomic::Ordering::Release);
Ok(lsp::DocumentLink {
range: link.range,
target: Some(other_uri),
tooltip: Some(tooltip.to_string()),
data: None,
})
}
});
document_link_handle.next().await.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
assert_eq!(
1,
document_link_requests.load(atomic::Ordering::Acquire),
"Host opening the file should issue exactly one documentLink request"
);
assert_eq!(
0,
resolve_requests.load(atomic::Ordering::Acquire),
"No resolve happens until a hover triggers it"
);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
executor.run_until_parked();
assert_eq!(
1,
document_link_requests.load(atomic::Ordering::Acquire),
"Guest's proto fetch should be served from the host's cached document links \
without issuing a fresh documentLink LSP request"
);
let guest_buffer = editor_b
.read_with(cx_b, |editor, cx| editor.buffer().read(cx).as_singleton())
.unwrap();
let buffer_id = guest_buffer.read_with(cx_b, |buffer, _| buffer.remote_id());
let unresolved = project_b
.read_with(cx_b, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
})
.into_values()
.flat_map(|per_server| per_server.into_values())
.next()
.expect("guest should mirror the fetched document link");
assert!(
!unresolved.resolved,
"freshly fetched links must come back unresolved"
);
let resolve_task = editor_b
.update(cx_b, |editor, cx| {
editor.document_links_at(guest_buffer.clone(), unresolved.range.start, cx)
})
.expect("editor should have a cached link covering the position");
let resolved_links = resolve_task.await;
assert_eq!(
1,
resolved_links.len(),
"`document_links_at` should yield the single matching link"
);
executor.run_until_parked();
assert_eq!(
1,
resolve_requests.load(atomic::Ordering::Acquire),
"Guest's resolve should reach the host's LSP exactly once"
);
let guest_links = project_b.read_with(cx_b, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
});
assert_eq!(
1,
guest_links.values().map(|m| m.len()).sum::<usize>(),
"Guest should mirror exactly one document link from the host"
);
let link = guest_links
.values()
.flat_map(|per_server| per_server.values())
.next()
.expect("guest cache should contain the mirrored link");
assert_eq!(
link.target.as_deref(),
Some(other_target.as_str()),
"Guest should see the resolved file:// target from the host"
);
assert_eq!(link.tooltip.as_deref(), Some(tooltip));
let click_anchor = guest_buffer.read_with(cx_b, |buffer, _| buffer.anchor_before(10));
let resolved_at_click = editor_b
.update(cx_b, |editor, cx| {
editor.document_links_at(guest_buffer.clone(), click_anchor, cx)
})
.expect("cached document link should cover the click anchor")
.await;
let (click_server_id, click_link) = resolved_at_click
.into_iter()
.next()
.expect("resolved links should not be empty");
let click_target = click_link
.target
.as_deref()
.expect("link should be resolved")
.to_owned();
let navigated = editor_b
.update_in(cx_b, |editor, window, cx| {
let hover_link = editor::hover_links::document_link_target_to_hover_link(
&click_target,
click_server_id,
);
editor.navigate_to_hover_links(None, vec![hover_link], None, false, window, cx)
})
.await
.expect("navigation task should complete");
assert_eq!(
navigated,
editor::Navigated::Yes,
"Clicking a resolved file:// document link should navigate",
);
executor.run_until_parked();
let other_editor = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item_as::<Editor>(cx).unwrap()
});
other_editor.update(cx_b, |editor, cx| {
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
assert_eq!(
buffer.read(cx).text(),
other_contents,
"Following the resolved link should open other.rs from the same worktree",
);
let head = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.head();
assert_eq!(
head,
Point::new(2, 4),
"Cursor should land at the URI fragment's line/column (1-based 3,5 -> 0-based 2,4)",
);
});
}
async fn test_lsp_pull_diagnostics(
should_stream_workspace_diagnostic: bool,
cx_a: &mut TestAppContext,

View file

@ -4,7 +4,7 @@ use collections::{HashMap, HashSet};
use dap::{Capabilities, adapters::DebugTaskDefinition, transport::RequestHandling};
use debugger_ui::debugger_panel::DebugPanel;
use editor::{Editor, EditorMode, MultiBuffer};
use editor::{Editor, EditorMode, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultiBuffer};
use extension::ExtensionHostProxy;
use fs::{FakeFs, Fs as _, RemoveOptions};
use futures::StreamExt as _;
@ -1367,3 +1367,260 @@ async fn test_ssh_remote_worktree_trust(cx_a: &mut TestAppContext, server_cx: &m
"should have no restricted worktrees after trusting both"
);
}
#[gpui::test]
async fn test_ssh_document_links_resolve(
cx_a: &mut TestAppContext,
server_cx: &mut TestAppContext,
) {
cx_a.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
project::trusted_worktrees::init(HashMap::default(), cx);
});
server_cx.update(|cx| {
release_channel::init(semver::Version::new(0, 0, 0), cx);
project::trusted_worktrees::init(HashMap::default(), cx);
});
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let document_link_count = Arc::new(AtomicUsize::new(0));
let resolve_count = Arc::new(AtomicUsize::new(0));
let (opts, server_ssh, _) = RemoteClient::fake_server(cx_a, server_cx);
let remote_fs = FakeFs::new(server_cx.executor());
remote_fs
.insert_tree(
path!("/code"),
json!({
"main.rs": "// see LICENSE for details\nfn main() {}",
"other.rs": "fn other() {}\n",
}),
)
.await;
server_cx.update(HeadlessProject::init);
let remote_http_client = Arc::new(BlockedHttpClient);
let node = NodeRuntime::unavailable();
let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
languages.add(rust_lang());
let capabilities = lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(true),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
};
let other_path_for_remote = path!("/code/other.rs");
let mut fake_language_servers = languages.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new({
let document_link_count = document_link_count.clone();
let resolve_count = resolve_count.clone();
move |fake_server| {
let document_link_count = document_link_count.clone();
fake_server.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>({
move |_params, _| {
let document_link_count = document_link_count.clone();
async move {
document_link_count.fetch_add(1, Ordering::Release);
Ok(Some(vec![lsp::DocumentLink {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 7,
},
end: lsp::Position {
line: 0,
character: 14,
},
},
target: None,
tooltip: None,
data: Some(serde_json::json!({"id": 7})),
}]))
}
}
});
let resolve_count = resolve_count.clone();
fake_server.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>({
move |link, _| {
let resolve_count = resolve_count.clone();
async move {
resolve_count.fetch_add(1, Ordering::Release);
Ok(lsp::DocumentLink {
range: link.range,
target: Some(
lsp::Uri::from_file_path(other_path_for_remote).unwrap(),
),
tooltip: Some("Open other.rs".into()),
data: None,
})
}
}
});
}
})),
..FakeLspAdapter::default()
},
);
let _headless_project = server_cx.new(|cx| {
HeadlessProject::new(
HeadlessAppState {
session: server_ssh,
fs: remote_fs.clone(),
http_client: remote_http_client,
node_runtime: node,
languages,
extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
startup_time: std::time::Instant::now(),
},
true,
cx,
)
});
let client_ssh = RemoteClient::connect_mock(opts, cx_a).await;
let (project_a, worktree_id) = client_a
.build_ssh_project(path!("/code"), client_ssh.clone(), true, cx_a)
.await;
cx_a.run_until_parked();
let trusted_worktrees =
cx_a.update(|cx| TrustedWorktrees::try_get_global(cx).expect("trust global"));
let worktree_store = project_a.read_with(cx_a, |project, _| project.worktree_store());
trusted_worktrees.update(cx_a, |store, cx| {
store.trust(
&worktree_store,
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
cx_a.run_until_parked();
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_links = Some(true);
});
});
});
project_a.update(cx_a, |project, _| {
project.languages().add(rust_lang());
project.languages().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
});
let (buffer, _registration) = project_a
.update(cx_a, |project, cx| {
project.open_buffer_with_lsp((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let buffer_id = buffer.read_with(cx_a, |buffer, _| buffer.remote_id());
cx_a.run_until_parked();
let _fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.run_until_parked();
let (editor, cx_a) = cx_a.add_window_view(|window, cx| {
Editor::new(
EditorMode::full(),
cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)),
Some(project_a.clone()),
window,
cx,
)
});
cx_a.executor()
.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
cx_a.run_until_parked();
let fetched = project_a.read_with(cx_a, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
});
assert_eq!(
fetched.values().map(|links| links.len()).sum::<usize>(),
1,
"Editor should auto-pull a single document link via SSH"
);
assert!(
document_link_count.load(Ordering::Acquire) >= 1,
"Remote LSP should have served the fetch request"
);
let unresolved = fetched
.values()
.flat_map(|per_server| per_server.values())
.next()
.expect("local cache should mirror the remote document link");
assert!(
!unresolved.resolved,
"freshly fetched links must come back unresolved"
);
let anchor = buffer.read_with(cx_a, |buffer, _| buffer.anchor_after(10));
let resolved = editor
.update(cx_a, |editor, cx| {
editor.document_links_at(buffer.clone(), anchor, cx)
})
.expect("editor should expose the cached document link at the cursor")
.await;
cx_a.run_until_parked();
assert_eq!(
resolved.len(),
1,
"Editor should surface exactly one resolved link at the cursor"
);
assert!(
resolve_count.load(Ordering::Acquire) >= 1,
"Local resolve should be forwarded over SSH and run on the remote LSP"
);
let other_uri = lsp::Uri::from_file_path(path!("/code/other.rs"))
.unwrap()
.to_string();
let links = project_a.read_with(cx_a, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
});
assert_eq!(
1,
links.values().map(|m| m.len()).sum::<usize>(),
"Local cache should mirror the single document link"
);
let link = links
.values()
.flat_map(|per_server| per_server.values())
.next()
.expect("local cache should contain the mirrored link");
assert_eq!(
link.target.as_deref(),
Some(other_uri.as_str()),
"Local should see the file:// target resolved on the remote"
);
assert_eq!(link.tooltip.as_deref(), Some("Open other.rs"));
let executor = cx_a.executor();
client_ssh.update(cx_a, |a, _| {
a.shutdown_processes(Some(proto::ShutdownRemoteServer {}), executor)
});
}

View file

@ -158,7 +158,7 @@ fn try_show_references(
let nav_entry = editor.navigation_entry(editor.selections.newest_anchor().head(), cx);
let links = locations
.into_iter()
.map(|location| HoverLink::InlayHint(location, server_id))
.map(|location| HoverLink::LspLocation(location, server_id))
.collect();
editor
.navigate_to_hover_links(None, links, nav_entry, false, window, cx)

View file

@ -0,0 +1,209 @@
use collections::HashMap;
use futures::future::join_all;
use gpui::{App, Entity, Task};
use itertools::Itertools;
use language::{Buffer, BufferSnapshot};
use lsp::LanguageServerId;
use project::lsp_store::{BufferDocumentLinks, LspDocumentLink, ResolvedDocumentLink};
use settings::Settings;
use text::BufferId;
use ui::Context;
use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, editor_settings::EditorSettings};
pub(super) struct LspDocumentLinks {
pub(super) enabled: bool,
pub(super) per_buffer: HashMap<BufferId, BufferDocumentLinks>,
pub(super) refresh_task: Task<()>,
}
impl LspDocumentLinks {
pub(super) fn new(cx: &App) -> Self {
Self {
enabled: EditorSettings::get_global(cx).lsp_document_links,
per_buffer: HashMap::default(),
refresh_task: Task::ready(()),
}
}
}
impl Editor {
pub(super) fn refresh_document_links(
&mut self,
for_buffer: Option<BufferId>,
cx: &mut Context<Self>,
) {
if !self.lsp_data_enabled() || !self.lsp_document_links.enabled {
return;
}
let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
return;
};
let buffers_to_query = self
.visible_buffers(cx)
.into_iter()
.filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx))
.chain(for_buffer.and_then(|id| self.buffer.read(cx).buffer(id)))
.filter(|buffer| {
let id = buffer.read(cx).remote_id();
for_buffer.is_none_or(|target| target == id)
&& self.registered_buffers.contains_key(&id)
})
.unique_by(|buffer| buffer.read(cx).remote_id())
.collect::<Vec<_>>();
if buffers_to_query.is_empty() {
self.lsp_document_links.refresh_task = Task::ready(());
return;
}
self.lsp_document_links.refresh_task = cx.spawn(async move |editor, cx| {
cx.background_executor()
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
.await;
let Some(tasks_for_buffers) = project
.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
buffers_to_query
.into_iter()
.map(|buffer| {
let buffer_id = buffer.read(cx).remote_id();
let task = lsp_store.fetch_document_links(&buffer, cx);
async move { (buffer_id, task.await) }
})
.collect::<Vec<_>>()
})
})
.ok()
else {
return;
};
let new_links_for_buffers = join_all(tasks_for_buffers).await;
editor
.update(cx, |editor, _| {
for (buffer_id, links) in new_links_for_buffers {
let Some(links) = links else {
continue;
};
if links.is_empty() {
editor.lsp_document_links.per_buffer.remove(&buffer_id);
} else {
editor
.lsp_document_links
.per_buffer
.insert(buffer_id, links);
}
}
})
.ok();
});
}
/// Returns a task yielding the resolved document links covering `position`
/// in `buffer`, paired with the language server that owns each link.
/// Resolution is deduplicated through `LspStore`'s per-`(server_id,
/// link_id)` `Shared` task; the editor's mirror is updated when the
/// resolves complete so subsequent renders/hovers find resolved data
/// without re-issuing requests.
///
/// Returns `None` when nothing is cached at `position` so callers can skip
/// spawning anything.
pub fn document_links_at(
&mut self,
buffer: Entity<Buffer>,
position: text::Anchor,
cx: &mut Context<Self>,
) -> Option<Task<Vec<(LanguageServerId, LspDocumentLink)>>> {
let buffer_id = buffer.read(cx).remote_id();
let snapshot = buffer.read(cx).snapshot();
let matches = self
.lsp_document_links
.per_buffer
.get(&buffer_id)?
.iter()
.flat_map(|(server_id, per_server)| {
per_server
.iter()
.map(move |(link_id, link)| (*server_id, *link_id, link))
})
.filter(|(_, _, link)| link_contains(link, &position, &snapshot))
.map(|(server_id, link_id, link)| (server_id, link_id, link.clone()))
.collect::<Vec<_>>();
if matches.is_empty() {
return None;
}
let project = self.project.clone()?;
let mut resolved_links = Vec::with_capacity(matches.len());
let mut pending = Vec::new();
project.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
for (server_id, link_id, _link) in matches {
match lsp_store.resolved_document_link(&buffer, server_id, link_id, cx) {
Some(ResolvedDocumentLink::Resolved(resolved)) => {
resolved_links.push((server_id, link_id, resolved));
}
Some(ResolvedDocumentLink::Resolving(task)) => {
pending.push((server_id, task));
}
None => {
// Cache no longer holds the link (likely a version
// bump between the mirror snapshot and now); skip.
}
}
}
})
});
if pending.is_empty() {
return Some(Task::ready(
resolved_links
.into_iter()
.map(|(server_id, _, link)| (server_id, link))
.collect(),
));
}
Some(cx.spawn(async move |editor, cx| {
let pending_results =
join_all(pending.into_iter().map(|(server_id, task)| async move {
task.await.map(|(link_id, link)| (server_id, link_id, link))
}))
.await;
resolved_links.extend(pending_results.into_iter().flatten());
editor
.update(cx, |editor, cx| {
if let Some(by_server) =
editor.lsp_document_links.per_buffer.get_mut(&buffer_id)
{
for (server_id, link_id, resolved) in &resolved_links {
if let Some(slot) = by_server
.get_mut(server_id)
.and_then(|links| links.get_mut(link_id))
{
*slot = resolved.clone();
}
}
}
cx.notify();
})
.ok();
resolved_links
.into_iter()
.map(|(server_id, _, link)| (server_id, link))
.collect()
}))
}
}
fn link_contains(
link: &LspDocumentLink,
position: &text::Anchor,
snapshot: &BufferSnapshot,
) -> bool {
link.range.start.cmp(position, snapshot).is_le()
&& link.range.end.cmp(position, snapshot).is_ge()
}

View file

@ -145,7 +145,7 @@ impl Editor {
if !self.lsp_data_enabled() {
return;
}
let Some(project) = self.project.clone() else {
let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
return;
};
@ -191,9 +191,9 @@ impl Editor {
.timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
.await;
let Some(tasks) = editor
.update(cx, |_, cx| {
project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
let Some(tasks) = project
.update(cx, |project, cx| {
project.lsp_store().update(cx, |lsp_store, cx| {
buffers_to_query
.into_iter()
.map(|buffer| {

View file

@ -19,6 +19,7 @@ pub mod code_context_menus;
mod code_lens;
pub mod display_map;
mod document_colors;
mod document_links;
mod document_symbols;
mod editor_settings;
mod element;
@ -26,7 +27,7 @@ mod fold;
mod folding_ranges;
mod git;
mod highlight_matching_bracket;
mod hover_links;
pub mod hover_links;
pub mod hover_popover;
mod indent_guides;
mod inlays;
@ -140,6 +141,7 @@ use convert_case::{Case, Casing};
use dap::TelemetrySpawnLocation;
use display_map::*;
use document_colors::LspColorData;
use document_links::LspDocumentLinks;
use edit_prediction_types::{
EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionDiscardReason,
EditPredictionGranularity, SuggestionDisplayType,
@ -1114,6 +1116,7 @@ pub struct Editor {
semantic_token_state: SemanticTokenState,
pub(crate) refresh_matching_bracket_highlights_task: Task<()>,
refresh_document_symbols_task: Shared<Task<()>>,
lsp_document_links: LspDocumentLinks,
lsp_document_symbols: HashMap<BufferId, Vec<OutlineItem<text::Anchor>>>,
refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>,
outline_symbols_at_cursor: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
@ -2322,6 +2325,7 @@ impl Editor {
number_deleted_lines: false,
refresh_matching_bracket_highlights_task: Task::ready(()),
refresh_document_symbols_task: Task::ready(()).shared(),
lsp_document_links: LspDocumentLinks::new(cx),
lsp_document_symbols: HashMap::default(),
refresh_outline_symbols_at_cursor_at_cursor_task: Task::ready(()),
outline_symbols_at_cursor: None,
@ -9319,6 +9323,8 @@ impl Editor {
self.registered_buffers.remove(buffer_id);
self.clear_runnables(Some(*buffer_id));
self.semantic_token_state.invalidate_buffer(buffer_id);
self.lsp_document_symbols.remove(buffer_id);
self.lsp_document_links.per_buffer.remove(buffer_id);
self.display_map.update(cx, |display_map, cx| {
display_map.invalidate_semantic_highlights(*buffer_id);
display_map.clear_lsp_folding_ranges(*buffer_id, cx);
@ -9542,6 +9548,17 @@ impl Editor {
self.toggle_code_lens(code_lens_inline, window, cx);
}
let lsp_document_links_enabled = EditorSettings::get_global(cx).lsp_document_links;
if lsp_document_links_enabled != self.lsp_document_links.enabled {
self.lsp_document_links.enabled = lsp_document_links_enabled;
if lsp_document_links_enabled {
self.refresh_document_links(None, cx);
} else {
self.lsp_document_links.per_buffer.clear();
self.lsp_document_links.refresh_task = Task::ready(());
}
}
self.refresh_inlay_hints(
InlayHintRefreshReason::SettingsChange(inlay_hint_settings(
self.selections.newest_anchor().head(),
@ -10472,6 +10489,7 @@ impl Editor {
}
self.refresh_semantic_tokens(for_buffer, None, cx);
self.refresh_document_colors(for_buffer, window, cx);
self.refresh_document_links(for_buffer, cx);
self.refresh_folding_ranges(for_buffer, window, cx);
self.refresh_code_lenses(for_buffer, window, cx);
self.refresh_document_symbols(for_buffer, cx);
@ -10650,6 +10668,7 @@ impl Editor {
self.colorize_brackets(false, cx);
self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
self.resolve_visible_code_lenses(cx);
if !self.buffer().read(cx).is_singleton() || self.needs_initial_data_update {
self.needs_initial_data_update = false;
self.update_lsp_data(None, window, cx);

View file

@ -61,6 +61,7 @@ pub struct EditorSettings {
pub drag_and_drop_selection: DragAndDropSelection,
pub code_lens: CodeLens,
pub lsp_document_colors: DocumentColorsRenderMode,
pub lsp_document_links: bool,
pub minimum_contrast_for_highlights: f32,
pub completion_menu_scrollbar: ShowScrollbar,
pub completion_detail_alignment: CompletionDetailAlignment,
@ -300,6 +301,7 @@ impl Settings for EditorSettings {
},
code_lens: editor.code_lens.unwrap(),
lsp_document_colors: editor.lsp_document_colors.unwrap(),
lsp_document_links: editor.lsp_document_links.unwrap(),
minimum_contrast_for_highlights: editor.minimum_contrast_for_highlights.unwrap().0,
completion_menu_scrollbar: editor
.completion_menu_scrollbar

View file

@ -4,17 +4,20 @@ use crate::{
HighlightKey, Navigated, PointForPosition, SelectPhase,
editor_settings::GoToDefinitionFallback, scroll::ScrollAmount,
};
use gpui::{App, AsyncWindowContext, Context, Entity, Modifiers, Pixels, Task, Window, px};
use gpui::{
App, AsyncWindowContext, Context, Entity, HighlightStyle, Modifiers, Pixels, Task,
UnderlineStyle, Window, px,
};
use language::{Bias, ToOffset};
use linkify::{LinkFinder, LinkKind};
use lsp::LanguageServerId;
use project::{InlayId, LocationLink, Project, ResolvedPath};
use regex::Regex;
use settings::Settings;
use std::{ops::Range, sync::LazyLock};
use std::{ops::Range, str::FromStr as _, sync::LazyLock};
use text::OffsetRangeExt;
use theme::ActiveTheme as _;
use util::{ResultExt, TryFutureExt as _, maybe, paths::PathWithPosition};
use util::{ResultExt, TryFutureExt as _, paths::PathWithPosition};
#[derive(Debug)]
pub struct HoveredLinkState {
@ -65,7 +68,52 @@ pub enum HoverLink {
Url(String),
File(ResolvedFileTarget),
Text(LocationLink),
InlayHint(lsp::Location, LanguageServerId),
/// Navigate to an LSP-given location whose buffer may not be loaded yet.
/// Used by inlay-hint hover, code-lens references, and document-link
/// targets that point inside a workspace file (e.g. `file:///foo#9,16`).
LspLocation(lsp::Location, LanguageServerId),
}
/// Convert a `documentLink` target URI into a [`HoverLink`], reusing the
/// existing navigation paths: `file://` URIs go through the LSP location
/// pipeline (so an optional `#line[,column]` fragment is honored), while
/// any other scheme is opened as a regular URL.
pub fn document_link_target_to_hover_link(target: &str, server_id: LanguageServerId) -> HoverLink {
if let Ok(url) = url::Url::parse(target)
&& url.scheme() == "file"
&& let Ok(uri) = lsp::Uri::from_str(target)
{
let position = url
.fragment()
.and_then(parse_uri_fragment_position)
.unwrap_or_default();
return HoverLink::LspLocation(
lsp::Location {
uri,
range: lsp::Range::new(position, position),
},
server_id,
);
}
HoverLink::Url(target.to_string())
}
/// Parse a URI fragment such as `9,16`, `9:16`, `L9`, or `L9:16` into an
/// LSP position (1-based input, 0-based output). Servers like the JSON
/// language server attach this fragment to `file://` document link
/// targets to point at a specific row/column inside the file.
fn parse_uri_fragment_position(fragment: &str) -> Option<lsp::Position> {
let stripped = fragment.strip_prefix('L').unwrap_or(fragment);
let (line_str, column_str) = match stripped.split_once([',', ':']) {
Some((line, column)) => (line, Some(column)),
None => (stripped, None),
};
let line = line_str.parse::<u32>().ok()?.checked_sub(1)?;
let character = column_str
.and_then(|column| column.parse::<u32>().ok())
.and_then(|column| column.checked_sub(1))
.unwrap_or(0);
Some(lsp::Position { line, character })
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -239,7 +287,7 @@ impl Editor {
else {
return Task::ready(Ok(Navigated::No));
};
let Some(mb_anchor) = self
let Some(multi_buffer_anchor) = self
.buffer()
.read(cx)
.snapshot(cx)
@ -258,7 +306,7 @@ impl Editor {
}
})
.collect();
let nav_entry = self.navigation_entry(mb_anchor, cx);
let nav_entry = self.navigation_entry(multi_buffer_anchor, cx);
let split = Self::is_alt_pressed(&modifiers, cx);
let navigate_task =
self.navigate_to_hover_links(None, links, nav_entry, split, window, cx);
@ -341,7 +389,7 @@ pub fn show_link_definition(
|| hovered_link_state
.links
.first()
.is_some_and(|d| matches!(d, HoverLink::Url(_)));
.is_some_and(|d| matches!(d, HoverLink::Url(_) | HoverLink::LspLocation(_, _)));
if same_kind {
if is_cached && (hovered_link_state.last_trigger_point == trigger_point)
@ -361,60 +409,107 @@ pub fn show_link_definition(
let project = editor.project.clone();
let provider = editor.semantics_provider.clone();
let snapshot = snapshot.buffer_snapshot().clone();
hovered_link_state.task = Some(cx.spawn_in(window, async move |this, cx| {
async move {
// LSP document links take priority: the server explicitly
// declares which ranges are clickable, so they are more
// accurate than the heuristic-based URL/file detection.
//
// Resolution is deduplicated by `LspStore`; awaiting here only
// blocks until either the cached resolved entry is returned or
// the in-flight `Shared` task completes.
let resolved_document_links = this
.update(cx, |editor, cx| {
editor.document_links_at(buffer.clone(), anchor, cx)
})
.ok()
.flatten();
let resolved_document_links = match resolved_document_links {
Some(task) => task.await,
None => Vec::new(),
};
let snapshot = this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
let detected_document_link =
resolved_document_links
.into_iter()
.find_map(|(server_id, link)| {
let multi_buffer_range =
snapshot.buffer_anchor_range_to_anchor_range(link.range.clone())?;
Some((link.range, multi_buffer_range, link.target, server_id))
});
drop(snapshot);
let result = match &trigger_point {
TriggerPoint::Text(_) => {
if let Some((url_range, url)) = find_url(&buffer, anchor, cx.clone()) {
this.read_with(cx, |_, _| {
let range = maybe!({
let range =
snapshot.buffer_anchor_range_to_anchor_range(url_range)?;
Some(RangeInEditor::Text(range))
});
(range, vec![HoverLink::Url(url)])
})
.ok()
let mut links = Vec::new();
let mut symbol_range = None;
// LSP-provided document link wins over heuristic URL/file
// detection at the same position: the server tells us the
// exact range and target, while `find_url`/`find_file` are
// best-effort text matches.
if let Some((_, multi_buffer_range, Some(target), server_id)) =
detected_document_link.clone()
{
symbol_range = Some(RangeInEditor::Text(multi_buffer_range));
links.push(document_link_target_to_hover_link(&target, server_id));
} else if let Some((url_range, url)) = find_url(&buffer, anchor, cx) {
let snapshot =
this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
if let Some(range) = snapshot.buffer_anchor_range_to_anchor_range(url_range)
{
symbol_range = Some(RangeInEditor::Text(range));
}
links.push(HoverLink::Url(url));
} else if let Some((filename_range, file_target)) =
find_file(&buffer, project.clone(), anchor, cx).await
{
let range = maybe!({
let range =
snapshot.buffer_anchor_range_to_anchor_range(filename_range)?;
Some(RangeInEditor::Text(range))
});
let snapshot =
this.read_with(cx, |editor, cx| editor.buffer.read(cx).snapshot(cx))?;
if let Some(range) =
snapshot.buffer_anchor_range_to_anchor_range(filename_range)
{
symbol_range = Some(RangeInEditor::Text(range));
}
links.push(HoverLink::File(file_target));
}
Some((range, vec![HoverLink::File(file_target)]))
} else if let Some(provider) = provider {
// Always also collect LSP definitions so that cmd-click
// reveals every applicable target (e.g. a position that
// carries both a document link and a definition).
if let Some(provider) = provider {
let task = cx.update(|_, cx| {
provider.definitions(&buffer, anchor, preferred_kind, cx)
})?;
if let Some(task) = task {
task.await.ok().flatten().map(|definition_result| {
(
definition_result.iter().find_map(|link| {
link.origin.as_ref().and_then(|origin| {
let range = snapshot
.buffer_anchor_range_to_anchor_range(
origin.range.clone(),
)?;
Some(RangeInEditor::Text(range))
})
}),
definition_result.into_iter().map(HoverLink::Text).collect(),
)
})
} else {
None
if let Some(task) = task
&& let Some(definition_result) = task.await.ok().flatten()
{
if symbol_range.is_none() {
let snapshot = this.read_with(cx, |editor, cx| {
editor.buffer.read(cx).snapshot(cx)
})?;
symbol_range = definition_result.iter().find_map(|link| {
link.origin.as_ref().and_then(|origin| {
let range = snapshot.buffer_anchor_range_to_anchor_range(
origin.range.clone(),
)?;
Some(RangeInEditor::Text(range))
})
});
}
links.extend(definition_result.into_iter().map(HoverLink::Text));
}
} else {
}
if links.is_empty() {
None
} else {
Some((symbol_range, links))
}
}
TriggerPoint::InlayHint(highlight, lsp_location, server_id) => Some((
Some(RangeInEditor::Inlay(highlight.clone())),
vec![HoverLink::InlayHint(lsp_location.clone(), *server_id)],
vec![HoverLink::LspLocation(lsp_location.clone(), *server_id)],
)),
};
@ -428,7 +523,18 @@ pub fn show_link_definition(
hovered_link_state.preferred_kind = preferred_kind;
hovered_link_state.symbol_range = result
.as_ref()
.and_then(|(symbol_range, _)| symbol_range.clone());
.and_then(|(symbol_range, _)| symbol_range.clone())
.or_else(|| {
// Even if we have no click target yet (e.g. an
// unresolved document link), record the link's range
// so subsequent mouse moves on the same link
// short-circuit in `show_link_definition`.
detected_document_link
.as_ref()
.map(|(_, multi_buffer_range, _, _)| {
RangeInEditor::Text(multi_buffer_range.clone())
})
});
if let Some((symbol_range, definitions)) = result {
hovered_link_state.links = definitions;
@ -437,17 +543,18 @@ pub fn show_link_definition(
|| hovered_link_state.symbol_range.is_some();
if underline_hovered_link {
let style = gpui::HighlightStyle {
underline: Some(gpui::UnderlineStyle {
let style = HighlightStyle {
underline: Some(UnderlineStyle {
thickness: px(1.),
..Default::default()
..UnderlineStyle::default()
}),
color: Some(cx.theme().colors().link_text_hover),
..Default::default()
..HighlightStyle::default()
};
let highlight_range =
symbol_range.unwrap_or_else(|| match &trigger_point {
TriggerPoint::Text(trigger_anchor) => {
let snapshot = editor.buffer.read(cx).snapshot(cx);
// If no symbol range returned from language server, use the surrounding word.
let (offset_range, _) =
snapshot.surrounding_word(*trigger_anchor, None);
@ -476,6 +583,22 @@ pub fn show_link_definition(
),
}
}
} else if let Some((_, multi_buffer_range, _, _)) = detected_document_link.as_ref()
{
let style = HighlightStyle {
underline: Some(UnderlineStyle {
thickness: px(1.),
..UnderlineStyle::default()
}),
color: Some(cx.theme().colors().link_text_hover),
..HighlightStyle::default()
};
editor.highlight_text(
HighlightKey::HoveredLinkState,
vec![multi_buffer_range.clone()],
style,
cx,
);
} else {
editor.hide_hovered_link(cx);
}
@ -493,11 +616,11 @@ pub fn show_link_definition(
pub(crate) fn find_url(
buffer: &Entity<language::Buffer>,
position: text::Anchor,
cx: AsyncWindowContext,
cx: &AsyncWindowContext,
) -> Option<(Range<text::Anchor>, String)> {
const LIMIT: usize = 2048;
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let offset = position.to_offset(&snapshot);
let mut token_start = offset;
@ -553,11 +676,11 @@ pub(crate) fn find_url(
pub(crate) fn find_url_from_range(
buffer: &Entity<language::Buffer>,
range: Range<text::Anchor>,
cx: AsyncWindowContext,
cx: &AsyncWindowContext,
) -> Option<String> {
const LIMIT: usize = 2048;
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let start_offset = range.start.to_offset(&snapshot);
let end_offset = range.end.to_offset(&snapshot);
@ -609,7 +732,7 @@ pub(crate) fn find_url_from_range(
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedFileTarget {
pub struct ResolvedFileTarget {
pub resolved_path: ResolvedPath,
pub row: Option<u32>,
pub column: Option<u32>,
@ -942,9 +1065,98 @@ mod tests {
use lsp::request::{GotoDefinition, GotoTypeDefinition};
use multi_buffer::MultiBufferOffset;
use settings::InlayHintSettingsContent;
use std::str::FromStr;
use util::{assert_set_eq, path};
use workspace::item::Item;
#[test]
fn test_parse_uri_fragment_position() {
// json-language-server style: 1-based `line,column`.
assert_eq!(
parse_uri_fragment_position("9,16"),
Some(lsp::Position {
line: 8,
character: 15,
})
);
assert_eq!(
parse_uri_fragment_position("33,33"),
Some(lsp::Position {
line: 32,
character: 32,
})
);
// GitHub-style `L<line>` and `L<line>:<col>`.
assert_eq!(
parse_uri_fragment_position("L42"),
Some(lsp::Position {
line: 41,
character: 0,
})
);
assert_eq!(
parse_uri_fragment_position("L42:7"),
Some(lsp::Position {
line: 41,
character: 6,
})
);
// Bare line number, no column.
assert_eq!(
parse_uri_fragment_position("5"),
Some(lsp::Position {
line: 4,
character: 0,
})
);
// Garbage / unparseable / 0-based fragments are rejected.
assert_eq!(parse_uri_fragment_position(""), None);
assert_eq!(parse_uri_fragment_position("section-name"), None);
assert_eq!(parse_uri_fragment_position("0,0"), None);
}
#[test]
fn test_document_link_target_to_hover_link_file_uri_with_fragment() {
let server_id = LanguageServerId(0);
let target = "file:///Users/me/work/local_test/document-links-test.json#9,16";
match document_link_target_to_hover_link(target, server_id) {
HoverLink::LspLocation(location, returned_id) => {
assert_eq!(returned_id, server_id);
assert_eq!(
location.uri.as_str(),
"file:///Users/me/work/local_test/document-links-test.json#9,16",
);
assert_eq!(
location.range,
lsp::Range {
start: lsp::Position {
line: 8,
character: 15,
},
end: lsp::Position {
line: 8,
character: 15,
},
}
);
}
other => panic!("expected LspLocation variant, got {other:?}"),
}
}
#[test]
fn test_document_link_target_to_hover_link_http_url() {
let server_id = LanguageServerId(0);
let target = "https://opensource.org/licenses/MIT";
match document_link_target_to_hover_link(target, server_id) {
HoverLink::Url(url) => assert_eq!(url, target),
other => panic!("expected Url variant, got {other:?}"),
}
}
#[gpui::test]
async fn test_hover_type_links(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
@ -2384,4 +2596,510 @@ Sentence ending file2.rs.
fn «do_workˇ»() { test(); }
"});
}
#[gpui::test]
async fn test_document_links(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
// See LICENSE for details
fn main() {
println!(\"hello\");
}ˇ
"});
let link_range = cx.lsp_range(indoc! {"
// See «LICENSE» for details
fn main() {
println!(\"hello\");
}
"});
let mut requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
move |_, _| async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: Some(
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
),
tooltip: Some("Open license".to_string()),
data: None,
}]))
},
);
// Trigger document link fetch via LSP data refresh
cx.run_until_parked();
requests.next().await;
cx.run_until_parked();
// Cmd-hover over "LICENSE" should highlight it as a link
let screen_coord = cx.pixel_position(indoc! {"
// See LICˇENSE for details
fn main() {
println!(\"hello\");
}
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.run_until_parked();
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
// See «LICENSEˇ» for details
fn main() {
println!(\"hello\");
}
"},
);
// Clicking opens the URL
cx.simulate_click(screen_coord, Modifiers::secondary_key());
assert_eq!(
cx.opened_url(),
Some("https://opensource.org/licenses/MIT".into())
);
}
#[gpui::test]
async fn test_document_links_take_priority_over_url_detection(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
// Text contains a URL, but the LSP provides a document link that
// covers a broader range and points to a different target.
cx.set_state(indoc! {"
// See https://example.com for more infoˇ
"});
let link_range = cx.lsp_range(indoc! {"
// «See https://example.com for more info»
"});
let mut requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
move |_, _| async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: Some(
lsp::Uri::from_str("https://lsp-provided.example.com").unwrap(),
),
tooltip: None,
data: None,
}]))
},
);
cx.run_until_parked();
requests.next().await;
cx.run_until_parked();
let screen_coord = cx.pixel_position(indoc! {"
// See https://examˇple.com for more info
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
cx.run_until_parked();
// LSP document link range is highlighted, not just the URL portion
cx.assert_editor_text_highlights(
HighlightKey::HoveredLinkState,
indoc! {"
// «See https://example.com for more infoˇ»
"},
);
// Clicking navigates to the LSP-provided target, not the detected URL.
// (Uri::to_string normalizes "https://host" to "https://host/")
cx.simulate_click(screen_coord, Modifiers::secondary_key());
assert_eq!(
cx.opened_url(),
Some("https://lsp-provided.example.com/".into())
);
}
#[gpui::test]
async fn test_cmd_hover_aggregates_document_link_and_definition(cx: &mut gpui::TestAppContext) {
// VSCode behavior: when a position carries multiple link sources
// (LSP document link, go-to-definition, ...), cmd-click should reveal
// every applicable target. We assert this by inspecting the
// aggregated `hovered_link_state.links` after a cmd-hover.
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
definition_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
// See LICENSE for details
fn definition() {}ˇ
"});
let link_range = cx.lsp_range(indoc! {"
// See «LICENSE» for details
fn definition() {}
"});
let definition_target_range = cx.lsp_range(indoc! {"
// See LICENSE for details
fn «definition»() {}
"});
let mut document_link_requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
move |_, _| async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: Some(
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
),
tooltip: Some("Open license".to_string()),
data: None,
}]))
},
);
let mut definition_requests =
cx.set_request_handler::<GotoDefinition, _, _>(move |url, _, _| async move {
Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
lsp::LocationLink {
origin_selection_range: Some(link_range),
target_uri: url.clone(),
target_range: definition_target_range,
target_selection_range: definition_target_range,
},
])))
});
cx.run_until_parked();
document_link_requests.next().await;
cx.run_until_parked();
let screen_coord = cx.pixel_position(indoc! {"
// See LICˇENSE for details
fn definition() {}
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::secondary_key());
definition_requests.next().await;
cx.run_until_parked();
cx.update_editor(|editor, _, _| {
let links = &editor
.hovered_link_state
.as_ref()
.expect("cmd-hover should populate `hovered_link_state`")
.links;
let url_count = links
.iter()
.filter(|link| matches!(link, HoverLink::Url(_)))
.count();
let text_count = links
.iter()
.filter(|link| matches!(link, HoverLink::Text(_)))
.count();
assert_eq!(
url_count, 1,
"document link should contribute exactly one Url hover link, got {links:?}"
);
assert_eq!(
text_count, 1,
"go-to-definition should contribute exactly one Text hover link, got {links:?}"
);
});
// Cmd-click resolves the in-buffer location (definition) since the
// mixed Url + Text case lets `navigate_to_hover_links` prefer the
// location target over the external URL.
cx.simulate_click(screen_coord, Modifiers::secondary_key());
cx.run_until_parked();
cx.assert_editor_state(indoc! {"
// See LICENSE for details
fn «definitionˇ»() {}
"});
}
#[gpui::test]
async fn test_document_link_tooltip_popover(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
// See LICENSE for detailsˇ
"});
let link_range = cx.lsp_range(indoc! {"
// See «LICENSE» for details
"});
let mut requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
move |_, _| async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: Some(
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
),
tooltip: Some("Open license".to_string()),
data: None,
}]))
},
);
cx.run_until_parked();
requests.next().await;
cx.run_until_parked();
let screen_coord = cx.pixel_position(indoc! {"
// See LICˇENSE for details
"});
// Plain hover (no modifier) is enough; the doc-link tooltip stacks
// alongside the regular LSP hover popovers.
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
cx.background_executor
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
cx.run_until_parked();
cx.update_editor(|editor, _, cx| {
let tooltip_text = editor
.hover_state
.info_popovers
.iter()
.find_map(|popover| {
let parsed = popover.parsed_content.as_ref()?;
let text = parsed.read(cx).parsed_markdown().source().to_string();
(text == "Open license").then_some(text)
})
.expect("doc-link tooltip should appear in info_popovers on plain hover");
assert_eq!(tooltip_text, "Open license");
});
// Move the mouse off the link; `show_hover` re-fires for the new
// position and rebuilds `info_popovers` without the tooltip.
let off_link = cx.pixel_position(indoc! {"
// ˇSee LICENSE for details
"});
cx.simulate_mouse_move(off_link, None, Modifiers::none());
cx.background_executor
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
cx.run_until_parked();
cx.update_editor(|editor, _, cx| {
let still_present = editor.hover_state.info_popovers.iter().any(|popover| {
popover
.parsed_content
.as_ref()
.map(|parsed| *parsed.read(cx).parsed_markdown().source() == "Open license")
.unwrap_or(false)
});
assert!(
!still_present,
"doc-link tooltip should be cleared once the mouse leaves the link"
);
});
}
#[gpui::test]
async fn test_document_link_resolve_on_hover(cx: &mut gpui::TestAppContext) {
init_test(cx, |_| {});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(true),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
// See LICENSE for detailsˇ
"});
let link_range = cx.lsp_range(indoc! {"
// See «LICENSE» for details
"});
let resolve_data = serde_json::json!({"id": 42});
let mut document_link_requests = {
let resolve_data = resolve_data.clone();
cx.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |_, _| {
let resolve_data = resolve_data.clone();
async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: None,
tooltip: None,
data: Some(resolve_data),
}]))
}
})
};
let mut resolve_requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(
move |req, _| async move {
Ok(lsp::DocumentLink {
range: req.range,
target: Some(
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
),
tooltip: Some("Resolved tooltip".to_string()),
data: None,
})
},
);
cx.run_until_parked();
document_link_requests.next().await;
cx.run_until_parked();
let screen_coord = cx.pixel_position(indoc! {"
// See LICˇENSE for details
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
let delay_ms = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay.0);
cx.background_executor
.advance_clock(std::time::Duration::from_millis(delay_ms + 100));
cx.run_until_parked();
// Hover triggers resolve, not a viewport sweep.
resolve_requests.next().await;
cx.run_until_parked();
cx.update_editor(|editor, _, cx| {
let tooltip_text = editor
.hover_state
.info_popovers
.iter()
.find_map(|popover| {
let parsed = popover.parsed_content.as_ref()?;
let text = parsed.read(cx).parsed_markdown().source().to_string();
(text == "Resolved tooltip").then_some(text)
})
.expect("resolved doc-link tooltip should appear in info_popovers");
assert_eq!(tooltip_text, "Resolved tooltip");
});
}
#[gpui::test]
async fn test_document_link_tooltip_respects_hover_popover_enabled(
cx: &mut gpui::TestAppContext,
) {
init_test(cx, |_| {});
cx.update(|cx| {
use gpui::BorrowAppContext as _;
cx.update_global::<settings::SettingsStore, _>(|settings, cx| {
settings.update_user_settings(cx, |settings| {
settings.editor.hover_popover_enabled = Some(false);
});
});
});
let mut cx = EditorLspTestContext::new_rust(
lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(false),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
},
cx,
)
.await;
cx.set_state(indoc! {"
// See LICENSE for detailsˇ
"});
let link_range = cx.lsp_range(indoc! {"
// See «LICENSE» for details
"});
let mut requests = cx
.lsp
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(
move |_, _| async move {
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: Some(
lsp::Uri::from_str("https://opensource.org/licenses/MIT").unwrap(),
),
tooltip: Some("Open license".to_string()),
data: None,
}]))
},
);
cx.run_until_parked();
requests.next().await;
cx.run_until_parked();
let screen_coord = cx.pixel_position(indoc! {"
// See LICˇENSE for details
"});
cx.simulate_mouse_move(screen_coord, None, Modifiers::none());
cx.background_executor
.advance_clock(std::time::Duration::from_millis(2000));
cx.run_until_parked();
cx.update_editor(|editor, _, _| {
assert!(
editor.hover_state.info_popovers.is_empty(),
"no popovers should appear when hover_popover_enabled is false"
);
});
}
}

View file

@ -510,6 +510,27 @@ fn show_hover(
})
}
let doc_link_task = this
.update(cx, |editor, cx| {
editor.document_links_at(buffer.clone(), buffer_position, cx)
})
.ok()
.flatten();
let doc_link_tooltips = match doc_link_task {
Some(task) => task
.await
.into_iter()
.filter_map(|(_, link)| {
let multi_buffer_range = snapshot
.buffer_snapshot()
.buffer_anchor_range_to_anchor_range(link.range.clone())?;
let tooltip = link.tooltip?;
Some((multi_buffer_range, tooltip))
})
.collect::<Vec<_>>(),
None => Vec::new(),
};
for hover_result in hovers_response {
// Create symbol range of anchors for highlighting and filtering of future requests.
let range = hover_result
@ -552,6 +573,32 @@ fn show_hover(
});
}
for (multi_buffer_range, tooltip) in doc_link_tooltips {
let blocks = vec![HoverBlock {
text: tooltip.to_string(),
kind: HoverBlockKind::Markdown,
}];
let parsed_content = parse_blocks(&blocks, language_registry.as_ref(), None, cx);
let scroll_handle = ScrollHandle::new();
let subscription = this
.update(cx, |_, cx| {
parsed_content.as_ref().map(|parsed_content| {
cx.observe(parsed_content, |_, _, cx| cx.notify())
})
})
.ok()
.flatten();
info_popovers.push(InfoPopover {
symbol_range: RangeInEditor::Text(multi_buffer_range),
parsed_content,
scroll_handle,
keyboard_grace: Rc::new(RefCell::new(ignore_timeout)),
anchor: Some(anchor),
last_bounds: Rc::new(Cell::new(None)),
_subscription: subscription,
});
}
this.update_in(cx, |editor, window, cx| {
if hover_highlights.is_empty() {
editor.clear_background_highlights(HighlightKey::HoverState, cx);

View file

@ -1074,9 +1074,9 @@ impl Editor {
let url_finder = cx.spawn_in(window, async move |_editor, cx| {
let url = if let Some(end_pos) = end_position {
find_url_from_range(&buffer, start_position..end_pos, cx.clone())
find_url_from_range(&buffer, start_position..end_pos, cx)
} else {
find_url(&buffer, start_position, cx.clone()).map(|(_, url)| url)
find_url(&buffer, start_position, cx).map(|(_, url)| url)
};
if let Some(url) = url {
@ -1576,7 +1576,7 @@ impl Editor {
}
}
pub(super) fn navigate_to_hover_links(
pub fn navigate_to_hover_links(
&mut self,
kind: Option<GotoDefinitionKind>,
definitions: Vec<HoverLink>,
@ -1591,7 +1591,7 @@ impl Editor {
.into_iter()
.filter_map(|def| match def {
HoverLink::Text(link) => Some(Task::ready(anyhow::Ok(Some(link.target)))),
HoverLink::InlayHint(lsp_location, server_id) => {
HoverLink::LspLocation(lsp_location, server_id) => {
let computation =
self.compute_target_location(lsp_location, server_id, window, cx);
Some(cx.background_spawn(computation))

View file

@ -1000,6 +1000,10 @@ impl LanguageServer {
color_provider: Some(DocumentColorClientCapabilities {
dynamic_registration: Some(true),
}),
document_link: Some(DocumentLinkClientCapabilities {
dynamic_registration: Some(true),
tooltip_support: Some(true),
}),
folding_range: Some(FoldingRangeClientCapabilities {
dynamic_registration: Some(true),
line_folding_only: Some(false),

View file

@ -6,7 +6,7 @@ use crate::{
InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse,
ProjectTransaction, PulledDiagnostics, ResolveState,
lsp_store::{LocalLspStore, LspFoldingRange, LspStore},
lsp_store::{LocalLspStore, LspDocumentLink, LspFoldingRange, LspStore},
};
use anyhow::{Context as _, Result};
use async_trait::async_trait;
@ -304,6 +304,9 @@ pub(crate) struct GetDocumentColor;
#[derive(Debug, Copy, Clone)]
pub(crate) struct GetFoldingRanges;
#[derive(Debug, Copy, Clone)]
pub(crate) struct GetDocumentLinks;
impl GetCodeLens {
pub(crate) fn can_resolve_lens(capabilities: &ServerCapabilities) -> bool {
capabilities
@ -4906,6 +4909,147 @@ impl LspCommand for GetFoldingRanges {
}
}
#[async_trait(?Send)]
impl LspCommand for GetDocumentLinks {
type Response = Vec<LspDocumentLink>;
type LspRequest = lsp::request::DocumentLinkRequest;
type ProtoRequest = proto::GetDocumentLinks;
fn display_name(&self) -> &str {
"Document links"
}
fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
server_capabilities
.server_capabilities
.document_link_provider
.is_some()
}
fn to_lsp(
&self,
path: &Path,
_: &Buffer,
_: &Arc<LanguageServer>,
_: &App,
) -> Result<lsp::DocumentLinkParams> {
Ok(lsp::DocumentLinkParams {
text_document: make_text_document_identifier(path)?,
work_done_progress_params: lsp::WorkDoneProgressParams::default(),
partial_result_params: lsp::PartialResultParams::default(),
})
}
async fn response_from_lsp(
self,
message: Option<Vec<lsp::DocumentLink>>,
_: Entity<LspStore>,
buffer: Entity<Buffer>,
_: LanguageServerId,
cx: AsyncApp,
) -> Result<Self::Response> {
let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
Ok(message
.unwrap_or_default()
.into_iter()
.map(|link| {
let start = snapshot.clip_point_utf16(
Unclipped(PointUtf16::new(
link.range.start.line,
link.range.start.character,
)),
Bias::Left,
);
let end = snapshot.clip_point_utf16(
Unclipped(PointUtf16::new(
link.range.end.line,
link.range.end.character,
)),
Bias::Right,
);
LspDocumentLink {
range: snapshot.anchor_after(start)..snapshot.anchor_before(end),
target: link.target.map(|url| url.to_string().into()),
tooltip: link.tooltip.map(SharedString::from),
data: link.data,
resolved: false,
}
})
.collect())
}
fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest {
proto::GetDocumentLinks {
project_id,
buffer_id: buffer.remote_id().to_proto(),
version: serialize_version(&buffer.version()),
}
}
async fn from_proto(
_: Self::ProtoRequest,
_: Entity<LspStore>,
_: Entity<Buffer>,
_: AsyncApp,
) -> Result<Self> {
Ok(Self)
}
fn response_to_proto(
response: Self::Response,
_: &mut LspStore,
_: PeerId,
buffer_version: &clock::Global,
_: &mut App,
) -> proto::GetDocumentLinksResponse {
proto::GetDocumentLinksResponse {
links: response
.into_iter()
.map(|link| proto::DocumentLinkProto {
range: Some(serialize_anchor_range(link.range)),
target: link.target.map(String::from),
tooltip: link.tooltip.map(String::from),
data: link
.data
.map(|d| serde_json::to_string(&d).unwrap_or_default()),
})
.collect(),
version: serialize_version(buffer_version),
}
}
async fn response_from_proto(
self,
message: proto::GetDocumentLinksResponse,
_: Entity<LspStore>,
buffer: Entity<Buffer>,
mut cx: AsyncApp,
) -> Result<Self::Response> {
buffer
.update(&mut cx, |buffer, _| {
buffer.wait_for_version(deserialize_version(&message.version))
})
.await?;
message
.links
.into_iter()
.map(|link| {
Ok(LspDocumentLink {
range: deserialize_anchor_range(link.range.context("missing range")?)?,
target: link.target.map(SharedString::from),
tooltip: link.tooltip.map(SharedString::from),
data: link.data.and_then(|d| serde_json::from_str(&d).ok()),
resolved: false,
})
})
.collect()
}
fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result<BufferId> {
BufferId::new(message.buffer_id)
}
}
fn process_related_documents(
diagnostics: &mut HashMap<lsp::Uri, LspPullDiagnostics>,
server_id: LanguageServerId,

View file

@ -12,6 +12,7 @@
pub mod clangd_ext;
pub mod code_lens;
mod document_colors;
mod document_links;
mod document_symbols;
mod folding_ranges;
mod inlay_hints;
@ -24,6 +25,7 @@ pub mod vue_language_server_ext;
use self::code_lens::CodeLensData;
use self::document_colors::DocumentColorData;
use self::document_links::DocumentLinksData;
use self::document_symbols::DocumentSymbolsData;
use self::inlay_hints::BufferInlayHints;
use crate::{
@ -146,6 +148,10 @@ use util::{
};
pub use document_colors::DocumentColors;
pub use document_links::{
BufferDocumentLinks, DocumentLinkId, DocumentLinkResolveTask, LspDocumentLink,
ResolvedDocumentLink,
};
pub use folding_ranges::LspFoldingRange;
pub use fs::*;
pub use language::Location;
@ -4006,6 +4012,7 @@ pub struct BufferLspData {
code_lens: Option<CodeLensData>,
semantic_tokens: Option<SemanticTokensData>,
folding_ranges: Option<FoldingRangeData>,
document_links: Option<DocumentLinksData>,
document_symbols: Option<DocumentSymbolsData>,
inlay_hints: BufferInlayHints,
lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
@ -4026,6 +4033,7 @@ impl BufferLspData {
code_lens: None,
semantic_tokens: None,
folding_ranges: None,
document_links: None,
document_symbols: None,
inlay_hints: BufferInlayHints::new(buffer, cx),
lsp_requests: HashMap::default(),
@ -4052,6 +4060,10 @@ impl BufferLspData {
folding_ranges.ranges.remove(&for_server);
}
if let Some(document_links) = &mut self.document_links {
document_links.remove_server_data(for_server);
}
if let Some(document_symbols) = &mut self.document_symbols {
document_symbols.remove_server_data(for_server);
}
@ -4174,6 +4186,7 @@ impl LspStore {
client.add_entity_request_handler(Self::handle_apply_code_action);
client.add_entity_request_handler(Self::handle_get_project_symbols);
client.add_entity_request_handler(Self::handle_resolve_inlay_hint);
client.add_entity_request_handler(Self::handle_resolve_document_link);
client.add_entity_request_handler(Self::handle_get_color_presentation);
client.add_entity_request_handler(Self::handle_open_buffer_for_symbol);
client.add_entity_request_handler(Self::handle_refresh_inlay_hints);
@ -9260,6 +9273,36 @@ impl LspStore {
)
.await?;
}
Request::GetDocumentLinks(get_document_links) => {
let (buffer_version, buffer) = Self::wait_for_buffer_version::<GetDocumentLinks>(
&lsp_store,
&get_document_links,
&mut cx,
)
.await?;
lsp_store.update(&mut cx, |lsp_store, cx| {
let document_links_task = lsp_store.fetch_document_links(&buffer, cx);
let fetch_task = cx.background_spawn(async move {
document_links_task
.await
.unwrap_or_default()
.into_iter()
.map(|(server_id, links)| {
(server_id, links.into_values().collect::<Vec<_>>())
})
.collect()
});
lsp_store.serve_lsp_query::<GetDocumentLinks>(
server_id,
sender_id,
lsp_request_id,
&buffer,
buffer_version,
fetch_task,
cx,
);
});
}
Request::GetHover(get_hover) => {
let position = get_hover.position.clone().and_then(deserialize_anchor);
Self::query_lsp_locally::<GetHover>(
@ -9457,66 +9500,19 @@ impl LspStore {
.await?;
let for_server = semantic_tokens.for_server.map(LanguageServerId::from_proto);
lsp_store.update(&mut cx, |lsp_store, cx| {
if let Some((client, project_id)) = lsp_store.downstream_client.clone() {
let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
let key = LspKey {
request_type: TypeId::of::<SemanticTokensFull>(),
server_queried: server_id,
};
if <SemanticTokensFull as LspCommand>::ProtoRequest::stop_previous_requests() {
if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) {
lsp_requests.clear();
};
}
lsp_data.lsp_requests.entry(key).or_default().insert(
lsp_request_id,
cx.spawn(async move |lsp_store, cx| {
let tokens_fetch = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store
.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx)
})
.ok();
if let Some(tokens_fetch) = tokens_fetch {
let new_tokens = tokens_fetch.await;
if let Some(new_tokens) = new_tokens {
lsp_store
.update(cx, |lsp_store, cx| {
let response = new_tokens
.into_iter()
.map(|(server_id, response)| {
(
server_id.to_proto(),
SemanticTokensFull::response_to_proto(
response,
lsp_store,
sender_id,
&buffer_version,
cx,
),
)
})
.collect::<HashMap<_, _>>();
match client.send_lsp_response::<<SemanticTokensFull as LspCommand>::ProtoRequest>(
project_id,
lsp_request_id,
response,
) {
Ok(()) => {}
Err(e) => {
log::error!(
"Failed to send semantic tokens LSP response: {e:#}",
)
}
}
})
.ok();
}
}
}),
);
}
let semantic_tokens_task =
lsp_store.fetch_semantic_tokens_for_buffer(&buffer, for_server, cx);
lsp_store.serve_lsp_query::<SemanticTokensFull>(
server_id,
sender_id,
lsp_request_id,
&buffer,
buffer_version,
cx.background_spawn(async move {
semantic_tokens_task.await.unwrap_or_default()
}),
cx,
);
});
}
}
@ -13479,6 +13475,72 @@ impl LspStore {
Ok(())
}
fn serve_lsp_query<T>(
&mut self,
server_id: Option<LanguageServerId>,
sender_id: proto::PeerId,
lsp_request_id: LspRequestId,
buffer: &Entity<Buffer>,
buffer_version: Global,
fetch_task: Task<HashMap<LanguageServerId, T::Response>>,
cx: &mut Context<Self>,
) where
T: LspCommand + 'static,
T::ProtoRequest: proto::LspRequestMessage,
<T::ProtoRequest as proto::RequestMessage>::Response:
Into<<T::ProtoRequest as proto::LspRequestMessage>::Response>,
{
let Some((client, project_id)) = self.downstream_client.clone() else {
return;
};
let lsp_data = self.latest_lsp_data(buffer, cx);
let key = LspKey {
request_type: TypeId::of::<T>(),
server_queried: server_id,
};
if T::ProtoRequest::stop_previous_requests() {
if let Some(lsp_requests) = lsp_data.lsp_requests.get_mut(&key) {
lsp_requests.clear();
}
}
lsp_data.lsp_requests.entry(key).or_default().insert(
lsp_request_id,
cx.spawn(async move |lsp_store, cx| {
let by_server = fetch_task.await;
lsp_store
.update(cx, |lsp_store, cx| {
let response = by_server
.into_iter()
.map(|(server_id, response)| {
(
server_id.to_proto(),
T::response_to_proto(
response,
lsp_store,
sender_id,
&buffer_version,
cx,
)
.into(),
)
})
.collect::<HashMap<_, _>>();
if let Err(e) = client.send_lsp_response::<T::ProtoRequest>(
project_id,
lsp_request_id,
response,
) {
log::error!(
"Failed to send {} LSP response: {e:#}",
std::any::type_name::<T>()
);
}
})
.ok();
}),
);
}
async fn wait_for_buffer_version<T>(
lsp_store: &Entity<Self>,
proto_request: &T::ProtoRequest,

View file

@ -0,0 +1,456 @@
use std::ops::Range;
use std::str::FromStr as _;
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context as _;
use clock::Global;
use collections::HashMap;
use futures::FutureExt as _;
use futures::future::{Shared, join_all};
use gpui::{AppContext as _, AsyncApp, Context, Entity, SharedString, Task};
use language::{Buffer, point_to_lsp};
use lsp::LanguageServerId;
use lsp::request::DocumentLinkResolve;
use rpc::{TypedEnvelope, proto};
use settings::Settings as _;
use text::{Anchor, BufferId, ToPointUtf16 as _};
use util::ResultExt as _;
use crate::lsp_command::{GetDocumentLinks, LspCommand as _};
use crate::lsp_store::LspStore;
use crate::project_settings::ProjectSettings;
#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Debug)]
pub struct DocumentLinkId(u64);
#[derive(Clone, Debug)]
pub struct LspDocumentLink {
pub range: Range<Anchor>,
pub target: Option<SharedString>,
pub tooltip: Option<SharedString>,
pub data: Option<serde_json::Value>,
pub resolved: bool,
}
pub type BufferDocumentLinks = HashMap<LanguageServerId, HashMap<DocumentLinkId, LspDocumentLink>>;
pub(super) type DocumentLinksTask =
Shared<Task<std::result::Result<Option<BufferDocumentLinks>, Arc<anyhow::Error>>>>;
pub type DocumentLinkResolveTask = Shared<Task<Option<(DocumentLinkId, LspDocumentLink)>>>;
#[derive(Debug, Default)]
pub(super) struct DocumentLinksData {
pub(super) links: BufferDocumentLinks,
pub(super) next_id: u64,
links_update: Option<(Global, DocumentLinksTask)>,
pub(super) link_resolves: HashMap<(LanguageServerId, DocumentLinkId), DocumentLinkResolveTask>,
}
impl DocumentLinksData {
pub(super) fn remove_server_data(&mut self, server_id: LanguageServerId) {
self.links.remove(&server_id);
self.link_resolves
.retain(|(resolved_server, _), _| *resolved_server != server_id);
}
}
/// Mirror of [`crate::lsp_store::ResolvedHint`] for document links: callers
/// either get the resolved entry directly, an in-flight `Shared` task to await
/// (deduplicated across editors), or `None` when the cache no longer contains
/// a matching link.
pub enum ResolvedDocumentLink {
Resolved(LspDocumentLink),
Resolving(DocumentLinkResolveTask),
}
impl LspStore {
/// `Some(..)` means the underlying state was actually refreshed; `None`
/// means the fetch was skipped or failed, and the caller should keep its
/// previous data.
pub fn fetch_document_links(
&mut self,
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<Option<BufferDocumentLinks>> {
let version_queried_for = buffer.read(cx).version();
let buffer_id = buffer.read(cx).remote_id();
let current_language_servers = self.as_local().map(|local| {
local
.buffers_opened_in_servers
.get(&buffer_id)
.cloned()
.unwrap_or_default()
});
if let Some(lsp_data) = self.current_lsp_data(buffer_id)
&& let Some(cached) = &lsp_data.document_links
&& !version_queried_for.changed_since(&lsp_data.buffer_version)
{
let has_different_servers =
current_language_servers.is_some_and(|current_language_servers| {
current_language_servers != cached.links.keys().copied().collect()
});
if !has_different_servers {
return Task::ready(Some(cached.links.clone()));
}
}
let links_lsp_data = self
.latest_lsp_data(buffer, cx)
.document_links
.get_or_insert_default();
if let Some((updating_for, running_update)) = &links_lsp_data.links_update
&& !version_queried_for.changed_since(updating_for)
{
let running = running_update.clone();
return cx.background_spawn(async move { running.await.ok().flatten() });
}
let buffer = buffer.clone();
let query_version = version_queried_for.clone();
let new_task = cx
.spawn(async move |lsp_store, cx| {
cx.background_executor()
.timer(Duration::from_millis(30))
.await;
let fetched = lsp_store
.update(cx, |lsp_store, cx| {
lsp_store.fetch_document_links_for_buffer(&buffer, cx)
})
.map_err(Arc::new)?
.await
.context("fetching document links")
.map_err(Arc::new);
let fetched = match fetched {
Ok(fetched) => fetched,
Err(e) => {
lsp_store
.update(cx, |lsp_store, _| {
if let Some(lsp_data) = lsp_store.lsp_data.get_mut(&buffer_id)
&& let Some(document_links) = &mut lsp_data.document_links
{
document_links.links_update = None;
}
})
.ok();
return Err(e);
}
};
lsp_store
.update(cx, |lsp_store, cx| {
let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
let links_data = lsp_data.document_links.get_or_insert_default();
links_data.links_update = None;
let Some(fetched_links) = fetched else {
return None;
};
let mut tagged = BufferDocumentLinks::default();
for (server_id, server_links) in fetched_links {
let mut by_id = HashMap::default();
by_id.reserve(server_links.len());
for link in server_links {
let id = DocumentLinkId(links_data.next_id);
links_data.next_id += 1;
by_id.insert(id, link);
}
tagged.insert(server_id, by_id);
}
if lsp_data.buffer_version == query_version {
for (server_id, new_links) in &tagged {
links_data.links.insert(*server_id, new_links.clone());
}
// The newly inserted links are unresolved by definition; drop any
// pending resolves that were keyed against the prior entries for
// those servers so callers re-issue against the fresh ids.
links_data.link_resolves.clear();
Some(links_data.links.clone())
} else if !lsp_data.buffer_version.changed_since(&query_version) {
lsp_data.buffer_version = query_version;
links_data.links = tagged;
links_data.link_resolves.clear();
Some(links_data.links.clone())
} else {
None
}
})
.map_err(Arc::new)
})
.shared();
links_lsp_data.links_update = Some((version_queried_for, new_task.clone()));
cx.background_spawn(async move { new_task.await.ok().flatten() })
}
#[cfg(any(test, feature = "test-support"))]
pub fn document_links_for_buffer(&self, buffer_id: BufferId) -> Option<BufferDocumentLinks> {
let data = self.lsp_data.get(&buffer_id)?;
let document_links = data.document_links.as_ref()?;
Some(document_links.links.clone())
}
fn fetch_document_links_for_buffer(
&mut self,
buffer: &Entity<Buffer>,
cx: &mut Context<Self>,
) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<LspDocumentLink>>>>> {
if let Some((client, project_id)) = self.upstream_client() {
let request = GetDocumentLinks;
if !self.is_capable_for_proto_request(buffer, &request, cx) {
return Task::ready(Ok(None));
}
let request_timeout = ProjectSettings::get_global(cx)
.global_lsp_settings
.get_request_timeout();
let request_task = client.request_lsp(
project_id,
None,
request_timeout,
cx.background_executor().clone(),
request.to_proto(project_id, buffer.read(cx)),
);
let buffer = buffer.clone();
cx.spawn(async move |weak_lsp_store, cx| {
let Some(lsp_store) = weak_lsp_store.upgrade() else {
return Ok(None);
};
let Some(responses) = request_task.await? else {
return Ok(None);
};
let document_links = join_all(responses.payload.into_iter().map(|response| {
let lsp_store = lsp_store.clone();
let buffer = buffer.clone();
let cx = cx.clone();
async move {
let server_id = LanguageServerId::from_proto(response.server_id);
let links = GetDocumentLinks
.response_from_proto(response.response, lsp_store, buffer, cx)
.await;
(server_id, links)
}
}))
.await;
let mut has_errors = false;
let result = document_links
.into_iter()
.filter_map(|(server_id, links)| match links {
Ok(links) => Some((server_id, links)),
Err(e) => {
has_errors = true;
log::error!(
"Failed to fetch document links for server {server_id}: {e:#}"
);
None
}
})
.collect::<HashMap<_, _>>();
anyhow::ensure!(
!has_errors || !result.is_empty(),
"Failed to fetch document links"
);
Ok(Some(result))
})
} else {
let links_task =
self.request_multiple_lsp_locally(buffer, None::<usize>, GetDocumentLinks, cx);
cx.background_spawn(async move { Ok(Some(links_task.await.into_iter().collect())) })
}
}
/// Returns the resolved state for a cached document link, deduplicating
/// in-flight `documentLink/resolve` requests across editors via a `Shared`
/// task stored on `DocumentLinksData`.
///
/// `link_id` is the [`DocumentLinkId`] stamped on the cached link by
/// [`Self::fetch_document_links`]; sibling links sharing the same buffer
/// range are disambiguated by it. `None` is returned when the cache no
/// longer holds a matching link (likely a version bump in between).
pub fn resolved_document_link(
&mut self,
buffer: &Entity<Buffer>,
server_id: LanguageServerId,
link_id: DocumentLinkId,
cx: &mut Context<Self>,
) -> Option<ResolvedDocumentLink> {
let buffer_id = buffer.read(cx).remote_id();
let document_links = self.lsp_data.get(&buffer_id)?.document_links.as_ref()?;
let cached_link = document_links.links.get(&server_id)?.get(&link_id)?.clone();
if cached_link.resolved {
return Some(ResolvedDocumentLink::Resolved(cached_link));
}
let key = (server_id, link_id);
if let Some(running) = document_links.link_resolves.get(&key) {
return Some(ResolvedDocumentLink::Resolving(running.clone()));
}
let resolve_task = self.resolve_document_link_request(buffer, server_id, &cached_link, cx);
let query_version = self.lsp_data.get(&buffer_id)?.buffer_version.clone();
let resolve_task = cx
.spawn(async move |lsp_store, cx| {
let resolved = resolve_task.await;
lsp_store
.update(cx, |lsp_store, _| {
let lsp_data = lsp_store.lsp_data.get_mut(&buffer_id)?;
if lsp_data.buffer_version != query_version {
return None;
}
let links_data = lsp_data.document_links.as_mut()?;
links_data.link_resolves.remove(&key);
let updated = match resolved {
Some(resolved) => lsp_store
.cache_resolved_link(buffer_id, server_id, link_id, &resolved)?,
None => {
// No further resolution is possible (no capability,
// missing server, or LSP error); mark as resolved so we
// do not keep retrying on every hover, and yield the
// entry as-is so awaiters can still surface it.
let links_data = lsp_data.document_links.as_mut()?;
let link =
links_data.links.get_mut(&server_id)?.get_mut(&link_id)?;
link.resolved = true;
link.clone()
}
};
Some((link_id, updated))
})
.ok()
.flatten()
})
.shared();
let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?;
document_links
.link_resolves
.insert(key, resolve_task.clone());
Some(ResolvedDocumentLink::Resolving(resolve_task))
}
/// Builds the LSP/proto request task for a single unresolved link. Returns
/// a task that yields `None` when the resolve request cannot be issued
/// (no upstream capability, no local server, or no `resolveProvider`).
fn resolve_document_link_request(
&self,
buffer: &Entity<Buffer>,
server_id: LanguageServerId,
cached_link: &LspDocumentLink,
cx: &mut Context<Self>,
) -> Task<Option<lsp::DocumentLink>> {
let snapshot = buffer.read(cx).snapshot();
let buffer_id = buffer.read(cx).remote_id();
let lsp_link = lsp::DocumentLink {
range: lsp::Range {
start: point_to_lsp(cached_link.range.start.to_point_utf16(&snapshot)),
end: point_to_lsp(cached_link.range.end.to_point_utf16(&snapshot)),
},
target: cached_link
.target
.as_ref()
.and_then(|s| lsp::Uri::from_str(s).ok()),
tooltip: cached_link.tooltip.as_deref().map(str::to_string),
data: cached_link.data.clone(),
};
if let Some((upstream_client, project_id)) = self.upstream_client() {
if !self.check_if_capable_for_proto_request(buffer, can_resolve_link, cx) {
return Task::ready(None);
}
let request = proto::ResolveDocumentLink {
project_id,
buffer_id: buffer_id.into(),
language_server_id: server_id.0 as u64,
lsp_link: serde_json::to_vec(&lsp_link).unwrap_or_default(),
};
cx.background_spawn(async move {
let response = upstream_client.request(request).await.log_err()?;
serde_json::from_slice::<lsp::DocumentLink>(&response.lsp_link).log_err()
})
} else {
let Some(server) = self.language_server_for_id(server_id) else {
return Task::ready(None);
};
if !can_resolve_link(&server.capabilities()) {
return Task::ready(None);
}
let request_timeout = ProjectSettings::get_global(cx)
.global_lsp_settings
.get_request_timeout();
cx.background_spawn(async move {
server
.request::<DocumentLinkResolve>(lsp_link, request_timeout)
.await
.into_response()
.log_err()
})
}
}
fn cache_resolved_link(
&mut self,
buffer_id: BufferId,
server_id: LanguageServerId,
link_id: DocumentLinkId,
resolved: &lsp::DocumentLink,
) -> Option<LspDocumentLink> {
let document_links = self.lsp_data.get_mut(&buffer_id)?.document_links.as_mut()?;
let link = document_links
.links
.get_mut(&server_id)?
.get_mut(&link_id)?;
link.target = resolved.target.as_ref().map(|u| u.to_string().into());
if let Some(tooltip) = &resolved.tooltip {
link.tooltip = Some(tooltip.clone().into());
}
link.data = resolved.data.clone();
link.resolved = true;
Some(link.clone())
}
pub(super) async fn handle_resolve_document_link(
lsp_store: Entity<Self>,
envelope: TypedEnvelope<proto::ResolveDocumentLink>,
mut cx: AsyncApp,
) -> anyhow::Result<proto::ResolveDocumentLinkResponse> {
let lsp_link: lsp::DocumentLink = serde_json::from_slice(&envelope.payload.lsp_link)
.context("deserializing document link to resolve")?;
let server_id = LanguageServerId::from_proto(envelope.payload.language_server_id);
let resolve_task = lsp_store.update(&mut cx, |lsp_store, cx| {
let server = lsp_store
.language_server_for_id(server_id)
.with_context(|| format!("No language server {server_id}"))?;
let timeout = ProjectSettings::get_global(cx)
.global_lsp_settings
.get_request_timeout();
anyhow::Ok(server.request::<DocumentLinkResolve>(lsp_link, timeout))
})?;
let resolved = resolve_task.await.into_response()?;
Ok(proto::ResolveDocumentLinkResponse {
lsp_link: serde_json::to_vec(&resolved)
.context("serializing resolved document link")?,
})
}
}
fn can_resolve_link(capabilities: &lsp::ServerCapabilities) -> bool {
capabilities
.document_link_provider
.as_ref()
.and_then(|opts| opts.resolve_provider)
.unwrap_or(false)
}

View file

@ -122,6 +122,35 @@ message GetDocumentSymbolsResponse {
repeated DocumentSymbol symbols = 1;
}
message GetDocumentLinks {
uint64 project_id = 1;
uint64 buffer_id = 2;
repeated VectorClockEntry version = 3;
}
message GetDocumentLinksResponse {
repeated DocumentLinkProto links = 1;
repeated VectorClockEntry version = 2;
}
message DocumentLinkProto {
optional AnchorRange range = 1;
optional string target = 2;
optional string tooltip = 3;
optional string data = 4;
}
message ResolveDocumentLink {
uint64 project_id = 1;
uint64 buffer_id = 2;
uint64 language_server_id = 3;
bytes lsp_link = 4;
}
message ResolveDocumentLinkResponse {
bytes lsp_link = 1;
}
message DocumentSymbol {
string name = 1;
int32 kind = 2;
@ -853,6 +882,7 @@ message LspQuery {
SemanticTokens semantic_tokens = 16;
GetFoldingRanges get_folding_ranges = 17;
GetDocumentSymbols get_document_symbols = 18;
GetDocumentLinks get_document_links = 19;
}
}
@ -879,6 +909,7 @@ message LspResponse {
SemanticTokensResponse semantic_tokens_response = 14;
GetFoldingRangesResponse get_folding_ranges_response = 15;
GetDocumentSymbolsResponse get_document_symbols_response = 16;
GetDocumentLinksResponse get_document_links_response = 17;
}
uint64 server_id = 7;
}

View file

@ -484,7 +484,11 @@ message Envelope {
SearchCommits search_commits = 449;
SearchCommitsResponse search_commits_response = 450;
GetInitialGraphData get_initial_graph_data = 451;
GetInitialGraphDataResponse get_initial_graph_data_response = 452; // current max
GetInitialGraphDataResponse get_initial_graph_data_response = 452;
GetDocumentLinks get_document_links = 453;
GetDocumentLinksResponse get_document_links_response = 454;
ResolveDocumentLink resolve_document_link = 455;
ResolveDocumentLinkResponse resolve_document_link_response = 456; // current max
}
reserved 87 to 88;

View file

@ -224,6 +224,10 @@ messages!(
(GetDocumentColorResponse, Background),
(GetColorPresentation, Background),
(GetColorPresentationResponse, Background),
(GetDocumentLinks, Background),
(GetDocumentLinksResponse, Background),
(ResolveDocumentLink, Background),
(ResolveDocumentLinkResponse, Background),
(GetFoldingRanges, Background),
(GetFoldingRangesResponse, Background),
(RefreshCodeLens, Background),
@ -469,6 +473,8 @@ request_messages!(
),
(ResolveInlayHint, ResolveInlayHintResponse),
(GetDocumentColor, GetDocumentColorResponse),
(GetDocumentLinks, GetDocumentLinksResponse),
(ResolveDocumentLink, ResolveDocumentLinkResponse),
(GetFoldingRanges, GetFoldingRangesResponse),
(GetColorPresentation, GetColorPresentationResponse),
(RespondToChannelInvite, Ack),
@ -595,6 +601,7 @@ lsp_messages!(
(GetDocumentColor, GetDocumentColorResponse, true),
(GetFoldingRanges, GetFoldingRangesResponse, true),
(GetDocumentSymbols, GetDocumentSymbolsResponse, true),
(GetDocumentLinks, GetDocumentLinksResponse, true),
(GetHover, GetHoverResponse, true),
(GetCodeActions, GetCodeActionsResponse, true),
(GetSignatureHelp, GetSignatureHelpResponse, true),
@ -628,6 +635,8 @@ entity_messages!(
CreateImageForPeer,
CreateProjectEntry,
GetDocumentColor,
GetDocumentLinks,
ResolveDocumentLink,
GetFoldingRanges,
DeleteProjectEntry,
ExpandProjectEntry,
@ -979,6 +988,7 @@ impl LspQuery {
Some(lsp_query::Request::GetDocumentColor(_)) => ("GetDocumentColor", false),
Some(lsp_query::Request::GetFoldingRanges(_)) => ("GetFoldingRanges", false),
Some(lsp_query::Request::GetDocumentSymbols(_)) => ("GetDocumentSymbols", false),
Some(lsp_query::Request::GetDocumentLinks(_)) => ("GetDocumentLinks", false),
Some(lsp_query::Request::InlayHints(_)) => ("InlayHints", false),
Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false),
None => ("<unknown>", true),

View file

@ -417,6 +417,9 @@ impl AnyProtoClient {
Response::GetDocumentSymbolsResponse(response) => {
to_any_envelope(&envelope, response)
}
Response::GetDocumentLinksResponse(response) => {
to_any_envelope(&envelope, response)
}
};
Some(proto::ProtoLspResponse {
server_id,

View file

@ -277,6 +277,7 @@ impl VsCodeSettings {
code_lens: None,
jupyter: None,
lsp_document_colors: None,
lsp_document_links: self.read_bool("editor.links"),
lsp_highlight_debounce: None,
middle_click_paste: None,
minimap: self.minimap_content(),

View file

@ -226,6 +226,10 @@ pub struct EditorSettingsContent {
///
/// Default: [`DocumentColorsRenderMode::Inlay`]
pub lsp_document_colors: Option<DocumentColorsRenderMode>,
/// Whether to query and display LSP `textDocument/documentLink` links in the editor.
///
/// Default: true
pub lsp_document_links: Option<bool>,
/// When to show the scrollbar in the completion menu.
/// This setting can take four values:
///

View file

@ -3004,6 +3004,16 @@ Configuration for various AI model providers including API URLs and authenticati
3. `border`: Draw a border around the color text.
4. `none`: Do not query and render document colors.
## LSP Document Links
- Description: Whether to query and display LSP `textDocument/documentLink` links in the editor
- Setting: `lsp_document_links`
- Default: `true`
**Options**
`boolean` values
## Max Tabs
- Description: Maximum number of tabs to show in the tab bar

View file

@ -382,6 +382,8 @@ TBD: Centered layout related settings
// How to render LSP `textDocument/documentColor` colors in the editor.
"lsp_document_colors": "inlay", // none, inlay, border, background
// Whether to query and display LSP document links in the editor.
"lsp_document_links": true,
// When to show the scrollbar in the completion menu.
"completion_menu_scrollbar": "never", // auto, system, always, never