From 6c253a7d6855978f7e6958b7bfe95bef7fe4d2b9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 6 Feb 2026 20:06:01 +0200 Subject: [PATCH] Add `textDocument/foldingRange` LSP support (#48611) 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 --- assets/settings/default.json | 8 + .../collab/tests/integration/editor_tests.rs | 194 ++++- crates/editor/src/display_map.rs | 69 +- .../src/{lsp_colors.rs => 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 +- .../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, 1472 insertions(+), 25 deletions(-) rename crates/editor/src/{lsp_colors.rs => document_colors.rs} (98%) create mode 100644 crates/editor/src/folding_ranges.rs create mode 100644 crates/project/src/lsp_store/folding_ranges.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 6e834933d1a326ba7fceb61c939e2bc01673c842..5a8e0047b401a5edbc2755dda2af4183fd4b6f5b 100644 --- a/assets/settings/default.json +++ b/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. // diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 0d93b6d903b43beba16bc7a4ecbc6dc12fdbadbd..2ef5f52ea625e2266ab96ec0f285b48b6dfa3d55 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/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::() + .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::(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::() + .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, diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 388c7649919e836a60510c1ca3500430a2767a7f..5892c0a28e3adacbf37c68979fc1724aa1bf41b8 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -92,7 +92,7 @@ pub use inlay_map::{InlayOffset, InlayPoint}; pub use invisibles::{is_invisible, replacement}; pub use wrap_map::{WrapPoint, WrapRow, WrapSnapshot}; -use collections::{HashMap, HashSet, IndexSet}; +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, Entity)>, + lsp_folding_crease_ids: HashMap>, } // 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>, + cx: &mut Context, + ) { + 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::>(); + + 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) { + 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); diff --git a/crates/editor/src/lsp_colors.rs b/crates/editor/src/document_colors.rs similarity index 98% rename from crates/editor/src/lsp_colors.rs rename to crates/editor/src/document_colors.rs index 14af12748851cf091978defc428dfe416d666b2c..f99abcb9783bb53c4437dbe58fb73f49f6248d62 100644 --- a/crates/editor/src/lsp_colors.rs +++ b/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 { @@ -561,7 +562,7 @@ mod tests { .set_request_handler::(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!( diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 5456e7cd6d5cc5853f4b6106b697442b0d3d7b58..89a53ed8c800f942605cd88a169ff6d899e80018 100644 --- a/crates/editor/src/editor.rs +++ b/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, post_scroll_update: Task<()>, refresh_colors_task: Task<()>, + use_document_folding_ranges: bool, + refresh_folding_ranges_task: Task<()>, inlay_hints: Option, folding_newlines: Task<()>, select_next_is_case_sensitive: Option, @@ -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) { diff --git a/crates/editor/src/folding_ranges.rs b/crates/editor/src/folding_ranges.rs new file mode 100644 index 0000000000000000000000000000000000000000..46578f85e5d86e72f65ba160ec27beb87fc9a149 --- /dev/null +++ b/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, + _window: &Window, + cx: &mut Context, + ) { + 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::>(); + + 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::>() + }) + }) + .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, + ) { + 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::>(); + + 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::( + 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::( + 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::( + 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::( + 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::( + 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",); + }); + } +} diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index b8b6f81cf21a8827170e99edc6ad54df1c801260..9bb591d82f2e37d0db818d93d5c07d623879d990 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -9,7 +9,7 @@ use ec4rs::{ use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{App, Modifiers, SharedString}; use itertools::{Either, Itertools}; -use settings::{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, /// 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(), diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 46d6a83582ada7a45c1efc93bc8dc108058d173d..17833768160ad43ef8b7f814d89b2460f4ada7e4 100644 --- a/crates/lsp/src/lsp.rs +++ b/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!({ diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 498157067efce0d465578aa74d7ba26b84cd30dd..7f472eefb6b52f81456dd55d48aa7529d77d99e8 100644 --- a/crates/project/src/lsp_command.rs +++ b/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>; + 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, + _: &App, + ) -> Result { + 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>, + _: Entity, + buffer: Entity, + _: LanguageServerId, + cx: AsyncApp, + ) -> Result { + 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, + _: Entity, + _: AsyncApp, + ) -> Result { + 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, + _: Entity, + _: AsyncApp, + ) -> Result { + message + .ranges + .into_iter() + .map(deserialize_anchor_range) + .collect() + } + + fn buffer_id_from_proto(message: &Self::ProtoRequest) -> Result { + BufferId::new(message.buffer_id) + } +} + fn process_related_documents( diagnostics: &mut HashMap, server_id: LanguageServerId, diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index dd428f032ef37e5ac75facc71911d013117f4016..e3cd59a5e85e78fc43f5da7a1a69c5caab05066f 100644 --- a/crates/project/src/lsp_store.rs +++ b/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, code_lens: Option, semantic_tokens: Option, + folding_ranges: Option, inlay_hints: BufferInlayHints, lsp_requests: HashMap>>, chunk_lsp_requests: HashMap>, @@ -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::( + 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::( @@ -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:?}"), } } diff --git a/crates/project/src/lsp_store/folding_ranges.rs b/crates/project/src/lsp_store/folding_ranges.rs new file mode 100644 index 0000000000000000000000000000000000000000..fc62824f9e4e8e5330516558a6ac28d99de826e3 --- /dev/null +++ b/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>, Arc>>>; + +#[derive(Debug, Default)] +pub(super) struct FoldingRangeData { + pub(super) ranges: HashMap>>, + 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, + cx: &mut Context, + ) -> Task>> { + let version_queried_for = buffer.read(cx).version(); + let buffer_id = buffer.read(cx).remote_id(); + + let current_language_servers = self.as_local().map(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }); + + if let Some(lsp_data) = self.current_lsp_data(buffer_id) { + if let Some(cached) = &lsp_data.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, + cx: &mut Context, + ) -> Task>>>>> { + 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::>(); + 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::, GetFoldingRanges, cx); + cx.background_spawn(async move { Ok(Some(folding_task.await.into_iter().collect())) }) + } + } +} diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 59fa3677363037c219c1a4d3c00b628dbe597ebd..1ec0d9fc338c13f0b7771c5b4a5ee2710e4d3b39 100644 --- a/crates/proto/proto/lsp.proto +++ b/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; +} diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 75263da3b083d3ae96c4897b89fc16a3cdb6318f..1246d0dda41d7fc32ee1d3d0ee56ebd8c94b5d9d 100644 --- a/crates/proto/proto/zed.proto +++ b/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; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index df33d9e64233b7fac924303c188caa3891c96005..69607f103b3dccd34d2c64f8d6347ea6570fbbae 100644 --- a/crates/proto/src/proto.rs +++ b/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 => ("", true), diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index dd0886554678337b92960693d66936f760976f09..d1ebe54c5f0ff023bea9f7fec69f0a748f1f66b1 100644 --- a/crates/rpc/src/proto_client.rs +++ b/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, diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 48d9cb81d7be9ea00b75638d943a7e3713fc015d..b2e5be65c157e412cd5303e9ebe4cc001f97abe7 100644 --- a/crates/settings/src/vscode_import.rs +++ b/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, diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index 124718e9d179891c608ae18397500313a35c6e17..bf5977bbd6abd3354c9a13480121d3aa512b1823 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize, de::Error as _}; use settings_macros::{MergeFrom, with_fallible_options}; use std::sync::Arc; -use crate::{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, + /// 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, /// Controls where the `editor::Rewrap` action is allowed for this language. /// /// Note: This setting has no effect in Vim mode, as rewrap is already diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index f3ac1f930ccc56f5a028136da78445d11ebfddab..ed9a7aac5280447fe014e6b3796778be11928f92 100644 --- a/crates/settings_content/src/workspace.rs +++ b/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 + } +} diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 5bc9393fe469a743f98346761f22b3c99d699e6b..44761d6c94ef15d22e3ef2661ae016dd6910909c 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/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, + }), ] } diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index cb54f4fb7b3a7b6f20ac0b946218298448e40db5..d7327650fc636ca33c2bf35bd9f60d5ddcd78e49 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -538,6 +538,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_editable_number_field) .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) // please semicolon stay on next line ; } diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 9a156dc09152ce7bcbb6e72c7a773149a1927adc..504d43d8c8c263d8fecac1acd29bf3c90e0d9403 100644 --- a/docs/src/reference/all-settings.md +++ b/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. \