Detailed changes
@@ -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.
//
@@ -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>,
@@ -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);
@@ -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!(
@@ -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>) {
@@ -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",);
+ });
+ }
+}
@@ -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(),
@@ -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!({
@@ -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,
@@ -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:?}"),
}
}
@@ -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())) })
+ }
+ }
+}
@@ -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;
+}
@@ -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;
@@ -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),
@@ -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,
@@ -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,
@@ -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
@@ -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
+ }
+}
@@ -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,
+ }),
]
}
@@ -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
;
}
@@ -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. \