From 3e77442f2eba3ad80f9f2bc1a44af2fbc09f1c94 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 26 May 2026 10:09:47 +0300 Subject: [PATCH] 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) --- assets/settings/default.json | 4 + crates/collab/src/rpc.rs | 1 + .../collab/tests/integration/editor_tests.rs | 312 +++++++ .../remote_editing_collaboration_tests.rs | 259 +++++- crates/editor/src/code_lens.rs | 2 +- crates/editor/src/document_links.rs | 209 +++++ crates/editor/src/document_symbols.rs | 8 +- crates/editor/src/editor.rs | 21 +- crates/editor/src/editor_settings.rs | 2 + crates/editor/src/hover_links.rs | 826 ++++++++++++++++-- crates/editor/src/hover_popover.rs | 47 + crates/editor/src/navigation.rs | 8 +- crates/lsp/src/lsp.rs | 4 + crates/project/src/lsp_command.rs | 146 +++- crates/project/src/lsp_store.rs | 182 ++-- .../project/src/lsp_store/document_links.rs | 456 ++++++++++ crates/proto/proto/lsp.proto | 31 + crates/proto/proto/zed.proto | 6 +- crates/proto/src/proto.rs | 10 + crates/rpc/src/proto_client.rs | 3 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/editor.rs | 4 + docs/src/reference/all-settings.md | 10 + docs/src/visual-customization.md | 2 + 24 files changed, 2427 insertions(+), 127 deletions(-) create mode 100644 crates/editor/src/document_links.rs create mode 100644 crates/project/src/lsp_store/document_links.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 17f35962a8f..37c06960555 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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. diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7af986dc2a0..acc3708a6d1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -364,6 +364,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 4cd66b2a121..016ae3dd16c 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -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::() + .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::(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::(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::() + .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::(), + "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::(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::(&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, diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index df4a219597c..86bd71f6eb1 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -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::({ + 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::({ + 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::(), + 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::(), + "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) + }); +} diff --git a/crates/editor/src/code_lens.rs b/crates/editor/src/code_lens.rs index b47bc88b4a8..7dd45b5453d 100644 --- a/crates/editor/src/code_lens.rs +++ b/crates/editor/src/code_lens.rs @@ -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) diff --git a/crates/editor/src/document_links.rs b/crates/editor/src/document_links.rs new file mode 100644 index 00000000000..a906a2d160c --- /dev/null +++ b/crates/editor/src/document_links.rs @@ -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, + 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, + cx: &mut Context, + ) { + 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::>(); + 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::>() + }) + }) + .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, + position: text::Anchor, + cx: &mut Context, + ) -> Option>> { + 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::>(); + 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() +} diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 074e5a5ed27..ae28538cc93 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -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| { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 95ab2d193a1..4df67f8fdee 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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>, + lsp_document_links: LspDocumentLinks, lsp_document_symbols: HashMap>>, refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>, outline_symbols_at_cursor: Option<(BufferId, Vec>)>, @@ -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); diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index 9953f657bc8..05a89a5907c 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -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 diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 7dbf0bab0e8..215389e3037 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -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 { + 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::().ok()?.checked_sub(1)?; + let character = column_str + .and_then(|column| column.parse::().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, position: text::Anchor, - cx: AsyncWindowContext, + cx: &AsyncWindowContext, ) -> Option<(Range, 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, range: Range, - cx: AsyncWindowContext, + cx: &AsyncWindowContext, ) -> Option { 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, pub column: Option, @@ -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` and `L:`. + 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::( + 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::( + 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::( + 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::(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::( + 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::(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::( + 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, 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::( + 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" + ); + }); + } } diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index cfee889ac27..5f256f2197b 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -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::>(), + 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); diff --git a/crates/editor/src/navigation.rs b/crates/editor/src/navigation.rs index db95b4b5680..0b3e150c8be 100644 --- a/crates/editor/src/navigation.rs +++ b/crates/editor/src/navigation.rs @@ -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, definitions: Vec, @@ -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)) diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 00fe0f59f1c..1fa39f32cb2 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -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), diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 438f8f66375..2f05aba49b5 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -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; + 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, + _: &App, + ) -> Result { + 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>, + _: Entity, + buffer: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result { + 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, + _: Entity, + _: AsyncApp, + ) -> Result { + 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, + buffer: Entity, + mut cx: AsyncApp, + ) -> Result { + 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::new(message.buffer_id) + } +} + fn process_related_documents( diagnostics: &mut HashMap, server_id: LanguageServerId, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 4561ace3b3d..b33cd6286fe 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -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, semantic_tokens: Option, folding_ranges: Option, + document_links: Option, document_symbols: Option, inlay_hints: BufferInlayHints, lsp_requests: HashMap>>, @@ -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::( + &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::>()) + }) + .collect() + }); + lsp_store.serve_lsp_query::( + 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::( @@ -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::(), - server_queried: server_id, - }; - if ::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::>(); - match client.send_lsp_response::<::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::( + 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( + &mut self, + server_id: Option, + sender_id: proto::PeerId, + lsp_request_id: LspRequestId, + buffer: &Entity, + buffer_version: Global, + fetch_task: Task>, + cx: &mut Context, + ) where + T: LspCommand + 'static, + T::ProtoRequest: proto::LspRequestMessage, + ::Response: + Into<::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::(), + 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::>(); + if let Err(e) = client.send_lsp_response::( + project_id, + lsp_request_id, + response, + ) { + log::error!( + "Failed to send {} LSP response: {e:#}", + std::any::type_name::() + ); + } + }) + .ok(); + }), + ); + } + async fn wait_for_buffer_version( lsp_store: &Entity, proto_request: &T::ProtoRequest, diff --git a/crates/project/src/lsp_store/document_links.rs b/crates/project/src/lsp_store/document_links.rs new file mode 100644 index 00000000000..5bfcab4913b --- /dev/null +++ b/crates/project/src/lsp_store/document_links.rs @@ -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, + pub target: Option, + pub tooltip: Option, + pub data: Option, + pub resolved: bool, +} + +pub type BufferDocumentLinks = HashMap>; + +pub(super) type DocumentLinksTask = + Shared, Arc>>>; + +pub type DocumentLinkResolveTask = Shared>>; + +#[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, + cx: &mut Context, + ) -> Task> { + 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 { + 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, + cx: &mut Context, + ) -> Task>>>> { + 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::>(); + 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::, 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, + server_id: LanguageServerId, + link_id: DocumentLinkId, + cx: &mut Context, + ) -> Option { + 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, + server_id: LanguageServerId, + cached_link: &LspDocumentLink, + cx: &mut Context, + ) -> Task> { + 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::(&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::(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 { + 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, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> anyhow::Result { + 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::(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) +} diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index f6891a9170e..ff9ec4d4e64 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -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; } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index a0fde40a84b..ab5ea5ce729 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 044415dbfda..2836c28a552 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -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 => ("", true), diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index cb45948d5cd..4b0b4bc7440 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -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, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 43808c7b153..5954453769a 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -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(), diff --git a/crates/settings_content/src/editor.rs b/crates/settings_content/src/editor.rs index 5cd64b044a1..499fb10e3b6 100644 --- a/crates/settings_content/src/editor.rs +++ b/crates/settings_content/src/editor.rs @@ -226,6 +226,10 @@ pub struct EditorSettingsContent { /// /// Default: [`DocumentColorsRenderMode::Inlay`] pub lsp_document_colors: Option, + /// Whether to query and display LSP `textDocument/documentLink` links in the editor. + /// + /// Default: true + pub lsp_document_links: Option, /// When to show the scrollbar in the completion menu. /// This setting can take four values: /// diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 6591a47b353..e5b7d7d2dee 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -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 diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 70e1ba0bb72..528086d0491 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -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