Support LSP document symbols in breadcrumbs and outline UI (#48780)

Kirill Bulatov created

Change summary

Cargo.lock                                       |   2 
assets/settings/default.json                     |   7 
crates/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 
crates/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, 2,320 insertions(+), 132 deletions(-)

Detailed changes

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",

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.
   //

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::<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(),

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<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,

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<BufferId>,
         _: &Window,

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<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"
+        );
+    }
+}

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<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,

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();
             });

crates/editor/src/semantic_tokens.rs πŸ”—

@@ -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>(

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(),

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"] }

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::<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>,

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
 

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<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 &current_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"
+            );
+        });
+    }
 }

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 {

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<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>(

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<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());
+    }
+}

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;
 }

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 => ("<unknown>", true),

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,

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,

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<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

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
+    }
+}

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,
+            }),
         ]
     }
 

crates/settings_ui/src/settings_ui.rs πŸ”—

@@ -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
         ;
 }

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. \

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]