Detailed changes
@@ -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",
@@ -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.
//
@@ -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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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::<Editor>()
+ .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<u32>) -> git::blame::BlameEntry {
git::blame::BlameEntry {
sha: sha.parse().unwrap(),
@@ -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<Self>) {
- 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<usize>,
+ syntax_theme: &theme::SyntaxTheme,
+ ) -> Vec<(Range<usize>, 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,
@@ -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<BufferId>,
_: &Window,
@@ -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<Self>,
+ ) -> Task<Vec<OutlineItem<text::Anchor>>> {
+ 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<Self>,
+ ) -> 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<Self>,
+ ) -> Option<(BufferId, Vec<OutlineItem<Anchor>>)> {
+ 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::<Vec<_>>();
+
+ 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<BufferId>,
+ cx: &mut Context<Self>,
+ ) {
+ 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::<Vec<_>>();
+
+ 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::<Vec<_>>()
+ })
+ })
+ .ok()
+ else {
+ return;
+ };
+
+ let results = join_all(tasks).await.into_iter().collect::<HashMap<_, _>>();
+ 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<text::Anchor>],
+ 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<T> Trait<T> 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<usize>,
+ selection_start_offset: usize,
+ syntax_theme: &SyntaxTheme,
+) -> Option<Vec<(Range<usize>, 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<usize>,
+ buffer_id: BufferId,
+ display_snapshot: &DisplaySnapshot,
+ syntax_theme: &SyntaxTheme,
+) -> Option<Vec<(Range<usize>, 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>,
+ ) -> 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(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"
+ );
+ }
+}
@@ -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<ExcerptId, HashSet<Range<BufferRow>>>,
semantic_token_state: SemanticTokenState,
pub(crate) refresh_matching_bracket_highlights_task: Task<()>,
- refresh_outline_symbols_task: Task<()>,
- outline_symbols: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
+ refresh_document_symbols_task: Shared<Task<()>>,
+ lsp_document_symbols: HashMap<BufferId, Vec<OutlineItem<text::Anchor>>>,
+ refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>,
+ outline_symbols_at_cursor: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
sticky_headers_task: Task<()>,
sticky_headers: Option<Vec<OutlineItem<Anchor>>>,
}
@@ -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<Editor>) {
+ fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context<Editor>) {
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<Self>) {
@@ -25312,10 +25332,11 @@ impl Editor {
show_underlines: self.diagnostics_enabled(),
}
}
+
fn breadcrumbs_inner(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
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,
@@ -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();
});
@@ -102,17 +102,25 @@ impl Editor {
cx: &mut Context<Self>,
) {
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<BufferId>) {
+ 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<BufferId>,
for_server: Option<RefreshForServer>,
cx: &mut Context<Self>,
) {
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>) {
- self.semantic_token_state.fetched_for_buffers.clear();
- self.update_semantic_tokens(None, None, cx);
- }
}
fn buffer_into_editor_highlights<'a, 'b>(
@@ -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(),
@@ -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"] }
@@ -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::<Workspace>().flatten();
- if let Some((workspace, outline)) = workspace.zip(outline) {
+ let Some(workspace) = window.root::<Workspace>().flatten() else {
+ return;
+ };
+ if workspace.read(cx).active_modal::<OutlineView>(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<Editor>,
+ cx: &mut App,
+) -> Option<Task<Vec<OutlineItem<Anchor>>>> {
+ 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<T>(
#[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<Picker<OutlineViewDelegate>> {
cx.dispatch_action(zed_actions::outline::ToggleOutline);
+ cx.executor().advance_clock(Duration::from_millis(200));
workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<OutlineView>(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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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::<u32>::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<Editor>,
@@ -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
@@ -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<anyhow::Result<()>>,
- outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>,
+ outline_fetch_tasks: HashMap<BufferId, Task<()>>,
excerpts: HashMap<BufferId, HashMap<ExcerptId, Excerpt>>,
cached_entries: Vec<CachedEntry>,
filter_editor: Entity<Editor>,
@@ -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<Workspace>,
) -> Entity<Self> {
@@ -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::<SettingsStore>(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::<HashSet<_>>();
+ let excerpt_ids = excerpt_ranges.keys().copied().collect::<Vec<_>>();
+ 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::<HashSet<_>>();
- 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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"
+ );
+ });
+ }
}
@@ -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 {
@@ -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<CodeLensData>,
semantic_tokens: Option<SemanticTokensData>,
folding_ranges: Option<FoldingRangeData>,
+ document_symbols: Option<DocumentSymbolsData>,
inlay_hints: BufferInlayHints,
lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
chunk_lsp_requests: HashMap<LspKey, HashMap<RowChunk, LspRequestId>>,
@@ -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::<GetDocumentSymbols>(
+ 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::<GetHover>(
@@ -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<Task<std::result::Result<Vec<OutlineItem<Anchor>>, Arc<anyhow::Error>>>>;
+
+#[derive(Debug, Default)]
+pub(super) struct DocumentSymbolsData {
+ symbols: HashMap<LanguageServerId, Vec<OutlineItem<Anchor>>>,
+ 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<Buffer>,
+ cx: &mut Context<Self>,
+ ) -> Task<Vec<OutlineItem<Anchor>>> {
+ 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<Buffer>,
+ cx: &mut Context<Self>,
+ ) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<DocumentSymbol>>>>> {
+ 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::<HashMap<_, _>>();
+ 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::<usize>, 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<OutlineItem<Anchor>>,
+) {
+ 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>,
+ ) -> 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<DocumentSymbol> = Vec::new();
+ let mut items = Vec::new();
+ flatten_document_symbols(&symbols, &snapshot, 0, &mut items);
+ assert!(items.is_empty());
+ }
+}
@@ -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;
}
@@ -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 => ("<unknown>", true),
@@ -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,
@@ -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,
@@ -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<DocumentFoldingRanges>,
+ /// 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<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
@@ -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
+ }
+}
@@ -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,
+ }),
]
}
@@ -540,6 +540,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
.add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
.add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
+ .add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
// please semicolon stay on next line
;
}
@@ -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. \
@@ -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]