From 56504fdd0f1baa1ed9af5a32434e8afc61ed14a5 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 11 Feb 2026 21:30:03 +0200 Subject: [PATCH] Support LSP document symbols in breadcrumbs and outline UI (#48780) --- Cargo.lock | 2 + assets/settings/default.json | 7 + .../collab/tests/integration/editor_tests.rs | 178 +++- crates/editor/src/display_map.rs | 98 +- crates/editor/src/document_colors.rs | 2 +- crates/editor/src/document_symbols.rs | 855 ++++++++++++++++++ crates/editor/src/editor.rs | 88 +- crates/editor/src/scroll.rs | 2 +- crates/editor/src/semantic_tokens.rs | 20 +- crates/language/src/language_settings.rs | 5 +- crates/outline/Cargo.toml | 2 + crates/outline/src/outline.rs | 266 +++++- crates/outline_panel/Cargo.toml | 1 + crates/outline_panel/src/outline_panel.rs | 393 ++++++-- crates/project/src/lsp_command.rs | 2 +- crates/project/src/lsp_store.rs | 20 + .../project/src/lsp_store/document_symbols.rs | 405 +++++++++ crates/proto/proto/lsp.proto | 2 + crates/proto/src/proto.rs | 2 + crates/rpc/src/proto_client.rs | 3 + crates/settings/src/vscode_import.rs | 1 + crates/settings_content/src/language.rs | 10 +- crates/settings_content/src/workspace.rs | 33 + crates/settings_ui/src/page_data.rs | 21 +- crates/settings_ui/src/settings_ui.rs | 1 + docs/src/reference/all-settings.md | 31 + typos.toml | 2 + 27 files changed, 2320 insertions(+), 132 deletions(-) create mode 100644 crates/editor/src/document_symbols.rs create mode 100644 crates/project/src/lsp_store/document_symbols.rs diff --git a/Cargo.lock b/Cargo.lock index ecd3759132a3602d24880db5c2ce59d5f3eb5a1e..6b2e66bff3b6c4306d80fbb0d5bc933323a764e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11370,6 +11370,7 @@ dependencies = [ "gpui", "indoc", "language", + "lsp", "menu", "ordered-float 2.10.1", "picker", @@ -11401,6 +11402,7 @@ dependencies = [ "itertools 0.14.0", "language", "log", + "lsp", "menu", "outline", "pretty_assertions", diff --git a/assets/settings/default.json b/assets/settings/default.json index e1d38e08b72c3928698139887eea6346735dc29b..5c04662021e9506a5581b8017da51b69b19f9f43 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1123,6 +1123,13 @@ // - "on": Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server. "document_folding_ranges": "off", + // Controls the source of document symbols used for outlines and breadcrumbs. + // + // Options: + // - "off": Use tree-sitter queries to compute document symbols (default). + // - "on": Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + "document_symbols": "off", + // When to automatically save edited buffers. This setting can // take four values. // diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 1612e32833dd07dd5fa2294d5bb5a90442883f71..34bed0086b9af8bc2ed39580f4ecda2c6c609338 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -35,8 +35,8 @@ use recent_projects::disconnected_overlay::DisconnectedOverlay; use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::{ - DocumentFoldingRanges, InlayHintSettingsContent, InlineBlameSettings, SemanticTokens, - SettingsStore, + DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings, + SemanticTokens, SettingsStore, }; use std::{ collections::BTreeSet, @@ -51,6 +51,7 @@ use std::{ }; use text::Point; use util::{path, rel_path::rel_path, uri}; +use workspace::item::Item as _; use workspace::{CloseIntent, Workspace}; #[gpui::test(iterations = 10)] @@ -5503,6 +5504,179 @@ async fn test_remote_project_worktree_trust(cx_a: &mut TestAppContext, cx_b: &mu ); } +#[gpui::test] +async fn test_document_symbols(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); + + let capabilities = lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + #[allow(deprecated)] + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_language_server| { + #[allow(deprecated)] + fake_language_server + .set_request_handler::( + move |_, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(2, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/a"), + json!({ + "main.rs": "struct Foo {\n bar: u32,\n}\n", + }), + ) + .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(); + executor.run_until_parked(); + + cx_a.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(DocumentSymbols::On); + }); + }); + }); + executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100)); + executor.run_until_parked(); + + editor_a.update(cx_a, |editor, cx| { + let breadcrumbs = editor + .breadcrumbs(cx) + .expect("Host should have breadcrumbs"); + let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + vec!["main.rs", "Foo"], + "Host should see file path and LSP symbol 'Foo' in breadcrumbs" + ); + }); + + cx_b.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(DocumentSymbols::On); + }); + }); + }); + 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(); + + editor_b.update(cx_b, |editor, cx| { + let breadcrumbs = editor + .breadcrumbs(cx) + .expect("Client B should have breadcrumbs"); + let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + vec!["main.rs", "Foo"], + "Client B should see file path and LSP symbol 'Foo' via remote project" + ); + }); +} + fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry { git::blame::BlameEntry { sha: sha.parse().unwrap(), diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 21c8a15ef194ddabfa9145f182a87fdd1f1f75e1..7342113192830fe6d2324e410bfd6482186f190d 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -92,7 +92,7 @@ pub use inlay_map::{InlayOffset, InlayPoint}; pub use invisibles::{is_invisible, replacement}; pub use wrap_map::{WrapPoint, WrapRow, WrapSnapshot}; -use collections::{HashMap, HashSet, IndexSet, hash_map}; +use collections::{HashMap, HashSet, IndexSet}; use gpui::{ App, Context, Entity, EntityId, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle, WeakEntity, @@ -106,7 +106,7 @@ use project::project_settings::DiagnosticSeverity; use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType}; use serde::Deserialize; use sum_tree::{Bias, TreeMap}; -use text::{BufferId, LineIndent, Patch}; +use text::{BufferId, LineIndent, Patch, ToOffset as _}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; use ztracing::instrument; @@ -1040,8 +1040,7 @@ impl DisplayMap { /// Removes all LSP folding-range creases for a single buffer. pub(super) fn clear_lsp_folding_ranges(&mut self, buffer_id: BufferId, cx: &mut Context) { - if let hash_map::Entry::Occupied(entry) = self.lsp_folding_crease_ids.entry(buffer_id) { - let old_ids = entry.remove(); + if let Some(old_ids) = self.lsp_folding_crease_ids.remove(&buffer_id) { let snapshot = self.buffer.read(cx).snapshot(cx); self.crease_map.remove(old_ids, &snapshot); } @@ -1881,6 +1880,97 @@ impl DisplaySnapshot { }) } + /// Returns combined highlight styles (tree-sitter syntax + semantic tokens) + /// for a byte range within the specified buffer. + /// Returned ranges are 0-based relative to `buffer_range.start`. + pub(super) fn combined_highlights( + &self, + buffer_id: BufferId, + buffer_range: Range, + syntax_theme: &theme::SyntaxTheme, + ) -> Vec<(Range, HighlightStyle)> { + let multibuffer = self.buffer_snapshot(); + + let multibuffer_range = multibuffer + .excerpts() + .find_map(|(excerpt_id, buffer, range)| { + if buffer.remote_id() != buffer_id { + return None; + } + let context_start = range.context.start.to_offset(buffer); + let context_end = range.context.end.to_offset(buffer); + if buffer_range.start < context_start || buffer_range.end > context_end { + return None; + } + let start_anchor = buffer.anchor_before(buffer_range.start); + let end_anchor = buffer.anchor_after(buffer_range.end); + let mb_range = + multibuffer.anchor_range_in_excerpt(excerpt_id, start_anchor..end_anchor)?; + Some(mb_range.start.to_offset(multibuffer)..mb_range.end.to_offset(multibuffer)) + }); + + let Some(multibuffer_range) = multibuffer_range else { + // Range is outside all excerpts (e.g. symbol name not in a + // multi-buffer excerpt). Fall back to buffer-level syntax highlights. + let buffer_snapshot = multibuffer.excerpts().find_map(|(_, buffer, _)| { + (buffer.remote_id() == buffer_id).then(|| buffer.clone()) + }); + let Some(buffer_snapshot) = buffer_snapshot else { + return Vec::new(); + }; + let mut highlights = Vec::new(); + let mut offset = 0usize; + for chunk in buffer_snapshot.chunks(buffer_range, true) { + let chunk_len = chunk.text.len(); + if chunk_len == 0 { + continue; + } + if let Some(style) = chunk + .syntax_highlight_id + .and_then(|id| id.style(syntax_theme)) + { + highlights.push((offset..offset + chunk_len, style)); + } + offset += chunk_len; + } + return highlights; + }; + + let chunks = custom_highlights::CustomHighlightsChunks::new( + multibuffer_range, + true, + None, + Some(&self.semantic_token_highlights), + multibuffer, + ); + + let mut highlights = Vec::new(); + let mut offset = 0usize; + for chunk in chunks { + let chunk_len = chunk.text.len(); + if chunk_len == 0 { + continue; + } + + let syntax_style = chunk + .syntax_highlight_id + .and_then(|id| id.style(syntax_theme)); + let overlay_style = chunk.highlight_style; + + let combined = match (syntax_style, overlay_style) { + (Some(syntax), Some(overlay)) => Some(syntax.highlight(overlay)), + (some @ Some(_), None) | (None, some @ Some(_)) => some, + (None, None) => None, + }; + + if let Some(style) = combined { + highlights.push((offset..offset + chunk_len, style)); + } + offset += chunk_len; + } + highlights + } + #[instrument(skip_all)] pub fn layout_row( &self, diff --git a/crates/editor/src/document_colors.rs b/crates/editor/src/document_colors.rs index f99abcb9783bb53c4437dbe58fb73f49f6248d62..95f64e4f357b9dfb0ec33867a47bca7dcd8252cb 100644 --- a/crates/editor/src/document_colors.rs +++ b/crates/editor/src/document_colors.rs @@ -139,7 +139,7 @@ impl LspColorData { } impl Editor { - pub(super) fn refresh_colors_for_visible_range( + pub(super) fn refresh_document_colors( &mut self, buffer_id: Option, _: &Window, diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs new file mode 100644 index 0000000000000000000000000000000000000000..7e1586baffb78f3814f46de6f0b739f965c9fcc3 --- /dev/null +++ b/crates/editor/src/document_symbols.rs @@ -0,0 +1,855 @@ +use std::ops::Range; + +use collections::HashMap; +use futures::FutureExt; +use futures::future::join_all; +use gpui::{App, Context, HighlightStyle, Task}; +use itertools::Itertools as _; +use language::language_settings::language_settings; +use language::{Buffer, BufferSnapshot, OutlineItem}; +use multi_buffer::{Anchor, MultiBufferSnapshot}; +use text::{BufferId, OffsetRangeExt as _, ToOffset as _}; +use theme::{ActiveTheme as _, SyntaxTheme}; + +use crate::display_map::DisplaySnapshot; +use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT}; + +impl Editor { + /// Returns all document outline items for a buffer, using LSP or + /// tree-sitter based on the `document_symbols` setting. + /// External consumers (outline modal, outline panel, breadcrumbs) should use this. + pub fn buffer_outline_items( + &self, + buffer_id: BufferId, + cx: &mut Context, + ) -> Task>> { + let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { + return Task::ready(Vec::new()); + }; + + if lsp_symbols_enabled(buffer.read(cx), cx) { + let refresh_task = self.refresh_document_symbols_task.clone(); + cx.spawn(async move |editor, cx| { + refresh_task.await; + editor + .read_with(cx, |editor, _| { + editor + .lsp_document_symbols + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }) + .ok() + .unwrap_or_default() + }) + } else { + let buffer_snapshot = buffer.read(cx).snapshot(); + let syntax = cx.theme().syntax().clone(); + cx.background_executor() + .spawn(async move { buffer_snapshot.outline(Some(&syntax)).items }) + } + } + + /// Whether the buffer at `cursor` has LSP document symbols enabled. + pub(super) fn uses_lsp_document_symbols( + &self, + cursor: Anchor, + multi_buffer_snapshot: &MultiBufferSnapshot, + cx: &Context, + ) -> bool { + let Some(excerpt) = multi_buffer_snapshot.excerpt_containing(cursor..cursor) else { + return false; + }; + let Some(buffer) = self.buffer.read(cx).buffer(excerpt.buffer_id()) else { + return false; + }; + lsp_symbols_enabled(buffer.read(cx), cx) + } + + /// Filters editor-local LSP document symbols to the ancestor chain + /// containing `cursor`. Never triggers an LSP request. + pub(super) fn lsp_symbols_at_cursor( + &self, + cursor: Anchor, + multi_buffer_snapshot: &MultiBufferSnapshot, + cx: &Context, + ) -> Option<(BufferId, Vec>)> { + let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?; + let excerpt_id = excerpt.id(); + let buffer_id = excerpt.buffer_id(); + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let cursor_text_anchor = cursor.text_anchor; + + let all_items = self.lsp_document_symbols.get(&buffer_id)?; + if all_items.is_empty() { + return None; + } + + let mut symbols = all_items + .iter() + .filter(|item| { + item.range + .start + .cmp(&cursor_text_anchor, &buffer_snapshot) + .is_le() + && item + .range + .end + .cmp(&cursor_text_anchor, &buffer_snapshot) + .is_ge() + }) + .map(|item| OutlineItem { + depth: item.depth, + range: Anchor::range_in_buffer(excerpt_id, item.range.clone()), + source_range_for_text: Anchor::range_in_buffer( + excerpt_id, + item.source_range_for_text.clone(), + ), + text: item.text.clone(), + highlight_ranges: item.highlight_ranges.clone(), + name_ranges: item.name_ranges.clone(), + body_range: item + .body_range + .as_ref() + .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())), + annotation_range: item + .annotation_range + .as_ref() + .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())), + }) + .collect::>(); + + let mut prev_depth = None; + symbols.retain(|item| { + let retain = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth); + prev_depth = Some(item.depth); + retain + }); + + Some((buffer_id, symbols)) + } + + /// Fetches document symbols from the LSP for buffers that have the setting + /// enabled. Called from `update_lsp_data` on edits, server events, etc. + /// When the fetch completes, stores results in `self.lsp_document_symbols` + /// and triggers `refresh_outline_symbols_at_cursor` so breadcrumbs pick up the new data. + pub(super) fn refresh_document_symbols( + &mut self, + for_buffer: Option, + cx: &mut Context, + ) { + if !self.mode().is_full() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let buffers_to_query = self + .visible_excerpts(true, cx) + .into_iter() + .filter_map(|(_, (buffer, _, _))| { + let id = buffer.read(cx).remote_id(); + if for_buffer.is_none_or(|target| target == id) + && lsp_symbols_enabled(buffer.read(cx), cx) + { + Some(buffer) + } else { + None + } + }) + .unique_by(|buffer| buffer.read(cx).remote_id()) + .collect::>(); + + let mut symbols_altered = false; + let multi_buffer = self.buffer().clone(); + self.lsp_document_symbols.retain(|buffer_id, _| { + let Some(buffer) = multi_buffer.read(cx).buffer(*buffer_id) else { + symbols_altered = true; + return false; + }; + let retain = lsp_symbols_enabled(buffer.read(cx), cx); + symbols_altered |= !retain; + retain + }); + if symbols_altered { + self.refresh_outline_symbols_at_cursor(cx); + } + + if buffers_to_query.is_empty() { + return; + } + + self.refresh_document_symbols_task = cx + .spawn(async move |editor, cx| { + cx.background_executor() + .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT) + .await; + + let Some(tasks) = editor + .update(cx, |_, cx| { + project.read(cx).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_symbols(&buffer, cx); + async move { (buffer_id, task.await) } + }) + .collect::>() + }) + }) + .ok() + else { + return; + }; + + let results = join_all(tasks).await.into_iter().collect::>(); + editor + .update(cx, |editor, cx| { + let syntax = cx.theme().syntax().clone(); + let display_snapshot = + editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut highlighted_results = results; + for (buffer_id, items) in &mut highlighted_results { + if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) { + let snapshot = buffer.read(cx).snapshot(); + apply_highlights( + items, + *buffer_id, + &snapshot, + &display_snapshot, + &syntax, + ); + } + } + editor.lsp_document_symbols.extend(highlighted_results); + editor.refresh_outline_symbols_at_cursor(cx); + }) + .ok(); + }) + .shared(); + } +} + +fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool { + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + .document_symbols + .lsp_enabled() +} + +/// Applies combined syntax + semantic token highlights to LSP document symbol +/// outline items that were built without highlights by the project layer. +fn apply_highlights( + items: &mut [OutlineItem], + buffer_id: BufferId, + buffer_snapshot: &BufferSnapshot, + display_snapshot: &DisplaySnapshot, + syntax_theme: &SyntaxTheme, +) { + for item in items { + let symbol_range = item.range.to_offset(buffer_snapshot); + let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot); + + if let Some(highlights) = highlights_from_buffer( + &item.text, + 0, + buffer_id, + buffer_snapshot, + display_snapshot, + symbol_range, + selection_start, + syntax_theme, + ) { + item.highlight_ranges = highlights; + } + } +} + +/// Finds where the symbol name appears in the buffer and returns combined +/// (tree-sitter + semantic token) highlights for those positions. +/// +/// First tries to find the name verbatim near the selection range so that +/// complex names (`impl Trait for Type`) get full highlighting. Falls back +/// to word-by-word matching for cases like `impl Trait for Type` +/// where the LSP name doesn't appear verbatim in the buffer. +fn highlights_from_buffer( + name: &str, + name_offset_in_text: usize, + buffer_id: BufferId, + buffer_snapshot: &BufferSnapshot, + display_snapshot: &DisplaySnapshot, + symbol_range: Range, + selection_start_offset: usize, + syntax_theme: &SyntaxTheme, +) -> Option, HighlightStyle)>> { + if name.is_empty() { + return None; + } + + let range_start_offset = symbol_range.start; + let range_end_offset = symbol_range.end; + + // Try to find the name verbatim in the buffer near the selection range. + let search_start = selection_start_offset + .saturating_sub(name.len()) + .max(range_start_offset); + let search_end = (selection_start_offset + name.len() * 2).min(range_end_offset); + + if search_start < search_end { + let buffer_text: String = buffer_snapshot + .text_for_range(search_start..search_end) + .collect(); + if let Some(found_at) = buffer_text.find(name) { + let name_start_offset = search_start + found_at; + let name_end_offset = name_start_offset + name.len(); + let result = highlights_for_buffer_range( + name_offset_in_text, + name_start_offset..name_end_offset, + buffer_id, + display_snapshot, + syntax_theme, + ); + if result.is_some() { + return result; + } + } + } + + // Fallback: match word-by-word. Split the name on whitespace and find + // each word sequentially in the buffer's symbol range. + let mut highlights = Vec::new(); + let mut got_any = false; + let buffer_text: String = buffer_snapshot + .text_for_range(range_start_offset..range_end_offset) + .collect(); + let mut buf_search_from = 0usize; + let mut name_search_from = 0usize; + for word in name.split_whitespace() { + let name_word_start = name[name_search_from..] + .find(word) + .map(|pos| name_search_from + pos) + .unwrap_or(name_search_from); + if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) { + let buf_word_start = range_start_offset + buf_search_from + found_in_buf; + let buf_word_end = buf_word_start + word.len(); + let text_cursor = name_offset_in_text + name_word_start; + if let Some(mut word_highlights) = highlights_for_buffer_range( + text_cursor, + buf_word_start..buf_word_end, + buffer_id, + display_snapshot, + syntax_theme, + ) { + got_any = true; + highlights.append(&mut word_highlights); + } + buf_search_from = buf_search_from + found_in_buf + word.len(); + } + name_search_from = name_word_start + word.len(); + } + + got_any.then_some(highlights) +} + +/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte +/// range via the editor's display snapshot, then shifts the returned ranges +/// so they start at `text_cursor_start` (the position in the outline item text). +fn highlights_for_buffer_range( + text_cursor_start: usize, + buffer_range: Range, + buffer_id: BufferId, + display_snapshot: &DisplaySnapshot, + syntax_theme: &SyntaxTheme, +) -> Option, HighlightStyle)>> { + let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme); + if raw.is_empty() { + return None; + } + Some( + raw.into_iter() + .map(|(range, style)| { + ( + range.start + text_cursor_start..range.end + text_cursor_start, + style, + ) + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{Arc, atomic}, + time::Duration, + }; + + use futures::StreamExt as _; + use gpui::TestAppContext; + use settings::DocumentSymbols; + use util::path; + use zed_actions::editor::{MoveDown, MoveUp}; + + use crate::{ + Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, + editor_tests::{init_test, update_test_language_settings}, + test::editor_lsp_test_context::EditorLspTestContext, + }; + + fn outline_symbol_names(editor: &Editor) -> Vec<&str> { + editor + .outline_symbols_at_cursor + .as_ref() + .expect("Should have outline symbols") + .1 + .iter() + .map(|s| s.text.as_str()) + .collect() + } + + fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range { + lsp::Range { + start: lsp::Position::new(start_line, start_char), + end: lsp::Position::new(end_line, end_char), + } + } + + fn nested_symbol( + name: &str, + kind: lsp::SymbolKind, + range: lsp::Range, + selection_range: lsp::Range, + children: Vec, + ) -> lsp::DocumentSymbol { + #[allow(deprecated)] + lsp::DocumentSymbol { + name: name.to_string(), + detail: None, + kind, + tags: None, + deprecated: None, + range, + selection_range, + children: if children.is_empty() { + None + } else { + Some(children) + }, + } + } + + #[gpui::test] + async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "main", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!(outline_symbol_names(editor), vec!["main"]); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + lsp_range(0, 0, 3, 1), + lsp_range(0, 7, 0, 10), + vec![ + nested_symbol( + "bar", + lsp::SymbolKind::FIELD, + lsp_range(1, 4, 1, 13), + lsp_range(1, 4, 1, 7), + Vec::new(), + ), + nested_symbol( + "baz", + lsp::SymbolKind::FIELD, + lsp_range(2, 4, 2, 15), + lsp_range(2, 4, 2, 7), + Vec::new(), + ), + ], + ), + ]))) + }, + ); + + cx.set_state("struct Foo {\n baˇr: u32,\n baz: String,\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["Foo", "bar"], + "cursor is inside Foo > bar, so we expect the containing chain" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Start with tree-sitter (default) + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "lsp_main_symbol", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + cx.run_until_parked(); + + // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "Tree-sitter should produce 'fn main'" + ); + }); + + // Step 2: Switch to LSP + update_test_language_settings(&mut cx.cx.cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["lsp_main_symbol"], + "After switching to LSP, should see LSP symbols" + ); + }); + + // Step 3: Switch back to tree-sitter + update_test_language_settings(&mut cx.cx.cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::Off); + }); + cx.run_until_parked(); + + // Force another selection change + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "After switching back to tree-sitter, should see tree-sitter symbols again" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let request_count = Arc::new(atomic::AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut symbol_request = cx + .set_request_handler::(move |_, _, _| { + request_count_clone.fetch_add(1, atomic::Ordering::AcqRel); + async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "main", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + } + }); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + let first_count = request_count.load(atomic::Ordering::Acquire); + assert_eq!(first_count, 1, "Should have made exactly one request"); + + // Move cursor within the same buffer version — should use cache + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + }); + cx.background_executor + .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100)); + cx.run_until_parked(); + + assert_eq!( + first_count, + request_count.load(atomic::Ordering::Acquire), + "Moving cursor without editing should use cached symbols" + ); + } + + #[gpui::test] + async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + #[allow(deprecated)] + Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![ + lsp::SymbolInformation { + name: "main".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + deprecated: None, + location: lsp::Location { + uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + range: lsp_range(0, 0, 2, 1), + }, + container_name: None, + }, + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!(outline_symbol_names(editor), vec!["main"]); + }); + } + + #[gpui::test] + async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "MyModule", + lsp::SymbolKind::MODULE, + lsp_range(0, 0, 4, 1), + lsp_range(0, 4, 0, 12), + vec![nested_symbol( + "my_function", + lsp::SymbolKind::FUNCTION, + lsp_range(1, 4, 3, 5), + lsp_range(1, 7, 1, 18), + Vec::new(), + )], + ), + ]))) + }, + ); + + cx.set_state("mod MyModule {\n fn my_fuˇnction() {\n let x = 1;\n }\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["MyModule", "my_function"] + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new()))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + cx.update_editor(|editor, _window, _cx| { + // With LSP enabled but empty response, outline_symbols_at_cursor should be None + // (no symbols to show in breadcrumbs) + assert!( + editor.outline_symbols_at_cursor.is_none(), + "Empty LSP response should result in no outline symbols" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let request_count = Arc::new(atomic::AtomicUsize::new(0)); + // Do NOT enable document_symbols — defaults to Off + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let request_count_clone = request_count.clone(); + let _symbol_request = + cx.set_request_handler::(move |_, _, _| { + request_count_clone.fetch_add(1, atomic::Ordering::AcqRel); + async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "should_not_appear", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + } + }); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + cx.run_until_parked(); + + // Tree-sitter should be used instead + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "With document_symbols off, should use tree-sitter" + ); + }); + + assert_eq!( + request_count.load(atomic::Ordering::Acquire), + 0, + "Should not have made any LSP document symbol requests when setting is off" + ); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index dc70903c576f47d8ac92c7fb3c762d05cd440f4c..b42227add563867bc600a584bae32b239a5dd2d3 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -18,6 +18,7 @@ mod clangd_ext; pub mod code_context_menus; pub mod display_map; mod document_colors; +mod document_symbols; mod editor_settings; mod element; mod folding_ranges; @@ -249,7 +250,7 @@ pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); -pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); +pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict"; @@ -1346,8 +1347,10 @@ pub struct Editor { fetched_tree_sitter_chunks: HashMap>>, semantic_token_state: SemanticTokenState, pub(crate) refresh_matching_bracket_highlights_task: Task<()>, - refresh_outline_symbols_task: Task<()>, - outline_symbols: Option<(BufferId, Vec>)>, + refresh_document_symbols_task: Shared>, + lsp_document_symbols: HashMap>>, + refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>, + outline_symbols_at_cursor: Option<(BufferId, Vec>)>, sticky_headers_task: Task<()>, sticky_headers: Option>>, } @@ -2149,7 +2152,7 @@ impl Editor { server_id, request_id, } => { - editor.update_semantic_tokens( + editor.refresh_semantic_tokens( None, Some(RefreshForServer { server_id: *server_id, @@ -2158,9 +2161,10 @@ impl Editor { cx, ); } - project::Event::LanguageServerRemoved(_server_id) => { + project::Event::LanguageServerRemoved(_) => { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); + editor.invalidate_semantic_tokens(None); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); if editor.tasks_update_task.is_none() { @@ -2592,8 +2596,10 @@ impl Editor { fetched_tree_sitter_chunks: HashMap::default(), number_deleted_lines: false, refresh_matching_bracket_highlights_task: Task::ready(()), - refresh_outline_symbols_task: Task::ready(()), - outline_symbols: None, + refresh_document_symbols_task: Task::ready(()).shared(), + lsp_document_symbols: HashMap::default(), + refresh_outline_symbols_at_cursor_at_cursor_task: Task::ready(()), + outline_symbols_at_cursor: None, sticky_headers_task: Task::ready(()), sticky_headers: None, }; @@ -2641,13 +2647,14 @@ impl Editor { editor .update_in(cx, |editor, window, cx| { editor.register_visible_buffers(cx); - editor.refresh_colors_for_visible_range(None, window, cx); - editor.refresh_folding_ranges(None, window, cx); + editor.colorize_brackets(false, cx); editor.refresh_inlay_hints( InlayHintRefreshReason::NewLinesShown, cx, ); - editor.colorize_brackets(false, cx); + if !editor.buffer().read(cx).is_singleton() { + editor.update_lsp_data(None, window, cx); + } }) .ok(); }); @@ -3598,7 +3605,7 @@ impl Editor { self.refresh_selected_text_highlights(false, window, cx); self.refresh_matching_bracket_highlights(window, cx); - self.refresh_outline_symbols(cx); + self.refresh_outline_symbols_at_cursor(cx); self.update_visible_edit_prediction(window, cx); self.edit_prediction_requires_modifier_in_indent_conflict = true; self.inline_blame_popover.take(); @@ -7596,23 +7603,34 @@ impl Editor { } #[ztracing::instrument(skip_all)] - fn refresh_outline_symbols(&mut self, cx: &mut Context) { + fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { if !self.mode.is_full() { return; } let cursor = self.selections.newest_anchor().head(); - let multibuffer = self.buffer().read(cx).snapshot(cx); - let syntax = cx.theme().syntax().clone(); - let background_task = cx - .background_spawn(async move { multibuffer.symbols_containing(cursor, Some(&syntax)) }); - self.refresh_outline_symbols_task = cx.spawn(async move |this, cx| { - let symbols = background_task.await; - this.update(cx, |this, cx| { - this.outline_symbols = symbols; - cx.notify(); - }) - .ok(); - }); + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + + if self.uses_lsp_document_symbols(cursor, &multi_buffer_snapshot, cx) { + self.outline_symbols_at_cursor = + self.lsp_symbols_at_cursor(cursor, &multi_buffer_snapshot, cx); + cx.emit(EditorEvent::OutlineSymbolsChanged); + cx.notify(); + } else { + let syntax = cx.theme().syntax().clone(); + let background_task = cx.background_spawn(async move { + multi_buffer_snapshot.symbols_containing(cursor, Some(&syntax)) + }); + self.refresh_outline_symbols_at_cursor_at_cursor_task = + cx.spawn(async move |this, cx| { + let symbols = background_task.await; + this.update(cx, |this, cx| { + this.outline_symbols_at_cursor = symbols; + cx.emit(EditorEvent::OutlineSymbolsChanged); + cx.notify(); + }) + .ok(); + }); + } } #[ztracing::instrument(skip_all)] @@ -23863,7 +23881,7 @@ impl Editor { self.refresh_code_actions(window, cx); self.refresh_single_line_folds(window, cx); self.refresh_matching_bracket_highlights(window, cx); - self.refresh_outline_symbols(cx); + self.refresh_outline_symbols_at_cursor(cx); self.refresh_sticky_headers(&self.snapshot(window, cx), cx); if self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); @@ -24195,6 +24213,7 @@ impl Editor { if language_settings_changed { self.clear_disabled_lsp_folding_ranges(window, cx); + self.refresh_document_symbols(None, cx); } if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| { @@ -24203,7 +24222,7 @@ impl Editor { if !inlay_splice.is_empty() { self.splice_inlays(&inlay_splice.to_remove, inlay_splice.to_insert, cx); } - self.refresh_colors_for_visible_range(None, window, cx); + self.refresh_document_colors(None, window, cx); } self.refresh_inlay_hints( @@ -24223,7 +24242,8 @@ impl Editor { .semantic_token_state .update_rules(new_semantic_token_rules) { - self.refresh_semantic_token_highlights(cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } } @@ -24241,7 +24261,8 @@ impl Editor { self.colorize_brackets(true, cx); } - self.refresh_semantic_token_highlights(cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } pub fn set_searchable(&mut self, searchable: bool) { @@ -25224,12 +25245,11 @@ impl Editor { ) { if let Some(buffer_id) = for_buffer { self.pull_diagnostics(buffer_id, window, cx); - self.update_semantic_tokens(Some(buffer_id), None, cx); - } else { - self.refresh_semantic_token_highlights(cx); } - self.refresh_colors_for_visible_range(for_buffer, window, cx); + self.refresh_semantic_tokens(for_buffer, None, cx); + self.refresh_document_colors(for_buffer, window, cx); self.refresh_folding_ranges(for_buffer, window, cx); + self.refresh_document_symbols(for_buffer, cx); } fn register_visible_buffers(&mut self, cx: &mut Context) { @@ -25312,10 +25332,11 @@ impl Editor { show_underlines: self.diagnostics_enabled(), } } + fn breadcrumbs_inner(&self, cx: &App) -> Option> { let multibuffer = self.buffer().read(cx); let is_singleton = multibuffer.is_singleton(); - let (buffer_id, symbols) = self.outline_symbols.as_ref()?; + let (buffer_id, symbols) = self.outline_symbols_at_cursor.as_ref()?; let buffer = multibuffer.buffer(*buffer_id)?; let buffer = buffer.read(cx); @@ -27659,6 +27680,7 @@ pub enum EditorEvent { }, CursorShapeChanged, BreadcrumbsChanged, + OutlineSymbolsChanged, PushedToNavHistory { anchor: Anchor, is_deactivate: bool, diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 0c275278389c3c1b8aff26dcdeaf09eaa9b419e7..3341764383b594e8ee3fcb84486f71f8c94c5cb1 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -669,9 +669,9 @@ impl Editor { editor .update_in(cx, |editor, window, cx| { editor.register_visible_buffers(cx); + editor.colorize_brackets(false, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); editor.update_lsp_data(None, window, cx); - editor.colorize_brackets(false, cx); }) .ok(); }); diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index e2500b742d0585f43e654aaa1791b3cea4fa50ba..24188ed5d84b033e771e4fbe4a1579c57376d828 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -102,17 +102,25 @@ impl Editor { cx: &mut Context, ) { self.semantic_token_state.toggle_enabled(); - self.update_semantic_tokens(None, None, cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } - pub(crate) fn update_semantic_tokens( + pub(super) fn invalidate_semantic_tokens(&mut self, for_buffer: Option) { + match for_buffer { + Some(for_buffer) => self.semantic_token_state.invalidate_buffer(&for_buffer), + None => self.semantic_token_state.fetched_for_buffers.clear(), + } + } + + pub(super) fn refresh_semantic_tokens( &mut self, buffer_id: Option, for_server: Option, cx: &mut Context, ) { if !self.mode().is_full() || !self.semantic_token_state.enabled() { - self.semantic_token_state.fetched_for_buffers.clear(); + self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { display_map.semantic_token_highlights.clear(); }); @@ -193,6 +201,7 @@ impl Editor { editor.display_map.update(cx, |display_map, _| { for buffer_id in invalidate_semantic_highlights_for_buffers { display_map.invalidate_semantic_highlights(buffer_id); + editor.semantic_token_state.invalidate_buffer(&buffer_id); } }); @@ -276,11 +285,6 @@ impl Editor { }).ok(); }); } - - pub(super) fn refresh_semantic_token_highlights(&mut self, cx: &mut Context) { - self.semantic_token_state.fetched_for_buffers.clear(); - self.update_semantic_tokens(None, None, cx); - } } fn buffer_into_editor_highlights<'a, 'b>( diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index f0b21f864fc3c0abf2d51e385b85bd22da5e6522..d837c5f9cfed0c5069f682d71bfe01f22a46d18b 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -9,7 +9,7 @@ use ec4rs::{ use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{App, Modifiers, SharedString}; use itertools::{Either, Itertools}; -use settings::{DocumentFoldingRanges, IntoGpui, SemanticTokens}; +use settings::{DocumentFoldingRanges, DocumentSymbols, IntoGpui, SemanticTokens}; pub use settings::{ CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, @@ -111,6 +111,8 @@ pub struct LanguageSettings { /// Controls whether folding ranges from language servers are used instead of /// tree-sitter and indent-based folding. pub document_folding_ranges: DocumentFoldingRanges, + /// Controls the source of document symbols used for outlines and breadcrumbs. + pub document_symbols: DocumentSymbols, /// Controls where the `editor::Rewrap` action is allowed for this language. /// /// Note: This setting has no effect in Vim mode, as rewrap is already @@ -596,6 +598,7 @@ impl settings::Settings for AllLanguageSettings { language_servers: settings.language_servers.unwrap(), semantic_tokens: settings.semantic_tokens.unwrap(), document_folding_ranges: settings.document_folding_ranges.unwrap(), + document_symbols: settings.document_symbols.unwrap(), allow_rewrap: settings.allow_rewrap.unwrap(), show_edit_predictions: settings.show_edit_predictions.unwrap(), edit_predictions_disabled_in: settings.edit_predictions_disabled_in.unwrap(), diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 5069fa2373d16e7afb69f8f9899d86edb09d55a9..905f323624437d988ff9a9eb3bde4f9a7becaa91 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -32,10 +32,12 @@ editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } +lsp.workspace = true menu.workspace = true project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 9e6cc045a76204c71bd5812d002a873cfc5dd461..f172cf2c44e6a9a388bd37d37f747ee19cf2b65c 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -41,21 +41,73 @@ pub fn toggle( window: &mut Window, cx: &mut App, ) { - let outline = editor - .read(cx) - .buffer() - .read(cx) - .snapshot(cx) - .outline(Some(cx.theme().syntax())); - - let workspace = window.root::().flatten(); - if let Some((workspace, outline)) = workspace.zip(outline) { + let Some(workspace) = window.root::().flatten() else { + return; + }; + if workspace.read(cx).active_modal::(cx).is_some() { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { - OutlineView::new(outline, editor, window, cx) + OutlineView::new(Outline::new(Vec::new()), editor.clone(), window, cx) }); - }) + }); + return; } + + let Some(task) = outline_for_editor(&editor, cx) else { + return; + }; + let editor = editor.clone(); + window + .spawn(cx, async move |cx| { + let items = task.await; + if items.is_empty() { + return; + } + cx.update(|window, cx| { + let outline = Outline::new(items); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + OutlineView::new(outline, editor, window, cx) + }); + }); + }) + .ok(); + }) + .detach(); +} + +fn outline_for_editor( + editor: &Entity, + cx: &mut App, +) -> Option>>> { + let multibuffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = multibuffer.as_singleton()?; + let excerpt_id = *excerpt_id; + let buffer_id = buffer_snapshot.remote_id(); + let task = editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)); + + Some(cx.background_executor().spawn(async move { + task.await + .into_iter() + .map(|item| OutlineItem { + depth: item.depth, + range: Anchor::range_in_buffer(excerpt_id, item.range), + source_range_for_text: Anchor::range_in_buffer( + excerpt_id, + item.source_range_for_text, + ), + text: item.text, + highlight_ranges: item.highlight_ranges, + name_ranges: item.name_ranges, + body_range: item + .body_range + .map(|r| Anchor::range_in_buffer(excerpt_id, r)), + annotation_range: item + .annotation_range + .map(|r| Anchor::range_in_buffer(excerpt_id, r)), + }) + .collect() + })) } pub struct OutlineView { @@ -390,11 +442,16 @@ pub fn render_item( #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use indoc::indoc; + use language::FakeLspAdapter; use project::{FakeFs, Project}; use serde_json::json; + use settings::SettingsStore; + use smol::stream::StreamExt as _; use util::{path, rel_path::rel_path}; use workspace::{AppState, Workspace}; @@ -533,6 +590,7 @@ mod tests { cx: &mut VisualTestContext, ) -> Entity> { cx.dispatch_action(zed_actions::outline::ToggleOutline); + cx.executor().advance_clock(Duration::from_millis(200)); workspace.update(cx, |workspace, cx| { workspace .active_modal::(cx) @@ -584,6 +642,190 @@ mod tests { }) } + #[gpui::test] + async fn test_outline_modal_lsp_document_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc!{" + struct Foo { + bar: u32, + baz: String, + } + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()); + project.languages().clone() + }); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(|fake_language_server| { + #[allow(deprecated)] + fake_language_server + .set_request_handler::( + move |_, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![ + lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }, + lsp::DocumentSymbol { + name: "lsp_only_field".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 15), + ), + selection_range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 7), + ), + children: None, + }, + ]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let _fake_language_server = fake_language_servers.next().await.unwrap(); + cx.run_until_parked(); + + // Step 1: tree-sitter outlines by default + let outline_view = open_outline_view(&workspace, cx); + let tree_sitter_names = outline_names(&outline_view, cx); + assert_eq!( + tree_sitter_names, + vec!["struct Foo", "bar", "baz"], + "Step 1: tree-sitter outlines should be displayed by default" + ); + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + // Step 2: Switch to LSP document symbols + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::On); + }); + }); + }); + let outline_view = open_outline_view(&workspace, cx); + let lsp_names = outline_names(&outline_view, cx); + assert_eq!( + lsp_names, + vec!["Foo", "bar", "lsp_only_field"], + "Step 2: LSP-provided symbols should be displayed" + ); + assert_eq!( + highlighted_display_rows(&editor, cx), + Vec::::new(), + "Step 2: initially opened outline view should have no highlights" + ); + assert_single_caret_at_row(&editor, 0, cx); + + cx.dispatch_action(menu::SelectNext); + assert_eq!( + highlighted_display_rows(&editor, cx), + vec![1], + "Step 2: bar's row should be highlighted after SelectNext" + ); + assert_single_caret_at_row(&editor, 0, cx); + + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_single_caret_at_row(&editor, 1, cx); + + // Step 3: Switch back to tree-sitter + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::Off); + }); + }); + }); + + let outline_view = open_outline_view(&workspace, cx); + let restored_names = outline_names(&outline_view, cx); + assert_eq!( + restored_names, + vec!["struct Foo", "bar", "baz"], + "Step 3: tree-sitter outlines should be restored after switching back" + ); + } + #[track_caller] fn assert_single_caret_at_row( editor: &Entity, diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 72e2d1eb63b1253e66bf2b7ef46dfb714fb24db6..fbcbd7ba74f42fc86976bb090102b86802cd4a1b 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -40,6 +40,7 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] +lsp.workspace = true search = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 638f2381836786474e691830aea1d003db2d49df..3405679a96f33cce35011d197d5176bdd4b830e2 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -11,6 +11,7 @@ use editor::{ scroll::{Autoscroll, ScrollAnchor}, }; use file_icons::FileIcons; + use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, @@ -22,6 +23,7 @@ use gpui::{ uniform_list, }; use itertools::Itertools; +use language::language_settings::language_settings; use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ @@ -126,7 +128,7 @@ pub struct OutlinePanel { fs_entries_update_task: Task<()>, cached_entries_update_task: Task<()>, reveal_selection_task: Task>, - outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>, + outline_fetch_tasks: HashMap>, excerpts: HashMap>, cached_entries: Vec, filter_editor: Entity, @@ -698,11 +700,10 @@ impl OutlinePanel { }; workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = Self::new(workspace, window, cx); + let panel = Self::new(workspace, serialized_panel.as_ref(), window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|px| px.round()); - panel.active = serialized_panel.active.unwrap_or(false); cx.notify(); }); } @@ -712,6 +713,7 @@ impl OutlinePanel { fn new( workspace: &mut Workspace, + serialized: Option<&SerializedOutlinePanel>, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -769,10 +771,12 @@ impl OutlinePanel { let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); + let mut document_symbols_by_buffer = HashMap::default(); let settings_subscription = cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); + let mut outlines_invalidated = false; if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); @@ -781,6 +785,7 @@ impl OutlinePanel { excerpt.invalidate_outlines(); } } + outlines_invalidated = true; let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); @@ -837,13 +842,50 @@ impl OutlinePanel { cx.notify(); } } + + if !outlines_invalidated { + let new_document_symbols = outline_panel + .excerpts + .keys() + .filter_map(|buffer_id| { + let buffer = outline_panel + .project + .read(cx) + .buffer_for_id(*buffer_id, cx)?; + let buffer = buffer.read(cx); + let doc_symbols = language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ) + .document_symbols; + Some((*buffer_id, doc_symbols)) + }) + .collect(); + if new_document_symbols != document_symbols_by_buffer { + document_symbols_by_buffer = new_document_symbols; + for excerpts in outline_panel.excerpts.values_mut() { + for excerpt in excerpts.values_mut() { + excerpt.invalidate_outlines(); + } + } + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries( + Some(UPDATE_DEBOUNCE), + window, + cx, + ); + } + } + } }); let scroll_handle = UniformListScrollHandle::new(); let mut outline_panel = Self { mode: ItemsDisplayMode::Outline, - active: false, + active: serialized.and_then(|s| s.active).unwrap_or(false), pinned: false, workspace: workspace_handle, project, @@ -3413,68 +3455,56 @@ impl OutlinePanel { return; } - let syntax_theme = cx.theme().syntax().clone(); let first_update = Arc::new(AtomicBool::new(true)); - for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { - for (excerpt_id, excerpt_range) in excerpt_ranges { - let syntax_theme = syntax_theme.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - let first_update = first_update.clone(); - self.outline_fetch_tasks.insert( - (buffer_id, excerpt_id), - cx.spawn_in(window, async move |outline_panel, cx| { - let buffer_language = buffer_snapshot.language().cloned(); - let fetched_outlines = cx - .background_spawn(async move { - let mut outlines = buffer_snapshot.outline_items_containing( - excerpt_range.context, - false, - Some(&syntax_theme), - ); - outlines.retain(|outline| { - buffer_language.is_none() - || buffer_language.as_ref() - == buffer_snapshot.language_at(outline.range.start) - }); + for (buffer_id, (_buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { + let outline_task = self.active_editor().map(|editor| { + editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)) + }); - let outlines_with_children = outlines - .windows(2) - .filter_map(|window| { - let current = &window[0]; - let next = &window[1]; - if next.depth > current.depth { - Some((current.range.clone(), current.depth)) - } else { - None - } - }) - .collect::>(); + let excerpt_ids = excerpt_ranges.keys().copied().collect::>(); + let first_update = first_update.clone(); - (outlines, outlines_with_children) - }) - .await; - - let (fetched_outlines, outlines_with_children) = fetched_outlines; + self.outline_fetch_tasks.insert( + buffer_id, + cx.spawn_in(window, async move |outline_panel, cx| { + let Some(outline_task) = outline_task else { + return; + }; + let fetched_outlines = outline_task.await; + let outlines_with_children = fetched_outlines + .windows(2) + .filter_map(|window| { + let current = &window[0]; + let next = &window[1]; + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); - outline_panel - .update_in(cx, |outline_panel, window, cx| { - let pending_default_depth = - outline_panel.pending_default_expansion_depth.take(); + outline_panel + .update_in(cx, |outline_panel, window, cx| { + let pending_default_depth = + outline_panel.pending_default_expansion_depth.take(); - let debounce = - if first_update.fetch_and(false, atomic::Ordering::AcqRel) { - None - } else { - Some(UPDATE_DEBOUNCE) - }; + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + for excerpt_id in &excerpt_ids { if let Some(excerpt) = outline_panel .excerpts .entry(buffer_id) .or_default() - .get_mut(&excerpt_id) + .get_mut(excerpt_id) { - excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); + excerpt.outlines = + ExcerptOutlines::Outlines(fetched_outlines.clone()); if let Some(default_depth) = pending_default_depth && let ExcerptOutlines::Outlines(outlines) = @@ -3494,22 +3524,20 @@ impl OutlinePanel { outline_panel.collapsed_entries.insert( CollapsedEntry::Outline( buffer_id, - excerpt_id, + *excerpt_id, outline.range.clone(), ), ); }); } - - // Even if no outlines to check, we still need to update cached entries - // to show the outline entries that were just fetched - outline_panel.update_cached_entries(debounce, window, cx); } - }) - .ok(); - }), - ); - } + } + + outline_panel.update_cached_entries(debounce, window, cx); + }) + .ok(); + }), + ); } } @@ -5297,6 +5325,22 @@ fn subscribe_for_editor_events( outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } + EditorEvent::OutlineSymbolsChanged => { + for excerpts in outline_panel.excerpts.values_mut() { + for excerpt in excerpts.values_mut() { + excerpt.invalidate_outlines(); + } + } + if matches!( + outline_panel.selected_entry(), + Some(PanelEntry::Outline(..)), + ) { + outline_panel.selected_entry.invalidate(); + } + if outline_panel.update_non_fs_items(window, cx) { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } + } EditorEvent::TitleChanged => { outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } @@ -5332,8 +5376,8 @@ impl GenerationState { #[cfg(test)] mod tests { use db::indoc; - use gpui::{TestAppContext, VisualTestContext, WindowHandle}; - use language::rust_lang; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; + use language::{self, FakeLspAdapter, rust_lang}; use pretty_assertions::assert_eq; use project::FakeFs; use search::{ @@ -5341,6 +5385,7 @@ mod tests { project_search::{self, perform_project_search}, }; use serde_json::json; + use smol::stream::StreamExt as _; use util::path; use workspace::{OpenOptions, OpenVisible, ToolbarItemView}; @@ -7870,4 +7915,218 @@ search: | Field | Meaning « »|" ); }); } + + #[gpui::test] + async fn test_outline_panel_lsp_document_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let root = path!("/root"); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + root, + json!({ + "src": { + "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; + let language_registry = project.read_with(cx, |project, _| { + project.languages().add(rust_lang()); + project.languages().clone() + }); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(|fake_language_server| { + fake_language_server + .set_request_handler::( + move |_, _| async move { + #[allow(deprecated)] + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![ + lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }, + lsp::DocumentSymbol { + name: "lsp_only_field".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 15), + ), + selection_range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 7), + ), + children: None, + }, + ]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let workspace = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let outline_panel = outline_panel(&workspace, cx); + cx.update(|window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.set_active(true, window, cx) + }); + }); + + let _editor = workspace + .update(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/root/src/lib.rs")), + OpenOptions { + visible: Some(OpenVisible::All), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .unwrap() + .await + .expect("Failed to open Rust source file") + .downcast::() + .expect("Should open an editor for Rust source file"); + let _fake_language_server = fake_language_servers.next().await.unwrap(); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + // Step 1: tree-sitter outlines by default + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Foo <==== selected + outline: bar + outline: baz" + ), + "Step 1: tree-sitter outlines should be displayed by default" + ); + }); + + // Step 2: Switch to LSP document symbols + cx.update(|_, cx| { + settings::SettingsStore::update_global( + cx, + |store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::On); + }); + }, + ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: Foo <==== selected + outline: bar + outline: lsp_only_field" + ), + "Step 2: After switching to LSP, should see LSP-provided symbols" + ); + }); + + // Step 3: Switch back to tree-sitter + cx.update(|_, cx| { + settings::SettingsStore::update_global( + cx, + |store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::Off); + }); + }, + ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Foo <==== selected + outline: bar + outline: baz" + ), + "Step 3: tree-sitter outlines should be restored" + ); + }); + } } diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index f0d9427d35b90a3a5258cf998903cff490dbbbed..c8e51db90e159769612e2ed0fa5beb5ea218c833 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -1706,7 +1706,7 @@ impl LspCommand for GetDocumentSymbols { return Ok(Vec::new()); }; - let symbols: Vec<_> = match lsp_symbols { + let symbols = match lsp_symbols { lsp::DocumentSymbolResponse::Flat(symbol_information) => symbol_information .into_iter() .map(|lsp_symbol| DocumentSymbol { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e27e46e955bcecede6f0457af065b743fb9690ec..6be1b233b75de2cdd1572b0e36186e6994a1745b 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -12,6 +12,7 @@ pub mod clangd_ext; mod code_lens; mod document_colors; +mod document_symbols; mod folding_ranges; mod inlay_hints; pub mod json_language_server_ext; @@ -23,6 +24,7 @@ pub mod vue_language_server_ext; use self::code_lens::CodeLensData; use self::document_colors::DocumentColorData; +use self::document_symbols::DocumentSymbolsData; use self::inlay_hints::BufferInlayHints; use crate::{ CodeAction, Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, @@ -3910,6 +3912,7 @@ pub struct BufferLspData { code_lens: Option, semantic_tokens: Option, folding_ranges: Option, + document_symbols: Option, inlay_hints: BufferInlayHints, lsp_requests: HashMap>>, chunk_lsp_requests: HashMap>, @@ -3929,6 +3932,7 @@ impl BufferLspData { code_lens: None, semantic_tokens: None, folding_ranges: None, + document_symbols: None, inlay_hints: BufferInlayHints::new(buffer, cx), lsp_requests: HashMap::default(), chunk_lsp_requests: HashMap::default(), @@ -3956,6 +3960,10 @@ impl BufferLspData { if let Some(folding_ranges) = &mut self.folding_ranges { folding_ranges.ranges.remove(&for_server); } + + if let Some(document_symbols) = &mut self.document_symbols { + document_symbols.remove_server_data(for_server); + } } #[cfg(any(test, feature = "test-support"))] @@ -8804,6 +8812,18 @@ impl LspStore { ) .await?; } + Request::GetDocumentSymbols(get_document_symbols) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_document_symbols, + None, + &mut cx, + ) + .await?; + } Request::GetHover(get_hover) => { let position = get_hover.position.clone().and_then(deserialize_anchor); Self::query_lsp_locally::( diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs new file mode 100644 index 0000000000000000000000000000000000000000..b18d27a889554c19e0cbeb7c1ae863656dbe3efe --- /dev/null +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -0,0 +1,405 @@ +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 _, Context, Entity, Task}; +use itertools::Itertools; +use language::{Buffer, BufferSnapshot, OutlineItem}; +use lsp::LanguageServerId; +use settings::Settings as _; +use text::{Anchor, Bias}; +use util::ResultExt; + +use crate::DocumentSymbol; +use crate::lsp_command::{GetDocumentSymbols, LspCommand as _}; +use crate::lsp_store::LspStore; +use crate::project_settings::ProjectSettings; + +pub(super) type DocumentSymbolsTask = + Shared>, Arc>>>; + +#[derive(Debug, Default)] +pub(super) struct DocumentSymbolsData { + symbols: HashMap>>, + symbols_update: Option<(Global, DocumentSymbolsTask)>, +} + +impl DocumentSymbolsData { + pub(super) fn remove_server_data(&mut self, for_server: LanguageServerId) { + self.symbols.remove(&for_server); + } +} + +impl LspStore { + /// Returns a task that resolves to the document symbol outline items for + /// the given buffer. + /// + /// Caches results per buffer version so repeated calls for the same version + /// return immediately. Deduplicates concurrent in-flight requests. + /// + /// The returned items contain text and ranges but no syntax highlights. + /// Callers (e.g. the editor) are responsible for applying highlights + /// via the buffer's tree-sitter data and the active theme. + pub fn fetch_document_symbols( + &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) { + if let Some(cached) = &lsp_data.document_symbols { + if !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.symbols.keys().copied().collect() + }); + if !has_different_servers { + let snapshot = buffer.read(cx).snapshot(); + return Task::ready( + cached + .symbols + .values() + .flatten() + .cloned() + .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) + .collect(), + ); + } + } + } + } + + let doc_symbols_data = self + .latest_lsp_data(buffer, cx) + .document_symbols + .get_or_insert_default(); + if let Some((updating_for, running_update)) = &doc_symbols_data.symbols_update { + if !version_queried_for.changed_since(updating_for) { + let running = running_update.clone(); + return cx + .background_spawn(async move { running.await.log_err().unwrap_or_default() }); + } + } + + 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_symbols_for_buffer(&buffer, cx) + }) + .map_err(Arc::new)? + .await + .context("fetching document symbols") + .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) { + if let Some(document_symbols) = &mut lsp_data.document_symbols { + document_symbols.symbols_update = None; + } + } + }) + .ok(); + return Err(e); + } + }; + + lsp_store + .update(cx, |lsp_store, cx| { + let snapshot = buffer.read(cx).snapshot(); + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let doc_symbols = lsp_data.document_symbols.get_or_insert_default(); + + if let Some(fetched_symbols) = fetched { + let converted = fetched_symbols + .iter() + .map(|(&server_id, symbols)| { + let mut items = Vec::new(); + flatten_document_symbols(symbols, &snapshot, 0, &mut items); + (server_id, items) + }) + .collect(); + if lsp_data.buffer_version == query_version { + doc_symbols.symbols.extend(converted); + } else if !lsp_data.buffer_version.changed_since(&query_version) { + lsp_data.buffer_version = query_version; + doc_symbols.symbols = converted; + } + } + doc_symbols.symbols_update = None; + doc_symbols + .symbols + .values() + .flatten() + .cloned() + .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) + .collect() + }) + .map_err(Arc::new) + }) + .shared(); + + doc_symbols_data.symbols_update = Some((version_queried_for, new_task.clone())); + + cx.background_spawn(async move { new_task.await.log_err().unwrap_or_default() }) + } + + fn fetch_document_symbols_for_buffer( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task>>>> { + if let Some((client, project_id)) = self.upstream_client() { + let request = GetDocumentSymbols; + 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_symbols = join_all(responses.payload.into_iter().map(|response| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + async move { + ( + LanguageServerId::from_proto(response.server_id), + GetDocumentSymbols + .response_from_proto(response.response, lsp_store, buffer, cx) + .await, + ) + } + })) + .await; + + let mut has_errors = false; + let result = document_symbols + .into_iter() + .filter_map(|(server_id, symbols)| match symbols { + Ok(symbols) => Some((server_id, symbols)), + Err(e) => { + has_errors = true; + log::error!("Failed to fetch document symbols: {e:#}"); + None + } + }) + .collect::>(); + anyhow::ensure!( + !has_errors || !result.is_empty(), + "Failed to fetch document symbols" + ); + Ok(Some(result)) + }) + } else { + let symbols_task = + self.request_multiple_lsp_locally(buffer, None::, GetDocumentSymbols, cx); + cx.background_spawn(async move { Ok(Some(symbols_task.await.into_iter().collect())) }) + } + } +} + +fn flatten_document_symbols( + symbols: &[DocumentSymbol], + snapshot: &BufferSnapshot, + depth: usize, + output: &mut Vec>, +) { + for symbol in symbols { + let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Right); + let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left); + let selection_start = snapshot.clip_point_utf16(symbol.selection_range.start, Bias::Right); + let selection_end = snapshot.clip_point_utf16(symbol.selection_range.end, Bias::Left); + + let range = snapshot.anchor_after(start)..snapshot.anchor_before(end); + let selection_range = + snapshot.anchor_after(selection_start)..snapshot.anchor_before(selection_end); + + let text = symbol.name.clone(); + let name_ranges = vec![0..text.len()]; + + output.push(OutlineItem { + depth, + range, + source_range_for_text: selection_range, + text, + highlight_ranges: Vec::new(), + name_ranges, + body_range: None, + annotation_range: None, + }); + + if !symbol.children.is_empty() { + flatten_document_symbols(&symbol.children, snapshot, depth + 1, output); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use text::Unclipped; + + fn make_symbol( + name: &str, + kind: lsp::SymbolKind, + range: std::ops::Range<(u32, u32)>, + selection_range: std::ops::Range<(u32, u32)>, + children: Vec, + ) -> DocumentSymbol { + use text::PointUtf16; + DocumentSymbol { + name: name.to_string(), + kind, + range: Unclipped(PointUtf16::new(range.start.0, range.start.1)) + ..Unclipped(PointUtf16::new(range.end.0, range.end.1)), + selection_range: Unclipped(PointUtf16::new( + selection_range.start.0, + selection_range.start.1, + )) + ..Unclipped(PointUtf16::new( + selection_range.end.0, + selection_range.end.1, + )), + children, + } + } + + #[gpui::test] + async fn test_flatten_document_symbols(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| { + Buffer::local( + concat!( + "struct Foo {\n", + " bar: u32,\n", + " baz: String,\n", + "}\n", + "\n", + "impl Foo {\n", + " fn new() -> Self {\n", + " Foo { bar: 0, baz: String::new() }\n", + " }\n", + "}\n", + ), + cx, + ) + }); + + let symbols = vec![ + make_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + (0, 0)..(3, 1), + (0, 7)..(0, 10), + vec![ + make_symbol( + "bar", + lsp::SymbolKind::FIELD, + (1, 4)..(1, 13), + (1, 4)..(1, 7), + vec![], + ), + make_symbol( + "baz", + lsp::SymbolKind::FIELD, + (2, 4)..(2, 15), + (2, 4)..(2, 7), + vec![], + ), + ], + ), + make_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + (5, 0)..(9, 1), + (5, 5)..(5, 8), + vec![make_symbol( + "new", + lsp::SymbolKind::FUNCTION, + (6, 4)..(8, 5), + (6, 7)..(6, 10), + vec![], + )], + ), + ]; + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let mut items = Vec::new(); + flatten_document_symbols(&symbols, &snapshot, 0, &mut items); + + assert_eq!(items.len(), 5); + + assert_eq!(items[0].depth, 0); + assert_eq!(items[0].text, "Foo"); + assert_eq!(items[0].name_ranges, vec![0..3]); + + assert_eq!(items[1].depth, 1); + assert_eq!(items[1].text, "bar"); + assert_eq!(items[1].name_ranges, vec![0..3]); + + assert_eq!(items[2].depth, 1); + assert_eq!(items[2].text, "baz"); + assert_eq!(items[2].name_ranges, vec![0..3]); + + assert_eq!(items[3].depth, 0); + assert_eq!(items[3].text, "Foo"); + assert_eq!(items[3].name_ranges, vec![0..3]); + + assert_eq!(items[4].depth, 1); + assert_eq!(items[4].text, "new"); + assert_eq!(items[4].name_ranges, vec![0..3]); + } + + #[gpui::test] + async fn test_empty_symbols(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let symbols: Vec = Vec::new(); + let mut items = Vec::new(); + flatten_document_symbols(&symbols, &snapshot, 0, &mut items); + assert!(items.is_empty()); + } +} diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 99b29ac224549d9371f5a71bf54cd918090863f1..9132dafbd42be8e1f7d0de2b1278d7bf757aa9ac 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -851,6 +851,7 @@ message LspQuery { InlayHints inlay_hints = 14; SemanticTokens semantic_tokens = 16; GetFoldingRanges get_folding_ranges = 17; + GetDocumentSymbols get_document_symbols = 18; } } @@ -876,6 +877,7 @@ message LspResponse { InlayHintsResponse inlay_hints_response = 13; SemanticTokensResponse semantic_tokens_response = 14; GetFoldingRangesResponse get_folding_ranges_response = 15; + GetDocumentSymbolsResponse get_document_symbols_response = 16; } uint64 server_id = 7; } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 69607f103b3dccd34d2c64f8d6347ea6570fbbae..4bd716d92d899dbf2d47cc649eaebb9b9ae667ec 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -561,6 +561,7 @@ lsp_messages!( (GetReferences, GetReferencesResponse, true), (GetDocumentColor, GetDocumentColorResponse, true), (GetFoldingRanges, GetFoldingRangesResponse, true), + (GetDocumentSymbols, GetDocumentSymbolsResponse, true), (GetHover, GetHoverResponse, true), (GetCodeActions, GetCodeActionsResponse, true), (GetSignatureHelp, GetSignatureHelpResponse, true), @@ -926,6 +927,7 @@ impl LspQuery { Some(lsp_query::Request::GetReferences(_)) => ("GetReferences", false), 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::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 d1ebe54c5f0ff023bea9f7fec69f0a748f1f66b1..57cf7d6f67ffd11af612320ce5c07984565a14a3 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -382,6 +382,9 @@ impl AnyProtoClient { Response::GetFoldingRangesResponse(response) => { to_any_envelope(&envelope, response) } + Response::GetDocumentSymbolsResponse(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 09e6bd7c6b3126c9a06bc65428ee0ac7c76633a8..7a49e751bb239766f8082cfa5bbd8473f0e309bb 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -563,6 +563,7 @@ impl VsCodeSettings { } }), document_folding_ranges: None, + document_symbols: None, linked_edits: self.read_bool("editor.linkedEditing"), preferred_line_length: self.read_u32("editor.wordWrapColumn"), prettier: None, diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index d0c4a99984e1b09944d2dd2d964a7446dfd25818..1440b0995d5bc55e6240e91845f7f97793621fca 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize, de::Error as _}; use settings_macros::{MergeFrom, with_fallible_options}; use std::sync::Arc; -use crate::{DocumentFoldingRanges, ExtendingVec, SemanticTokens, merge_from}; +use crate::{DocumentFoldingRanges, DocumentSymbols, ExtendingVec, SemanticTokens, merge_from}; /// The state of the modifier keys at some point in time #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)] @@ -438,6 +438,14 @@ pub struct LanguageSettingsContent { /// /// Default: "off" pub document_folding_ranges: Option, + /// Controls the source of document symbols used for outlines and breadcrumbs. + /// + /// Options: + /// - "off": Use tree-sitter queries to compute document symbols (default). + /// - "on": Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + /// + /// Default: "off" + pub document_symbols: Option, /// Controls where the `editor::Rewrap` action is allowed for this language. /// /// Note: This setting has no effect in Vim mode, as rewrap is already diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index ed9a7aac5280447fe014e6b3796778be11928f92..8f628455a8dd90aa16cdc86f48984c84af5ebe10 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -860,3 +860,36 @@ impl DocumentFoldingRanges { self != &Self::Off } } + +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum DocumentSymbols { + /// Use tree-sitter queries to compute document symbols for outlines and breadcrumbs (default). + #[default] + #[serde(alias = "tree_sitter")] + Off, + /// Use the language server's `textDocument/documentSymbol` LSP response for outlines and + /// breadcrumbs. When enabled, tree-sitter is not used for document symbols. + #[serde(alias = "language_server")] + On, +} + +impl DocumentSymbols { + /// Returns true if LSP document symbols should be used instead of tree-sitter. + pub fn lsp_enabled(&self) -> bool { + self == &Self::On + } +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0d2836ff8b7ee82d1124df252f68108b90bfa023..44ef9da1191c1e9b14767cd175001d8e3ef9891c 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -8489,7 +8489,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { /// LanguageSettings items that should be included in the "Languages & Tools" page /// not the "Editor" page fn non_editor_language_settings_data() -> Box<[SettingsPageItem]> { - fn lsp_section() -> [SettingsPageItem; 7] { + fn lsp_section() -> [SettingsPageItem; 8] { [ SettingsPageItem::SectionHeader("LSP"), SettingsPageItem::SettingItem(SettingItem { @@ -8625,6 +8625,25 @@ fn non_editor_language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER | PROJECT, }), + SettingsPageItem::SettingItem(SettingItem { + title: "LSP Document Symbols", + description: "When enabled, use the language server's document symbols for outlines and breadcrumbs instead of tree-sitter.", + field: Box::new(SettingField { + json_path: Some("languages.$(language).document_symbols"), + pick: |settings_content| { + language_settings_field(settings_content, |language| { + language.document_symbols.as_ref() + }) + }, + write: |settings_content, value| { + language_settings_field_mut(settings_content, value, |language, value| { + language.document_symbols = value; + }) + }, + }), + metadata: None, + files: USER | PROJECT, + }), ] } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f1eb917785557c18a587a15e4ffcc1096e5dfa06..154cb5ff91a3f120059f966110bad58ce0bc2002 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -540,6 +540,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) // please semicolon stay on next line ; } diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 72f5bace87e5f41fddcbac043465b455d40a91bd..4aaf979993802da20d0b1a0f43d1a1323008611c 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3365,6 +3365,37 @@ To enable LSP folding ranges for a specific language: } ``` +## LSP Document Symbols + +- Description: Controls the source of document symbols used for outlines and breadcrumbs. This is an LSP feature — when enabled, tree-sitter is not used for document symbols, and the language server's `textDocument/documentSymbol` response is used instead. +- Setting: `document_symbols` +- Default: `off` + +**Options** + +1. `off`: Use tree-sitter queries to compute document symbols. +2. `on`: Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + +To enable LSP document symbols globally: + +```json [settings] +{ + "document_symbols": "on" +} +``` + +To enable LSP document symbols for a specific language: + +```json [settings] +{ + "languages": { + "Rust": { + "document_symbols": "on" + } + } +} +``` + ## Use Smartcase Search - Description: When enabled, automatically adjusts search case sensitivity based on your query. If your search query contains any uppercase letters, the search becomes case-sensitive; if it contains only lowercase letters, the search becomes case-insensitive. \ diff --git a/typos.toml b/typos.toml index 7ce5d047e6113dc9b22755dcdfb2d0c3f016db12..402fb6169297619b7f24aa59f6a817918eba81a7 100644 --- a/typos.toml +++ b/typos.toml @@ -60,6 +60,8 @@ extend-exclude = [ "crates/gpui/src/platform/mac/dispatcher.rs", # Tests contain partially incomplete words (by design) "crates/edit_prediction_cli/src/split_commit.rs", + # Tests contain `baˇr` that cause `"ba" should be "by" or "be".`-like false-positives + "crates/editor/src/document_symbols.rs", ] [default]