use futures::future::join_all; use itertools::Itertools; use language::language_settings::LanguageSettings; 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.lsp_data_enabled() || !self.use_document_folding_ranges { return; } let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else { return; }; let buffers_to_query = self .visible_buffers(cx) .into_iter() .filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx)) .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) && LanguageSettings::for_buffer(buffer.read(cx), 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| { let project = project.upgrade()?; Some(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() .flatten() 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); !LanguageSettings::for_buffer(&buffer, 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 pretty_assertions::assert_eq; 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",); }); 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",); }); } #[gpui::test] async fn test_lsp_folding_ranges_collapsed_text(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![ // main: custom collapsed text FoldingRange { start_line: 0, start_character: Some(10), end_line: 4, end_character: Some(1), kind: None, collapsed_text: Some("{ fn body }".to_string()), }, // other: collapsed text longer than the original folded content FoldingRange { start_line: 6, start_character: Some(11), end_line: 8, end_character: Some(1), kind: None, collapsed_text: Some("{ this collapsed text is intentionally much longer than the original function body it replaces }".to_string()), }, // emoji: collapsed text WITH emoji and multi-byte chars FoldingRange { start_line: 10, start_character: Some(11), end_line: 13, end_character: Some(1), kind: None, collapsed_text: Some("{ 🦀…café }".to_string()), }, // outer: collapsed text on the outer fn FoldingRange { start_line: 15, start_character: Some(11), end_line: 22, end_character: Some(1), kind: None, collapsed_text: Some("{ outer… }".to_string()), }, // inner_a: nested inside outer, with collapsed text FoldingRange { start_line: 16, start_character: Some(17), end_line: 18, end_character: Some(5), kind: None, collapsed_text: Some("{ a }".to_string()), }, // inner_b: nested inside outer, no collapsed text FoldingRange { start_line: 19, start_character: Some(17), end_line: 21, end_character: Some(5), kind: None, collapsed_text: None, }, // newline: collapsed text containing \n FoldingRange { start_line: 24, start_character: Some(13), end_line: 27, end_character: Some(1), kind: None, collapsed_text: Some("{\n …\n}".to_string()), }, ])) }, ); cx.set_state( &[ "ˇfn main() {\n", " if true {\n", " println!(\"hello\");\n", " }\n", "}\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() {\n", " fn inner_a() {\n", " let x = 1;\n", " }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); assert!(folding_request.next().await.is_some()); cx.run_until_parked(); let unfolded_text = [ "fn main() {\n", " if true {\n", " println!(\"hello\");\n", " }\n", "}\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() {\n", " fn inner_a() {\n", " let x = 1;\n", " }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(); // Fold newline fn — collapsed text that itself contains \n // (newlines are sanitized to spaces to keep folds single-line). cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(24), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() {\n", " if true {\n", " println!(\"hello\");\n", " }\n", "}\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() {\n", " fn inner_a() {\n", " let x = 1;\n", " }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() { … }\n", ] .concat(), ); }); cx.update_editor(|editor, window, cx| { editor.unfold_all(&crate::actions::UnfoldAll, window, cx); }); // Fold main — custom collapsed text. 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() { fn body }\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() {\n", " fn inner_a() {\n", " let x = 1;\n", " }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); }); // Fold emoji fn — multi-byte / emoji collapsed text (main still folded). cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(10), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() { fn body }\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() { 🦀…café }\n", "\n", "fn outer() {\n", " fn inner_a() {\n", " let x = 1;\n", " }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); }); // Fold a nested range (inner_a) while outer is still unfolded. cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(16), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() { fn body }\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() { 🦀…café }\n", "\n", "fn outer() {\n", " fn inner_a() { a }\n", " fn inner_b() {\n", " let y = 2;\n", " }\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); }); // Unfold everything to reset. cx.update_editor(|editor, window, cx| { editor.unfold_all(&crate::actions::UnfoldAll, window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!(editor.display_text(cx), unfolded_text); }); // Fold ALL at once and verify every fold. cx.update_editor(|editor, window, cx| { editor.fold_all(&crate::actions::FoldAll, window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() { fn body }\n", "\n", "fn other() { this collapsed text is intentionally much longer than the original function body it replaces }\n", "\n", "fn emoji() { 🦀…café }\n", "\n", "fn outer() { outer… }\n", "\n", "fn newline() { … }\n", ] .concat(), ); }); // Unfold all again, then fold only the outer, which should swallow inner folds. cx.update_editor(|editor, window, cx| { editor.unfold_all(&crate::actions::UnfoldAll, window, cx); }); cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(15), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() {\n", " if true {\n", " println!(\"hello\");\n", " }\n", "}\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() { outer… }\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); }); // Unfold the outer, then fold both inners independently. cx.update_editor(|editor, window, cx| { editor.unfold_all(&crate::actions::UnfoldAll, window, cx); }); cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(16), window, cx); editor.fold_at(MultiBufferRow(19), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn main() {\n", " if true {\n", " println!(\"hello\");\n", " }\n", "}\n", "\n", "fn other() {\n", " let x = 1;\n", "}\n", "\n", "fn emoji() {\n", " let a = \"🦀🔥\";\n", " let b = \"café\";\n", "}\n", "\n", "fn outer() {\n", " fn inner_a() { a }\n", " fn inner_b() ⋯\n", "}\n", "\n", "fn newline() {\n", " let a = 1;\n", " let b = 2;\n", "}\n", ] .concat(), ); }); } #[gpui::test] async fn test_lsp_folding_ranges_with_multibyte_characters(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; // √ is 3 bytes in UTF-8 but 1 code unit in UTF-16. // LSP character offsets are UTF-16, so interpreting them as byte // offsets lands inside a multi-byte character and panics. let mut folding_request = cx .set_request_handler::( move |_, _, _| async move { Ok(Some(vec![ // Outer fold: start/end on ASCII-only lines (sanity check). FoldingRange { start_line: 0, start_character: Some(16), end_line: 8, end_character: Some(1), kind: None, collapsed_text: None, }, // Inner fold whose start_character falls among multi-byte chars. // Line 1 is " //√√√√√√√√√√" // UTF-16 offsets: 0-3=' ', 4='/', 5='/', 6-15='√'×10 // Byte offsets: 0-3=' ', 4='/', 5='/', 6..35='√'×10 (3 bytes each) // start_character=8 (UTF-16) → after " //√√", byte offset would be 12 // but naively using 8 as byte offset hits inside the first '√'. FoldingRange { start_line: 1, start_character: Some(8), end_line: 3, end_character: Some(5), kind: None, collapsed_text: None, }, ])) }, ); // Line 0: "fn multibyte() {" (16 UTF-16 units) // Line 1: " //√√√√√√√√√√" (16 UTF-16 units, 36 bytes) // Line 2: " let y = 2;" (14 UTF-16 units) // Line 3: " //√√√|end" (13 UTF-16 units; '|' is just a visual marker) // Line 4: " if true {" (14 UTF-16 units) // Line 5: " let a = \"√√\";" (22 UTF-16 units, 28 bytes) // Line 6: " }" (5 UTF-16 units) // Line 7: " let z = 3;" (14 UTF-16 units) // Line 8: "}" (1 UTF-16 unit) cx.set_state( &[ "ˇfn multibyte() {\n", " //√√√√√√√√√√\n", " let y = 2;\n", " //√√√|end\n", " if true {\n", " let a = \"√√\";\n", " }\n", " let z = 3;\n", "}\n", ] .concat(), ); assert!(folding_request.next().await.is_some()); cx.run_until_parked(); // Fold the inner range whose start_character lands among √ chars. // Fold spans from line 1 char 8 (" //√√" visible) to line 3 char 5 // ("/√√√|end" visible after fold marker). cx.update_editor(|editor, window, cx| { editor.fold_at(MultiBufferRow(1), window, cx); }); cx.update_editor(|editor, _window, cx| { assert_eq!( editor.display_text(cx), [ "fn multibyte() {\n", " //√√⋯/√√√|end\n", " if true {\n", " let a = \"√√\";\n", " }\n", " let z = 3;\n", "}\n", ] .concat(), ); }); // Unfold, then fold the outer range to make sure it works too. cx.update_editor(|editor, window, cx| { editor.unfold_all(&crate::actions::UnfoldAll, window, cx); }); 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 multibyte() {⋯\n",); }); } }