Add `textDocument/foldingRange` LSP support (#48611)

Kirill Bulatov created

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

Off in language settings by default: ` "lsp_folding_ranges": "off",`,
when enabled, disables tree-sitter indent-based folding and enables
fetching of LSP ones instead.
Falls back to tree-sitter if LSP-based one brings no results.

Release Notes:

- Added `textDocument/foldingRange` LSP support, use `
"lsp_folding_ranges": "on",` language settings to fetch and prefer the
LSP folds

Change summary

assets/settings/default.json                    |   8 
crates/collab/tests/integration/editor_tests.rs | 194 +++++
crates/editor/src/display_map.rs                |  69 +
crates/editor/src/document_colors.rs            |  15 
crates/editor/src/editor.rs                     |  21 
crates/editor/src/folding_ranges.rs             | 671 +++++++++++++++++++
crates/language/src/language_settings.rs        |   6 
crates/lsp/src/lsp.rs                           |  15 
crates/project/src/lsp_command.rs               | 123 +++
crates/project/src/lsp_store.rs                 |  40 +
crates/project/src/lsp_store/folding_ranges.rs  | 214 ++++++
crates/proto/proto/lsp.proto                    |  13 
crates/proto/proto/zed.proto                    |   4 
crates/proto/src/proto.rs                       |   6 
crates/rpc/src/proto_client.rs                  |   3 
crates/settings/src/vscode_import.rs            |   1 
crates/settings_content/src/language.rs         |  11 
crates/settings_content/src/workspace.rs        |  30 
crates/settings_ui/src/page_data.rs             |  21 
crates/settings_ui/src/settings_ui.rs           |   1 
docs/src/reference/all-settings.md              |  31 
21 files changed, 1,472 insertions(+), 25 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1102,6 +1102,14 @@
   // May require language server restart to properly apply.
   "semantic_tokens": "off",
 
+  // Controls whether folding ranges from language servers are used instead of
+  // tree-sitter and indent-based folding.
+  //
+  // Options:
+  // - "off": Use tree-sitter and indent-based folding (default).
+  // - "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",
+
   // When to automatically save edited buffers. This setting can
   // take four values.
   //

crates/collab/tests/integration/editor_tests.rs 🔗

@@ -3,7 +3,7 @@ use call::ActiveCall;
 use collab::rpc::RECONNECT_TIMEOUT;
 use collections::{HashMap, HashSet};
 use editor::{
-    DocumentColorsRenderMode, Editor, FETCH_COLORS_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo,
+    DocumentColorsRenderMode, Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo,
     SelectionEffects,
     actions::{
         ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation,
@@ -24,7 +24,7 @@ use gpui::{
 use indoc::indoc;
 use language::{FakeLspAdapter, language_settings::language_settings, rust_lang};
 use lsp::LSP_REQUEST_TIMEOUT;
-use multi_buffer::AnchorRangeExt as _;
+use multi_buffer::{AnchorRangeExt as _, MultiBufferRow};
 use pretty_assertions::assert_eq;
 use project::{
     ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
@@ -34,7 +34,10 @@ use project::{
 use recent_projects::disconnected_overlay::DisconnectedOverlay;
 use rpc::RECEIVE_TIMEOUT;
 use serde_json::json;
-use settings::{InlayHintSettingsContent, InlineBlameSettings, SemanticTokens, SettingsStore};
+use settings::{
+    DocumentFoldingRanges, InlayHintSettingsContent, InlineBlameSettings, SemanticTokens,
+    SettingsStore,
+};
 use std::{
     collections::BTreeSet,
     num::NonZeroU32,
@@ -2557,7 +2560,7 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo
         .unwrap();
 
     color_request_handle.next().await.unwrap();
-    executor.advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT);
+    executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
     executor.run_until_parked();
 
     assert_eq!(
@@ -5187,7 +5190,7 @@ async fn test_semantic_token_refresh_is_forwarded(
         .into_response()
         .expect("semantic tokens refresh request failed");
     // wait out the debounce timeout
-    executor.advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT);
+    executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
     executor.run_until_parked();
     editor_a.update(cx_a, |editor, cx| {
         assert!(
@@ -5206,6 +5209,187 @@ async fn test_semantic_token_refresh_is_forwarded(
     });
 }
 
+#[gpui::test]
+async fn test_document_folding_ranges(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 {
+        folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+        ..lsp::ServerCapabilities::default()
+    };
+    client_a.language_registry().add(rust_lang());
+    let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
+        "Rust",
+        FakeLspAdapter {
+            capabilities: capabilities.clone(),
+            ..FakeLspAdapter::default()
+        },
+    );
+    client_b.language_registry().add(rust_lang());
+    client_b.language_registry().register_fake_lsp_adapter(
+        "Rust",
+        FakeLspAdapter {
+            capabilities,
+            ..FakeLspAdapter::default()
+        },
+    );
+
+    client_a
+        .fs()
+        .insert_tree(
+            path!("/a"),
+            json!({
+                "main.rs": "fn main() {\n    if true {\n        println!(\"hello\");\n    }\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 _buffer_a = project_a
+        .update(cx_a, |project, cx| {
+            project.open_local_buffer(path!("/a/main.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let editor_a = workspace_a
+        .update_in(cx_a, |workspace, window, cx| {
+            workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
+        })
+        .await
+        .unwrap()
+        .downcast::<Editor>()
+        .unwrap();
+
+    let fake_language_server = fake_language_servers.next().await.unwrap();
+
+    let folding_request_count = Arc::new(AtomicUsize::new(0));
+    let closure_count = Arc::clone(&folding_request_count);
+    let mut folding_request_handle = fake_language_server
+        .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(move |_, _| {
+            let count = Arc::clone(&closure_count);
+            async move {
+                count.fetch_add(1, atomic::Ordering::Release);
+                Ok(Some(vec![lsp::FoldingRange {
+                    start_line: 0,
+                    start_character: Some(10),
+                    end_line: 4,
+                    end_character: Some(1),
+                    kind: None,
+                    collapsed_text: None,
+                }]))
+            }
+        });
+
+    executor.run_until_parked();
+
+    assert_eq!(
+        0,
+        folding_request_count.load(atomic::Ordering::Acquire),
+        "LSP folding ranges are off by default, no request should have been made"
+    );
+    editor_a.update(cx_a, |editor, cx| {
+        assert!(
+            !editor.document_folding_ranges_enabled(cx),
+            "Host should not have LSP folding ranges enabled"
+        );
+    });
+
+    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.run_until_parked();
+
+    editor_b.update(cx_b, |editor, cx| {
+        assert!(
+            !editor.document_folding_ranges_enabled(cx),
+            "Client should not have LSP folding ranges enabled by default"
+        );
+    });
+
+    cx_b.update(|_, cx| {
+        SettingsStore::update_global(cx, |store, cx| {
+            store.update_user_settings(cx, |settings| {
+                settings
+                    .project
+                    .all_languages
+                    .defaults
+                    .document_folding_ranges = Some(DocumentFoldingRanges::On);
+            });
+        });
+    });
+    executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
+    folding_request_handle.next().await.unwrap();
+    executor.run_until_parked();
+
+    assert!(
+        folding_request_count.load(atomic::Ordering::Acquire) > 0,
+        "After the client enables LSP folding ranges, a request should be made"
+    );
+    editor_b.update(cx_b, |editor, cx| {
+        assert!(
+            editor.document_folding_ranges_enabled(cx),
+            "Client should have LSP folding ranges enabled after toggling the setting on"
+        );
+    });
+    editor_a.update(cx_a, |editor, cx| {
+        assert!(
+            !editor.document_folding_ranges_enabled(cx),
+            "Host should remain unaffected by the client's setting change"
+        );
+    });
+
+    editor_b.update_in(cx_b, |editor, window, cx| {
+        let snapshot = editor.display_snapshot(cx);
+        assert!(
+            !snapshot.is_line_folded(MultiBufferRow(0)),
+            "Line 0 should not be folded before fold_at"
+        );
+        editor.fold_at(MultiBufferRow(0), window, cx);
+    });
+    executor.run_until_parked();
+
+    editor_b.update(cx_b, |editor, cx| {
+        let snapshot = editor.display_snapshot(cx);
+        assert!(
+            snapshot.is_line_folded(MultiBufferRow(0)),
+            "Line 0 should be folded after fold_at using LSP folding range"
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_remote_project_worktree_trust(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
     let has_restricted_worktrees = |project: &gpui::Entity<project::Project>,

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};
+use collections::{HashMap, HashSet, IndexSet, hash_map};
 use gpui::{
     App, Context, Entity, EntityId, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle,
     WeakEntity,
@@ -225,6 +225,7 @@ pub struct DisplayMap {
     pub(crate) masked: bool,
     pub(crate) diagnostics_max_severity: DiagnosticSeverity,
     pub(crate) companion: Option<(WeakEntity<DisplayMap>, Entity<Companion>)>,
+    lsp_folding_crease_ids: HashMap<BufferId, Vec<CreaseId>>,
 }
 
 // test change
@@ -463,6 +464,7 @@ impl DisplayMap {
             clip_at_line_ends: false,
             masked: false,
             companion: None,
+            lsp_folding_crease_ids: HashMap::default(),
         }
     }
 
@@ -671,6 +673,7 @@ impl DisplayMap {
             semantic_token_highlights: self.semantic_token_highlights.clone(),
             clip_at_line_ends: self.clip_at_line_ends,
             masked: self.masked,
+            use_lsp_folding_ranges: !self.lsp_folding_crease_ids.is_empty(),
             fold_placeholder: self.fold_placeholder.clone(),
         }
     }
@@ -694,6 +697,7 @@ impl DisplayMap {
             semantic_token_highlights: self.semantic_token_highlights.clone(),
             clip_at_line_ends: self.clip_at_line_ends,
             masked: self.masked,
+            use_lsp_folding_ranges: !self.lsp_folding_crease_ids.is_empty(),
             fold_placeholder: self.fold_placeholder.clone(),
         }
     }
@@ -1332,6 +1336,63 @@ impl DisplayMap {
         self.crease_map.remove(crease_ids, &snapshot)
     }
 
+    /// Replaces the LSP folding-range creases for a single buffer.
+    /// Converts the supplied buffer-anchor ranges into multi-buffer creases
+    /// by mapping them through the appropriate excerpts.
+    pub(super) fn set_lsp_folding_ranges(
+        &mut self,
+        buffer_id: BufferId,
+        ranges: Vec<Range<text::Anchor>>,
+        cx: &mut Context<Self>,
+    ) {
+        let snapshot = self.buffer.read(cx).snapshot(cx);
+
+        let old_ids = self
+            .lsp_folding_crease_ids
+            .remove(&buffer_id)
+            .unwrap_or_default();
+        if !old_ids.is_empty() {
+            self.crease_map.remove(old_ids, &snapshot);
+        }
+
+        if ranges.is_empty() {
+            return;
+        }
+
+        let excerpt_ids = snapshot
+            .excerpts()
+            .filter(|(_, buf, _)| buf.remote_id() == buffer_id)
+            .map(|(id, _, _)| id)
+            .collect::<Vec<_>>();
+
+        let placeholder = self.fold_placeholder.clone();
+        let creases = ranges.into_iter().filter_map(|range| {
+            let mb_range = excerpt_ids
+                .iter()
+                .find_map(|&id| snapshot.anchor_range_in_excerpt(id, range.clone()))?;
+            Some(Crease::simple(mb_range, placeholder.clone()))
+        });
+
+        let new_ids = self.crease_map.insert(creases, &snapshot);
+        if !new_ids.is_empty() {
+            self.lsp_folding_crease_ids.insert(buffer_id, new_ids);
+        }
+    }
+
+    /// 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();
+            let snapshot = self.buffer.read(cx).snapshot(cx);
+            self.crease_map.remove(old_ids, &snapshot);
+        }
+    }
+
+    /// Returns `true` when at least one buffer has LSP folding-range creases.
+    pub(super) fn has_lsp_folding_ranges(&self) -> bool {
+        !self.lsp_folding_crease_ids.is_empty()
+    }
+
     #[instrument(skip_all)]
     pub fn insert_blocks(
         &mut self,
@@ -2000,6 +2061,9 @@ pub struct DisplaySnapshot {
     masked: bool,
     diagnostics_max_severity: DiagnosticSeverity,
     pub(crate) fold_placeholder: FoldPlaceholder,
+    /// When true, LSP folding ranges are used via the crease map and the
+    /// indent-based fallback in `crease_for_buffer_row` is skipped.
+    pub(crate) use_lsp_folding_ranges: bool,
 }
 
 impl DisplaySnapshot {
@@ -2615,7 +2679,8 @@ impl DisplaySnapshot {
                     render_toggle: render_toggle.clone(),
                 }),
             }
-        } else if self.starts_indent(MultiBufferRow(start.row))
+        } else if !self.use_lsp_folding_ranges
+            && self.starts_indent(MultiBufferRow(start.row))
             && !self.is_line_folded(MultiBufferRow(start.row))
         {
             let start_line_indent = self.line_indent_for_buffer_row(buffer_row);

crates/editor/src/lsp_colors.rs → crates/editor/src/document_colors.rs 🔗

@@ -13,8 +13,9 @@ use ui::{App, Context, Window};
 use util::post_inc;
 
 use crate::{
-    DisplayPoint, Editor, EditorSettings, EditorSnapshot, FETCH_COLORS_DEBOUNCE_TIMEOUT,
-    InlaySplice, RangeToAnchorExt, editor_settings::DocumentColorsRenderMode, inlays::Inlay,
+    DisplayPoint, Editor, EditorSettings, EditorSnapshot, InlaySplice,
+    LSP_REQUEST_DEBOUNCE_TIMEOUT, RangeToAnchorExt, editor_settings::DocumentColorsRenderMode,
+    inlays::Inlay,
 };
 
 #[derive(Debug)]
@@ -174,7 +175,7 @@ impl Editor {
         let project = project.downgrade();
         self.refresh_colors_task = cx.spawn(async move |editor, cx| {
             cx.background_executor()
-                .timer(FETCH_COLORS_DEBOUNCE_TIMEOUT)
+                .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
                 .await;
 
             let Some(all_colors_task) = project
@@ -426,7 +427,7 @@ mod tests {
     };
 
     use crate::{
-        Editor, FETCH_COLORS_DEBOUNCE_TIMEOUT, actions::MoveToEnd, editor_tests::init_test,
+        Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, actions::MoveToEnd, editor_tests::init_test,
     };
 
     fn extract_color_inlays(editor: &Editor, cx: &gpui::App) -> Vec<Rgba> {
@@ -561,7 +562,7 @@ mod tests {
             .set_request_handler::<lsp::request::DocumentColor, _, _>(move |_, _| async move {
                 panic!("Should not be called");
             });
-        cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT);
+        cx.executor().advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
         color_request_handle.next().await.unwrap();
         cx.run_until_parked();
         assert_eq!(
@@ -688,7 +689,7 @@ mod tests {
                 })
             })
             .unwrap();
-        cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT);
+        cx.executor().advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
         cx.run_until_parked();
         let editor = workspace
             .update(cx, |workspace, _, cx| {
@@ -742,7 +743,7 @@ mod tests {
         });
         save.await.unwrap();
 
-        cx.executor().advance_clock(FETCH_COLORS_DEBOUNCE_TIMEOUT);
+        cx.executor().advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
         empty_color_request_handle.next().await.unwrap();
         cx.run_until_parked();
         assert_eq!(

crates/editor/src/editor.rs 🔗

@@ -17,8 +17,10 @@ mod bracket_colorization;
 mod clangd_ext;
 pub mod code_context_menus;
 pub mod display_map;
+mod document_colors;
 mod editor_settings;
 mod element;
+mod folding_ranges;
 mod git;
 mod highlight_matching_bracket;
 mod hover_links;
@@ -28,7 +30,6 @@ mod inlays;
 pub mod items;
 mod jsx_tag_auto_close;
 mod linked_editing_ranges;
-mod lsp_colors;
 mod lsp_ext;
 mod mouse_context_menu;
 pub mod movement;
@@ -95,6 +96,7 @@ use collections::{BTreeMap, HashMap, HashSet, VecDeque};
 use convert_case::{Case, Casing};
 use dap::TelemetrySpawnLocation;
 use display_map::*;
+use document_colors::LspColorData;
 use edit_prediction_types::{
     EditPredictionDelegate, EditPredictionDelegateHandle, EditPredictionDiscardReason,
     EditPredictionGranularity, SuggestionDisplayType,
@@ -141,7 +143,6 @@ use lsp::{
     CodeActionKind, CompletionItemKind, CompletionTriggerKind, InsertTextFormat, InsertTextMode,
     LanguageServerId,
 };
-use lsp_colors::LspColorData;
 use markdown::Markdown;
 use mouse_context_menu::MouseContextMenu;
 use movement::TextLayoutDetails;
@@ -246,7 +247,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 FETCH_COLORS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150);
+pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150);
 
 pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction";
 pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict";
@@ -1328,6 +1329,8 @@ pub struct Editor {
     colors: Option<LspColorData>,
     post_scroll_update: Task<()>,
     refresh_colors_task: Task<()>,
+    use_document_folding_ranges: bool,
+    refresh_folding_ranges_task: Task<()>,
     inlay_hints: Option<LspInlayHintData>,
     folding_newlines: Task<()>,
     select_next_is_case_sensitive: Option<bool>,
@@ -2552,6 +2555,8 @@ impl Editor {
             pull_diagnostics_task: Task::ready(()),
             colors: None,
             refresh_colors_task: Task::ready(()),
+            use_document_folding_ranges: false,
+            refresh_folding_ranges_task: Task::ready(()),
             inlay_hints: None,
             next_color_inlay_id: 0,
             post_scroll_update: Task::ready(()),
@@ -2640,6 +2645,7 @@ impl 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.refresh_inlay_hints(
                                         InlayHintRefreshReason::NewLinesShown,
                                         cx,
@@ -2732,6 +2738,7 @@ impl Editor {
             editor.minimap =
                 editor.create_minimap(EditorSettings::get_global(cx).minimap, window, cx);
             editor.colors = Some(LspColorData::new(cx));
+            editor.use_document_folding_ranges = true;
             editor.inlay_hints = Some(LspInlayHintData::new(inlay_hint_settings));
 
             if let Some(buffer) = multi_buffer.read(cx).as_singleton() {
@@ -23934,8 +23941,9 @@ impl Editor {
                     self.tasks
                         .retain(|(task_buffer_id, _), _| task_buffer_id != buffer_id);
                     self.semantic_token_state.invalidate_buffer(buffer_id);
-                    self.display_map.update(cx, |display_map, _| {
+                    self.display_map.update(cx, |display_map, cx| {
                         display_map.invalidate_semantic_highlights(*buffer_id);
+                        display_map.clear_lsp_folding_ranges(*buffer_id, cx);
                     });
                 }
                 jsx_tag_auto_close::refresh_enabled_in_any_buffer(self, multibuffer, cx);
@@ -24182,6 +24190,10 @@ impl Editor {
                 self.colorize_brackets(true, cx);
             }
 
+            if language_settings_changed {
+                self.clear_disabled_lsp_folding_ranges(window, cx);
+            }
+
             if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| {
                 colors.render_mode_updated(EditorSettings::get_global(cx).lsp_document_colors)
             }) {
@@ -25214,6 +25226,7 @@ impl Editor {
             self.refresh_semantic_token_highlights(cx);
         }
         self.refresh_colors_for_visible_range(for_buffer, window, cx);
+        self.refresh_folding_ranges(for_buffer, window, cx);
     }
 
     fn register_visible_buffers(&mut self, cx: &mut Context<Self>) {

crates/editor/src/folding_ranges.rs 🔗

@@ -0,0 +1,671 @@
+use futures::future::join_all;
+use itertools::Itertools;
+use language::language_settings::language_settings;
+use text::BufferId;
+use ui::{Context, Window};
+
+use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT};
+
+impl Editor {
+    pub(super) fn refresh_folding_ranges(
+        &mut self,
+        for_buffer: Option<BufferId>,
+        _window: &Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !self.mode().is_full() || !self.use_document_folding_ranges {
+            return;
+        }
+        let Some(project) = self.project.clone() else {
+            return;
+        };
+
+        let buffers_to_query = self
+            .visible_excerpts(true, cx)
+            .into_values()
+            .map(|(buffer, ..)| buffer)
+            .chain(for_buffer.and_then(|id| self.buffer.read(cx).buffer(id)))
+            .filter(|buffer| {
+                let id = buffer.read(cx).remote_id();
+                (for_buffer.is_none_or(|target| target == id))
+                    && self.registered_buffers.contains_key(&id)
+                    && language_settings(
+                        buffer.read(cx).language().map(|l| l.name()),
+                        buffer.read(cx).file(),
+                        cx,
+                    )
+                    .document_folding_ranges
+                    .enabled()
+            })
+            .unique_by(|buffer| buffer.read(cx).remote_id())
+            .collect::<Vec<_>>();
+
+        self.refresh_folding_ranges_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_folding_ranges(&buffer, cx);
+                                async move { (buffer_id, task.await) }
+                            })
+                            .collect::<Vec<_>>()
+                    })
+                })
+                .ok()
+            else {
+                return;
+            };
+
+            let results = join_all(tasks).await;
+            if results.is_empty() {
+                return;
+            }
+
+            editor
+                .update(cx, |editor, cx| {
+                    editor.display_map.update(cx, |display_map, cx| {
+                        for (buffer_id, ranges) in results {
+                            display_map.set_lsp_folding_ranges(buffer_id, ranges, cx);
+                        }
+                    });
+                    cx.notify();
+                })
+                .ok();
+        });
+    }
+
+    pub fn document_folding_ranges_enabled(&self, cx: &ui::App) -> bool {
+        self.use_document_folding_ranges && self.display_map.read(cx).has_lsp_folding_ranges()
+    }
+
+    /// Removes LSP folding creases for buffers whose `lsp_folding_ranges`
+    /// setting has been turned off, and triggers a refresh so newly-enabled
+    /// buffers get their ranges fetched.
+    pub(super) fn clear_disabled_lsp_folding_ranges(
+        &mut self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if !self.use_document_folding_ranges {
+            return;
+        }
+
+        let buffers_to_clear = self
+            .buffer
+            .read(cx)
+            .all_buffers()
+            .into_iter()
+            .filter(|buffer| {
+                let buffer = buffer.read(cx);
+                !language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx)
+                    .document_folding_ranges
+                    .enabled()
+            })
+            .map(|buffer| buffer.read(cx).remote_id())
+            .collect::<Vec<_>>();
+
+        if !buffers_to_clear.is_empty() {
+            self.display_map.update(cx, |display_map, cx| {
+                for buffer_id in buffers_to_clear {
+                    display_map.clear_lsp_folding_ranges(buffer_id, cx);
+                }
+            });
+            cx.notify();
+        }
+
+        self.refresh_folding_ranges(None, window, cx);
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use futures::StreamExt as _;
+    use gpui::TestAppContext;
+    use lsp::FoldingRange;
+    use multi_buffer::MultiBufferRow;
+    use settings::DocumentFoldingRanges;
+
+    use crate::{
+        editor_tests::{init_test, update_test_language_settings},
+        test::editor_lsp_test_context::EditorLspTestContext,
+    };
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_populates_creases(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        update_test_language_settings(cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![
+                        FoldingRange {
+                            start_line: 0,
+                            start_character: Some(10),
+                            end_line: 4,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 1,
+                            start_character: Some(13),
+                            end_line: 3,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 6,
+                            start_character: Some(11),
+                            end_line: 8,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                    ]))
+                },
+            );
+
+        cx.set_state(
+            "ˇfn main() {\n    if true {\n        println!(\"hello\");\n    }\n}\n\nfn other() {\n    let x = 1;\n}\n",
+        );
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                editor.document_folding_ranges_enabled(cx),
+                "Expected LSP folding ranges to be populated"
+            );
+        });
+
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                !snapshot.is_line_folded(MultiBufferRow(0)),
+                "Line 0 should not be folded before any fold action"
+            );
+            assert!(
+                !snapshot.is_line_folded(MultiBufferRow(6)),
+                "Line 6 should not be folded before any fold action"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                snapshot.is_line_folded(MultiBufferRow(0)),
+                "Line 0 should be folded after fold_at on an LSP crease"
+            );
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() ⋯\n\nfn other() {\n    let x = 1;\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(6), window, cx);
+        });
+
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                snapshot.is_line_folded(MultiBufferRow(6)),
+                "Line 6 should be folded after fold_at on the second LSP crease"
+            );
+            assert_eq!(editor.display_text(cx), "fn main() ⋯\n\nfn other() ⋯\n",);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_disabled_by_default(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        cx.set_state("ˇfn main() {\n    let x = 1;\n}\n");
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                !editor.document_folding_ranges_enabled(cx),
+                "LSP folding ranges should not be enabled by default"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_toggling_off_removes_creases(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        update_test_language_settings(cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![FoldingRange {
+                        start_line: 0,
+                        start_character: Some(10),
+                        end_line: 4,
+                        end_character: Some(1),
+                        kind: None,
+                        collapsed_text: None,
+                    }]))
+                },
+            );
+
+        cx.set_state("ˇfn main() {\n    if true {\n        println!(\"hello\");\n    }\n}\n");
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                editor.document_folding_ranges_enabled(cx),
+                "Expected LSP folding ranges to be active before toggling off"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                snapshot.is_line_folded(MultiBufferRow(0)),
+                "Line 0 should be folded via LSP crease before toggling off"
+            );
+            assert_eq!(editor.display_text(cx), "fn main() ⋯\n",);
+        });
+
+        update_test_language_settings(&mut cx.cx.cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::Off);
+        });
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                !editor.document_folding_ranges_enabled(cx),
+                "LSP folding ranges should be cleared after toggling off"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_nested_folds(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        update_test_language_settings(cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![
+                        FoldingRange {
+                            start_line: 0,
+                            start_character: Some(10),
+                            end_line: 7,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 1,
+                            start_character: Some(12),
+                            end_line: 3,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 4,
+                            start_character: Some(13),
+                            end_line: 6,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                    ]))
+                },
+            );
+
+        cx.set_state(
+            "ˇfn main() {\n    if true {\n        a();\n    }\n    if false {\n        b();\n    }\n}\n",
+        );
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(1), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(snapshot.is_line_folded(MultiBufferRow(1)));
+            assert!(!snapshot.is_line_folded(MultiBufferRow(0)));
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() {\n    if true ⋯\n    if false {\n        b();\n    }\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(4), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(snapshot.is_line_folded(MultiBufferRow(4)));
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() {\n    if true ⋯\n    if false ⋯\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(snapshot.is_line_folded(MultiBufferRow(0)));
+            assert_eq!(editor.display_text(cx), "fn main() ⋯\n",);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_unsorted_from_server(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        update_test_language_settings(cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![
+                        FoldingRange {
+                            start_line: 6,
+                            start_character: Some(11),
+                            end_line: 8,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 0,
+                            start_character: Some(10),
+                            end_line: 4,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 1,
+                            start_character: Some(13),
+                            end_line: 3,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                    ]))
+                },
+            );
+
+        cx.set_state(
+            "ˇfn main() {\n    if true {\n        println!(\"hello\");\n    }\n}\n\nfn other() {\n    let x = 1;\n}\n",
+        );
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                editor.document_folding_ranges_enabled(cx),
+                "Expected LSP folding ranges to be populated despite unsorted server response"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() ⋯\n\nfn other() {\n    let x = 1;\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(6), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(editor.display_text(cx), "fn main() ⋯\n\nfn other() ⋯\n",);
+        });
+    }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_switch_between_treesitter_and_lsp(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let source =
+            "fn main() {\n    let a = 1;\n    let b = 2;\n    let c = 3;\n    let d = 4;\n}\n";
+        cx.set_state(&format!("ˇ{source}"));
+        cx.run_until_parked();
+
+        // Phase 1: tree-sitter / indentation-based folding (LSP folding OFF by default).
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                !editor.document_folding_ranges_enabled(cx),
+                "LSP folding ranges should be off by default"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                snapshot.is_line_folded(MultiBufferRow(0)),
+                "Indentation-based fold should work on the function"
+            );
+            assert_eq!(editor.display_text(cx), "fn main() {⋯\n}\n",);
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert!(
+                !editor
+                    .display_snapshot(cx)
+                    .is_line_folded(MultiBufferRow(0)),
+                "Function should be unfolded"
+            );
+        });
+
+        // Phase 2: switch to LSP folding with non-syntactic ("odd") ranges.
+        // The LSP returns two ranges that each cover a pair of let-bindings,
+        // which is not something tree-sitter / indentation folding would produce.
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![
+                        FoldingRange {
+                            start_line: 1,
+                            start_character: Some(14),
+                            end_line: 2,
+                            end_character: Some(14),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        FoldingRange {
+                            start_line: 3,
+                            start_character: Some(14),
+                            end_line: 4,
+                            end_character: Some(14),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                    ]))
+                },
+            );
+
+        update_test_language_settings(&mut cx.cx.cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                editor.document_folding_ranges_enabled(cx),
+                "LSP folding ranges should now be active"
+            );
+        });
+
+        // The indentation fold at row 0 should no longer be available;
+        // only the LSP ranges exist.
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert!(
+                !editor
+                    .display_snapshot(cx)
+                    .is_line_folded(MultiBufferRow(0)),
+                "Row 0 has no LSP crease, so fold_at should be a no-op"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(1), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert!(
+                editor
+                    .display_snapshot(cx)
+                    .is_line_folded(MultiBufferRow(1)),
+                "First odd LSP range should fold"
+            );
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() {\n    let a = 1;⋯\n    let c = 3;\n    let d = 4;\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(3), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert!(
+                editor
+                    .display_snapshot(cx)
+                    .is_line_folded(MultiBufferRow(3)),
+                "Second odd LSP range should fold"
+            );
+            assert_eq!(
+                editor.display_text(cx),
+                "fn main() {\n    let a = 1;⋯\n    let c = 3;⋯\n}\n",
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_at(MultiBufferRow(1), window, cx);
+            editor.unfold_at(MultiBufferRow(3), window, cx);
+        });
+
+        // Phase 3: switch back to tree-sitter by disabling LSP folding ranges.
+        update_test_language_settings(&mut cx.cx.cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::Off);
+        });
+        cx.run_until_parked();
+
+        cx.editor.read_with(&cx.cx.cx, |editor, cx| {
+            assert!(
+                !editor.document_folding_ranges_enabled(cx),
+                "LSP folding ranges should be cleared after switching back"
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            let snapshot = editor.display_snapshot(cx);
+            assert!(
+                snapshot.is_line_folded(MultiBufferRow(0)),
+                "Indentation-based fold should work again after switching back"
+            );
+            assert_eq!(editor.display_text(cx), "fn main() {⋯\n}\n",);
+        });
+    }
+}

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::{IntoGpui, SemanticTokens};
+use settings::{DocumentFoldingRanges, IntoGpui, SemanticTokens};
 
 pub use settings::{
     CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave,
@@ -108,6 +108,9 @@ pub struct LanguageSettings {
     pub language_servers: Vec<String>,
     /// Controls how semantic tokens from language servers are used for syntax highlighting.
     pub semantic_tokens: SemanticTokens,
+    /// Controls whether folding ranges from language servers are used instead of
+    /// tree-sitter and indent-based folding.
+    pub document_folding_ranges: DocumentFoldingRanges,
     /// Controls where the `editor::Rewrap` action is allowed for this language.
     ///
     /// Note: This setting has no effect in Vim mode, as rewrap is already
@@ -593,6 +596,7 @@ impl settings::Settings for AllLanguageSettings {
                 enable_language_server: settings.enable_language_server.unwrap(),
                 language_servers: settings.language_servers.unwrap(),
                 semantic_tokens: settings.semantic_tokens.unwrap(),
+                document_folding_ranges: settings.document_folding_ranges.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/lsp/src/lsp.rs 🔗

@@ -961,6 +961,21 @@ impl LanguageServer {
                     color_provider: Some(DocumentColorClientCapabilities {
                         dynamic_registration: Some(true),
                     }),
+                    folding_range: Some(FoldingRangeClientCapabilities {
+                        dynamic_registration: Some(true),
+                        line_folding_only: Some(false),
+                        range_limit: None,
+                        folding_range: Some(FoldingRangeCapability {
+                            collapsed_text: Some(false),
+                        }),
+                        folding_range_kind: Some(FoldingRangeKindCapability {
+                            value_set: Some(vec![
+                                FoldingRangeKind::Comment,
+                                FoldingRangeKind::Region,
+                                FoldingRangeKind::Imports,
+                            ]),
+                        }),
+                    }),
                     ..TextDocumentClientCapabilities::default()
                 }),
                 experimental: Some(json!({

crates/project/src/lsp_command.rs 🔗

@@ -20,7 +20,10 @@ use language::{
     OffsetRangeExt, PointUtf16, ToOffset, ToPointUtf16, Transaction, Unclipped,
     language_settings::{InlayHintKind, LanguageSettings, language_settings},
     point_from_lsp, point_to_lsp,
-    proto::{deserialize_anchor, deserialize_version, serialize_anchor, serialize_version},
+    proto::{
+        deserialize_anchor, deserialize_anchor_range, deserialize_version, serialize_anchor,
+        serialize_anchor_range, serialize_version,
+    },
     range_from_lsp, range_to_lsp,
 };
 use lsp::{
@@ -295,6 +298,9 @@ pub(crate) struct GetCodeLens;
 #[derive(Debug, Copy, Clone)]
 pub(crate) struct GetDocumentColor;
 
+#[derive(Debug, Copy, Clone)]
+pub(crate) struct GetFoldingRanges;
+
 impl GetCodeLens {
     pub(crate) fn can_resolve_lens(capabilities: &ServerCapabilities) -> bool {
         capabilities
@@ -4729,6 +4735,121 @@ impl LspCommand for GetDocumentColor {
     }
 }
 
+#[async_trait(?Send)]
+impl LspCommand for GetFoldingRanges {
+    type Response = Vec<Range<Anchor>>;
+    type LspRequest = lsp::request::FoldingRangeRequest;
+    type ProtoRequest = proto::GetFoldingRanges;
+
+    fn display_name(&self) -> &str {
+        "Folding ranges"
+    }
+
+    fn check_capabilities(&self, server_capabilities: AdapterServerCapabilities) -> bool {
+        server_capabilities
+            .server_capabilities
+            .folding_range_provider
+            .as_ref()
+            .is_some_and(|capability| match capability {
+                lsp::FoldingRangeProviderCapability::Simple(supported) => *supported,
+                lsp::FoldingRangeProviderCapability::FoldingProvider(..)
+                | lsp::FoldingRangeProviderCapability::Options(..) => true,
+            })
+    }
+
+    fn to_lsp(
+        &self,
+        path: &Path,
+        _: &Buffer,
+        _: &Arc<LanguageServer>,
+        _: &App,
+    ) -> Result<lsp::FoldingRangeParams> {
+        Ok(lsp::FoldingRangeParams {
+            text_document: make_text_document_identifier(path)?,
+            work_done_progress_params: Default::default(),
+            partial_result_params: Default::default(),
+        })
+    }
+
+    async fn response_from_lsp(
+        self,
+        message: Option<Vec<lsp::FoldingRange>>,
+        _: Entity<LspStore>,
+        buffer: Entity<Buffer>,
+        _: LanguageServerId,
+        cx: AsyncApp,
+    ) -> Result<Self::Response> {
+        let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot());
+        let max_point = snapshot.max_point();
+        Ok(message
+            .unwrap_or_default()
+            .into_iter()
+            .filter(|range| range.start_line < range.end_line)
+            .filter(|range| range.start_line <= max_point.row && range.end_line <= max_point.row)
+            .map(|range| {
+                let start_col = range
+                    .start_character
+                    .unwrap_or(snapshot.line_len(range.start_line));
+                let end_col = range
+                    .end_character
+                    .unwrap_or(snapshot.line_len(range.end_line));
+                let start =
+                    snapshot.anchor_after(language::Point::new(range.start_line, start_col));
+                let end = snapshot.anchor_before(language::Point::new(range.end_line, end_col));
+                start..end
+            })
+            .collect())
+    }
+
+    fn to_proto(&self, project_id: u64, buffer: &Buffer) -> Self::ProtoRequest {
+        proto::GetFoldingRanges {
+            project_id,
+            buffer_id: buffer.remote_id().to_proto(),
+            version: serialize_version(&buffer.version()),
+        }
+    }
+
+    async fn from_proto(
+        _: Self::ProtoRequest,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self> {
+        Ok(Self)
+    }
+
+    fn response_to_proto(
+        response: Self::Response,
+        _: &mut LspStore,
+        _: PeerId,
+        buffer_version: &clock::Global,
+        _: &mut App,
+    ) -> proto::GetFoldingRangesResponse {
+        proto::GetFoldingRangesResponse {
+            ranges: response.into_iter().map(serialize_anchor_range).collect(),
+            version: serialize_version(buffer_version),
+        }
+    }
+
+    async fn response_from_proto(
+        self,
+        message: proto::GetFoldingRangesResponse,
+        _: Entity<LspStore>,
+        _: Entity<Buffer>,
+        _: AsyncApp,
+    ) -> Result<Self::Response> {
+        message
+            .ranges
+            .into_iter()
+            .map(deserialize_anchor_range)
+            .collect()
+    }
+
+    fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result<BufferId> {
+        BufferId::new(message.buffer_id)
+    }
+}
+
 fn process_related_documents(
     diagnostics: &mut HashMap<lsp::Uri, LspPullDiagnostics>,
     server_id: LanguageServerId,

crates/project/src/lsp_store.rs 🔗

@@ -12,6 +12,8 @@
 pub mod clangd_ext;
 mod code_lens;
 mod document_colors;
+mod folding_ranges;
+mod inlay_hints;
 pub mod json_language_server_ext;
 pub mod log_store;
 pub mod lsp_ext_command;
@@ -19,8 +21,6 @@ pub mod rust_analyzer_ext;
 mod semantic_tokens;
 pub mod vue_language_server_ext;
 
-mod inlay_hints;
-
 use self::code_lens::CodeLensData;
 use self::document_colors::DocumentColorData;
 use self::inlay_hints::BufferInlayHints;
@@ -34,6 +34,7 @@ use crate::{
     lsp_command::{self, *},
     lsp_store::{
         self,
+        folding_ranges::FoldingRangeData,
         log_store::{GlobalLogStore, LanguageServerKind},
         semantic_tokens::{SemanticTokenConfig, SemanticTokensData},
     },
@@ -3857,6 +3858,7 @@ pub struct BufferLspData {
     document_colors: Option<DocumentColorData>,
     code_lens: Option<CodeLensData>,
     semantic_tokens: Option<SemanticTokensData>,
+    folding_ranges: Option<FoldingRangeData>,
     inlay_hints: BufferInlayHints,
     lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
     chunk_lsp_requests: HashMap<LspKey, HashMap<RowChunk, LspRequestId>>,
@@ -3875,6 +3877,7 @@ impl BufferLspData {
             document_colors: None,
             code_lens: None,
             semantic_tokens: None,
+            folding_ranges: None,
             inlay_hints: BufferInlayHints::new(buffer, cx),
             lsp_requests: HashMap::default(),
             chunk_lsp_requests: HashMap::default(),
@@ -3898,6 +3901,10 @@ impl BufferLspData {
                 .latest_invalidation_requests
                 .remove(&for_server);
         }
+
+        if let Some(folding_ranges) = &mut self.folding_ranges {
+            folding_ranges.ranges.remove(&for_server);
+        }
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -8659,6 +8666,18 @@ impl LspStore {
                 )
                 .await?;
             }
+            Request::GetFoldingRanges(get_folding_ranges) => {
+                Self::query_lsp_locally::<GetFoldingRanges>(
+                    lsp_store,
+                    server_id,
+                    sender_id,
+                    lsp_request_id,
+                    get_folding_ranges,
+                    None,
+                    &mut cx,
+                )
+                .await?;
+            }
             Request::GetHover(get_hover) => {
                 let position = get_hover.position.clone().and_then(deserialize_anchor);
                 Self::query_lsp_locally::<GetHover>(
@@ -12349,6 +12368,17 @@ impl LspStore {
                     });
                     notify_server_capabilities_updated(&server, cx);
                 }
+                "textDocument/foldingRange" => {
+                    let options = parse_register_capabilities(reg)?;
+                    let provider = match options {
+                        OneOf::Left(value) => lsp::FoldingRangeProviderCapability::Simple(value),
+                        OneOf::Right(caps) => caps,
+                    };
+                    server.update_capabilities(|capabilities| {
+                        capabilities.folding_range_provider = Some(provider);
+                    });
+                    notify_server_capabilities_updated(&server, cx);
+                }
                 _ => log::warn!("unhandled capability registration: {reg:?}"),
             }
         }
@@ -12546,6 +12576,12 @@ impl LspStore {
                     });
                     notify_server_capabilities_updated(&server, cx);
                 }
+                "textDocument/foldingRange" => {
+                    server.update_capabilities(|capabilities| {
+                        capabilities.folding_range_provider = None;
+                    });
+                    notify_server_capabilities_updated(&server, cx);
+                }
                 _ => log::warn!("unhandled capability unregistration: {unreg:?}"),
             }
         }

crates/project/src/lsp_store/folding_ranges.rs 🔗

@@ -0,0 +1,214 @@
+use std::ops::Range;
+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;
+use lsp::{LSP_REQUEST_TIMEOUT, LanguageServerId};
+use text::Anchor;
+
+use crate::lsp_command::{GetFoldingRanges, LspCommand as _};
+use crate::lsp_store::LspStore;
+
+pub(super) type FoldingRangeTask =
+    Shared<Task<std::result::Result<Vec<Range<Anchor>>, Arc<anyhow::Error>>>>;
+
+#[derive(Debug, Default)]
+pub(super) struct FoldingRangeData {
+    pub(super) ranges: HashMap<LanguageServerId, Vec<Range<Anchor>>>,
+    ranges_update: Option<(Global, FoldingRangeTask)>,
+}
+
+impl LspStore {
+    /// Returns a task that resolves to the folding ranges for the given buffer.
+    ///
+    /// Caches results per buffer version so repeated calls for the same version
+    /// return immediately. Deduplicates concurrent in-flight requests.
+    pub fn fetch_folding_ranges(
+        &mut self,
+        buffer: &Entity<Buffer>,
+        cx: &mut Context<Self>,
+    ) -> Task<Vec<Range<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.folding_ranges {
+                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.ranges.keys().copied().collect()
+                        });
+                    if !has_different_servers {
+                        let snapshot = buffer.read(cx).snapshot();
+                        return Task::ready(
+                            cached
+                                .ranges
+                                .values()
+                                .flatten()
+                                .cloned()
+                                .sorted_by(|a, b| a.start.cmp(&b.start, &snapshot))
+                                .collect(),
+                        );
+                    }
+                }
+            }
+        }
+
+        let folding_lsp_data = self
+            .latest_lsp_data(buffer, cx)
+            .folding_ranges
+            .get_or_insert_default();
+        if let Some((updating_for, running_update)) = &folding_lsp_data.ranges_update {
+            if !version_queried_for.changed_since(updating_for) {
+                let running = running_update.clone();
+                return cx.background_spawn(async move { running.await.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_folding_ranges_for_buffer(&buffer, cx)
+                    })
+                    .map_err(Arc::new)?
+                    .await
+                    .context("fetching folding ranges")
+                    .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(folding_ranges) = &mut lsp_data.folding_ranges {
+                                        folding_ranges.ranges_update = None;
+                                    }
+                                }
+                            })
+                            .ok();
+                        return Err(e);
+                    }
+                };
+
+                lsp_store
+                    .update(cx, |lsp_store, cx| {
+                        let lsp_data = lsp_store.latest_lsp_data(&buffer, cx);
+                        let folding = lsp_data.folding_ranges.get_or_insert_default();
+
+                        if let Some(fetched_ranges) = fetched {
+                            if lsp_data.buffer_version == query_version {
+                                folding.ranges.extend(fetched_ranges);
+                            } else if !lsp_data.buffer_version.changed_since(&query_version) {
+                                lsp_data.buffer_version = query_version;
+                                folding.ranges = fetched_ranges;
+                            }
+                        }
+                        folding.ranges_update = None;
+                        let snapshot = buffer.read(cx).snapshot();
+                        folding
+                            .ranges
+                            .values()
+                            .flatten()
+                            .cloned()
+                            .sorted_by(|a, b| a.start.cmp(&b.start, &snapshot))
+                            .collect()
+                    })
+                    .map_err(Arc::new)
+            })
+            .shared();
+
+        folding_lsp_data.ranges_update = Some((version_queried_for, new_task.clone()));
+
+        cx.background_spawn(async move { new_task.await.unwrap_or_default() })
+    }
+
+    fn fetch_folding_ranges_for_buffer(
+        &mut self,
+        buffer: &Entity<Buffer>,
+        cx: &mut Context<Self>,
+    ) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<Range<Anchor>>>>>> {
+        if let Some((client, project_id)) = self.upstream_client() {
+            let request = GetFoldingRanges;
+            if !self.is_capable_for_proto_request(buffer, &request, cx) {
+                return Task::ready(Ok(None));
+            }
+
+            let request_task = client.request_lsp(
+                project_id,
+                None,
+                LSP_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 folding_ranges = 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),
+                            GetFoldingRanges
+                                .response_from_proto(response.response, lsp_store, buffer, cx)
+                                .await,
+                        )
+                    }
+                }))
+                .await;
+
+                let mut has_errors = false;
+                let result = folding_ranges
+                    .into_iter()
+                    .filter_map(|(server_id, ranges)| match ranges {
+                        Ok(ranges) => Some((server_id, ranges)),
+                        Err(e) => {
+                            has_errors = true;
+                            log::error!("Failed to fetch folding ranges: {e:#}");
+                            None
+                        }
+                    })
+                    .collect::<HashMap<_, _>>();
+                anyhow::ensure!(
+                    !has_errors || !result.is_empty(),
+                    "Failed to fetch folding ranges"
+                );
+                Ok(Some(result))
+            })
+        } else {
+            let folding_task =
+                self.request_multiple_lsp_locally(buffer, None::<usize>, GetFoldingRanges, cx);
+            cx.background_spawn(async move { Ok(Some(folding_task.await.into_iter().collect())) })
+        }
+    }
+}

crates/proto/proto/lsp.proto 🔗

@@ -850,6 +850,7 @@ message LspQuery {
     GetImplementation get_implementation = 13;
     InlayHints inlay_hints = 14;
     SemanticTokens semantic_tokens = 16;
+    GetFoldingRanges get_folding_ranges = 17;
   }
 }
 
@@ -874,6 +875,7 @@ message LspResponse {
     GetReferencesResponse get_references_response = 12;
     InlayHintsResponse inlay_hints_response = 13;
     SemanticTokensResponse semantic_tokens_response = 14;
+    GetFoldingRangesResponse get_folding_ranges_response = 15;
   }
   uint64 server_id = 7;
 }
@@ -1003,3 +1005,14 @@ message ToggleLspLogs {
     RPC = 2;
   }
 }
+
+message GetFoldingRanges {
+  uint64 project_id = 1;
+  uint64 buffer_id = 2;
+  repeated VectorClockEntry version = 3;
+}
+
+message GetFoldingRangesResponse {
+  repeated AnchorRange ranges = 1;
+  repeated VectorClockEntry version = 2;
+}

crates/proto/proto/zed.proto 🔗

@@ -467,7 +467,9 @@ message Envelope {
 
         SemanticTokens semantic_tokens = 418;
         SemanticTokensResponse semantic_tokens_response = 419;
-        RefreshSemanticTokens refresh_semantic_tokens = 420; // current max
+        RefreshSemanticTokens refresh_semantic_tokens = 420;
+        GetFoldingRanges get_folding_ranges = 421;
+        GetFoldingRangesResponse get_folding_ranges_response = 422; // current max
     }
 
     reserved 87 to 88;

crates/proto/src/proto.rs 🔗

@@ -229,6 +229,8 @@ messages!(
     (GetDocumentColorResponse, Background),
     (GetColorPresentation, Background),
     (GetColorPresentationResponse, Background),
+    (GetFoldingRanges, Background),
+    (GetFoldingRangesResponse, Background),
     (RefreshCodeLens, Background),
     (GetCodeLens, Background),
     (GetCodeLensResponse, Background),
@@ -450,6 +452,7 @@ request_messages!(
     ),
     (ResolveInlayHint, ResolveInlayHintResponse),
     (GetDocumentColor, GetDocumentColorResponse),
+    (GetFoldingRanges, GetFoldingRangesResponse),
     (GetColorPresentation, GetColorPresentationResponse),
     (RespondToChannelInvite, Ack),
     (RespondToContactRequest, Ack),
@@ -557,6 +560,7 @@ request_messages!(
 lsp_messages!(
     (GetReferences, GetReferencesResponse, true),
     (GetDocumentColor, GetDocumentColorResponse, true),
+    (GetFoldingRanges, GetFoldingRangesResponse, true),
     (GetHover, GetHoverResponse, true),
     (GetCodeActions, GetCodeActionsResponse, true),
     (GetSignatureHelp, GetSignatureHelpResponse, true),
@@ -590,6 +594,7 @@ entity_messages!(
     CreateImageForPeer,
     CreateProjectEntry,
     GetDocumentColor,
+    GetFoldingRanges,
     DeleteProjectEntry,
     ExpandProjectEntry,
     ExpandAllForProjectEntry,
@@ -920,6 +925,7 @@ impl LspQuery {
             Some(lsp_query::Request::GetImplementation(_)) => ("GetImplementation", false),
             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::InlayHints(_)) => ("InlayHints", false),
             Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false),
             None => ("<unknown>", true),

crates/rpc/src/proto_client.rs 🔗

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

crates/settings/src/vscode_import.rs 🔗

@@ -561,6 +561,7 @@ impl VsCodeSettings {
                         SemanticTokens::Off
                     }
                 }),
+            document_folding_ranges: 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::{ExtendingVec, SemanticTokens, merge_from};
+use crate::{DocumentFoldingRanges, 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)]
@@ -431,6 +431,15 @@ pub struct LanguageSettingsContent {
     ///
     /// Default: "off"
     pub semantic_tokens: Option<SemanticTokens>,
+    /// Controls whether folding ranges from language servers are used instead of
+    /// tree-sitter and indent-based folding.
+    ///
+    /// Options:
+    /// - "off": Use tree-sitter and indent-based folding (default).
+    /// - "on": Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server.
+    ///
+    /// Default: "off"
+    pub document_folding_ranges: Option<DocumentFoldingRanges>,
     /// 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 🔗

@@ -830,3 +830,33 @@ impl SemanticTokens {
         self != &Self::Full
     }
 }
+
+#[derive(
+    Debug,
+    PartialEq,
+    Eq,
+    Clone,
+    Copy,
+    Default,
+    Serialize,
+    Deserialize,
+    JsonSchema,
+    MergeFrom,
+    strum::VariantArray,
+    strum::VariantNames,
+)]
+#[serde(rename_all = "snake_case")]
+pub enum DocumentFoldingRanges {
+    /// Do not request folding ranges from language servers; use tree-sitter and indent-based folding.
+    #[default]
+    Off,
+    /// Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server.
+    On,
+}
+
+impl DocumentFoldingRanges {
+    /// Returns true if LSP folding ranges should be requested from language servers.
+    pub fn enabled(&self) -> bool {
+        self != &Self::Off
+    }
+}

crates/settings_ui/src/page_data.rs 🔗

@@ -8498,7 +8498,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; 6] {
+    fn lsp_section() -> [SettingsPageItem; 7] {
         [
             SettingsPageItem::SectionHeader("LSP"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -8615,6 +8615,25 @@ fn non_editor_language_settings_data() -> Box<[SettingsPageItem]> {
                 metadata: None,
                 files: USER | PROJECT,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "LSP Folding Ranges",
+                description: "When enabled, use folding ranges from the language server instead of indent-based folding.",
+                field: Box::new(SettingField {
+                    json_path: Some("languages.$(language).document_folding_ranges"),
+                    pick: |settings_content| {
+                        language_settings_field(settings_content, |language| {
+                            language.document_folding_ranges.as_ref()
+                        })
+                    },
+                    write: |settings_content, value| {
+                        language_settings_field_mut(settings_content, value, |language, value| {
+                            language.document_folding_ranges = value;
+                        })
+                    },
+                }),
+                metadata: None,
+                files: USER | PROJECT,
+            }),
         ]
     }
 

crates/settings_ui/src/settings_ui.rs 🔗

@@ -538,6 +538,7 @@ fn init_renderers(cx: &mut App) {
         .add_basic_renderer::<settings::FontSize>(render_editable_number_field)
         .add_basic_renderer::<settings::OllamaModelName>(render_ollama_model_picker)
         .add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
+        .add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
         // please semicolon stay on next line
         ;
 }

docs/src/reference/all-settings.md 🔗

@@ -3332,6 +3332,37 @@ To enable semantic tokens for a specific language:
 
 May require language server restart to properly apply.
 
+## LSP Folding Ranges
+
+- Description: Controls whether folding ranges from language servers are used instead of tree-sitter and indent-based folding. Tree-sitter and indent-based folding is the default; it is used as a fallback when LSP folding data is not returned or this setting is turned off.
+- Setting: `document_folding_ranges`
+- Default: `off`
+
+**Options**
+
+1. `off`: Use tree-sitter and indent-based folding.
+2. `on`: Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server.
+
+To enable LSP folding ranges globally:
+
+```json [settings]
+{
+  "document_folding_ranges": "on"
+}
+```
+
+To enable LSP folding ranges for a specific language:
+
+```json [settings]
+{
+  "languages": {
+    "Rust": {
+      "document_folding_ranges": "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. \